From 39270a21afbf2d473cbfdd80f8118c5a7d4e2dfa Mon Sep 17 00:00:00 2001 From: LilSpazJoekp <15524072+LilSpazJoekp@users.noreply.github.com> Date: Sun, 22 Oct 2023 10:53:32 -0500 Subject: [PATCH] General cleanup --- .pre-commit-config.yaml | 1 - CHANGES.rst | 2 +- praw/config.py | 15 +- praw/endpoints.py | 1 - praw/exceptions.py | 11 +- praw/models/base.py | 2 +- praw/models/comment_forest.py | 42 +- praw/models/helpers.py | 19 +- praw/models/listing/generator.py | 6 +- praw/models/listing/mixins/base.py | 4 +- praw/models/listing/mixins/subreddit.py | 2 +- praw/models/mod_action.py | 4 +- praw/models/mod_notes.py | 2 +- praw/models/reddit/base.py | 8 +- praw/models/reddit/collections.py | 10 +- praw/models/reddit/comment.py | 21 +- praw/models/reddit/draft.py | 7 +- praw/models/reddit/emoji.py | 4 +- praw/models/reddit/live.py | 25 +- praw/models/reddit/message.py | 2 +- praw/models/reddit/mixins/__init__.py | 2 +- praw/models/reddit/mixins/votable.py | 2 +- praw/models/reddit/modmail.py | 50 +- praw/models/reddit/more.py | 4 +- praw/models/reddit/multi.py | 6 +- praw/models/reddit/redditor.py | 16 +- praw/models/reddit/removal_reasons.py | 16 +- praw/models/reddit/rules.py | 6 +- praw/models/reddit/submission.py | 26 +- praw/models/reddit/subreddit.py | 5510 ++++++++--------- praw/models/reddit/user_subreddit.py | 6 +- praw/models/reddit/widgets.py | 175 +- praw/models/reddit/wikipage.py | 6 +- praw/models/subreddits.py | 2 +- praw/models/trophy.py | 2 +- praw/models/user.py | 4 +- praw/models/util.py | 2 +- praw/objector.py | 5 +- praw/reddit.py | 43 +- praw/util/__init__.py | 6 +- praw/util/cache.py | 5 +- praw/util/deprecate_args.py | 4 +- praw/util/token_manager.py | 10 +- pyproject.toml | 1 + .../models/reddit/test_submission.py | 4 +- tools/set_version.py | 2 +- tox.ini | 8 - 47 files changed, 3049 insertions(+), 3062 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e87845cb..a88de57e7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,5 +58,4 @@ repos: - repo: https://github.com/LilSpazJoekp/docstrfmt hooks: - id: docstrfmt - require_serial: true rev: v1.5.1 diff --git a/CHANGES.rst b/CHANGES.rst index c75bfe733..cb824aa54 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Change Log ========== -PRAW follows `semantic versioning `_. +PRAW follows `semantic versioning `_. Unreleased ---------- diff --git a/praw/config.py b/praw/config.py index 5c0b6b3d5..a89f66d1c 100644 --- a/praw/config.py +++ b/praw/config.py @@ -6,6 +6,7 @@ import sys from pathlib import Path from threading import Lock +from typing import Any from .exceptions import ClientException @@ -32,13 +33,13 @@ class Config: } @staticmethod - def _config_boolean(item: bool | str) -> bool: # noqa: ANN001 + def _config_boolean(item: bool | str) -> bool: if isinstance(item, bool): return item return item.lower() in {"1", "yes", "true", "on"} @classmethod - def _load_config(cls, *, config_interpolation: str | None = None): # noqa: ANN001 + def _load_config(cls, *, config_interpolation: str | None = None): """Attempt to load settings from various praw.ini files.""" if config_interpolation is not None: interpolator_class = cls.INTERPOLATION_LEVEL[config_interpolation]() @@ -97,17 +98,19 @@ def __init__( self._initialize_attributes() - def _fetch(self, key): # noqa: ANN001 + def _fetch(self, key: str) -> Any: value = self.custom[key] del self.custom[key] return value - def _fetch_default(self, key, *, default=None): # noqa: ANN001 + def _fetch_default( + self, key: str, *, default: bool | float | str | None = None + ) -> Any: if key not in self.custom: return default return self._fetch(key) - def _fetch_or_not_set(self, key): # noqa: ANN001 + def _fetch_or_not_set(self, key: str) -> Any | _NotSet: if key in self._settings: # Passed in values have the highest priority return self._fetch(key) @@ -117,7 +120,7 @@ def _fetch_or_not_set(self, key): # noqa: ANN001 # Environment variables have higher priority than praw.ini settings return env_value or ini_value or self.CONFIG_NOT_SET - def _initialize_attributes(self): # noqa: ANN001 + def _initialize_attributes(self): self._short_url = self._fetch_default("short_url") or self.CONFIG_NOT_SET self.check_for_async = self._config_boolean( self._fetch_default("check_for_async", default=True) diff --git a/praw/endpoints.py b/praw/endpoints.py index d1b6a8151..1c4f12df1 100644 --- a/praw/endpoints.py +++ b/praw/endpoints.py @@ -1,5 +1,4 @@ """List of API endpoints PRAW knows about.""" -# flake8: noqa # fmt: off API_PATH = { "about_edited": "r/{subreddit}/about/edited/", diff --git a/praw/exceptions.py b/praw/exceptions.py index 619ef070c..98b7dec02 100644 --- a/praw/exceptions.py +++ b/praw/exceptions.py @@ -9,12 +9,13 @@ """ from __future__ import annotations +from typing import Any from warnings import warn from .util import _deprecate_args -class PRAWException(Exception): # noqa: N818 +class PRAWException(Exception): """The base PRAW Exception that all other exception classes extend.""" @@ -79,7 +80,7 @@ class ClientException(PRAWException): class DuplicateReplaceException(ClientException): """Indicate exceptions that involve the replacement of :class:`.MoreComments`.""" - def __init__(self) -> None: + def __init__(self): """Initialize a :class:`.DuplicateReplaceException` instance.""" super().__init__( "A duplicate comment has been detected. Are you attempting to call" @@ -101,7 +102,7 @@ def __init__(self, template_id: str): class InvalidImplicitAuth(ClientException): """Indicate exceptions where an implicit auth type is used incorrectly.""" - def __init__(self) -> None: + def __init__(self): """Initialize an :class:`.InvalidImplicitAuth` instance.""" super().__init__("Implicit authorization can only be used with installed apps.") @@ -189,7 +190,7 @@ def __init__(self, message: str, exception: Exception | None): class MediaPostFailed(WebSocketException): """Indicate exceptions where media uploads failed..""" - def __init__(self) -> None: + def __init__(self): """Initialize a :class:`.MediaPostFailed` instance.""" super().__init__( "The attempted media upload action has failed. Possible causes include the" @@ -287,7 +288,7 @@ def __init__( self.items = self.parse_exception_list(items) super().__init__(*self.items) - def _get_old_attr(self, attrname): # noqa: ANN001 + def _get_old_attr(self, attrname: str) -> Any: warn( f"Accessing attribute '{attrname}' through APIException is deprecated." " This behavior will be removed in PRAW 8.0. Check out" diff --git a/praw/models/base.py b/praw/models/base.py index 5df3a4a1b..8e78698d0 100644 --- a/praw/models/base.py +++ b/praw/models/base.py @@ -13,7 +13,7 @@ class PRAWBase: @staticmethod def _safely_add_arguments( - *, arguments, key, **new_arguments # noqa: ANN001,ANN003,ANN205 + *, arguments: dict[str, Any], key: str, **new_arguments: Any ): """Replace arguments[key] with a deepcopy and update. diff --git a/praw/models/comment_forest.py b/praw/models/comment_forest.py index cf235d096..a34f37325 100644 --- a/praw/models/comment_forest.py +++ b/praw/models/comment_forest.py @@ -20,7 +20,11 @@ class CommentForest: """ @staticmethod - def _gather_more_comments(tree, *, parent_tree=None): # noqa: ANN001,ANN205 + def _gather_more_comments( + tree: list[praw.models.MoreComments], + *, + parent_tree: list[praw.models.MoreComments] | None = None, + ) -> list[MoreComments]: """Return a list of :class:`.MoreComments` objects obtained from tree.""" more_comments = [] queue = [(None, x) for x in tree] @@ -57,27 +61,11 @@ def __getitem__(self, index: int) -> praw.models.Comment: """ return self._comments[index] - def __init__( - self, - submission: praw.models.Submission, - comments: list[praw.models.Comment] | None = None, - ): - """Initialize a :class:`.CommentForest` instance. - - :param submission: An instance of :class:`.Submission` that is the parent of the - comments. - :param comments: Initialize the forest with a list of comments (default: - ``None``). - - """ - self._comments = comments - self._submission = submission - def __len__(self) -> int: """Return the number of top-level comments in the forest.""" return len(self._comments) - def _insert_comment(self, comment): # noqa: ANN001 + def _insert_comment(self, comment: praw.models.Comment): if comment.name in self._submission._comments_by_id: raise DuplicateReplaceException comment.submission = self._submission @@ -91,7 +79,7 @@ def _insert_comment(self, comment): # noqa: ANN001 parent = self._submission._comments_by_id[comment.parent_id] parent.replies._comments.append(comment) - def _update(self, comments): # noqa: ANN001 + def _update(self, comments: list[praw.models.Comment]): self._comments = comments for comment in comments: comment.submission = self._submission @@ -114,6 +102,22 @@ def list( # noqa: A003 queue.extend(comment.replies) return comments + def __init__( + self, + submission: praw.models.Submission, + comments: list[praw.models.Comment] | None = None, + ): + """Initialize a :class:`.CommentForest` instance. + + :param submission: An instance of :class:`.Submission` that is the parent of the + comments. + :param comments: Initialize the forest with a list of comments (default: + ``None``). + + """ + self._comments = comments + self._submission = submission + @_deprecate_args("limit", "threshold") def replace_more( self, *, limit: int | None = 32, threshold: int = 0 diff --git a/praw/models/helpers.py b/praw/models/helpers.py index ec3acdc24..c562755ef 100644 --- a/praw/models/helpers.py +++ b/praw/models/helpers.py @@ -30,8 +30,8 @@ def __call__( ) -> list[praw.models.Draft] | praw.models.Draft: """Return a list of :class:`.Draft` instances. - :param draft_id: When provided, return :class:`.Draft` instance (default: - ``None``). + :param draft_id: When provided, this returns a :class:`.Draft` instance + (default: ``None``). :returns: A :class:`.Draft` instance if ``draft_id`` is provided. Otherwise, a list of :class:`.Draft` objects. @@ -54,7 +54,7 @@ def __call__( return Draft(self._reddit, id=draft_id) return self._draft_list() - def _draft_list(self) -> list[praw.models.Draft]: # noqa: ANN001 + def _draft_list(self) -> list[praw.models.Draft]: """Get a list of :class:`.Draft` instances. :returns: A list of :class:`.Draft` instances. @@ -139,9 +139,7 @@ def create( class LiveHelper(PRAWBase): r"""Provide a set of functions to interact with :class:`.LiveThread`\ s.""" - def __call__( - self, id: str - ) -> praw.models.LiveThread: # pylint: disable=invalid-name,redefined-builtin + def __call__(self, id: str) -> praw.models.LiveThread: """Return a new lazy instance of :class:`.LiveThread`. This method is intended to be used as: @@ -193,8 +191,13 @@ def info(self, ids: list[str]) -> Generator[praw.models.LiveThread, None, None]: :returns: A generator that yields :class:`.LiveThread` instances. - Live threads that cannot be matched will not be generated. Requests will be - issued in batches for each 100 IDs. + :raises: ``prawcore.ServerError`` if invalid live threads are requested. + + Requests will be issued in batches for each 100 IDs. + + .. note:: + + This method doesn't support IDs for live updates. .. warning:: diff --git a/praw/models/listing/generator.py b/praw/models/listing/generator.py index ed01fc898..2e2365bb3 100644 --- a/praw/models/listing/generator.py +++ b/praw/models/listing/generator.py @@ -52,7 +52,7 @@ def __init__( self.url = url self.yielded = 0 - def __iter__(self): # noqa: ANN204 + def __iter__(self) -> Any: """Permit :class:`.ListingGenerator` to operate as an iterator.""" return self @@ -68,7 +68,7 @@ def __next__(self) -> Any: self.yielded += 1 return self._listing[self._list_index - 1] - def _extract_sublist(self, listing): # noqa: ANN001 + def _extract_sublist(self, listing: dict[str, Any] | list[Any]): if isinstance(listing, list): return listing[1] # for submission duplicates if isinstance(listing, dict): @@ -82,7 +82,7 @@ def _extract_sublist(self, listing): # noqa: ANN001 raise ValueError(msg) return listing - def _next_batch(self): # noqa: ANN001 + def _next_batch(self): if self._exhausted: raise StopIteration diff --git a/praw/models/listing/mixins/base.py b/praw/models/listing/mixins/base.py index 8fe34aaaa..bba3f8fb0 100644 --- a/praw/models/listing/mixins/base.py +++ b/praw/models/listing/mixins/base.py @@ -15,7 +15,7 @@ class BaseListingMixin(PRAWBase): VALID_TIME_FILTERS = {"all", "day", "hour", "month", "week", "year"} @staticmethod - def _validate_time_filter(time_filter): # noqa: ANN001 + def _validate_time_filter(time_filter: str): """Validate ``time_filter``. :raises: :py:class:`ValueError` if ``time_filter`` is not valid. @@ -28,7 +28,7 @@ def _validate_time_filter(time_filter): # noqa: ANN001 msg = f"'time_filter' must be one of: {valid_time_filters}" raise ValueError(msg) - def _prepare(self, *, arguments, sort): # noqa: ANN001 + def _prepare(self, *, arguments: dict[str, Any], sort: str) -> str: """Fix for :class:`.Redditor` methods that use a query param rather than subpath.""" if self.__dict__.get("_listing_use_sort"): self._safely_add_arguments(arguments=arguments, key="params", sort=sort) diff --git a/praw/models/listing/mixins/subreddit.py b/praw/models/listing/mixins/subreddit.py index 5c40b351c..86bcb2059 100644 --- a/praw/models/listing/mixins/subreddit.py +++ b/praw/models/listing/mixins/subreddit.py @@ -19,7 +19,7 @@ class CommentHelper(PRAWBase): """Provide a set of functions to interact with a :class:`.Subreddit`'s comments.""" @property - def _path(self) -> str: # noqa: ANN001 + def _path(self) -> str: return urljoin(self.subreddit._path, "comments/") def __call__( diff --git a/praw/models/mod_action.py b/praw/models/mod_action.py index eeb692ed9..74d0d616c 100644 --- a/praw/models/mod_action.py +++ b/praw/models/mod_action.py @@ -15,8 +15,8 @@ class ModAction(PRAWBase): @property def mod(self) -> praw.models.Redditor: """Return the :class:`.Redditor` who the action was issued by.""" - return self._reddit.redditor(self._mod) # pylint: disable=no-member + return self._reddit.redditor(self._mod) @mod.setter def mod(self, value: str | praw.models.Redditor): - self._mod = value # pylint: disable=attribute-defined-outside-init + self._mod = value diff --git a/praw/models/mod_notes.py b/praw/models/mod_notes.py index bf413167a..01268208d 100644 --- a/praw/models/mod_notes.py +++ b/praw/models/mod_notes.py @@ -63,7 +63,7 @@ def _bulk_generator( for note_dict in response["mod_notes"]: yield self._reddit._objector.objectify(note_dict) - def _ensure_attribute(self, error_message: str, **attributes: Any): # noqa: ANN001 + def _ensure_attribute(self, error_message: str, **attributes: Any) -> Any: attribute, _value = attributes.popitem() value = _value or getattr(self, attribute, None) if value is None: diff --git a/praw/models/reddit/base.py b/praw/models/reddit/base.py index 0e7ae3ca6..316fee279 100644 --- a/praw/models/reddit/base.py +++ b/praw/models/reddit/base.py @@ -16,7 +16,7 @@ class RedditBase(PRAWBase): """Base class that represents actual Reddit objects.""" @staticmethod - def _url_parts(url): # noqa: ANN001,ANN205 + def _url_parts(url: str) -> list[str]: parsed = urlparse(url) if not parsed.netloc: raise InvalidURL(url) @@ -79,15 +79,15 @@ def __str__(self) -> str: """Return a string representation of the instance.""" return getattr(self, self.STR_FIELD) - def _fetch(self): # pragma: no cover + def _fetch(self): self._fetched = True - def _fetch_data(self): # noqa: ANN001 + def _fetch_data(self): name, fields, params = self._fetch_info() path = API_PATH[name].format(**fields) return self._reddit.request(method="GET", params=params, path=path) - def _reset_attributes(self, *attributes): # noqa: ANN001,ANN002 + def _reset_attributes(self, *attributes: str): for attribute in attributes: if attribute in self.__dict__: del self.__dict__[attribute] diff --git a/praw/models/reddit/collections.py b/praw/models/reddit/collections.py index eddda8797..23ef7ed9c 100644 --- a/praw/models/reddit/collections.py +++ b/praw/models/reddit/collections.py @@ -36,7 +36,7 @@ def __init__(self, reddit: praw.Reddit, collection_id: str): super().__init__(reddit, _data=None) self.collection_id = collection_id - def _post_fullname(self, post): # noqa: ANN001 + def _post_fullname(self, post: str | praw.models.Submission) -> str: """Get a post's fullname. :param post: A fullname, a :class:`.Submission`, a permalink, or an ID. @@ -240,7 +240,7 @@ def __init__( @_deprecate_args("title", "description", "display_layout") def create( self, *, description: str, display_layout: str | None = None, title: str - ) -> praw.models.Collection: + ) -> Collection: """Create a new :class:`.Collection`. The authenticated account must have appropriate moderator permissions in the @@ -523,7 +523,7 @@ def __setattr__(self, attribute: str, value: Any) -> None: value = self._reddit._objector.objectify(value) super().__setattr__(attribute, value) - def _fetch(self): # noqa: ANN001 + def _fetch(self): data = self._fetch_data() try: self._reddit._objector.check_error(data) @@ -536,9 +536,9 @@ def _fetch(self): # noqa: ANN001 other = type(self)(self._reddit, _data=data) self.__dict__.update(other.__dict__) - self._fetched = True + super()._fetch() - def _fetch_info(self): # noqa: ANN001 + def _fetch_info(self): return "collection", {}, self._info_params def follow(self): diff --git a/praw/models/reddit/comment.py b/praw/models/reddit/comment.py index 156fb7064..4db967b23 100644 --- a/praw/models/reddit/comment.py +++ b/praw/models/reddit/comment.py @@ -89,7 +89,7 @@ def mod(self) -> praw.models.reddit.comment.CommentModeration: return CommentModeration(self) @property - def _kind(self): # noqa: ANN001 + def _kind(self): """Return the class's kind.""" return self._reddit.config.kinds["comment"] @@ -138,14 +138,13 @@ def submission(self, submission: praw.models.Submission): """Update the :class:`.Submission` associated with the :class:`.Comment`.""" submission._comments_by_id[self.fullname] = self self._submission = submission - # pylint: disable=not-an-iterable for reply in getattr(self, "replies", []): reply.submission = submission def __init__( self, reddit: praw.Reddit, - id: str | None = None, # pylint: disable=redefined-builtin + id: str | None = None, url: str | None = None, _data: dict[str, Any] | None = None, ): @@ -168,7 +167,7 @@ def __setattr__( self, attribute: str, value: str | Redditor | CommentForest | praw.models.Subreddit, - ) -> None: + ): """Objectify author, replies, and subreddit.""" if attribute == "author": value = Redditor.from_data(self._reddit, value) @@ -182,12 +181,12 @@ def __setattr__( value = self._reddit.subreddit(value) super().__setattr__(attribute, value) - def _extract_submission_id(self): # noqa: ANN001 + def _extract_submission_id(self): if "context" in self.__dict__: return self.context.rsplit("/", 4)[1] return self.link_id.split("_", 1)[1] - def _fetch(self): # noqa: ANN001 + def _fetch(self): data = self._fetch_data() data = data["data"] @@ -198,12 +197,14 @@ def _fetch(self): # noqa: ANN001 comment_data = data["children"][0]["data"] other = type(self)(self._reddit, _data=comment_data) self.__dict__.update(other.__dict__) - self._fetched = True + super()._fetch() - def _fetch_info(self): # noqa: ANN001 + def _fetch_info(self): return "info", {}, {"id": self.fullname} - def parent(self) -> Comment | praw.models.Submission: + def parent( + self, + ) -> Comment | praw.models.Submission: """Return the parent of the comment. The returned parent will be an instance of either :class:`.Comment`, or @@ -253,14 +254,12 @@ def parent(self) -> Comment | praw.models.Submission: to :meth:`.refresh` it would make at least 31 network requests. """ - # pylint: disable=no-member if self.parent_id == self.submission.fullname: return self.submission if self.parent_id in self.submission._comments_by_id: # The Comment already exists, so simply return it return self.submission._comments_by_id[self.parent_id] - # pylint: enable=no-member parent = Comment(self._reddit, self.parent_id.split("_", 1)[1]) parent._submission = self.submission diff --git a/praw/models/reddit/draft.py b/praw/models/reddit/draft.py index 2872c8031..4f94d9e54 100644 --- a/praw/models/reddit/draft.py +++ b/praw/models/reddit/draft.py @@ -85,10 +85,7 @@ def _prepare_data( return data def __init__( - self, - reddit: praw.Reddit, - id: str | None = None, # pylint: disable=redefined-builtin - _data: dict[str, Any] = None, + self, reddit: praw.Reddit, id: str | None = None, _data: dict[str, Any] = None ): """Initialize a :class:`.Draft` instance.""" if (id, _data).count(None) != 1: @@ -119,7 +116,7 @@ def _fetch(self): for draft in self._reddit.drafts(): if draft.id == self.id: self.__dict__.update(draft.__dict__) - self._fetched = True + super()._fetch() return msg = ( f"The currently authenticated user not have a draft with an ID of {self.id}" diff --git a/praw/models/reddit/emoji.py b/praw/models/reddit/emoji.py index 41eca6aa6..791fb1d63 100644 --- a/praw/models/reddit/emoji.py +++ b/praw/models/reddit/emoji.py @@ -56,11 +56,11 @@ def __init__( self.subreddit = subreddit super().__init__(reddit, _data=_data) - def _fetch(self): # noqa: ANN001 + def _fetch(self): for emoji in self.subreddit.emoji: if emoji.name == self.name: self.__dict__.update(emoji.__dict__) - self._fetched = True + super()._fetch() return msg = f"r/{self.subreddit} does not have the emoji {self.name}" raise ClientException(msg) diff --git a/praw/models/reddit/live.py b/praw/models/reddit/live.py index b3e7f68e1..6db192810 100644 --- a/praw/models/reddit/live.py +++ b/praw/models/reddit/live.py @@ -1,7 +1,7 @@ """Provide the LiveThread class.""" from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterator +from typing import TYPE_CHECKING, Any, Iterable, Iterator from ...const import API_PATH from ...util import _deprecate_args @@ -21,7 +21,7 @@ class LiveContributorRelationship: """Provide methods to interact with live threads' contributors.""" @staticmethod - def _handle_permissions(permissions): # noqa: ANN001,ANN205 + def _handle_permissions(permissions: Iterable[str]) -> str: permissions = {"all"} if permissions is None else set(permissions) return ",".join(f"+{x}" for x in permissions) @@ -377,7 +377,7 @@ def __init__( self, reddit: praw.Reddit, id: str | None = None, - _data: dict[str, Any] | None = None, # pylint: disable=redefined-builtin + _data: dict[str, Any] | None = None, ): """Initialize a :class:`.LiveThread` instance. @@ -392,14 +392,14 @@ def __init__( self.id = id super().__init__(reddit, _data=_data) - def _fetch(self): # noqa: ANN001 + def _fetch(self): data = self._fetch_data() data = data["data"] other = type(self)(self._reddit, _data=data) self.__dict__.update(other.__dict__) - self._fetched = True + super()._fetch() - def _fetch_info(self): # noqa: ANN001 + def _fetch_info(self): return "liveabout", {"id": self.id}, None def discussions( @@ -428,11 +428,12 @@ def discussions( url = API_PATH["live_discussions"].format(id=self.id) return ListingGenerator(self._reddit, url, **generator_kwargs) - def report(self, type: str): # pylint: disable=redefined-builtin + def report(self, type: str): """Report the thread violating the Reddit rules. - :param type: One of ``"spam"``, ``"vote-manipulation"``, ``"personal- - information"``, ``"sexualizing-minors"``, or ``"site-breaking"``. + :param type: One of ``"spam"``, ``"vote-manipulation"``, + ``"personal-information"``, ``"sexualizing-minors"``, or + ``"site-breaking"``. Usage: @@ -789,14 +790,14 @@ def __init__( msg = "Either 'thread_id' and 'update_id', or '_data' must be provided." raise TypeError(msg) - def __setattr__(self, attribute: str, value: Any) -> None: + def __setattr__(self, attribute: str, value: Any): """Objectify author.""" if attribute == "author": value = Redditor(self._reddit, name=value) super().__setattr__(attribute, value) - def _fetch(self): # noqa: ANN001 + def _fetch(self): url = API_PATH["live_focus"].format(thread_id=self.thread.id, update_id=self.id) other = self._reddit.get(url)[0] self.__dict__.update(other.__dict__) - self._fetched = True + super()._fetch() diff --git a/praw/models/reddit/message.py b/praw/models/reddit/message.py index dfd9604ca..9a0b3571a 100644 --- a/praw/models/reddit/message.py +++ b/praw/models/reddit/message.py @@ -70,7 +70,7 @@ def parse( return cls(reddit, _data=data) @property - def _kind(self) -> str: # noqa: ANN001 + def _kind(self) -> str: """Return the class's kind.""" return self._reddit.config.kinds["message"] diff --git a/praw/models/reddit/mixins/__init__.py b/praw/models/reddit/mixins/__init__.py index 6b881abdd..211de63aa 100644 --- a/praw/models/reddit/mixins/__init__.py +++ b/praw/models/reddit/mixins/__init__.py @@ -194,7 +194,7 @@ def send_removal_message( *, message: str, title: str = "ignored", - type: str = "public", # pylint: disable=redefined-builtin + type: str = "public", ) -> praw.models.Comment | None: """Send a removal message for a :class:`.Comment` or :class:`.Submission`. diff --git a/praw/models/reddit/mixins/votable.py b/praw/models/reddit/mixins/votable.py index 96f9bc545..1deb3b323 100644 --- a/praw/models/reddit/mixins/votable.py +++ b/praw/models/reddit/mixins/votable.py @@ -7,7 +7,7 @@ class VotableMixin: """Interface for :class:`.RedditBase` classes that can be voted on.""" - def _vote(self, direction: int): # noqa: ANN001 + def _vote(self, direction: int): self._reddit.post( API_PATH["vote"], data={"dir": str(direction), "id": self.fullname} ) diff --git a/praw/models/reddit/modmail.py b/praw/models/reddit/modmail.py index 8c34f6608..932762771 100644 --- a/praw/models/reddit/modmail.py +++ b/praw/models/reddit/modmail.py @@ -11,6 +11,19 @@ import praw +class ModmailObject(RedditBase): + """A base class for objects within a modmail conversation.""" + + AUTHOR_ATTRIBUTE = "author" + STR_FIELD = "id" + + def __setattr__(self, attribute: str, value: Any): + """Objectify the AUTHOR_ATTRIBUTE attribute.""" + if attribute == self.AUTHOR_ATTRIBUTE: + value = self._reddit._objector.objectify(value) + super().__setattr__(attribute, value) + + class ModmailConversation(RedditBase): """A class for modmail conversations. @@ -52,7 +65,7 @@ class ModmailConversation(RedditBase): STR_FIELD = "id" @staticmethod - def _convert_conversation_objects(data, reddit): # noqa: ANN001 + def _convert_conversation_objects(data: dict[str, Any], reddit: praw.Reddit): """Convert messages and mod actions to PRAW objects.""" result = {"messages": [], "modActions": []} for thing in data["objIds"]: @@ -62,7 +75,7 @@ def _convert_conversation_objects(data, reddit): # noqa: ANN001 data.update(result) @staticmethod - def _convert_user_summary(data, reddit): # noqa: ANN001 + def _convert_user_summary(data: dict[str, Any], reddit: praw.Reddit): """Convert dictionaries of recent user history to PRAW objects.""" parsers = { "recentComments": reddit._objector.parsers[reddit.config.kinds["comment"]], @@ -110,7 +123,7 @@ def parse( def __init__( self, reddit: praw.Reddit, - id: str | None = None, # pylint: disable=redefined-builtin + id: str | None = None, mark_read: bool = False, _data: dict[str, Any] | None = None, ): @@ -131,18 +144,20 @@ def __init__( self._info_params = {"markRead": True} if mark_read else None - def _build_conversation_list(self, other_conversations): # noqa: ANN001 + def _build_conversation_list( + self, other_conversations: list[ModmailConversation] + ) -> str: """Return a comma-separated list of conversation IDs.""" conversations = [self] + (other_conversations or []) return ",".join(conversation.id for conversation in conversations) - def _fetch(self): # noqa: ANN001 + def _fetch(self): data = self._fetch_data() other = self._reddit._objector.objectify(data) self.__dict__.update(other.__dict__) - self._fetched = True + super()._fetch() - def _fetch_info(self): # noqa: ANN001 + def _fetch_info(self): return "modmail_conversation", {"id": self.id}, self._info_params def archive(self): @@ -197,9 +212,7 @@ def mute(self, *, num_days: int = 3): ) @_deprecate_args("other_conversations") - def read( - self, *, other_conversations: list[ModmailConversation] | None = None - ): # noqa: D207, D301 + def read(self, *, other_conversations: list[ModmailConversation] | None = None): """Mark the conversation(s) as read. :param other_conversations: A list of other conversations to mark (default: @@ -302,9 +315,7 @@ def unmute(self): ) @_deprecate_args("other_conversations") - def unread( - self, *, other_conversations: list[ModmailConversation] | None = None - ): # noqa: D207, D301 + def unread(self, *, other_conversations: list[ModmailConversation] | None = None): """Mark the conversation(s) as unread. :param other_conversations: A list of other conversations to mark (default: @@ -324,19 +335,6 @@ def unread( self._reddit.post(API_PATH["modmail_unread"], data=data) -class ModmailObject(RedditBase): - """A base class for objects within a modmail conversation.""" - - AUTHOR_ATTRIBUTE = "author" - STR_FIELD = "id" - - def __setattr__(self, attribute: str, value: Any) -> None: - """Objectify the AUTHOR_ATTRIBUTE attribute.""" - if attribute == self.AUTHOR_ATTRIBUTE: - value = self._reddit._objector.objectify(value) - super().__setattr__(attribute, value) - - class ModmailAction(ModmailObject): """A class for moderator actions on modmail conversations.""" diff --git a/praw/models/reddit/more.py b/praw/models/reddit/more.py index 026421feb..5a00f213f 100644 --- a/praw/models/reddit/more.py +++ b/praw/models/reddit/more.py @@ -42,7 +42,7 @@ def __repr__(self) -> str: children[-1] = "..." return f"<{self.__class__.__name__} count={self.count}, children={children!r}>" - def _continue_comments(self, update): # noqa: ANN001 + def _continue_comments(self, update: bool): assert not self.children, "Please file a bug report with PRAW." parent = self._load_comment(self.parent_id.split("_", 1)[1]) self._comments = parent.replies @@ -51,7 +51,7 @@ def _continue_comments(self, update): # noqa: ANN001 comment.submission = self.submission return self._comments - def _load_comment(self, comment_id): # noqa: ANN001 + def _load_comment(self, comment_id: str): path = f"{API_PATH['submission'].format(id=self.submission.id)}_/{comment_id}" _, comments = self._reddit.get( path, diff --git a/praw/models/reddit/multi.py b/praw/models/reddit/multi.py index d44c29b87..e6365f8cc 100644 --- a/praw/models/reddit/multi.py +++ b/praw/models/reddit/multi.py @@ -101,14 +101,14 @@ def __init__(self, reddit: praw.Reddit, _data: dict[str, Any]): if "subreddits" in self.__dict__: self.subreddits = [Subreddit(reddit, x["name"]) for x in self.subreddits] - def _fetch(self): # noqa: ANN001 + def _fetch(self): data = self._fetch_data() data = data["data"] other = type(self)(self._reddit, _data=data) self.__dict__.update(other.__dict__) - self._fetched = True + super()._fetch() - def _fetch_info(self): # noqa: ANN001 + def _fetch_info(self): return ( "multireddit_api", {"multi": self.name, "user": self._author.name}, diff --git a/praw/models/reddit/redditor.py b/praw/models/reddit/redditor.py index 791c318f8..3c7c77a1a 100644 --- a/praw/models/reddit/redditor.py +++ b/praw/models/reddit/redditor.py @@ -130,12 +130,12 @@ def stream(self) -> praw.models.reddit.redditor.RedditorStream: return RedditorStream(self) @property - def _kind(self) -> str: # noqa: ANN001 + def _kind(self) -> str: """Return the class's kind.""" return self._reddit.config.kinds["redditor"] @property - def _path(self) -> str: # noqa: ANN001 + def _path(self) -> str: return API_PATH["user"].format(user=self) def __init__( @@ -168,7 +168,7 @@ def __init__( self._fullname = fullname super().__init__(reddit, _data=_data, _extra_attribute_to_check="_fullname") - def __setattr__(self, name: str, value: Any) -> None: + def __setattr__(self, name: str, value: Any): """Objectify the subreddit attribute.""" if name == "subreddit" and value: from .user_subreddit import UserSubreddit @@ -176,24 +176,24 @@ def __setattr__(self, name: str, value: Any) -> None: value = UserSubreddit(reddit=self._reddit, _data=value) super().__setattr__(name, value) - def _fetch(self): # noqa: ANN001 + def _fetch(self): data = self._fetch_data() data = data["data"] other = type(self)(self._reddit, _data=data) self.__dict__.update(other.__dict__) - self._fetched = True + super()._fetch() - def _fetch_info(self): # noqa: ANN001 + def _fetch_info(self): if hasattr(self, "_fullname"): self.name = self._fetch_username(self._fullname) return "user_about", {"user": self.name}, None - def _fetch_username(self, fullname): # noqa: ANN001 + def _fetch_username(self, fullname: str): return self._reddit.get(API_PATH["user_by_fullname"], params={"ids": fullname})[ fullname ]["name"] - def _friend(self, *, data, method): # noqa: ANN001 + def _friend(self, *, data: dict[str:Any], method: str): url = API_PATH["friend_v1"].format(user=self) self._reddit.request(data=dumps(data), method=method, path=url) diff --git a/praw/models/reddit/removal_reasons.py b/praw/models/reddit/removal_reasons.py index d0e8801c3..55c0af8ce 100644 --- a/praw/models/reddit/removal_reasons.py +++ b/praw/models/reddit/removal_reasons.py @@ -31,9 +31,9 @@ class RemovalReason(RedditBase): STR_FIELD = "id" @staticmethod - def _warn_reason_id( # noqa: ANN205 + def _warn_reason_id( *, id_value: str | None, reason_id_value: str | None - ): + ) -> str | None: """Reason ID param is deprecated. Warns if it's used. :param id_value: Returns the actual value of parameter ``id`` is parameter @@ -67,7 +67,7 @@ def __init__( self, reddit: praw.Reddit, subreddit: praw.models.Subreddit, - id: str | None = None, # pylint: disable=redefined-builtin + id: str | None = None, reason_id: str | None = None, _data: dict[str, Any] | None = None, ): @@ -90,11 +90,11 @@ def __init__( self.subreddit = subreddit super().__init__(reddit, _data=_data) - def _fetch(self): # noqa: ANN001 + def _fetch(self): for removal_reason in self.subreddit.mod.removal_reasons: if removal_reason.id == self.id: self.__dict__.update(removal_reason.__dict__) - self._fetched = True + super()._fetch() return msg = f"Subreddit {self.subreddit} does not have the removal reason {self.id}" raise ClientException(msg) @@ -144,7 +144,7 @@ class SubredditRemovalReasons: """Provide a set of functions to a :class:`.Subreddit`'s removal reasons.""" @cachedproperty - def _removal_reason_list(self) -> list[RemovalReason]: # noqa: ANN001 + def _removal_reason_list(self) -> list[RemovalReason]: """Get a list of Removal Reason objects. :returns: A list of instances of :class:`.RemovalReason`. @@ -250,5 +250,5 @@ def add(self, *, message: str, title: str) -> RemovalReason: """ data = {"message": message, "title": title} url = API_PATH["removal_reasons_list"].format(subreddit=self.subreddit) - id = self._reddit.post(url, data=data) # noqa: A001 - return RemovalReason(self._reddit, self.subreddit, id) + reason_id = self._reddit.post(url, data=data) + return RemovalReason(self._reddit, self.subreddit, reason_id) diff --git a/praw/models/reddit/rules.py b/praw/models/reddit/rules.py index 3223fe666..281545c87 100644 --- a/praw/models/reddit/rules.py +++ b/praw/models/reddit/rules.py @@ -88,11 +88,11 @@ def __init__( self.subreddit = subreddit super().__init__(reddit, _data=_data) - def _fetch(self): # noqa: ANN001 + def _fetch(self): for rule in self.subreddit.rules: if rule.short_name == self.short_name: self.__dict__.update(rule.__dict__) - self._fetched = True + super()._fetch() return msg = f"Subreddit {self.subreddit} does not have the rule {self.short_name}" raise ClientException(msg) @@ -209,7 +209,7 @@ class SubredditRules: """ @cachedproperty - def _rule_list(self) -> list[Rule]: # noqa: ANN001 + def _rule_list(self) -> list[Rule]: """Get a list of :class:`.Rule` objects. :returns: A list of instances of :class:`.Rule`. diff --git a/praw/models/reddit/submission.py b/praw/models/reddit/submission.py index 9c7bfa8bc..345db0a32 100644 --- a/praw/models/reddit/submission.py +++ b/praw/models/reddit/submission.py @@ -3,7 +3,7 @@ import re from json import dumps -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Generator from urllib.parse import urljoin from warnings import warn @@ -527,7 +527,7 @@ def mod(self) -> SubmissionModeration: return SubmissionModeration(self) @property - def _kind(self) -> str: # noqa: ANN001 + def _kind(self) -> str: """Return the class's kind.""" return self._reddit.config.kinds["submission"] @@ -577,7 +577,7 @@ def shortlink(self) -> str: def __init__( self, reddit: praw.Reddit, - id: str | None = None, # pylint: disable=redefined-builtin + id: str | None = None, url: str | None = None, _data: dict[str, Any] | None = None, ): @@ -608,7 +608,7 @@ def __init__( self._additional_fetch_params = {} self._comments_by_id = {} - def __setattr__(self, attribute: str, value: Any) -> None: + def __setattr__(self, attribute: str, value: Any): """Objectify author, subreddit, and poll data attributes.""" if attribute == "author": value = Redditor.from_data(self._reddit, value) @@ -630,7 +630,12 @@ def __setattr__(self, attribute: str, value: Any) -> None: ) super().__setattr__(attribute, value) - def _chunk(self, *, chunk_size, other_submissions): # noqa: ANN001 + def _chunk( + self, + *, + chunk_size: int, + other_submissions: list[praw.models.Submission] | None, + ) -> Generator[str, None, None]: all_submissions = [self.fullname] if other_submissions: all_submissions += [x.fullname for x in other_submissions] @@ -716,7 +721,7 @@ def _edit_experimental( self.__dict__.update(updated) return self - def _fetch(self): # noqa: ANN001 + def _fetch(self): data = self._fetch_data() submission_listing, comment_listing = data comment_listing = Listing(self._reddit, _data=comment_listing["data"]) @@ -729,23 +734,22 @@ def _fetch(self): # noqa: ANN001 self.__dict__.update(submission.__dict__) self.comments._update(comment_listing.children) + super()._fetch() - self._fetched = True - - def _fetch_data(self): # noqa: ANN001 + def _fetch_data(self): name, fields, params = self._fetch_info() params.update(self._additional_fetch_params.copy()) path = API_PATH[name].format(**fields) return self._reddit.request(method="GET", params=params, path=path) - def _fetch_info(self): # noqa: ANN001 + def _fetch_info(self): return ( "submission", {"id": self.id}, {"limit": self.comment_limit, "sort": self.comment_sort}, ) - def _replace_richtext_links(self, richtext_json: dict): # noqa: ANN001 + def _replace_richtext_links(self, richtext_json: dict): parsed_media_types = { media_id: MEDIA_TYPE_MAPPING[value["e"]] for media_id, value in self.media_metadata.items() diff --git a/praw/models/reddit/subreddit.py b/praw/models/reddit/subreddit.py index 191c4f981..1613c2f69 100644 --- a/praw/models/reddit/subreddit.py +++ b/praw/models/reddit/subreddit.py @@ -59,7 +59,7 @@ class Modmail: def __call__( self, id: str | None = None, mark_read: bool = False - ) -> ModmailConversation: # noqa: D207, D301 + ) -> ModmailConversation: """Return an individual conversation. :param id: A reddit base36 conversation ID, e.g., ``"2gmz"``. @@ -100,7 +100,6 @@ def __call__( print(conversation.user.recent_posts) """ - # pylint: disable=invalid-name,redefined-builtin return ModmailConversation(self.subreddit._reddit, id=id, mark_read=mark_read) def __init__(self, subreddit: praw.models.Subreddit): @@ -166,7 +165,7 @@ def conversations( sort: str | None = None, state: str | None = None, **generator_kwargs: Any, - ) -> Iterator[ModmailConversation]: # noqa: D207, D301 + ) -> Iterator[ModmailConversation]: """Generate :class:`.ModmailConversation` objects for subreddit(s). :param after: A base36 modmail conversation id. When provided, the listing @@ -297,3739 +296,3730 @@ def unread_count(self) -> dict[str, int]: return self.subreddit._reddit.get(API_PATH["modmail_unread_count"]) -class Subreddit(MessageableMixin, SubredditListingMixin, FullnameMixin, RedditBase): - """A class for Subreddits. +class SubredditFilters: + """Provide functions to interact with the special :class:`.Subreddit`'s filters. - To obtain an instance of this class for r/test execute: + Members of this class should be utilized via :meth:`.Subreddit.filters`. For + example, to add a filter, run: .. code-block:: python - subreddit = reddit.subreddit("test") - - While r/all is not a real subreddit, it can still be treated like one. The following - outputs the titles of the 25 hottest submissions in r/all: + reddit.subreddit("all").filters.add("test") - .. code-block:: python + """ - for submission in reddit.subreddit("all").hot(limit=25): - print(submission.title) + def __init__(self, subreddit: praw.models.Subreddit): + """Initialize a :class:`.SubredditFilters` instance. - Multiple subreddits can be combined with a ``+`` like so: + :param subreddit: The special subreddit whose filters to work with. - .. code-block:: python + As of this writing filters can only be used with the special subreddits ``all`` + and ``mod``. - for submission in reddit.subreddit("redditdev+learnpython").top(time_filter="all"): - print(submission) + """ + self.subreddit = subreddit - Subreddits can be filtered from combined listings as follows. + def __iter__(self) -> Generator[praw.models.Subreddit, None, None]: + """Iterate through the special :class:`.Subreddit`'s filters. - .. note:: + This method should be invoked as: - These filters are ignored by certain methods, including :attr:`.comments`, - :meth:`.gilded`, and :meth:`.SubredditStream.comments`. + .. code-block:: python - .. code-block:: python + for subreddit in reddit.subreddit("test").filters: + ... - for submission in reddit.subreddit("all-redditdev").new(): - print(submission) + """ + url = API_PATH["subreddit_filter_list"].format( + special=self.subreddit, user=self.subreddit._reddit.user.me() + ) + params = {"unique": self.subreddit._reddit._next_unique} + response_data = self.subreddit._reddit.get(url, params=params) + yield from response_data.subreddits - .. include:: ../../typical_attributes.rst + def add(self, subreddit: praw.models.Subreddit | str): + """Add ``subreddit`` to the list of filtered subreddits. - ========================= ========================================================== - Attribute Description - ========================= ========================================================== - ``can_assign_link_flair`` Whether users can assign their own link flair. - ``can_assign_user_flair`` Whether users can assign their own user flair. - ``created_utc`` Time the subreddit was created, represented in `Unix - Time`_. - ``description`` Subreddit description, in Markdown. - ``description_html`` Subreddit description, in HTML. - ``display_name`` Name of the subreddit. - ``id`` ID of the subreddit. - ``name`` Fullname of the subreddit. - ``over18`` Whether the subreddit is NSFW. - ``public_description`` Description of the subreddit, shown in searches and on the - "You must be invited to visit this community" page (if - applicable). - ``spoilers_enabled`` Whether the spoiler tag feature is enabled. - ``subscribers`` Count of subscribers. - ``user_is_banned`` Whether the authenticated user is banned. - ``user_is_moderator`` Whether the authenticated user is a moderator. - ``user_is_subscriber`` Whether the authenticated user is subscribed. - ========================= ========================================================== + :param subreddit: The subreddit to add to the filter list. - .. note:: + Items from subreddits added to the filtered list will no longer be included when + obtaining listings for r/all. - Trying to retrieve attributes of quarantined or private subreddits will result - in a 403 error. Trying to retrieve attributes of a banned subreddit will result - in a 404 error. + Alternatively, you can filter a subreddit temporarily from a special listing in + a manner like so: - .. _unix time: https://en.wikipedia.org/wiki/Unix_time + .. code-block:: python - """ + reddit.subreddit("all-redditdev-learnpython") - # pylint: disable=too-many-public-methods + :raises: ``prawcore.NotFound`` when calling on a non-special subreddit. - STR_FIELD = "display_name" - MESSAGE_PREFIX = "#" + """ + url = API_PATH["subreddit_filter"].format( + special=self.subreddit, + user=self.subreddit._reddit.user.me(), + subreddit=subreddit, + ) + self.subreddit._reddit.put(url, data={"model": dumps({"name": str(subreddit)})}) - @staticmethod - def _create_or_update( - *, - _reddit, # noqa: ANN001 - allow_images=None, # noqa: ANN001 - allow_post_crossposts=None, # noqa: ANN001 - allow_top=None, # noqa: ANN001 - collapse_deleted_comments=None, # noqa: ANN001 - comment_score_hide_mins=None, # noqa: ANN001 - description=None, # noqa: ANN001 - domain=None, # noqa: ANN001 - exclude_banned_modqueue=None, # noqa: ANN001 - header_hover_text=None, # noqa: ANN001 - hide_ads=None, # noqa: ANN001 - lang=None, # noqa: ANN001 - key_color=None, # noqa: ANN001 - link_type=None, # noqa: ANN001 - name=None, # noqa: ANN001 - over_18=None, # noqa: ANN001 - public_description=None, # noqa: ANN001 - public_traffic=None, # noqa: ANN001 - show_media=None, # noqa: ANN001 - show_media_preview=None, # noqa: ANN001 - spam_comments=None, # noqa: ANN001 - spam_links=None, # noqa: ANN001 - spam_selfposts=None, # noqa: ANN001 - spoilers_enabled=None, # noqa: ANN001 - sr=None, # noqa: ANN001 - submit_link_label=None, # noqa: ANN001 - submit_text=None, # noqa: ANN001 - submit_text_label=None, # noqa: ANN001 - subreddit_type=None, # noqa: ANN001 - suggested_comment_sort=None, # noqa: ANN001 - title=None, # noqa: ANN001 - wiki_edit_age=None, # noqa: ANN001 - wiki_edit_karma=None, # noqa: ANN001 - wikimode=None, # noqa: ANN001 - **other_settings, # noqa: ANN001,ANN003 - ): - # pylint: disable=invalid-name,too-many-locals,too-many-arguments - model = { - "allow_images": allow_images, - "allow_post_crossposts": allow_post_crossposts, - "allow_top": allow_top, - "collapse_deleted_comments": collapse_deleted_comments, - "comment_score_hide_mins": comment_score_hide_mins, - "description": description, - "domain": domain, - "exclude_banned_modqueue": exclude_banned_modqueue, - "header-title": header_hover_text, # Remap here - better name - "hide_ads": hide_ads, - "key_color": key_color, - "lang": lang, - "link_type": link_type, - "name": name, - "over_18": over_18, - "public_description": public_description, - "public_traffic": public_traffic, - "show_media": show_media, - "show_media_preview": show_media_preview, - "spam_comments": spam_comments, - "spam_links": spam_links, - "spam_selfposts": spam_selfposts, - "spoilers_enabled": spoilers_enabled, - "sr": sr, - "submit_link_label": submit_link_label, - "submit_text": submit_text, - "submit_text_label": submit_text_label, - "suggested_comment_sort": suggested_comment_sort, - "title": title, - "type": subreddit_type, - "wiki_edit_age": wiki_edit_age, - "wiki_edit_karma": wiki_edit_karma, - "wikimode": wikimode, - } + def remove(self, subreddit: praw.models.Subreddit | str): + """Remove ``subreddit`` from the list of filtered subreddits. - model.update(other_settings) + :param subreddit: The subreddit to remove from the filter list. - _reddit.post(API_PATH["site_admin"], data=model) + :raises: ``prawcore.NotFound`` when calling on a non-special subreddit. - @staticmethod - def _subreddit_list(*, other_subreddits, subreddit): # noqa: ANN001,ANN205 - if other_subreddits: - return ",".join([str(subreddit)] + [str(x) for x in other_subreddits]) - return str(subreddit) + """ + url = API_PATH["subreddit_filter"].format( + special=self.subreddit, + user=self.subreddit._reddit.user.me(), + subreddit=str(subreddit), + ) + self.subreddit._reddit.delete(url) - @staticmethod - def _validate_gallery(images): # noqa: ANN001,ANN205 - for image in images: - image_path = image.get("image_path", "") - if image_path: - if not Path(image_path).is_file(): - msg = f"{image_path!r} is not a valid image path." - raise TypeError(msg) - else: - msg = "'image_path' is required." - raise TypeError(msg) - if not len(image.get("caption", "")) <= 180: - msg = "Caption must be 180 characters or less." - raise TypeError(msg) - @staticmethod - def _validate_inline_media(inline_media: praw.models.InlineMedia): # noqa: ANN001 - if not Path(inline_media.path).is_file(): - msg = f"{inline_media.path!r} is not a valid file path." - raise ValueError(msg) +class SubredditFlair: + """Provide a set of functions to interact with a :class:`.Subreddit`'s flair.""" @cachedproperty - def banned(self) -> praw.models.reddit.subreddit.SubredditRelationship: - """Provide an instance of :class:`.SubredditRelationship`. - - For example, to ban a user try: - - .. code-block:: python - - reddit.subreddit("test").banned.add("spez", ban_reason="...") + def link_templates( + self, + ) -> praw.models.reddit.subreddit.SubredditLinkFlairTemplates: + """Provide an instance of :class:`.SubredditLinkFlairTemplates`. - To list the banned users along with any notes, try: + Use this attribute for interacting with a :class:`.Subreddit`'s link flair + templates. For example to list all the link flair templates for a subreddit + which you have the ``flair`` moderator permission on try: .. code-block:: python - for ban in reddit.subreddit("test").banned(): - print(f"{ban}: {ban.note}") + for template in reddit.subreddit("test").flair.link_templates: + print(template) """ - return SubredditRelationship(self, "banned") + return SubredditLinkFlairTemplates(self.subreddit) @cachedproperty - def collections(self) -> praw.models.reddit.collections.SubredditCollections: - r"""Provide an instance of :class:`.SubredditCollections`. - - To see the permalinks of all :class:`.Collection`\ s that belong to a subreddit, - try: - - .. code-block:: python - - for collection in reddit.subreddit("test").collections: - print(collection.permalink) + def templates( + self, + ) -> praw.models.reddit.subreddit.SubredditRedditorFlairTemplates: + """Provide an instance of :class:`.SubredditRedditorFlairTemplates`. - To get a specific :class:`.Collection` by its UUID or permalink, use one of the - following: + Use this attribute for interacting with a :class:`.Subreddit`'s flair templates. + For example to list all the flair templates for a subreddit which you have the + ``flair`` moderator permission on try: .. code-block:: python - collection = reddit.subreddit("test").collections("some_uuid") - collection = reddit.subreddit("test").collections( - permalink="https://reddit.com/r/SUBREDDIT/collection/some_uuid" - ) + for template in reddit.subreddit("test").flair.templates: + print(template) """ - return self._subreddit_collections_class(self._reddit, self) + return SubredditRedditorFlairTemplates(self.subreddit) - @cachedproperty - def contributor(self) -> praw.models.reddit.subreddit.ContributorRelationship: - """Provide an instance of :class:`.ContributorRelationship`. + def __call__( + self, + redditor: praw.models.Redditor | str | None = None, + **generator_kwargs: Any, + ) -> Iterator[praw.models.Redditor]: + """Return a :class:`.ListingGenerator` for Redditors and their flairs. - Contributors are also known as approved submitters. + :param redditor: When provided, yield at most a single :class:`.Redditor` + instance (default: ``None``). - To add a contributor try: + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. + + Usage: .. code-block:: python - reddit.subreddit("test").contributor.add("spez") + for flair in reddit.subreddit("test").flair(limit=None): + print(flair) """ - return ContributorRelationship(self, "contributor") - - @cachedproperty - def emoji(self) -> SubredditEmoji: - """Provide an instance of :class:`.SubredditEmoji`. + Subreddit._safely_add_arguments( + arguments=generator_kwargs, key="params", name=redditor + ) + generator_kwargs.setdefault("limit", None) + url = API_PATH["flairlist"].format(subreddit=self.subreddit) + return ListingGenerator(self.subreddit._reddit, url, **generator_kwargs) - This attribute can be used to discover all emoji for a subreddit: + def __init__(self, subreddit: praw.models.Subreddit): + """Initialize a :class:`.SubredditFlair` instance. - .. code-block:: python + :param subreddit: The subreddit whose flair to work with. - for emoji in reddit.subreddit("test").emoji: - print(emoji) + """ + self.subreddit = subreddit - A single emoji can be lazily retrieved via: + @_deprecate_args("position", "self_assign", "link_position", "link_self_assign") + def configure( + self, + *, + link_position: str = "left", + link_self_assign: bool = False, + position: str = "right", + self_assign: bool = False, + **settings: Any, + ): + """Update the :class:`.Subreddit`'s flair configuration. - .. code-block:: python + :param link_position: One of ``"left"``, ``"right"``, or ``False`` to disable + (default: ``"left"``). + :param link_self_assign: Permit self assignment of link flair (default: + ``False``). + :param position: One of ``"left"``, ``"right"``, or ``False`` to disable + (default: ``"right"``). + :param self_assign: Permit self assignment of user flair (default: ``False``). - reddit.subreddit("test").emoji["emoji_name"] + .. code-block:: python - .. note:: + subreddit = reddit.subreddit("test") + subreddit.flair.configure(link_position="right", self_assign=True) - Attempting to access attributes of a nonexistent emoji will result in a - :class:`.ClientException`. + Additional keyword arguments can be provided to handle new settings as Reddit + introduces them. """ - return SubredditEmoji(self) + data = { + "flair_enabled": bool(position), + "flair_position": position or "right", + "flair_self_assign_enabled": self_assign, + "link_flair_position": link_position or "", + "link_flair_self_assign_enabled": link_self_assign, + } + data.update(settings) + url = API_PATH["flairconfig"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url, data=data) - @cachedproperty - def filters(self) -> praw.models.reddit.subreddit.SubredditFilters: - """Provide an instance of :class:`.SubredditFilters`. + def delete(self, redditor: praw.models.Redditor | str): + """Delete flair for a :class:`.Redditor`. - For example, to add a filter, run: + :param redditor: A redditor name or :class:`.Redditor` instance. - .. code-block:: python + .. seealso:: - reddit.subreddit("all").filters.add("test") + :meth:`~.SubredditFlair.update` to delete the flair of many redditors at + once. """ - return SubredditFilters(self) - - @cachedproperty - def flair(self) -> praw.models.reddit.subreddit.SubredditFlair: - """Provide an instance of :class:`.SubredditFlair`. - - Use this attribute for interacting with a :class:`.Subreddit`'s flair. For - example, to list all the flair for a subreddit which you have the ``flair`` - moderator permission on try: - - .. code-block:: python + url = API_PATH["deleteflair"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url, data={"name": str(redditor)}) - for flair in reddit.subreddit("test").flair(): - print(flair) + def delete_all(self) -> list[dict[str, str | bool | dict[str, str]]]: + """Delete all :class:`.Redditor` flair in the :class:`.Subreddit`. - Flair templates can be interacted with through this attribute via: + :returns: List of dictionaries indicating the success or failure of each delete. - .. code-block:: python + """ + return self.update(x["user"] for x in self()) - for template in reddit.subreddit("test").flair.templates: - print(template) + @_deprecate_args("redditor", "text", "css_class", "flair_template_id") + def set( # noqa: A003 + self, + redditor: praw.models.Redditor | str, + *, + css_class: str = "", + flair_template_id: str | None = None, + text: str = "", + ): + """Set flair for a :class:`.Redditor`. - """ - return SubredditFlair(self) + :param redditor: A redditor name or :class:`.Redditor` instance. + :param text: The flair text to associate with the :class:`.Redditor` or + :class:`.Submission` (default: ``""``). + :param css_class: The css class to associate with the flair html (default: + ``""``). Use either this or ``flair_template_id``. + :param flair_template_id: The ID of the flair template to be used (default: + ``None``). Use either this or ``css_class``. - @cachedproperty - def mod(self) -> SubredditModeration: - """Provide an instance of :class:`.SubredditModeration`. + This method can only be used by an authenticated user who is a moderator of the + associated :class:`.Subreddit`. - For example, to accept a moderation invite from r/test: + For example: .. code-block:: python - reddit.subreddit("test").mod.accept_invite() + reddit.subreddit("test").flair.set("bboe", text="PRAW author", css_class="mods") + template = "6bd28436-1aa7-11e9-9902-0e05ab0fad46" + reddit.subreddit("test").flair.set( + "spez", text="Reddit CEO", flair_template_id=template + ) """ - return SubredditModeration(self) + if css_class and flair_template_id is not None: + msg = "Parameter 'css_class' cannot be used in conjunction with 'flair_template_id'." + raise TypeError(msg) + data = {"name": str(redditor), "text": text} + if flair_template_id is not None: + data["flair_template_id"] = flair_template_id + url = API_PATH["select_flair"].format(subreddit=self.subreddit) + else: + data["css_class"] = css_class + url = API_PATH["flair"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url, data=data) - @cachedproperty - def moderator(self) -> praw.models.reddit.subreddit.ModeratorRelationship: - """Provide an instance of :class:`.ModeratorRelationship`. + @_deprecate_args("flair_list", "text", "css_class") + def update( + self, + flair_list: Iterator[ + str | praw.models.Redditor | dict[str, str | praw.models.Redditor] + ], + *, + text: str = "", + css_class: str = "", + ) -> list[dict[str, str | bool | dict[str, str]]]: + """Set or clear the flair for many redditors at once. - For example, to add a moderator try: + :param flair_list: Each item in this list should be either: - .. code-block:: python + - The name of a redditor. + - An instance of :class:`.Redditor`. + - A dictionary mapping keys ``"user"``, ``"flair_text"``, and + ``"flair_css_class"`` to their respective values. The ``"user"`` key + should map to a redditor, as described above. When a dictionary isn't + provided, or the dictionary is missing either ``"flair_text"`` or + ``"flair_css_class"`` keys, the default values will come from the other + arguments. + :param css_class: The css class to use when not explicitly provided in + ``flair_list`` (default: ``""``). + :param text: The flair text to use when not explicitly provided in + ``flair_list`` (default: ``""``). - reddit.subreddit("test").moderator.add("spez") + :returns: List of dictionaries indicating the success or failure of each update. - To list the moderators along with their permissions try: + For example, to clear the flair text, and set the ``"praw"`` flair css class on + a few users try: .. code-block:: python - for moderator in reddit.subreddit("test").moderator(): - print(f"{moderator}: {moderator.mod_permissions}") + subreddit.flair.update(["bboe", "spez", "spladug"], css_class="praw") """ - return ModeratorRelationship(self, "moderator") - - @cachedproperty - def modmail(self) -> praw.models.reddit.subreddit.Modmail: - """Provide an instance of :class:`.Modmail`. + temp_lines = StringIO() + for item in flair_list: + if isinstance(item, dict): + writer(temp_lines).writerow( + [ + str(item["user"]), + item.get("flair_text", text), + item.get("flair_css_class", css_class), + ] + ) + else: + writer(temp_lines).writerow([str(item), text, css_class]) - For example, to send a new modmail from r/test to u/spez with the subject - ``"test"`` along with a message body of ``"hello"``: + lines = temp_lines.getvalue().splitlines() + temp_lines.close() + response = [] + url = API_PATH["flaircsv"].format(subreddit=self.subreddit) + while lines: + data = {"flair_csv": "\n".join(lines[:100])} + response.extend(self.subreddit._reddit.post(url, data=data)) + lines = lines[100:] + return response - .. code-block:: python - reddit.subreddit("test").modmail.create(subject="test", body="hello", recipient="spez") +class SubredditFlairTemplates: + """Provide functions to interact with a :class:`.Subreddit`'s flair templates.""" - """ - return Modmail(self) + @staticmethod + def flair_type(is_link: bool) -> str: + """Return ``"LINK_FLAIR"`` or ``"USER_FLAIR"`` depending on ``is_link`` value.""" + return "LINK_FLAIR" if is_link else "USER_FLAIR" - @cachedproperty - def muted(self) -> praw.models.reddit.subreddit.SubredditRelationship: - """Provide an instance of :class:`.SubredditRelationship`. + def __init__(self, subreddit: praw.models.Subreddit): + """Initialize a :class:`.SubredditFlairTemplates` instance. - For example, muted users can be iterated through like so: + :param subreddit: The subreddit whose flair templates to work with. - .. code-block:: python + .. note:: - for mute in reddit.subreddit("test").muted(): - print(f"{mute}: {mute.date}") + This class should not be initialized directly. Instead, obtain an instance + via: ``reddit.subreddit("test").flair.templates`` or + ``reddit.subreddit("test").flair.link_templates``. """ - return SubredditRelationship(self, "muted") - - @cachedproperty - def quaran(self) -> praw.models.reddit.subreddit.SubredditQuarantine: - """Provide an instance of :class:`.SubredditQuarantine`. + self.subreddit = subreddit - This property is named ``quaran`` because ``quarantine`` is a subreddit - attribute returned by Reddit to indicate whether or not a subreddit is - quarantined. + def __iter__(self): + """Abstract method to return flair templates.""" + raise NotImplementedError - To opt-in into a quarantined subreddit: + def _add( + self, + *, + allowable_content: str | None = None, + background_color: str | None = None, + css_class: str = "", + is_link: bool | None = None, + max_emojis: int | None = None, + mod_only: bool | None = None, + text: str, + text_color: str | None = None, + text_editable: bool = False, + ): + url = API_PATH["flairtemplate_v2"].format(subreddit=self.subreddit) + data = { + "allowable_content": allowable_content, + "background_color": background_color, + "css_class": css_class, + "flair_type": self.flair_type(is_link), + "max_emojis": max_emojis, + "mod_only": bool(mod_only), + "text": text, + "text_color": text_color, + "text_editable": bool(text_editable), + } + self.subreddit._reddit.post(url, data=data) - .. code-block:: python + def _clear(self, *, is_link: bool | None = None): + url = API_PATH["flairtemplateclear"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url, data={"flair_type": self.flair_type(is_link)}) - reddit.subreddit("test").quaran.opt_in() + def _reorder(self, flair_list: list, *, is_link: bool | None = None): + url = API_PATH["flairtemplatereorder"].format(subreddit=self.subreddit) + self.subreddit._reddit.patch( + url, + params={ + "flair_type": self.flair_type(is_link), + "subreddit": self.subreddit.display_name, + }, + json=flair_list, + ) - """ - return SubredditQuarantine(self) + def delete(self, template_id: str): + """Remove a flair template provided by ``template_id``. - @cachedproperty - def rules(self) -> SubredditRules: - """Provide an instance of :class:`.SubredditRules`. + For example, to delete the first :class:`.Redditor` flair template listed, try: - Use this attribute for interacting with a :class:`.Subreddit`'s rules. + .. code-block:: python - For example, to list all the rules for a subreddit: + template_info = list(subreddit.flair.templates)[0] + subreddit.flair.templates.delete(template_info["id"]) - .. code-block:: python + """ + url = API_PATH["flairtemplatedelete"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url, data={"flair_template_id": template_id}) - for rule in reddit.subreddit("test").rules: - print(rule) + @_deprecate_args( + "template_id", + "text", + "css_class", + "text_editable", + "background_color", + "text_color", + "mod_only", + "allowable_content", + "max_emojis", + "fetch", + ) + def update( + self, + template_id: str, + *, + allowable_content: str | None = None, + background_color: str | None = None, + css_class: str | None = None, + fetch: bool = True, + max_emojis: int | None = None, + mod_only: bool | None = None, + text: str | None = None, + text_color: str | None = None, + text_editable: bool | None = None, + ): + """Update the flair template provided by ``template_id``. - Moderators can also add rules to the subreddit. For example, to make a rule - called ``"No spam"`` in r/test: + :param template_id: The flair template to update. If not valid then an exception + will be thrown. + :param allowable_content: If specified, most be one of ``"all"``, ``"emoji"``, + or ``"text"`` to restrict content to that type. If set to ``"emoji"`` then + the ``"text"`` param must be a valid emoji string, for example, + ``":snoo:"``. + :param background_color: The flair template's new background color, as a hex + color. + :param css_class: The flair template's new css_class (default: ``""``). + :param fetch: Whether PRAW will fetch existing information on the existing flair + before updating (default: ``True``). + :param max_emojis: Maximum emojis in the flair (Reddit defaults this value to + ``10``). + :param mod_only: Indicate if the flair can only be used by moderators. + :param text: The flair template's new text. + :param text_color: The flair template's new text color, either ``"light"`` or + ``"dark"``. + :param text_editable: Indicate if the flair text can be modified for each + redditor that sets it (default: ``False``). + + .. warning:: + + If parameter ``fetch`` is set to ``False``, all parameters not provided will + be reset to their default (``None`` or ``False``) values. + + For example, to make a user flair template text editable, try: .. code-block:: python - reddit.subreddit("test").rules.mod.add( - short_name="No spam", kind="all", description="Do not spam. Spam bad" + template_info = list(subreddit.flair.templates)[0] + subreddit.flair.templates.update( + template_info["id"], + text=template_info["flair_text"], + text_editable=True, ) """ - return SubredditRules(self) - - @cachedproperty - def stream(self) -> praw.models.reddit.subreddit.SubredditStream: - """Provide an instance of :class:`.SubredditStream`. + url = API_PATH["flairtemplate_v2"].format(subreddit=self.subreddit) + data = { + "allowable_content": allowable_content, + "background_color": background_color, + "css_class": css_class, + "flair_template_id": template_id, + "max_emojis": max_emojis, + "mod_only": mod_only, + "text": text, + "text_color": text_color, + "text_editable": text_editable, + } + if fetch: + _existing_data = [ + template for template in iter(self) if template["id"] == template_id + ] + if len(_existing_data) != 1: + raise InvalidFlairTemplateID(template_id) + existing_data = _existing_data[0] + for key, value in existing_data.items(): + if data.get(key) is None: + data[key] = value + self.subreddit._reddit.post(url, data=data) - Streams can be used to indefinitely retrieve new comments made to a subreddit, - like: - .. code-block:: python +class SubredditModeration: + """Provides a set of moderation functions to a :class:`.Subreddit`. - for comment in reddit.subreddit("test").stream.comments(): - print(comment) + For example, to accept a moderation invite from r/test: - Additionally, new submissions can be retrieved via the stream. In the following - example all submissions are fetched via the special r/all: + .. code-block:: python - .. code-block:: python + reddit.subreddit("test").mod.accept_invite() - for submission in reddit.subreddit("all").stream.submissions(): - print(submission) + """ - """ - return SubredditStream(self) + @staticmethod + def _handle_only(*, generator_kwargs: dict[str, Any], only: str | None): + if only is not None: + if only == "submissions": + only = "links" + RedditBase._safely_add_arguments( + arguments=generator_kwargs, key="params", only=only + ) @cachedproperty - def stylesheet(self) -> praw.models.reddit.subreddit.SubredditStylesheet: - """Provide an instance of :class:`.SubredditStylesheet`. + def notes(self) -> praw.models.SubredditModNotes: + """Provide an instance of :class:`.SubredditModNotes`. - For example, to add the css data ``.test{color:blue}`` to the existing - stylesheet: + This provides an interface for managing moderator notes for this subreddit. + + For example, all the notes for u/spez in r/test can be iterated through like so: .. code-block:: python subreddit = reddit.subreddit("test") - stylesheet = subreddit.stylesheet() - stylesheet.stylesheet += ".test{color:blue}" - subreddit.stylesheet.update(stylesheet.stylesheet) + + for note in subreddit.mod.notes.redditors("spez"): + print(f"{note.label}: {note.note}") """ - return SubredditStylesheet(self) + from praw.models.mod_notes import SubredditModNotes - @cachedproperty - def widgets(self) -> praw.models.SubredditWidgets: - """Provide an instance of :class:`.SubredditWidgets`. + return SubredditModNotes(self.subreddit._reddit, subreddit=self.subreddit) - **Example usage** + @cachedproperty + def removal_reasons(self) -> SubredditRemovalReasons: + """Provide an instance of :class:`.SubredditRemovalReasons`. - Get all sidebar widgets: + Use this attribute for interacting with a :class:`.Subreddit`'s removal reasons. + For example to list all the removal reasons for a subreddit which you have the + ``posts`` moderator permission on, try: .. code-block:: python - for widget in reddit.subreddit("test").widgets.sidebar: - print(widget) + for removal_reason in reddit.subreddit("test").mod.removal_reasons: + print(removal_reason) - Get ID card widget: + A single removal reason can be lazily retrieved via: .. code-block:: python - print(reddit.subreddit("test").widgets.id_card) + reddit.subreddit("test").mod.removal_reasons["reason_id"] + + .. note:: + + Attempting to access attributes of an nonexistent removal reason will result + in a :class:`.ClientException`. """ - return SubredditWidgets(self) + return SubredditRemovalReasons(self.subreddit) @cachedproperty - def wiki(self) -> praw.models.reddit.subreddit.SubredditWiki: - """Provide an instance of :class:`.SubredditWiki`. + def stream(self) -> praw.models.reddit.subreddit.SubredditModerationStream: + """Provide an instance of :class:`.SubredditModerationStream`. - This attribute can be used to discover all wikipages for a subreddit: + Streams can be used to indefinitely retrieve Moderator only items from + :class:`.SubredditModeration` made to moderated subreddits, like: .. code-block:: python - for wikipage in reddit.subreddit("test").wiki: - print(wikipage) + for log in reddit.subreddit("mod").mod.stream.log(): + print(f"Mod: {log.mod}, Subreddit: {log.subreddit}") - To fetch the content for a given wikipage try: + """ + return SubredditModerationStream(self.subreddit) - .. code-block:: python + def __init__(self, subreddit: praw.models.Subreddit): + """Initialize a :class:`.SubredditModeration` instance. - wikipage = reddit.subreddit("test").wiki["proof"] - print(wikipage.content_md) + :param subreddit: The subreddit to moderate. """ - return SubredditWiki(self) - - @property - def _kind(self) -> str: # noqa: ANN001 - """Return the class's kind.""" - return self._reddit.config.kinds["subreddit"] - - def __init__( - self, - reddit: praw.Reddit, - display_name: str | None = None, - _data: dict[str, Any] | None = None, - ): - """Initialize a :class:`.Subreddit` instance. + self.subreddit = subreddit + self._stream = None - :param reddit: An instance of :class:`.Reddit`. - :param display_name: The name of the subreddit. + def accept_invite(self): + """Accept an invitation as a moderator of the community.""" + url = API_PATH["accept_mod_invite"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url) - .. note:: + @_deprecate_args("only") + def edited( + self, *, only: str | None = None, **generator_kwargs: Any + ) -> Iterator[praw.models.Comment | praw.models.Submission]: + """Return a :class:`.ListingGenerator` for edited comments and submissions. - This class should not be initialized directly. Instead, obtain an instance - via: ``reddit.subreddit("test")`` + :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield + only results of that type. - """ - if (display_name, _data).count(None) != 1: - msg = "Either 'display_name' or '_data' must be provided." - raise TypeError(msg) - if display_name: - self.display_name = display_name - super().__init__(reddit, _data=_data) - self._path = API_PATH["subreddit"].format(subreddit=self) + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. - def _convert_to_fancypants(self, markdown_text: str) -> dict: # noqa: ANN001 - """Convert a Markdown string to a dict for use with the ``richtext_json`` param. + To print all items in the edited queue try: - :param markdown_text: A Markdown string to convert. + .. code-block:: python - :returns: A dict in ``richtext_json`` format. + for item in reddit.subreddit("mod").mod.edited(limit=None): + print(item) """ - text_data = {"output_mode": "rtjson", "markdown_text": markdown_text} - return self._reddit.post(API_PATH["convert_rte_body"], data=text_data)["output"] - - def _fetch(self): # noqa: ANN001 - data = self._fetch_data() - data = data["data"] - other = type(self)(self._reddit, _data=data) - self.__dict__.update(other.__dict__) - self._fetched = True - - def _fetch_data(self) -> dict: # noqa: ANN001 - name, fields, params = self._fetch_info() - path = API_PATH[name].format(**fields) - return self._reddit.request(method="GET", params=params, path=path) - - def _fetch_info(self): # noqa: ANN001 - return "subreddit_about", {"subreddit": self}, None + self._handle_only(generator_kwargs=generator_kwargs, only=only) + return ListingGenerator( + self.subreddit._reddit, + API_PATH["about_edited"].format(subreddit=self.subreddit), + **generator_kwargs, + ) - def _parse_xml_response(self, response: Response): # noqa: ANN001 - """Parse the XML from a response and raise any errors found.""" - xml = response.text - root = XML(xml) - tags = [element.tag for element in root] - if tags[:4] == ["Code", "Message", "ProposedSize", "MaxSizeAllowed"]: - # Returned if image is too big - code, message, actual, maximum_size = (element.text for element in root[:4]) - raise TooLargeMediaException( - actual=int(actual), maximum_size=int(maximum_size) - ) + def inbox(self, **generator_kwargs: Any) -> Iterator[praw.models.SubredditMessage]: + """Return a :class:`.ListingGenerator` for moderator messages. - def _read_and_post_media(self, media_path, upload_url, upload_data): # noqa: ANN001 - with media_path.open("rb") as media: - return self._reddit._core._requestor._http.post( - upload_url, data=upload_data, files={"file": media} - ) + .. warning:: - def _submit_media( - self, *, data: dict[Any, Any], timeout: int, without_websockets: bool - ): - """Submit and return an ``image``, ``video``, or ``videogif``. + Legacy modmail is being deprecated in June 2021. Please see + https://www.reddit.com/r/modnews/comments/mar9ha/even_more_modmail_improvements/ + for more info. - This is a helper method for submitting posts that are not link posts or self - posts. + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. - """ - response = self._reddit.post(API_PATH["submit"], data=data) - websocket_url = response["json"]["data"]["websocket_url"] - connection = None - if websocket_url is not None and not without_websockets: - try: - connection = websocket.create_connection(websocket_url, timeout=timeout) - except ( - OSError, - websocket.WebSocketException, - BlockingIOError, - ) as ws_exception: - msg = "Error establishing websocket connection." - raise WebSocketException(msg, ws_exception) from None + .. seealso:: - if connection is None: - return None + :meth:`.unread` for unread moderator messages. - try: - ws_update = loads(connection.recv()) - connection.close() - except (OSError, websocket.WebSocketException, BlockingIOError) as ws_exception: - msg = "Websocket error. Check your media file. Your post may still have been created." - raise WebSocketException( - msg, - ws_exception, - ) from None - if ws_update.get("type") == "failed": - raise MediaPostFailed - url = ws_update["payload"]["redirect"] - return self._reddit.submission(url=url) + To print the last 5 moderator mail messages and their replies, try: - def _upload_inline_media( - self, inline_media: praw.models.InlineMedia - ): # noqa: ANN001 - """Upload media for use in self posts and return ``inline_media``. + .. code-block:: python - :param inline_media: An :class:`.InlineMedia` object to validate and upload. + for message in reddit.subreddit("mod").mod.inbox(limit=5): + print(f"From: {message.author}, Body: {message.body}") + for reply in message.replies: + print(f"From: {reply.author}, Body: {reply.body}") """ - self._validate_inline_media(inline_media) - inline_media.media_id = self._upload_media( - media_path=inline_media.path, upload_type="selfpost" + warn( + "Legacy modmail is being deprecated in June 2021. Please see" + " https://www.reddit.com/r/modnews/comments/mar9ha/even_more_modmail_improvements/" + " for more info.", + category=DeprecationWarning, + stacklevel=3, + ) + return ListingGenerator( + self.subreddit._reddit, + API_PATH["moderator_messages"].format(subreddit=self.subreddit), + **generator_kwargs, ) - return inline_media - def _upload_media( + @_deprecate_args("action", "mod") + def log( self, *, - expected_mime_prefix: str | None = None, - media_path: str, - upload_type: str = "link", - ): - """Upload media and return its URL and a websocket (Undocumented endpoint). - - :param expected_mime_prefix: If provided, enforce that the media has a mime type - that starts with the provided prefix. - :param upload_type: One of ``"link"``, ``"gallery"'', or ``"selfpost"`` - (default: ``"link"``). - - :returns: A tuple containing ``(media_url, websocket_url)`` for the piece of - media. The websocket URL can be used to determine when media processing is - finished, or it can be ignored. - - """ - if media_path is None: - file = Path(__file__).absolute() - media_path = file.parent.parent.parent / "images" / "PRAW logo.png" - else: - file = Path(media_path) + action: str | None = None, + mod: praw.models.Redditor | str | None = None, + **generator_kwargs: Any, + ) -> Iterator[praw.models.ModAction]: + """Return a :class:`.ListingGenerator` for moderator log entries. - file_name = file.name.lower() - file_extension = file_name.rpartition(".")[2] - mime_type = { - "png": "image/png", - "mov": "video/quicktime", - "mp4": "video/mp4", - "jpg": "image/jpeg", - "jpeg": "image/jpeg", - "gif": "image/gif", - }.get( - file_extension, "image/jpeg" - ) # default to JPEG - if ( - expected_mime_prefix is not None - and mime_type.partition("/")[0] != expected_mime_prefix - ): - msg = f"Expected a mimetype starting with {expected_mime_prefix!r} but got mimetype {mime_type!r} (from file extension {file_extension!r})." - raise ClientException(msg) - img_data = {"filepath": file_name, "mimetype": mime_type} + :param action: If given, only return log entries for the specified action. + :param mod: If given, only return log entries for actions made by the passed in + redditor. - url = API_PATH["media_asset"] - # until we learn otherwise, assume this request always succeeds - upload_response = self._reddit.post(url, data=img_data) - upload_lease = upload_response["args"] - upload_url = f"https:{upload_lease['action']}" - upload_data = {item["name"]: item["value"] for item in upload_lease["fields"]} + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. - response = self._read_and_post_media(file, upload_url, upload_data) - if not response.ok: - self._parse_xml_response(response) - try: - response.raise_for_status() - except HTTPError as err: - raise ServerError(response=err.response) from None + To print the moderator and subreddit of the last 5 modlog entries try: - upload_response["asset"]["websocket_url"] + .. code-block:: python - if upload_type == "link": - return f"{upload_url}/{upload_data['key']}" - return upload_response["asset"]["asset_id"] + for log in reddit.subreddit("mod").mod.log(limit=5): + print(f"Mod: {log.mod}, Subreddit: {log.subreddit}") - def post_requirements(self) -> dict[str, str | int | bool]: - """Get the post requirements for a subreddit. + """ + params = {"mod": str(mod) if mod else mod, "type": action} + Subreddit._safely_add_arguments( + arguments=generator_kwargs, key="params", **params + ) + return ListingGenerator( + self.subreddit._reddit, + API_PATH["about_log"].format(subreddit=self.subreddit), + **generator_kwargs, + ) - :returns: A dict with the various requirements. + @_deprecate_args("only") + def modqueue( + self, *, only: str | None = None, **generator_kwargs: Any + ) -> Iterator[praw.models.Submission | praw.models.Comment]: + """Return a :class:`.ListingGenerator` for modqueue items. - The returned dict contains the following keys: + :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield + only results of that type. - - ``domain_blacklist`` - - ``body_restriction_policy`` - - ``domain_whitelist`` - - ``title_regexes`` - - ``body_blacklisted_strings`` - - ``body_required_strings`` - - ``title_text_min_length`` - - ``is_flair_required`` - - ``title_text_max_length`` - - ``body_regexes`` - - ``link_repost_age`` - - ``body_text_min_length`` - - ``link_restriction_policy`` - - ``body_text_max_length`` - - ``title_required_strings`` - - ``title_blacklisted_strings`` - - ``guidelines_text`` - - ``guidelines_display_policy`` + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. - For example, to fetch the post requirements for r/test: + To print all modqueue items try: .. code-block:: python - print(reddit.subreddit("test").post_requirements) + for item in reddit.subreddit("mod").mod.modqueue(limit=None): + print(item) """ - return self._reddit.get( - API_PATH["post_requirements"].format(subreddit=str(self)) + self._handle_only(generator_kwargs=generator_kwargs, only=only) + return ListingGenerator( + self.subreddit._reddit, + API_PATH["about_modqueue"].format(subreddit=self.subreddit), + **generator_kwargs, ) - def random(self) -> praw.models.Submission | None: - """Return a random :class:`.Submission`. + @_deprecate_args("only") + def reports( + self, *, only: str | None = None, **generator_kwargs: Any + ) -> Iterator[praw.models.Submission | praw.models.Comment]: + """Return a :class:`.ListingGenerator` for reported comments and submissions. - Returns ``None`` on subreddits that do not support the random feature. One - example, at the time of writing, is r/wallpapers. + :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield + only results of that type. - For example, to get a random submission off of r/AskReddit: + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. + + To print the user and mod report reasons in the report queue try: .. code-block:: python - submission = reddit.subreddit("AskReddit").random() - print(submission.title) + for reported_item in reddit.subreddit("mod").mod.reports(): + print(f"User Reports: {reported_item.user_reports}") + print(f"Mod Reports: {reported_item.mod_reports}") """ - url = API_PATH["subreddit_random"].format(subreddit=self) - try: - self._reddit.get(url, params={"unique": self._reddit._next_unique}) - except Redirect as redirect: - path = redirect.path - try: - return self._submission_class( - self._reddit, url=urljoin(self._reddit.config.reddit_url, path) - ) - except ClientException: - return None - - @_deprecate_args("query", "sort", "syntax", "time_filter") - def search( - self, - query: str, - *, - sort: str = "relevance", - syntax: str = "lucene", - time_filter: str = "all", - **generator_kwargs: Any, - ) -> Iterator[praw.models.Submission]: - """Return a :class:`.ListingGenerator` for items that match ``query``. + self._handle_only(generator_kwargs=generator_kwargs, only=only) + return ListingGenerator( + self.subreddit._reddit, + API_PATH["about_reports"].format(subreddit=self.subreddit), + **generator_kwargs, + ) - :param query: The query string to search for. - :param sort: Can be one of: ``"relevance"``, ``"hot"``, ``"top"``, ``"new"``, or - ``"comments"``. (default: ``"relevance"``). - :param syntax: Can be one of: ``"cloudsearch"``, ``"lucene"``, or ``"plain"`` - (default: ``"lucene"``). - :param time_filter: Can be one of: ``"all"``, ``"day"``, ``"hour"``, - ``"month"``, ``"week"``, or ``"year"`` (default: ``"all"``). + def settings(self) -> dict[str, str | int | bool]: + """Return a dictionary of the :class:`.Subreddit`'s current settings.""" + url = API_PATH["subreddit_settings"].format(subreddit=self.subreddit) + return self.subreddit._reddit.get(url)["data"] - For more information on building a search query see: - https://www.reddit.com/wiki/search + @_deprecate_args("only") + def spam( + self, *, only: str | None = None, **generator_kwargs: Any + ) -> Iterator[praw.models.Submission | praw.models.Comment]: + """Return a :class:`.ListingGenerator` for spam comments and submissions. - For example, to search all subreddits for ``"praw"`` try: + :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield + only results of that type. + + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. + + To print the items in the spam queue try: .. code-block:: python - for submission in reddit.subreddit("all").search("praw"): - print(submission.title) + for item in reddit.subreddit("mod").mod.spam(): + print(item) """ - self._validate_time_filter(time_filter) - not_all = self.display_name.lower() != "all" - self._safely_add_arguments( - arguments=generator_kwargs, - key="params", - q=query, - restrict_sr=not_all, - sort=sort, - syntax=syntax, - t=time_filter, + self._handle_only(generator_kwargs=generator_kwargs, only=only) + return ListingGenerator( + self.subreddit._reddit, + API_PATH["about_spam"].format(subreddit=self.subreddit), + **generator_kwargs, ) - url = API_PATH["search"].format(subreddit=self) - return ListingGenerator(self._reddit, url, **generator_kwargs) - @_deprecate_args("number") - def sticky(self, *, number: int = 1) -> praw.models.Submission: - """Return a :class:`.Submission` object for a sticky of the subreddit. - - :param number: Specify which sticky to return. 1 appears at the top (default: - ``1``). + def unmoderated(self, **generator_kwargs: Any) -> Iterator[praw.models.Submission]: + """Return a :class:`.ListingGenerator` for unmoderated submissions. - :raises: ``prawcore.NotFound`` if the sticky does not exist. + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. - For example, to get the stickied post on r/test: + To print the items in the unmoderated queue try: .. code-block:: python - reddit.subreddit("test").sticky() + for item in reddit.subreddit("mod").mod.unmoderated(): + print(item) """ - url = API_PATH["about_sticky"].format(subreddit=self) - try: - self._reddit.get(url, params={"num": number}) - except Redirect as redirect: - path = redirect.path - return self._submission_class( - self._reddit, url=urljoin(self._reddit.config.reddit_url, path) + return ListingGenerator( + self.subreddit._reddit, + API_PATH["about_unmoderated"].format(subreddit=self.subreddit), + **generator_kwargs, ) - @_deprecate_args( - "title", - "selftext", - "url", - "flair_id", - "flair_text", - "resubmit", - "send_replies", - "nsfw", - "spoiler", - "collection_id", - "discussion_type", - "inline_media", - "draft_id", - ) - def submit( - self, - title: str, - *, - collection_id: str | None = None, - discussion_type: str | None = None, - draft_id: str | None = None, - flair_id: str | None = None, - flair_text: str | None = None, - inline_media: dict[str, praw.models.InlineMedia] | None = None, - nsfw: bool = False, - resubmit: bool = True, - selftext: str | None = None, - send_replies: bool = True, - spoiler: bool = False, - url: str | None = None, - ) -> praw.models.Submission: # noqa: D301 - r"""Add a submission to the :class:`.Subreddit`. - - :param title: The title of the submission. - :param collection_id: The UUID of a :class:`.Collection` to add the - newly-submitted post to. - :param discussion_type: Set to ``"CHAT"`` to enable live discussion instead of - traditional comments (default: ``None``). - :param draft_id: The ID of a draft to submit. - :param flair_id: The flair template to select (default: ``None``). - :param flair_text: If the template's ``flair_text_editable`` value is ``True``, - this value will set a custom text (default: ``None``). ``flair_id`` is - required when ``flair_text`` is provided. - :param inline_media: A dict of :class:`.InlineMedia` objects where the key is - the placeholder name in ``selftext``. - :param nsfw: Whether the submission should be marked NSFW (default: ``False``). - :param resubmit: When ``False``, an error will occur if the URL has already been - submitted (default: ``True``). - :param selftext: The Markdown formatted content for a ``text`` submission. Use - an empty string, ``""``, to make a title-only submission. - :param send_replies: When ``True``, messages will be sent to the submission - author when comments are made to the submission (default: ``True``). - :param spoiler: Whether the submission should be marked as a spoiler (default: - ``False``). - :param url: The URL for a ``link`` submission. + def unread(self, **generator_kwargs: Any) -> Iterator[praw.models.SubredditMessage]: + """Return a :class:`.ListingGenerator` for unread moderator messages. - :returns: A :class:`.Submission` object for the newly created submission. + .. warning:: - Either ``selftext`` or ``url`` can be provided, but not both. + Legacy modmail is being deprecated in June 2021. Please see + https://www.reddit.com/r/modnews/comments/mar9ha/even_more_modmail_improvements/ + for more info. - For example, to submit a URL to r/test do: + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. - .. code-block:: python + .. seealso:: - title = "PRAW documentation" - url = "https://praw.readthedocs.io" - reddit.subreddit("test").submit(title, url=url) + :meth:`.inbox` for all messages. - For example, to submit a self post with inline media do: + To print the mail in the unread modmail queue try: .. code-block:: python - from praw.models import InlineGif, InlineImage, InlineVideo - - gif = InlineGif(path="path/to/image.gif", caption="optional caption") - image = InlineImage(path="path/to/image.jpg", caption="optional caption") - video = InlineVideo(path="path/to/video.mp4", caption="optional caption") - selftext = "Text with a gif {gif1} an image {image1} and a video {video1} inline" - media = {"gif1": gif, "image1": image, "video1": video} - reddit.subreddit("test").submit("title", inline_media=media, selftext=selftext) - - .. note:: - - Inserted media will have a padding of ``\\n\\n`` automatically added. This - is due to the weirdness with Reddit's API. Using the example above, the - result selftext body will look like so: - - .. code-block:: - - Text with a gif - - ![gif](u1rchuphryq51 "optional caption") - - an image - - ![img](srnr8tshryq51 "optional caption") - - and video - - ![video](gmc7rvthryq51 "optional caption") - - inline - - .. note:: - - To submit a post to a subreddit with the ``"news"`` flair, you can get the - flair id like this: - - .. code-block:: - - choices = list(subreddit.flair.link_templates.user_selectable()) - template_id = next(x for x in choices if x["flair_text"] == "news")["flair_template_id"] - subreddit.submit("title", flair_id=template_id, url="https://www.news.com/") + for message in reddit.subreddit("mod").mod.unread(): + print(f"From: {message.author}, To: {message.dest}") - .. seealso:: + """ + warn( + "Legacy modmail is being deprecated in June 2021. Please see" + " https://www.reddit.com/r/modnews/comments/mar9ha/even_more_modmail_improvements/" + " for more info.", + category=DeprecationWarning, + stacklevel=3, + ) + return ListingGenerator( + self.subreddit._reddit, + API_PATH["moderator_unread"].format(subreddit=self.subreddit), + **generator_kwargs, + ) - - :meth:`~.Subreddit.submit_gallery` to submit more than one image in the - same post - - :meth:`~.Subreddit.submit_image` to submit images - - :meth:`~.Subreddit.submit_poll` to submit polls - - :meth:`~.Subreddit.submit_video` to submit videos and videogifs + def update(self, **settings: str | int | bool) -> dict[str, str | int | bool]: + """Update the :class:`.Subreddit`'s settings. - """ - if (bool(selftext) or selftext == "") == bool(url): - msg = "Either 'selftext' or 'url' must be provided." - raise TypeError(msg) + See https://www.reddit.com/dev/api#POST_api_site_admin for the full list. - data = { - "sr": str(self), - "resubmit": bool(resubmit), - "sendreplies": bool(send_replies), - "title": title, - "nsfw": bool(nsfw), - "spoiler": bool(spoiler), - "validate_on_submit": self._reddit.validate_on_submit, - } - for key, value in ( - ("flair_id", flair_id), - ("flair_text", flair_text), - ("collection_id", collection_id), - ("discussion_type", discussion_type), - ("draft_id", draft_id), - ): - if value is not None: - data[key] = value - if selftext is not None: - data.update(kind="self") - if inline_media: - body = selftext.format( - **{ - placeholder: self._upload_inline_media(media) - for placeholder, media in inline_media.items() - } - ) - converted = self._convert_to_fancypants(body) - data.update(richtext_json=dumps(converted)) - else: - data.update(text=selftext) - else: - data.update(kind="link", url=url) - - return self._reddit.post(API_PATH["submit"], data=data) - - @_deprecate_args( - "title", - "images", - "collection_id", - "discussion_type", - "flair_id", - "flair_text", - "nsfw", - "send_replies", - "spoiler", - ) - def submit_gallery( - self, - title: str, - images: list[dict[str, str]], - *, - collection_id: str | None = None, - discussion_type: str | None = None, - flair_id: str | None = None, - flair_text: str | None = None, - nsfw: bool = False, - send_replies: bool = True, - spoiler: bool = False, - ) -> praw.models.Submission: - """Add an image gallery submission to the subreddit. - - :param title: The title of the submission. - :param images: The images to post in dict with the following structure: - ``{"image_path": "path", "caption": "caption", "outbound_url": "url"}``, - only ``image_path`` is required. - :param collection_id: The UUID of a :class:`.Collection` to add the - newly-submitted post to. - :param discussion_type: Set to ``"CHAT"`` to enable live discussion instead of - traditional comments (default: ``None``). - :param flair_id: The flair template to select (default: ``None``). - :param flair_text: If the template's ``flair_text_editable`` value is ``True``, - this value will set a custom text (default: ``None``). ``flair_id`` is - required when ``flair_text`` is provided. - :param nsfw: Whether the submission should be marked NSFW (default: ``False``). - :param send_replies: When ``True``, messages will be sent to the submission - author when comments are made to the submission (default: ``True``). - :param spoiler: Whether the submission should be marked asa spoiler (default: - ``False``). - - :returns: A :class:`.Submission` object for the newly created submission. - - :raises: :class:`.ClientException` if ``image_path`` in ``images`` refers to a - file that is not an image. - - For example, to submit an image gallery to r/test do: - - .. code-block:: python - - title = "My favorite pictures" - image = "/path/to/image.png" - image2 = "/path/to/image2.png" - image3 = "/path/to/image3.png" - images = [ - {"image_path": image}, - { - "image_path": image2, - "caption": "Image caption 2", - }, - { - "image_path": image3, - "caption": "Image caption 3", - "outbound_url": "https://example.com/link3", - }, - ] - reddit.subreddit("test").submit_gallery(title, images) - - .. seealso:: - - - :meth:`~.Subreddit.submit` to submit url posts and selftexts - - :meth:`~.Subreddit.submit_image` to submit single images - - :meth:`~.Subreddit.submit_poll` to submit polls - - :meth:`~.Subreddit.submit_video` to submit videos and videogifs - - """ - self._validate_gallery(images) - data = { - "api_type": "json", - "items": [], - "nsfw": bool(nsfw), - "sendreplies": bool(send_replies), - "show_error_list": True, - "spoiler": bool(spoiler), - "sr": str(self), - "title": title, - "validate_on_submit": self._reddit.validate_on_submit, - } - for key, value in ( - ("flair_id", flair_id), - ("flair_text", flair_text), - ("collection_id", collection_id), - ("discussion_type", discussion_type), - ): - if value is not None: - data[key] = value - for image in images: - data["items"].append( - { - "caption": image.get("caption", ""), - "outbound_url": image.get("outbound_url", ""), - "media_id": self._upload_media( - expected_mime_prefix="image", - media_path=image["image_path"], - upload_type="gallery", - ), - } - ) - response = self._reddit.request( - json=data, method="POST", path=API_PATH["submit_gallery_post"] - )["json"] - if response["errors"]: - raise RedditAPIException(response["errors"]) - return self._reddit.submission(url=response["data"]["url"]) - - @_deprecate_args( - "title", - "image_path", - "flair_id", - "flair_text", - "resubmit", - "send_replies", - "nsfw", - "spoiler", - "timeout", - "collection_id", - "without_websockets", - "discussion_type", - ) - def submit_image( - self, - title: str, - image_path: str, - *, - collection_id: str | None = None, - discussion_type: str | None = None, - flair_id: str | None = None, - flair_text: str | None = None, - nsfw: bool = False, - resubmit: bool = True, - send_replies: bool = True, - spoiler: bool = False, - timeout: int = 10, - without_websockets: bool = False, - ) -> praw.models.Submission: - """Add an image submission to the subreddit. - - :param collection_id: The UUID of a :class:`.Collection` to add the - newly-submitted post to. - :param discussion_type: Set to ``"CHAT"`` to enable live discussion instead of - traditional comments (default: ``None``). - :param flair_id: The flair template to select (default: ``None``). - :param flair_text: If the template's ``flair_text_editable`` value is ``True``, - this value will set a custom text (default: ``None``). ``flair_id`` is - required when ``flair_text`` is provided. - :param image_path: The path to an image, to upload and post. - :param nsfw: Whether the submission should be marked NSFW (default: ``False``). - :param resubmit: When ``False``, an error will occur if the URL has already been - submitted (default: ``True``). - :param send_replies: When ``True``, messages will be sent to the submission - author when comments are made to the submission (default: ``True``). - :param spoiler: Whether the submission should be marked as a spoiler (default: - ``False``). - :param timeout: Specifies a particular timeout, in seconds. Use to avoid - "Websocket error" exceptions (default: ``10``). - :param title: The title of the submission. - :param without_websockets: Set to ``True`` to disable use of WebSockets (see - note below for an explanation). If ``True``, this method doesn't return - anything (default: ``False``). - - :returns: A :class:`.Submission` object for the newly created submission, unless - ``without_websockets`` is ``True``. - - :raises: :class:`.ClientException` if ``image_path`` refers to a file that is - not an image. + :param all_original_content: Mandate all submissions to be original content + only. + :param allow_chat_post_creation: Allow users to create chat submissions. + :param allow_images: Allow users to upload images using the native image + hosting. + :param allow_polls: Allow users to post polls to the subreddit. + :param allow_post_crossposts: Allow users to crosspost submissions from other + subreddits. + :param allow_videos: Allow users to upload videos using the native image + hosting. + :param collapse_deleted_comments: Collapse deleted and removed comments on + comments pages by default. + :param comment_score_hide_mins: The number of minutes to hide comment scores. + :param content_options: The types of submissions users can make. One of + ``"any"``, ``"link"``, or ``"self"``. + :param crowd_control_chat_level: Controls the crowd control level for chat + rooms. Goes from 0-3. + :param crowd_control_level: Controls the crowd control level for submissions. + Goes from 0-3. + :param crowd_control_mode: Enables/disables crowd control. + :param default_set: Allow the subreddit to appear on r/all as well as the + default and trending lists. + :param disable_contributor_requests: Specifies whether redditors may send + automated modmail messages requesting approval as a submitter. + :param exclude_banned_modqueue: Exclude posts by site-wide banned users from + modqueue/unmoderated. + :param free_form_reports: Allow users to specify custom reasons in the report + menu. + :param header_hover_text: The text seen when hovering over the snoo. + :param hide_ads: Don't show ads within this subreddit. Only applies to + Premium-user only subreddits. + :param key_color: A 6-digit rgb hex color (e.g., ``"#AABBCC"``), used as a + thematic color for your subreddit on mobile. + :param language: A valid IETF language tag (underscore separated). + :param original_content_tag_enabled: Enables the use of the ``original content`` + label for submissions. + :param over_18: Viewers must be over 18 years old (i.e., NSFW). + :param public_description: Public description blurb. Appears in search results + and on the landing page for private subreddits. + :param restrict_commenting: Specifies whether approved users have the ability to + comment. + :param restrict_posting: Specifies whether approved users have the ability to + submit posts. + :param show_media: Show thumbnails on submissions. + :param show_media_preview: Expand media previews on comments pages. + :param spam_comments: Spam filter strength for comments. One of ``"all"``, + ``"low"``, or ``"high"``. + :param spam_links: Spam filter strength for links. One of ``"all"``, ``"low"``, + or ``"high"``. + :param spam_selfposts: Spam filter strength for selfposts. One of ``"all"``, + ``"low"``, or ``"high"``. + :param spoilers_enabled: Enable marking posts as containing spoilers. + :param submit_link_label: Custom label for submit link button (``None`` for + default). + :param submit_text: Text to show on submission page. + :param submit_text_label: Custom label for submit text post button (``None`` for + default). + :param subreddit_type: One of ``"archived"``, ``"employees_only"``, + ``"gold_only"``, ``gold_restricted``, ``"private"``, ``"public"``, or + ``"restricted"``. + :param suggested_comment_sort: All comment threads will use this sorting method + by default. Leave ``None``, or choose one of ``"confidence"``, + ``"controversial"``, ``"live"``, ``"new"``, ``"old"``, ``"qa"``, + ``"random"``, or ``"top"``. + :param title: The title of the subreddit. + :param welcome_message_enabled: Enables the subreddit welcome message. + :param welcome_message_text: The text to be used as a welcome message. A welcome + message is sent to all new subscribers by a Reddit bot. + :param wiki_edit_age: Account age, in days, required to edit and create wiki + pages. + :param wiki_edit_karma: Subreddit karma required to edit and create wiki pages. + :param wikimode: One of ``"anyone"``, ``"disabled"``, or ``"modonly"``. .. note:: - Reddit's API uses WebSockets to respond with the link of the newly created - post. If this fails, the method will raise :class:`.WebSocketException`. - Occasionally, the Reddit post will still be created. More often, there is an - error with the image file. If you frequently get exceptions but successfully - created posts, try setting the ``timeout`` parameter to a value above 10. - - To disable the use of WebSockets, set ``without_websockets=True``. This will - make the method return ``None``, though the post will still be created. You - may wish to do this if you are running your program in a restricted network - environment, or using a proxy that doesn't support WebSockets connections. - - For example, to submit an image to r/test do: + Updating the subreddit sidebar on old reddit (``description``) is no longer + supported using this method. You can update the sidebar by editing the + ``"config/sidebar"`` wiki page. For example: - .. code-block:: python + .. code-block:: python - title = "My favorite picture" - image = "/path/to/image.png" - reddit.subreddit("test").submit_image(title, image) + sidebar = reddit.subreddit("test").wiki["config/sidebar"] + sidebar.edit(content="new sidebar content") - .. seealso:: + Additional keyword arguments can be provided to handle new settings as Reddit + introduces them. - - :meth:`~.Subreddit.submit` to submit url posts and selftexts - - :meth:`~.Subreddit.submit_gallery` to submit more than one image in the - same post - - :meth:`~.Subreddit.submit_poll` to submit polls - - :meth:`~.Subreddit.submit_video` to submit videos and videogifs + Settings that are documented here and aren't explicitly set by you in a call to + :meth:`.SubredditModeration.update` should retain their current value. If they + do not, please file a bug. """ - data = { - "sr": str(self), - "resubmit": bool(resubmit), - "sendreplies": bool(send_replies), - "title": title, - "nsfw": bool(nsfw), - "spoiler": bool(spoiler), - "validate_on_submit": self._reddit.validate_on_submit, + # These attributes come out using different names than they go in. + remap = { + "content_options": "link_type", + "default_set": "allow_top", + "header_hover_text": "header_title", + "language": "lang", + "subreddit_type": "type", } - for key, value in ( - ("flair_id", flair_id), - ("flair_text", flair_text), - ("collection_id", collection_id), - ("discussion_type", discussion_type), - ): - if value is not None: - data[key] = value - - image_url = self._upload_media( - expected_mime_prefix="image", media_path=image_path - ) - data.update(kind="image", url=image_url) - return self._submit_media( - data=data, timeout=timeout, without_websockets=without_websockets - ) - - @_deprecate_args( - "title", - "selftext", - "options", - "duration", - "flair_id", - "flair_text", - "resubmit", - "send_replies", - "nsfw", - "spoiler", - "collection_id", - "discussion_type", - ) - def submit_poll( - self, - title: str, - *, - collection_id: str | None = None, - discussion_type: str | None = None, - duration: int, - flair_id: str | None = None, - flair_text: str | None = None, - nsfw: bool = False, - options: list[str], - resubmit: bool = True, - selftext: str, - send_replies: bool = True, - spoiler: bool = False, - ) -> praw.models.Submission: - """Add a poll submission to the subreddit. - - :param title: The title of the submission. - :param collection_id: The UUID of a :class:`.Collection` to add the - newly-submitted post to. - :param discussion_type: Set to ``"CHAT"`` to enable live discussion instead of - traditional comments (default: ``None``). - :param duration: The number of days the poll should accept votes, as an ``int``. - Valid values are between ``1`` and ``7``, inclusive. - :param flair_id: The flair template to select (default: ``None``). - :param flair_text: If the template's ``flair_text_editable`` value is ``True``, - this value will set a custom text (default: ``None``). ``flair_id`` is - required when ``flair_text`` is provided. - :param nsfw: Whether the submission should be marked NSFW (default: ``False``). - :param options: A list of two to six poll options as ``str``. - :param resubmit: When ``False``, an error will occur if the URL has already been - submitted (default: ``True``). - :param selftext: The Markdown formatted content for the submission. Use an empty - string, ``""``, to make a submission with no text contents. - :param send_replies: When ``True``, messages will be sent to the submission - author when comments are made to the submission (default: ``True``). - :param spoiler: Whether the submission should be marked as a spoiler (default: - ``False``). - - :returns: A :class:`.Submission` object for the newly created submission. - - For example, to submit a poll to r/test do: + settings = {remap.get(key, key): value for key, value in settings.items()} + settings["sr"] = self.subreddit.fullname + return self.subreddit._reddit.patch(API_PATH["update_settings"], json=settings) - .. code-block:: python - title = "Do you like PRAW?" - reddit.subreddit("test").submit_poll( - title, selftext="", options=["Yes", "No"], duration=3 - ) +class SubredditModerationStream: + """Provides moderator streams.""" - .. seealso:: + def __init__(self, subreddit: praw.models.Subreddit): + """Initialize a :class:`.SubredditModerationStream` instance. - - :meth:`~.Subreddit.submit` to submit url posts and selftexts - - :meth:`~.Subreddit.submit_gallery` to submit more than one image in the - same post - - :meth:`~.Subreddit.submit_image` to submit single images - - :meth:`~.Subreddit.submit_video` to submit videos and videogifs + :param subreddit: The moderated subreddit associated with the streams. """ - data = { - "sr": str(self), - "text": selftext, - "options": options, - "duration": duration, - "resubmit": bool(resubmit), - "sendreplies": bool(send_replies), - "title": title, - "nsfw": bool(nsfw), - "spoiler": bool(spoiler), - "validate_on_submit": self._reddit.validate_on_submit, - } - for key, value in ( - ("flair_id", flair_id), - ("flair_text", flair_text), - ("collection_id", collection_id), - ("discussion_type", discussion_type), - ): - if value is not None: - data[key] = value - - return self._reddit.post(API_PATH["submit_poll_post"], json=data) + self.subreddit = subreddit - @_deprecate_args( - "title", - "video_path", - "videogif", - "thumbnail_path", - "flair_id", - "flair_text", - "resubmit", - "send_replies", - "nsfw", - "spoiler", - "timeout", - "collection_id", - "without_websockets", - "discussion_type", - ) - def submit_video( - self, - title: str, - video_path: str, - *, - collection_id: str | None = None, - discussion_type: str | None = None, - flair_id: str | None = None, - flair_text: str | None = None, - nsfw: bool = False, - resubmit: bool = True, - send_replies: bool = True, - spoiler: bool = False, - thumbnail_path: str | None = None, - timeout: int = 10, - videogif: bool = False, - without_websockets: bool = False, - ) -> praw.models.Submission: - """Add a video or videogif submission to the subreddit. + @_deprecate_args("only") + def edited( + self, *, only: str | None = None, **stream_options: Any + ) -> Generator[praw.models.Comment | praw.models.Submission, None, None]: + """Yield edited comments and submissions as they become available. - :param title: The title of the submission. - :param video_path: The path to a video, to upload and post. - :param collection_id: The UUID of a :class:`.Collection` to add the - newly-submitted post to. - :param discussion_type: Set to ``"CHAT"`` to enable live discussion instead of - traditional comments (default: ``None``). - :param flair_id: The flair template to select (default: ``None``). - :param flair_text: If the template's ``flair_text_editable`` value is ``True``, - this value will set a custom text (default: ``None``). ``flair_id`` is - required when ``flair_text`` is provided. - :param nsfw: Whether the submission should be marked NSFW (default: ``False``). - :param resubmit: When ``False``, an error will occur if the URL has already been - submitted (default: ``True``). - :param send_replies: When ``True``, messages will be sent to the submission - author when comments are made to the submission (default: ``True``). - :param spoiler: Whether the submission should be marked as a spoiler (default: - ``False``). - :param thumbnail_path: The path to an image, to be uploaded and used as the - thumbnail for this video. If not provided, the PRAW logo will be used as the - thumbnail. - :param timeout: Specifies a particular timeout, in seconds. Use to avoid - "Websocket error" exceptions (default: ``10``). - :param videogif: If ``True``, the video is uploaded as a videogif, which is - essentially a silent video (default: ``False``). - :param without_websockets: Set to ``True`` to disable use of WebSockets (see - note below for an explanation). If ``True``, this method doesn't return - anything (default: ``False``). + :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield + only results of that type. - :returns: A :class:`.Submission` object for the newly created submission, unless - ``without_websockets`` is ``True``. + Keyword arguments are passed to :func:`.stream_generator`. - :raises: :class:`.ClientException` if ``video_path`` refers to a file that is - not a video. + For example, to retrieve all new edited submissions/comments made to all + moderated subreddits, try: - .. note:: + .. code-block:: python - Reddit's API uses WebSockets to respond with the link of the newly created - post. If this fails, the method will raise :class:`.WebSocketException`. - Occasionally, the Reddit post will still be created. More often, there is an - error with the image file. If you frequently get exceptions but successfully - created posts, try setting the ``timeout`` parameter to a value above 10. + for item in reddit.subreddit("mod").mod.stream.edited(): + print(item) - To disable the use of WebSockets, set ``without_websockets=True``. This will - make the method return ``None``, though the post will still be created. You - may wish to do this if you are running your program in a restricted network - environment, or using a proxy that doesn't support WebSockets connections. + """ + return stream_generator(self.subreddit.mod.edited, only=only, **stream_options) - For example, to submit a video to r/test do: + @_deprecate_args("action", "mod") + def log( + self, + *, + action: str | None = None, + mod: str | praw.models.Redditor | None = None, + **stream_options: Any, + ) -> Generator[praw.models.ModAction, None, None]: + """Yield moderator log entries as they become available. - .. code-block:: python + :param action: If given, only return log entries for the specified action. + :param mod: If given, only return log entries for actions made by the passed in + redditor. - title = "My favorite movie" - video = "/path/to/video.mp4" - reddit.subreddit("test").submit_video(title, video) + For example, to retrieve all new mod actions made to all moderated subreddits, + try: - .. seealso:: + .. code-block:: python - - :meth:`~.Subreddit.submit` to submit url posts and selftexts - - :meth:`~.Subreddit.submit_image` to submit images - - :meth:`~.Subreddit.submit_gallery` to submit more than one image in the - same post - - :meth:`~.Subreddit.submit_poll` to submit polls + for log in reddit.subreddit("mod").mod.stream.log(): + print(f"Mod: {log.mod}, Subreddit: {log.subreddit}") """ - data = { - "sr": str(self), - "resubmit": bool(resubmit), - "sendreplies": bool(send_replies), - "title": title, - "nsfw": bool(nsfw), - "spoiler": bool(spoiler), - "validate_on_submit": self._reddit.validate_on_submit, - } - for key, value in ( - ("flair_id", flair_id), - ("flair_text", flair_text), - ("collection_id", collection_id), - ("discussion_type", discussion_type), - ): - if value is not None: - data[key] = value - - video_url = self._upload_media( - expected_mime_prefix="video", media_path=video_path - ) - data.update( - kind="videogif" if videogif else "video", - url=video_url, - # if thumbnail_path is None, it uploads the PRAW logo - video_poster_url=self._upload_media(media_path=thumbnail_path), - ) - return self._submit_media( - data=data, timeout=timeout, without_websockets=without_websockets + return stream_generator( + self.subreddit.mod.log, + attribute_name="id", + action=action, + mod=mod, + **stream_options, ) - @_deprecate_args("other_subreddits") - def subscribe(self, *, other_subreddits: list[praw.models.Subreddit] | None = None): - """Subscribe to the subreddit. + @_deprecate_args("other_subreddits", "sort", "state") + def modmail_conversations( + self, + *, + other_subreddits: list[praw.models.Subreddit] | None = None, + sort: str | None = None, + state: str | None = None, + **stream_options: Any, + ) -> Generator[ModmailConversation, None, None]: + """Yield new-modmail conversations as they become available. - :param other_subreddits: When provided, also subscribe to the provided list of - subreddits. + :param other_subreddits: A list of :class:`.Subreddit` instances for which to + fetch conversations (default: ``None``). + :param sort: Can be one of: ``"mod"``, ``"recent"``, ``"unread"``, or ``"user"`` + (default: ``"recent"``). + :param state: Can be one of: ``"all"``, ``"appeals"``, ``"archived"``, + ``"default"``, ``"highlighted"``, ``"inbox"``, ``"inprogress"``, + ``"join_requests"``, ``"mod"``, ``"new"``, or ``"notifications"`` (default: + ``"all"``). ``"all"`` does not include mod or archived conversations. + ``"inbox"`` does not include appeals conversations. - For example, to subscribe to r/test: + Keyword arguments are passed to :func:`.stream_generator`. + + To print new mail in the unread modmail queue try: .. code-block:: python - reddit.subreddit("test").subscribe() + subreddit = reddit.subreddit("all") + for message in subreddit.mod.stream.modmail_conversations(): + print(f"From: {message.owner}, To: {message.participant}") """ - data = { - "action": "sub", - "skip_inital_defaults": True, - "sr_name": self._subreddit_list( - other_subreddits=other_subreddits, subreddit=self - ), - } - self._reddit.post(API_PATH["subscribe"], data=data) - - def traffic(self) -> dict[str, list[list[int]]]: - """Return a dictionary of the :class:`.Subreddit`'s traffic statistics. - - :raises: ``prawcore.NotFound`` when the traffic stats aren't available to the - authenticated user, that is, they are not public and the authenticated user - is not a moderator of the subreddit. + if self.subreddit == "mod": + self.subreddit = self.subreddit._reddit.subreddit("all") + return stream_generator( + self.subreddit.modmail.conversations, + attribute_name="id", + exclude_before=True, + other_subreddits=other_subreddits, + sort=sort, + state=state, + **stream_options, + ) - The traffic method returns a dict with three keys. The keys are ``day``, - ``hour`` and ``month``. Each key contains a list of lists with 3 or 4 values. - The first value is a timestamp indicating the start of the category (start of - the day for the ``day`` key, start of the hour for the ``hour`` key, etc.). The - second, third, and fourth values indicate the unique pageviews, total pageviews, - and subscribers, respectively. + @_deprecate_args("only") + def modqueue( + self, *, only: str | None = None, **stream_options: Any + ) -> Generator[praw.models.Comment | praw.models.Submission, None, None]: + r"""Yield :class:`.Comment`\ s and :class:`.Submission`\ s in the modqueue as they become available. - .. note:: + :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield + only results of that type. - The ``hour`` key does not contain subscribers, and therefore each sub-list - contains three values. + Keyword arguments are passed to :func:`.stream_generator`. - For example, to get the traffic stats for r/test: + To print all new modqueue items try: .. code-block:: python - stats = reddit.subreddit("test").traffic() + for item in reddit.subreddit("mod").mod.stream.modqueue(): + print(item) """ - return self._reddit.get(API_PATH["about_traffic"].format(subreddit=self)) + return stream_generator( + self.subreddit.mod.modqueue, only=only, **stream_options + ) - @_deprecate_args("other_subreddits") - def unsubscribe( - self, *, other_subreddits: list[praw.models.Subreddit] | None = None - ): - """Unsubscribe from the subreddit. + @_deprecate_args("only") + def reports( + self, *, only: str | None = None, **stream_options: Any + ) -> Generator[praw.models.Comment | praw.models.Submission, None, None]: + r"""Yield reported :class:`.Comment`\ s and :class:`.Submission`\ s as they become available. - :param other_subreddits: When provided, also unsubscribe from the provided list - of subreddits. + :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield + only results of that type. - To unsubscribe from r/test: + Keyword arguments are passed to :func:`.stream_generator`. + + To print new user and mod report reasons in the report queue try: .. code-block:: python - reddit.subreddit("test").unsubscribe() + for item in reddit.subreddit("mod").mod.stream.reports(): + print(item) """ - data = { - "action": "unsub", - "sr_name": self._subreddit_list( - other_subreddits=other_subreddits, subreddit=self - ), - } - self._reddit.post(API_PATH["subscribe"], data=data) + return stream_generator(self.subreddit.mod.reports, only=only, **stream_options) + + @_deprecate_args("only") + def spam( + self, *, only: str | None = None, **stream_options: Any + ) -> Generator[praw.models.Comment | praw.models.Submission, None, None]: + r"""Yield spam :class:`.Comment`\ s and :class:`.Submission`\ s as they become available. + :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield + only results of that type. -WidgetEncoder._subreddit_class = Subreddit + Keyword arguments are passed to :func:`.stream_generator`. + To print new items in the spam queue try: -class SubredditFilters: - """Provide functions to interact with the special :class:`.Subreddit`'s filters. + .. code-block:: python - Members of this class should be utilized via :meth:`.Subreddit.filters`. For - example, to add a filter, run: + for item in reddit.subreddit("mod").mod.stream.spam(): + print(item) - .. code-block:: python + """ + return stream_generator(self.subreddit.mod.spam, only=only, **stream_options) - reddit.subreddit("all").filters.add("test") + def unmoderated( + self, **stream_options: Any + ) -> Generator[praw.models.Submission, None, None]: + r"""Yield unmoderated :class:`.Submission`\ s as they become available. - """ + Keyword arguments are passed to :func:`.stream_generator`. - def __init__(self, subreddit: praw.models.Subreddit): - """Initialize a :class:`.SubredditFilters` instance. + To print new items in the unmoderated queue try: - :param subreddit: The special subreddit whose filters to work with. + .. code-block:: python - As of this writing filters can only be used with the special subreddits ``all`` - and ``mod``. + for item in reddit.subreddit("mod").mod.stream.unmoderated(): + print(item) """ - self.subreddit = subreddit + return stream_generator(self.subreddit.mod.unmoderated, **stream_options) - def __iter__(self) -> Generator[praw.models.Subreddit, None, None]: - """Iterate through the special :class:`.Subreddit`'s filters. + def unread( + self, **stream_options: Any + ) -> Generator[praw.models.SubredditMessage, None, None]: + """Yield unread old modmail messages as they become available. - This method should be invoked as: + Keyword arguments are passed to :func:`.stream_generator`. + + .. seealso:: + + :meth:`.SubredditModeration.inbox` for all messages. + + To print new mail in the unread modmail queue try: .. code-block:: python - for subreddit in reddit.subreddit("test").filters: - ... + for message in reddit.subreddit("mod").mod.stream.unread(): + print(f"From: {message.author}, To: {message.dest}") """ - url = API_PATH["subreddit_filter_list"].format( - special=self.subreddit, user=self.subreddit._reddit.user.me() - ) - params = {"unique": self.subreddit._reddit._next_unique} - response_data = self.subreddit._reddit.get(url, params=params) - yield from response_data.subreddits + return stream_generator(self.subreddit.mod.unread, **stream_options) + - def add(self, subreddit: praw.models.Subreddit | str): - """Add ``subreddit`` to the list of filtered subreddits. +class SubredditQuarantine: + """Provides subreddit quarantine related methods. - :param subreddit: The subreddit to add to the filter list. + To opt-in into a quarantined subreddit: - Items from subreddits added to the filtered list will no longer be included when - obtaining listings for r/all. + .. code-block:: python - Alternatively, you can filter a subreddit temporarily from a special listing in - a manner like so: + reddit.subreddit("test").quaran.opt_in() - .. code-block:: python + """ - reddit.subreddit("all-redditdev-learnpython") + def __init__(self, subreddit: praw.models.Subreddit): + """Initialize a :class:`.SubredditQuarantine` instance. - :raises: ``prawcore.NotFound`` when calling on a non-special subreddit. + :param subreddit: The :class:`.Subreddit` associated with the quarantine. """ - url = API_PATH["subreddit_filter"].format( - special=self.subreddit, - user=self.subreddit._reddit.user.me(), - subreddit=subreddit, - ) - self.subreddit._reddit.put(url, data={"model": dumps({"name": str(subreddit)})}) + self.subreddit = subreddit - def remove(self, subreddit: praw.models.Subreddit | str): - """Remove ``subreddit`` from the list of filtered subreddits. + def opt_in(self): + """Permit your user access to the quarantined subreddit. - :param subreddit: The subreddit to remove from the filter list. + Usage: - :raises: ``prawcore.NotFound`` when calling on a non-special subreddit. + .. code-block:: python - """ - url = API_PATH["subreddit_filter"].format( - special=self.subreddit, - user=self.subreddit._reddit.user.me(), - subreddit=str(subreddit), - ) - self.subreddit._reddit.delete(url) + subreddit = reddit.subreddit("QUESTIONABLE") + next(subreddit.hot()) # Raises prawcore.Forbidden + subreddit.quaran.opt_in() + next(subreddit.hot()) # Returns Submission -class SubredditFlair: - """Provide a set of functions to interact with a :class:`.Subreddit`'s flair.""" + """ + data = {"sr_name": self.subreddit} + with contextlib.suppress(Redirect): + self.subreddit._reddit.post(API_PATH["quarantine_opt_in"], data=data) - @cachedproperty - def link_templates( - self, - ) -> praw.models.reddit.subreddit.SubredditLinkFlairTemplates: - """Provide an instance of :class:`.SubredditLinkFlairTemplates`. + def opt_out(self): + """Remove access to the quarantined subreddit. - Use this attribute for interacting with a :class:`.Subreddit`'s link flair - templates. For example to list all the link flair templates for a subreddit - which you have the ``flair`` moderator permission on try: + Usage: .. code-block:: python - for template in reddit.subreddit("test").flair.link_templates: - print(template) + subreddit = reddit.subreddit("QUESTIONABLE") + next(subreddit.hot()) # Returns Submission + + subreddit.quaran.opt_out() + next(subreddit.hot()) # Raises prawcore.Forbidden """ - return SubredditLinkFlairTemplates(self.subreddit) + data = {"sr_name": self.subreddit} + with contextlib.suppress(Redirect): + self.subreddit._reddit.post(API_PATH["quarantine_opt_out"], data=data) - @cachedproperty - def templates( - self, - ) -> praw.models.reddit.subreddit.SubredditRedditorFlairTemplates: - """Provide an instance of :class:`.SubredditRedditorFlairTemplates`. - Use this attribute for interacting with a :class:`.Subreddit`'s flair templates. - For example to list all the flair templates for a subreddit which you have the - ``flair`` moderator permission on try: +class SubredditRelationship: + """Represents a relationship between a :class:`.Redditor` and :class:`.Subreddit`. - .. code-block:: python + Instances of this class can be iterated through in order to discover the Redditors + that make up the relationship. - for template in reddit.subreddit("test").flair.templates: - print(template) + For example, banned users of a subreddit can be iterated through like so: - """ - return SubredditRedditorFlairTemplates(self.subreddit) + .. code-block:: python + + for ban in reddit.subreddit("test").banned(): + print(f"{ban}: {ban.note}") + + """ def __call__( self, - redditor: praw.models.Redditor | str | None = None, + redditor: str | praw.models.Redditor | None = None, **generator_kwargs: Any, ) -> Iterator[praw.models.Redditor]: - """Return a :class:`.ListingGenerator` for Redditors and their flairs. + r"""Return a :class:`.ListingGenerator` for :class:`.Redditor`\ s in the relationship. :param redditor: When provided, yield at most a single :class:`.Redditor` - instance (default: ``None``). + instance. This is useful to confirm if a relationship exists, or to fetch + the metadata associated with a particular relationship (default: ``None``). Additional keyword arguments are passed in the initialization of :class:`.ListingGenerator`. - Usage: - - .. code-block:: python - - for flair in reddit.subreddit("test").flair(limit=None): - print(flair) - """ Subreddit._safely_add_arguments( - arguments=generator_kwargs, key="params", name=redditor + arguments=generator_kwargs, key="params", user=redditor ) - generator_kwargs.setdefault("limit", None) - url = API_PATH["flairlist"].format(subreddit=self.subreddit) + url = API_PATH[f"list_{self.relationship}"].format(subreddit=self.subreddit) return ListingGenerator(self.subreddit._reddit, url, **generator_kwargs) - def __init__(self, subreddit: praw.models.Subreddit): - """Initialize a :class:`.SubredditFlair` instance. + def __init__(self, subreddit: praw.models.Subreddit, relationship: str): + """Initialize a :class:`.SubredditRelationship` instance. - :param subreddit: The subreddit whose flair to work with. + :param subreddit: The :class:`.Subreddit` for the relationship. + :param relationship: The name of the relationship. """ + self.relationship = relationship self.subreddit = subreddit - @_deprecate_args("position", "self_assign", "link_position", "link_self_assign") - def configure( - self, - *, - link_position: str = "left", - link_self_assign: bool = False, - position: str = "right", - self_assign: bool = False, - **settings: Any, - ): - """Update the :class:`.Subreddit`'s flair configuration. - - :param link_position: One of ``"left"``, ``"right"``, or ``False`` to disable - (default: ``"left"``). - :param link_self_assign: Permit self assignment of link flair (default: - ``False``). - :param position: One of ``"left"``, ``"right"``, or ``False`` to disable - (default: ``"right"``). - :param self_assign: Permit self assignment of user flair (default: ``False``). - - .. code-block:: python - - subreddit = reddit.subreddit("test") - subreddit.flair.configure(link_position="right", self_assign=True) - - Additional keyword arguments can be provided to handle new settings as Reddit - introduces them. - - """ - data = { - "flair_enabled": bool(position), - "flair_position": position or "right", - "flair_self_assign_enabled": self_assign, - "link_flair_position": link_position or "", - "link_flair_self_assign_enabled": link_self_assign, - } - data.update(settings) - url = API_PATH["flairconfig"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url, data=data) - - def delete(self, redditor: praw.models.Redditor | str): - """Delete flair for a :class:`.Redditor`. + def add(self, redditor: str | praw.models.Redditor, **other_settings: Any): + """Add ``redditor`` to this relationship. :param redditor: A redditor name or :class:`.Redditor` instance. - .. seealso:: - - :meth:`~.SubredditFlair.update` to delete the flair of many redditors at - once. - - """ - url = API_PATH["deleteflair"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url, data={"name": str(redditor)}) - - def delete_all(self) -> list[dict[str, str | bool | dict[str, str]]]: - """Delete all :class:`.Redditor` flair in the :class:`.Subreddit`. - - :returns: List of dictionaries indicating the success or failure of each delete. - """ - return self.update(x["user"] for x in self()) + data = {"name": str(redditor), "type": self.relationship} + data.update(other_settings) + url = API_PATH["friend"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url, data=data) - @_deprecate_args("redditor", "text", "css_class", "flair_template_id") - def set( # noqa: A003 - self, - redditor: praw.models.Redditor | str, - *, - css_class: str = "", - flair_template_id: str | None = None, - text: str = "", - ): - """Set flair for a :class:`.Redditor`. + def remove(self, redditor: str | praw.models.Redditor): + """Remove ``redditor`` from this relationship. :param redditor: A redditor name or :class:`.Redditor` instance. - :param text: The flair text to associate with the :class:`.Redditor` or - :class:`.Submission` (default: ``""``). - :param css_class: The css class to associate with the flair html (default: - ``""``). Use either this or ``flair_template_id``. - :param flair_template_id: The ID of the flair template to be used (default: - ``None``). Use either this or ``css_class``. - - This method can only be used by an authenticated user who is a moderator of the - associated :class:`.Subreddit`. - - For example: - - .. code-block:: python - - reddit.subreddit("test").flair.set("bboe", text="PRAW author", css_class="mods") - template = "6bd28436-1aa7-11e9-9902-0e05ab0fad46" - reddit.subreddit("test").flair.set( - "spez", text="Reddit CEO", flair_template_id=template - ) """ - if css_class and flair_template_id is not None: - msg = "Parameter 'css_class' cannot be used in conjunction with 'flair_template_id'." - raise TypeError(msg) - data = {"name": str(redditor), "text": text} - if flair_template_id is not None: - data["flair_template_id"] = flair_template_id - url = API_PATH["select_flair"].format(subreddit=self.subreddit) - else: - data["css_class"] = css_class - url = API_PATH["flair"].format(subreddit=self.subreddit) + data = {"name": str(redditor), "type": self.relationship} + url = API_PATH["unfriend"].format(subreddit=self.subreddit) self.subreddit._reddit.post(url, data=data) - @_deprecate_args("flair_list", "text", "css_class") - def update( - self, - flair_list: Iterator[ - str | praw.models.Redditor | dict[str, str | praw.models.Redditor] - ], - *, - text: str = "", - css_class: str = "", - ) -> list[dict[str, str | bool | dict[str, str]]]: - """Set or clear the flair for many redditors at once. - :param flair_list: Each item in this list should be either: +class SubredditStream: + """Provides submission and comment streams.""" - - The name of a redditor. - - An instance of :class:`.Redditor`. - - A dictionary mapping keys ``"user"``, ``"flair_text"``, and - ``"flair_css_class"`` to their respective values. The ``"user"`` key - should map to a redditor, as described above. When a dictionary isn't - provided, or the dictionary is missing either ``"flair_text"`` or - ``"flair_css_class"`` keys, the default values will come from the other - arguments. - :param css_class: The css class to use when not explicitly provided in - ``flair_list`` (default: ``""``). - :param text: The flair text to use when not explicitly provided in - ``flair_list`` (default: ``""``). + def __init__(self, subreddit: praw.models.Subreddit): + """Initialize a :class:`.SubredditStream` instance. - :returns: List of dictionaries indicating the success or failure of each update. + :param subreddit: The subreddit associated with the streams. - For example, to clear the flair text, and set the ``"praw"`` flair css class on - a few users try: + """ + self.subreddit = subreddit - .. code-block:: python + def comments( + self, **stream_options: Any + ) -> Generator[praw.models.Comment, None, None]: + """Yield new comments as they become available. - subreddit.flair.update(["bboe", "spez", "spladug"], css_class="praw") + Comments are yielded oldest first. Up to 100 historical comments will initially + be returned. - """ - temp_lines = StringIO() - for item in flair_list: - if isinstance(item, dict): - writer(temp_lines).writerow( - [ - str(item["user"]), - item.get("flair_text", text), - item.get("flair_css_class", css_class), - ] - ) - else: - writer(temp_lines).writerow([str(item), text, css_class]) + Keyword arguments are passed to :func:`.stream_generator`. - lines = temp_lines.getvalue().splitlines() - temp_lines.close() - response = [] - url = API_PATH["flaircsv"].format(subreddit=self.subreddit) - while lines: - data = {"flair_csv": "\n".join(lines[:100])} - response.extend(self.subreddit._reddit.post(url, data=data)) - lines = lines[100:] - return response + .. note:: + While PRAW tries to catch all new comments, some high-volume streams, + especially the r/all stream, may drop some comments. -class SubredditFlairTemplates: - """Provide functions to interact with a :class:`.Subreddit`'s flair templates.""" + For example, to retrieve all new comments made to r/test, try: - @staticmethod - def flair_type(is_link: bool) -> str: - """Return ``"LINK_FLAIR"`` or ``"USER_FLAIR"`` depending on ``is_link`` value.""" - return "LINK_FLAIR" if is_link else "USER_FLAIR" + .. code-block:: python - def __init__(self, subreddit: praw.models.Subreddit): - """Initialize a :class:`.SubredditFlairTemplates` instance. + for comment in reddit.subreddit("test").stream.comments(): + print(comment) - :param subreddit: The subreddit whose flair templates to work with. + To only retrieve new submissions starting when the stream is created, pass + ``skip_existing=True``: - .. note:: + .. code-block:: python - This class should not be initialized directly. Instead, obtain an instance - via: ``reddit.subreddit("test").flair.templates`` or - ``reddit.subreddit("test").flair.link_templates``. + subreddit = reddit.subreddit("test") + for comment in subreddit.stream.comments(skip_existing=True): + print(comment) """ - self.subreddit = subreddit + return stream_generator(self.subreddit.comments, **stream_options) - def __iter__(self): - """Abstract method to return flair templates.""" - raise NotImplementedError + def submissions( + self, **stream_options: Any + ) -> Generator[praw.models.Submission, None, None]: + r"""Yield new :class:`.Submission`\ s as they become available. - def _add( - self, - *, - allowable_content: str | None = None, - background_color: str | None = None, - css_class: str = "", - is_link: bool | None = None, - max_emojis: int | None = None, - mod_only: bool | None = None, - text: str, - text_color: str | None = None, - text_editable: bool = False, - ): - url = API_PATH["flairtemplate_v2"].format(subreddit=self.subreddit) - data = { - "allowable_content": allowable_content, - "background_color": background_color, - "css_class": css_class, - "flair_type": self.flair_type(is_link), - "max_emojis": max_emojis, - "mod_only": bool(mod_only), - "text": text, - "text_color": text_color, - "text_editable": bool(text_editable), - } - self.subreddit._reddit.post(url, data=data) + Submissions are yielded oldest first. Up to 100 historical submissions will + initially be returned. - def _clear(self, *, is_link: bool | None = None): # noqa: ANN001 - url = API_PATH["flairtemplateclear"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url, data={"flair_type": self.flair_type(is_link)}) + Keyword arguments are passed to :func:`.stream_generator`. - def delete(self, template_id: str): - """Remove a flair template provided by ``template_id``. + .. note:: - For example, to delete the first :class:`.Redditor` flair template listed, try: + While PRAW tries to catch all new submissions, some high-volume streams, + especially the r/all stream, may drop some submissions. + + For example, to retrieve all new submissions made to all of Reddit, try: .. code-block:: python - template_info = list(subreddit.flair.templates)[0] - subreddit.flair.templates.delete(template_info["id"]) + for submission in reddit.subreddit("all").stream.submissions(): + print(submission) """ - url = API_PATH["flairtemplatedelete"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url, data={"flair_template_id": template_id}) + return stream_generator(self.subreddit.new, **stream_options) - def _reorder( - self, flair_list: list, *, is_link: bool | None = None - ): # noqa: ANN001 - url = API_PATH["flairtemplatereorder"].format(subreddit=self.subreddit) - self.subreddit._reddit.patch( - url, - params={ - "flair_type": self.flair_type(is_link), - "subreddit": self.subreddit.display_name, - }, - json=flair_list, - ) - @_deprecate_args( - "template_id", - "text", - "css_class", - "text_editable", - "background_color", - "text_color", - "mod_only", - "allowable_content", - "max_emojis", - "fetch", - ) - def update( - self, - template_id: str, - *, - allowable_content: str | None = None, - background_color: str | None = None, - css_class: str | None = None, - fetch: bool = True, - max_emojis: int | None = None, - mod_only: bool | None = None, - text: str | None = None, - text_color: str | None = None, - text_editable: bool | None = None, - ): - """Update the flair template provided by ``template_id``. +class SubredditStylesheet: + """Provides a set of stylesheet functions to a :class:`.Subreddit`. - :param template_id: The flair template to update. If not valid then an exception - will be thrown. - :param allowable_content: If specified, most be one of ``"all"``, ``"emoji"``, - or ``"text"`` to restrict content to that type. If set to ``"emoji"`` then - the ``"text"`` param must be a valid emoji string, for example, - ``":snoo:"``. - :param background_color: The flair template's new background color, as a hex - color. - :param css_class: The flair template's new css_class (default: ``""``). - :param fetch: Whether PRAW will fetch existing information on the existing flair - before updating (default: ``True``). - :param max_emojis: Maximum emojis in the flair (Reddit defaults this value to - ``10``). - :param mod_only: Indicate if the flair can only be used by moderators. - :param text: The flair template's new text. - :param text_color: The flair template's new text color, either ``"light"`` or - ``"dark"``. - :param text_editable: Indicate if the flair text can be modified for each - redditor that sets it (default: ``False``). + For example, to add the css data ``.test{color:blue}`` to the existing stylesheet: - .. warning:: + .. code-block:: python - If parameter ``fetch`` is set to ``False``, all parameters not provided will - be reset to their default (``None`` or ``False``) values. + subreddit = reddit.subreddit("test") + stylesheet = subreddit.stylesheet() + stylesheet.stylesheet += ".test{color:blue}" + subreddit.stylesheet.update(stylesheet.stylesheet) - For example, to make a user flair template text editable, try: + """ + + def __call__(self) -> praw.models.Stylesheet: + """Return the :class:`.Subreddit`'s stylesheet. + + To be used as: .. code-block:: python - template_info = list(subreddit.flair.templates)[0] - subreddit.flair.templates.update( - template_info["id"], - text=template_info["flair_text"], - text_editable=True, - ) + stylesheet = reddit.subreddit("test").stylesheet() """ - url = API_PATH["flairtemplate_v2"].format(subreddit=self.subreddit) - data = { - "allowable_content": allowable_content, - "background_color": background_color, - "css_class": css_class, - "flair_template_id": template_id, - "max_emojis": max_emojis, - "mod_only": mod_only, - "text": text, - "text_color": text_color, - "text_editable": text_editable, - } - if fetch: - _existing_data = [ - template for template in iter(self) if template["id"] == template_id - ] - if len(_existing_data) != 1: - raise InvalidFlairTemplateID(template_id) - existing_data = _existing_data[0] - for key, value in existing_data.items(): - if data.get(key) is None: - data[key] = value - self.subreddit._reddit.post(url, data=data) + url = API_PATH["about_stylesheet"].format(subreddit=self.subreddit) + return self.subreddit._reddit.get(url) + def __init__(self, subreddit: praw.models.Subreddit): + """Initialize a :class:`.SubredditStylesheet` instance. -class SubredditModeration: - """Provides a set of moderation functions to a :class:`.Subreddit`. + :param subreddit: The :class:`.Subreddit` associated with the stylesheet. - For example, to accept a moderation invite from r/test: + An instance of this class is provided as: - .. code-block:: python + .. code-block:: python + + reddit.subreddit("test").stylesheet + + """ + self.subreddit = subreddit + + def _update_structured_styles(self, style_data: dict[str, str | Any]): + url = API_PATH["structured_styles"].format(subreddit=self.subreddit) + self.subreddit._reddit.patch(url, data=style_data) + + def _upload_image( + self, *, data: dict[str, str | Any], image_path: str + ) -> dict[str, Any]: + with Path(image_path).open("rb") as image: + header = image.read(len(JPEG_HEADER)) + image.seek(0) + data["img_type"] = "jpg" if header == JPEG_HEADER else "png" + url = API_PATH["upload_image"].format(subreddit=self.subreddit) + response = self.subreddit._reddit.post( + url, data=data, files={"file": image} + ) + if response["errors"]: + error_type = response["errors"][0] + error_value = response.get("errors_values", [""])[0] + assert error_type in [ + "BAD_CSS_NAME", + "IMAGE_ERROR", + ], "Please file a bug with PRAW." + raise RedditAPIException([[error_type, error_value, None]]) + return response - reddit.subreddit("test").mod.accept_invite() + def _upload_style_asset(self, *, image_path: str, image_type: str) -> str: + file = Path(image_path) + data = {"imagetype": image_type, "filepath": file.name} + data["mimetype"] = "image/jpeg" + if image_path.lower().endswith(".png"): + data["mimetype"] = "image/png" + url = API_PATH["style_asset_lease"].format(subreddit=self.subreddit) - """ + upload_lease = self.subreddit._reddit.post(url, data=data)["s3UploadLease"] + upload_data = {item["name"]: item["value"] for item in upload_lease["fields"]} + upload_url = f"https:{upload_lease['action']}" - @staticmethod - def _handle_only( - *, generator_kwargs: dict[str, Any], only: str | None - ): # noqa: ANN001 - if only is not None: - if only == "submissions": - only = "links" - RedditBase._safely_add_arguments( - arguments=generator_kwargs, key="params", only=only + with file.open("rb") as image: + response = self.subreddit._reddit._core._requestor._http.post( + upload_url, data=upload_data, files={"file": image} ) + response.raise_for_status() - @cachedproperty - def notes(self) -> praw.models.SubredditModNotes: - """Provide an instance of :class:`.SubredditModNotes`. + return f"{upload_url}/{upload_data['key']}" - This provides an interface for managing moderator notes for this subreddit. + def delete_banner(self): + """Remove the current :class:`.Subreddit` (redesign) banner image. - For example, all the notes for u/spez in r/test can be iterated through like so: + Succeeds even if there is no banner image. - .. code-block:: python + For example: - subreddit = reddit.subreddit("test") + .. code-block:: python - for note in subreddit.mod.notes.redditors("spez"): - print(f"{note.label}: {note.note}") + reddit.subreddit("test").stylesheet.delete_banner() """ - from praw.models.mod_notes import SubredditModNotes + data = {"bannerBackgroundImage": ""} + self._update_structured_styles(data) - return SubredditModNotes(self.subreddit._reddit, subreddit=self.subreddit) + def delete_banner_additional_image(self): + """Remove the current :class:`.Subreddit` (redesign) banner additional image. - @cachedproperty - def removal_reasons(self) -> SubredditRemovalReasons: - """Provide an instance of :class:`.SubredditRemovalReasons`. + Succeeds even if there is no additional image. Will also delete any configured + hover image. - Use this attribute for interacting with a :class:`.Subreddit`'s removal reasons. - For example to list all the removal reasons for a subreddit which you have the - ``posts`` moderator permission on, try: + For example: .. code-block:: python - for removal_reason in reddit.subreddit("test").mod.removal_reasons: - print(removal_reason) + reddit.subreddit("test").stylesheet.delete_banner_additional_image() - A single removal reason can be lazily retrieved via: + """ + data = {"bannerPositionedImage": "", "secondaryBannerPositionedImage": ""} + self._update_structured_styles(data) - .. code-block:: python + def delete_banner_hover_image(self): + """Remove the current :class:`.Subreddit` (redesign) banner hover image. - reddit.subreddit("test").mod.removal_reasons["reason_id"] + Succeeds even if there is no hover image. - .. note:: + For example: - Attempting to access attributes of an nonexistent removal reason will result - in a :class:`.ClientException`. + .. code-block:: python + + reddit.subreddit("test").stylesheet.delete_banner_hover_image() """ - return SubredditRemovalReasons(self.subreddit) + data = {"secondaryBannerPositionedImage": ""} + self._update_structured_styles(data) - @cachedproperty - def stream(self) -> praw.models.reddit.subreddit.SubredditModerationStream: - """Provide an instance of :class:`.SubredditModerationStream`. + def delete_header(self): + """Remove the current :class:`.Subreddit` header image. - Streams can be used to indefinitely retrieve Moderator only items from - :class:`.SubredditModeration` made to moderated subreddits, like: + Succeeds even if there is no header image. + + For example: .. code-block:: python - for log in reddit.subreddit("mod").mod.stream.log(): - print(f"Mod: {log.mod}, Subreddit: {log.subreddit}") + reddit.subreddit("test").stylesheet.delete_header() """ - return SubredditModerationStream(self.subreddit) + url = API_PATH["delete_sr_header"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url) - def __init__(self, subreddit: praw.models.Subreddit): - """Initialize a :class:`.SubredditModeration` instance. + def delete_image(self, name: str): + """Remove the named image from the :class:`.Subreddit`. - :param subreddit: The subreddit to moderate. + Succeeds even if the named image does not exist. - """ - self.subreddit = subreddit - self._stream = None + For example: - def accept_invite(self): - """Accept an invitation as a moderator of the community.""" - url = API_PATH["accept_mod_invite"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url) + .. code-block:: python - @_deprecate_args("only") - def edited( - self, *, only: str | None = None, **generator_kwargs: Any - ) -> Iterator[praw.models.Comment | praw.models.Submission]: - """Return a :class:`.ListingGenerator` for edited comments and submissions. + reddit.subreddit("test").stylesheet.delete_image("smile") - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + """ + url = API_PATH["delete_sr_image"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url, data={"img_name": name}) - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + def delete_mobile_banner(self): + """Remove the current :class:`.Subreddit` (redesign) mobile banner. - To print all items in the edited queue try: + Succeeds even if there is no mobile banner. + + For example: .. code-block:: python - for item in reddit.subreddit("mod").mod.edited(limit=None): - print(item) + subreddit = reddit.subreddit("test") + subreddit.stylesheet.delete_banner_hover_image() """ - self._handle_only(generator_kwargs=generator_kwargs, only=only) - return ListingGenerator( - self.subreddit._reddit, - API_PATH["about_edited"].format(subreddit=self.subreddit), - **generator_kwargs, - ) + data = {"mobileBannerImage": ""} + self._update_structured_styles(data) - def inbox(self, **generator_kwargs: Any) -> Iterator[praw.models.SubredditMessage]: - """Return a :class:`.ListingGenerator` for moderator messages. + def delete_mobile_header(self): + """Remove the current :class:`.Subreddit` mobile header. - .. warning:: + Succeeds even if there is no mobile header. - Legacy modmail is being deprecated in June 2021. Please see - https://www.reddit.com/r/modnews/comments/mar9ha/even_more_modmail_improvements/ - for more info. + For example: - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + .. code-block:: python - .. seealso:: + reddit.subreddit("test").stylesheet.delete_mobile_header() - :meth:`.unread` for unread moderator messages. + """ + url = API_PATH["delete_sr_header"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url) - To print the last 5 moderator mail messages and their replies, try: + def delete_mobile_icon(self): + """Remove the current :class:`.Subreddit` mobile icon. + + Succeeds even if there is no mobile icon. + + For example: .. code-block:: python - for message in reddit.subreddit("mod").mod.inbox(limit=5): - print(f"From: {message.author}, Body: {message.body}") - for reply in message.replies: - print(f"From: {reply.author}, Body: {reply.body}") + reddit.subreddit("test").stylesheet.delete_mobile_icon() """ - warn( - "Legacy modmail is being deprecated in June 2021. Please see" - " https://www.reddit.com/r/modnews/comments/mar9ha/even_more_modmail_improvements/" - " for more info.", - category=DeprecationWarning, - stacklevel=3, - ) - return ListingGenerator( - self.subreddit._reddit, - API_PATH["moderator_messages"].format(subreddit=self.subreddit), - **generator_kwargs, - ) - - @_deprecate_args("action", "mod") - def log( - self, - *, - action: str | None = None, - mod: praw.models.Redditor | str | None = None, - **generator_kwargs: Any, - ) -> Iterator[praw.models.ModAction]: - """Return a :class:`.ListingGenerator` for moderator log entries. + url = API_PATH["delete_sr_icon"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url) - :param action: If given, only return log entries for the specified action. - :param mod: If given, only return log entries for actions made by the passed in - redditor. + @_deprecate_args("stylesheet", "reason") + def update(self, stylesheet: str, *, reason: str | None = None): + """Update the :class:`.Subreddit`'s stylesheet. - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + :param stylesheet: The CSS for the new stylesheet. + :param reason: The reason for updating the stylesheet. - To print the moderator and subreddit of the last 5 modlog entries try: + For example: .. code-block:: python - for log in reddit.subreddit("mod").mod.log(limit=5): - print(f"Mod: {log.mod}, Subreddit: {log.subreddit}") + reddit.subreddit("test").stylesheet.update( + "p { color: green; }", reason="color text green" + ) """ - params = {"mod": str(mod) if mod else mod, "type": action} - Subreddit._safely_add_arguments( - arguments=generator_kwargs, key="params", **params - ) - return ListingGenerator( - self.subreddit._reddit, - API_PATH["about_log"].format(subreddit=self.subreddit), - **generator_kwargs, - ) + data = {"op": "save", "reason": reason, "stylesheet_contents": stylesheet} + url = API_PATH["subreddit_stylesheet"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url, data=data) - @_deprecate_args("only") - def modqueue( - self, *, only: str | None = None, **generator_kwargs: Any - ) -> Iterator[praw.models.Submission | praw.models.Comment]: - """Return a :class:`.ListingGenerator` for modqueue items. + @_deprecate_args("name", "image_path") + def upload(self, *, image_path: str, name: str) -> dict[str, str]: + """Upload an image to the :class:`.Subreddit`. - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + :param image_path: A path to a jpeg or png image. + :param name: The name to use for the image. If an image already exists with the + same name, it will be replaced. - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + :returns: A dictionary containing a link to the uploaded image under the key + ``img_src``. - To print all modqueue items try: + :raises: ``prawcore.TooLarge`` if the overall request body is too large. + + :raises: :class:`.RedditAPIException` if there are other issues with the + uploaded image. Unfortunately the exception info might not be very specific, + so try through the website with the same image to see what the problem + actually might be. + + For example: .. code-block:: python - for item in reddit.subreddit("mod").mod.modqueue(limit=None): - print(item) + reddit.subreddit("test").stylesheet.upload(name="smile", image_path="img.png") """ - self._handle_only(generator_kwargs=generator_kwargs, only=only) - return ListingGenerator( - self.subreddit._reddit, - API_PATH["about_modqueue"].format(subreddit=self.subreddit), - **generator_kwargs, + return self._upload_image( + data={"name": name, "upload_type": "img"}, image_path=image_path ) - @_deprecate_args("only") - def reports( - self, *, only: str | None = None, **generator_kwargs: Any - ) -> Iterator[praw.models.Submission | praw.models.Comment]: - """Return a :class:`.ListingGenerator` for reported comments and submissions. + def upload_banner(self, image_path: str): + """Upload an image for the :class:`.Subreddit`'s (redesign) banner image. - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + :param image_path: A path to a jpeg or png image. - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + :raises: ``prawcore.TooLarge`` if the overall request body is too large. - To print the user and mod report reasons in the report queue try: + :raises: :class:`.RedditAPIException` if there are other issues with the + uploaded image. Unfortunately the exception info might not be very specific, + so try through the website with the same image to see what the problem + actually might be. + + For example: .. code-block:: python - for reported_item in reddit.subreddit("mod").mod.reports(): - print(f"User Reports: {reported_item.user_reports}") - print(f"Mod Reports: {reported_item.mod_reports}") + reddit.subreddit("test").stylesheet.upload_banner("banner.png") """ - self._handle_only(generator_kwargs=generator_kwargs, only=only) - return ListingGenerator( - self.subreddit._reddit, - API_PATH["about_reports"].format(subreddit=self.subreddit), - **generator_kwargs, + image_type = "bannerBackgroundImage" + image_url = self._upload_style_asset( + image_path=image_path, image_type=image_type ) + self._update_structured_styles({image_type: image_url}) - def settings(self) -> dict[str, str | int | bool]: - """Return a dictionary of the :class:`.Subreddit`'s current settings.""" - url = API_PATH["subreddit_settings"].format(subreddit=self.subreddit) - return self.subreddit._reddit.get(url)["data"] + @_deprecate_args("image_path", "align") + def upload_banner_additional_image( + self, + image_path: str, + *, + align: str | None = None, + ): + """Upload an image for the :class:`.Subreddit`'s (redesign) additional image. - @_deprecate_args("only") - def spam( - self, *, only: str | None = None, **generator_kwargs: Any - ) -> Iterator[praw.models.Submission | praw.models.Comment]: - """Return a :class:`.ListingGenerator` for spam comments and submissions. + :param image_path: A path to a jpeg or png image. + :param align: Either ``"left"``, ``"centered"``, or ``"right"``. (default: + ``"left"``). - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + :raises: ``prawcore.TooLarge`` if the overall request body is too large. - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + :raises: :class:`.RedditAPIException` if there are other issues with the + uploaded image. Unfortunately the exception info might not be very specific, + so try through the website with the same image to see what the problem + actually might be. - To print the items in the spam queue try: + For example: .. code-block:: python - for item in reddit.subreddit("mod").mod.spam(): - print(item) + subreddit = reddit.subreddit("test") + subreddit.stylesheet.upload_banner_additional_image("banner.png") """ - self._handle_only(generator_kwargs=generator_kwargs, only=only) - return ListingGenerator( - self.subreddit._reddit, - API_PATH["about_spam"].format(subreddit=self.subreddit), - **generator_kwargs, + alignment = {} + if align is not None: + if align not in {"left", "centered", "right"}: + msg = "'align' argument must be either 'left', 'centered', or 'right'" + raise ValueError(msg) + alignment["bannerPositionedImagePosition"] = align + + image_type = "bannerPositionedImage" + image_url = self._upload_style_asset( + image_path=image_path, image_type=image_type ) + style_data = {image_type: image_url} + if alignment: + style_data.update(alignment) + self._update_structured_styles(style_data) - def unmoderated(self, **generator_kwargs: Any) -> Iterator[praw.models.Submission]: - """Return a :class:`.ListingGenerator` for unmoderated submissions. + def upload_banner_hover_image(self, image_path: str): + """Upload an image for the :class:`.Subreddit`'s (redesign) additional image. - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + :param image_path: A path to a jpeg or png image. - To print the items in the unmoderated queue try: + Fails if the :class:`.Subreddit` does not have an additional image defined. + + :raises: ``prawcore.TooLarge`` if the overall request body is too large. + + :raises: :class:`.RedditAPIException` if there are other issues with the + uploaded image. Unfortunately the exception info might not be very specific, + so try through the website with the same image to see what the problem + actually might be. + + For example: .. code-block:: python - for item in reddit.subreddit("mod").mod.unmoderated(): - print(item) + subreddit = reddit.subreddit("test") + subreddit.stylesheet.upload_banner_hover_image("banner.png") """ - return ListingGenerator( - self.subreddit._reddit, - API_PATH["about_unmoderated"].format(subreddit=self.subreddit), - **generator_kwargs, + image_type = "secondaryBannerPositionedImage" + image_url = self._upload_style_asset( + image_path=image_path, image_type=image_type ) + self._update_structured_styles({image_type: image_url}) - def unread(self, **generator_kwargs: Any) -> Iterator[praw.models.SubredditMessage]: - """Return a :class:`.ListingGenerator` for unread moderator messages. - - .. warning:: + def upload_header(self, image_path: str) -> dict[str, str]: + """Upload an image to be used as the :class:`.Subreddit`'s header image. - Legacy modmail is being deprecated in June 2021. Please see - https://www.reddit.com/r/modnews/comments/mar9ha/even_more_modmail_improvements/ - for more info. + :param image_path: A path to a jpeg or png image. - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + :returns: A dictionary containing a link to the uploaded image under the key + ``img_src``. - .. seealso:: + :raises: ``prawcore.TooLarge`` if the overall request body is too large. - :meth:`.inbox` for all messages. + :raises: :class:`.RedditAPIException` if there are other issues with the + uploaded image. Unfortunately the exception info might not be very specific, + so try through the website with the same image to see what the problem + actually might be. - To print the mail in the unread modmail queue try: + For example: .. code-block:: python - for message in reddit.subreddit("mod").mod.unread(): - print(f"From: {message.author}, To: {message.dest}") + reddit.subreddit("test").stylesheet.upload_header("header.png") """ - warn( - "Legacy modmail is being deprecated in June 2021. Please see" - " https://www.reddit.com/r/modnews/comments/mar9ha/even_more_modmail_improvements/" - " for more info.", - category=DeprecationWarning, - stacklevel=3, - ) - return ListingGenerator( - self.subreddit._reddit, - API_PATH["moderator_unread"].format(subreddit=self.subreddit), - **generator_kwargs, - ) + return self._upload_image(data={"upload_type": "header"}, image_path=image_path) - def update(self, **settings: str | int | bool) -> dict[str, str | int | bool]: - """Update the :class:`.Subreddit`'s settings. + def upload_mobile_banner(self, image_path: str): + """Upload an image for the :class:`.Subreddit`'s (redesign) mobile banner. - See https://www.reddit.com/dev/api#POST_api_site_admin for the full list. + :param image_path: A path to a JPEG or PNG image. - :param all_original_content: Mandate all submissions to be original content - only. - :param allow_chat_post_creation: Allow users to create chat submissions. - :param allow_images: Allow users to upload images using the native image - hosting. - :param allow_polls: Allow users to post polls to the subreddit. - :param allow_post_crossposts: Allow users to crosspost submissions from other - subreddits. - :param allow_videos: Allow users to upload videos using the native image - hosting. - :param collapse_deleted_comments: Collapse deleted and removed comments on - comments pages by default. - :param comment_score_hide_mins: The number of minutes to hide comment scores. - :param content_options: The types of submissions users can make. One of - ``"any"``, ``"link"``, or ``"self"``. - :param crowd_control_chat_level: Controls the crowd control level for chat - rooms. Goes from 0-3. - :param crowd_control_level: Controls the crowd control level for submissions. - Goes from 0-3. - :param crowd_control_mode: Enables/disables crowd control. - :param default_set: Allow the subreddit to appear on r/all as well as the - default and trending lists. - :param disable_contributor_requests: Specifies whether redditors may send - automated modmail messages requesting approval as a submitter. - :param exclude_banned_modqueue: Exclude posts by site-wide banned users from - modqueue/unmoderated. - :param free_form_reports: Allow users to specify custom reasons in the report - menu. - :param header_hover_text: The text seen when hovering over the snoo. - :param hide_ads: Don't show ads within this subreddit. Only applies to - Premium-user only subreddits. - :param key_color: A 6-digit rgb hex color (e.g., ``"#AABBCC"``), used as a - thematic color for your subreddit on mobile. - :param language: A valid IETF language tag (underscore separated). - :param original_content_tag_enabled: Enables the use of the ``original content`` - label for submissions. - :param over_18: Viewers must be over 18 years old (i.e., NSFW). - :param public_description: Public description blurb. Appears in search results - and on the landing page for private subreddits. - :param restrict_commenting: Specifies whether approved users have the ability to - comment. - :param restrict_posting: Specifies whether approved users have the ability to - submit posts. - :param show_media: Show thumbnails on submissions. - :param show_media_preview: Expand media previews on comments pages. - :param spam_comments: Spam filter strength for comments. One of ``"all"``, - ``"low"``, or ``"high"``. - :param spam_links: Spam filter strength for links. One of ``"all"``, ``"low"``, - or ``"high"``. - :param spam_selfposts: Spam filter strength for selfposts. One of ``"all"``, - ``"low"``, or ``"high"``. - :param spoilers_enabled: Enable marking posts as containing spoilers. - :param submit_link_label: Custom label for submit link button (``None`` for - default). - :param submit_text: Text to show on submission page. - :param submit_text_label: Custom label for submit text post button (``None`` for - default). - :param subreddit_type: One of ``"archived"``, ``"employees_only"``, - ``"gold_only"``, ``gold_restricted``, ``"private"``, ``"public"``, or - ``"restricted"``. - :param suggested_comment_sort: All comment threads will use this sorting method - by default. Leave ``None``, or choose one of ``"confidence"``, - ``"controversial"``, ``"live"``, ``"new"``, ``"old"``, ``"qa"``, - ``"random"``, or ``"top"``. - :param title: The title of the subreddit. - :param welcome_message_enabled: Enables the subreddit welcome message. - :param welcome_message_text: The text to be used as a welcome message. A welcome - message is sent to all new subscribers by a Reddit bot. - :param wiki_edit_age: Account age, in days, required to edit and create wiki - pages. - :param wiki_edit_karma: Subreddit karma required to edit and create wiki pages. - :param wikimode: One of ``"anyone"``, ``"disabled"``, or ``"modonly"``. + For example: + + .. code-block:: python + + subreddit = reddit.subreddit("test") + subreddit.stylesheet.upload_mobile_banner("banner.png") + + Fails if the :class:`.Subreddit` does not have an additional image defined. + + :raises: ``prawcore.TooLarge`` if the overall request body is too large. + + :raises: :class:`.RedditAPIException` if there are other issues with the + uploaded image. Unfortunately the exception info might not be very specific, + so try through the website with the same image to see what the problem + actually might be. - .. note:: + """ + image_type = "mobileBannerImage" + image_url = self._upload_style_asset( + image_path=image_path, image_type=image_type + ) + self._update_structured_styles({image_type: image_url}) - Updating the subreddit sidebar on old reddit (``description``) is no longer - supported using this method. You can update the sidebar by editing the - ``"config/sidebar"`` wiki page. For example: + def upload_mobile_header(self, image_path: str) -> dict[str, str]: + """Upload an image to be used as the :class:`.Subreddit`'s mobile header. - .. code-block:: python + :param image_path: A path to a jpeg or png image. - sidebar = reddit.subreddit("test").wiki["config/sidebar"] - sidebar.edit(content="new sidebar content") + :returns: A dictionary containing a link to the uploaded image under the key + ``img_src``. - Additional keyword arguments can be provided to handle new settings as Reddit - introduces them. + :raises: ``prawcore.TooLarge`` if the overall request body is too large. - Settings that are documented here and aren't explicitly set by you in a call to - :meth:`.SubredditModeration.update` should retain their current value. If they - do not, please file a bug. + :raises: :class:`.RedditAPIException` if there are other issues with the + uploaded image. Unfortunately the exception info might not be very specific, + so try through the website with the same image to see what the problem + actually might be. + + For example: + + .. code-block:: python + + reddit.subreddit("test").stylesheet.upload_mobile_header("header.png") """ - # These attributes come out using different names than they go in. - remap = { - "content_options": "link_type", - "default_set": "allow_top", - "header_hover_text": "header_title", - "language": "lang", - "subreddit_type": "type", - } - settings = {remap.get(key, key): value for key, value in settings.items()} - settings["sr"] = self.subreddit.fullname - return self.subreddit._reddit.patch(API_PATH["update_settings"], json=settings) + return self._upload_image(data={"upload_type": "banner"}, image_path=image_path) + + def upload_mobile_icon(self, image_path: str) -> dict[str, str]: + """Upload an image to be used as the :class:`.Subreddit`'s mobile icon. + :param image_path: A path to a jpeg or png image. -class SubredditModerationStream: - """Provides moderator streams.""" + :returns: A dictionary containing a link to the uploaded image under the key + ``img_src``. - def __init__(self, subreddit: praw.models.Subreddit): - """Initialize a :class:`.SubredditModerationStream` instance. + :raises: ``prawcore.TooLarge`` if the overall request body is too large. - :param subreddit: The moderated subreddit associated with the streams. + :raises: :class:`.RedditAPIException` if there are other issues with the + uploaded image. Unfortunately the exception info might not be very specific, + so try through the website with the same image to see what the problem + actually might be. + + For example: + + .. code-block:: python + + reddit.subreddit("test").stylesheet.upload_mobile_icon("icon.png") """ - self.subreddit = subreddit + return self._upload_image(data={"upload_type": "icon"}, image_path=image_path) - @_deprecate_args("only") - def edited( - self, *, only: str | None = None, **stream_options: Any - ) -> Generator[praw.models.Comment | praw.models.Submission, None, None]: - """Yield edited comments and submissions as they become available. - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. +class SubredditWiki: + """Provides a set of wiki functions to a :class:`.Subreddit`.""" - Keyword arguments are passed to :func:`.stream_generator`. + def __getitem__(self, page_name: str) -> WikiPage: + """Lazily return the :class:`.WikiPage` for the :class:`.Subreddit` named ``page_name``. - For example, to retrieve all new edited submissions/comments made to all - moderated subreddits, try: + This method is to be used to fetch a specific wikipage, like so: .. code-block:: python - for item in reddit.subreddit("mod").mod.stream.edited(): - print(item) + wikipage = reddit.subreddit("test").wiki["proof"] + print(wikipage.content_md) """ - return stream_generator(self.subreddit.mod.edited, only=only, **stream_options) + return WikiPage(self.subreddit._reddit, self.subreddit, page_name.lower()) - @_deprecate_args("action", "mod") - def log( + def __init__(self, subreddit: praw.models.Subreddit): + """Initialize a :class:`.SubredditWiki` instance. + + :param subreddit: The subreddit whose wiki to work with. + + """ + self.banned = SubredditRelationship(subreddit, "wikibanned") + self.contributor = SubredditRelationship(subreddit, "wikicontributor") + self.subreddit = subreddit + + def __iter__(self) -> Generator[WikiPage, None, None]: + """Iterate through the pages of the wiki. + + This method is to be used to discover all wikipages for a subreddit: + + .. code-block:: python + + for wikipage in reddit.subreddit("test").wiki: + print(wikipage) + + """ + response = self.subreddit._reddit.get( + API_PATH["wiki_pages"].format(subreddit=self.subreddit), + params={"unique": self.subreddit._reddit._next_unique}, + ) + for page_name in response["data"]: + yield WikiPage(self.subreddit._reddit, self.subreddit, page_name) + + @_deprecate_args("name", "content", "reason") + def create( self, *, - action: str | None = None, - mod: str | praw.models.Redditor | None = None, - **stream_options: Any, - ) -> Generator[praw.models.ModAction, None, None]: - """Yield moderator log entries as they become available. + content: str, + name: str, + reason: str | None = None, + **other_settings: Any, + ) -> WikiPage: + """Create a new :class:`.WikiPage`. - :param action: If given, only return log entries for the specified action. - :param mod: If given, only return log entries for actions made by the passed in - redditor. + :param name: The name of the new :class:`.WikiPage`. This name will be + normalized. + :param content: The content of the new :class:`.WikiPage`. + :param reason: The reason for the creation. + :param other_settings: Additional keyword arguments to pass. - For example, to retrieve all new mod actions made to all moderated subreddits, - try: + To create the wiki page ``"praw_test"`` in r/test try: .. code-block:: python - for log in reddit.subreddit("mod").mod.stream.log(): - print(f"Mod: {log.mod}, Subreddit: {log.subreddit}") + reddit.subreddit("test").wiki.create( + name="praw_test", content="wiki body text", reason="PRAW Test Creation" + ) """ - return stream_generator( - self.subreddit.mod.log, - attribute_name="id", - action=action, - mod=mod, - **stream_options, + name = name.replace(" ", "_").lower() + new = WikiPage(self.subreddit._reddit, self.subreddit, name) + new.edit(content=content, reason=reason, **other_settings) + return new + + def revisions( + self, **generator_kwargs: Any + ) -> Generator[ + dict[str, praw.models.Redditor | WikiPage | str | int | bool | None], + None, + None, + ]: + """Return a :class:`.ListingGenerator` for recent wiki revisions. + + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. + + To view the wiki revisions for ``"praw_test"`` in r/test try: + + .. code-block:: python + + for item in reddit.subreddit("test").wiki["praw_test"].revisions(): + print(item) + + """ + url = API_PATH["wiki_revisions"].format(subreddit=self.subreddit) + return WikiPage._revision_generator( + generator_kwargs=generator_kwargs, subreddit=self.subreddit, url=url ) - @_deprecate_args("other_subreddits", "sort", "state") - def modmail_conversations( - self, + +class ContributorRelationship(SubredditRelationship): + r"""Provides methods to interact with a :class:`.Subreddit`'s contributors. + + Contributors are also known as approved submitters. + + Contributors of a subreddit can be iterated through like so: + + .. code-block:: python + + for contributor in reddit.subreddit("test").contributor(): + print(contributor) + + """ + + def leave(self): + """Abdicate the contributor position.""" + self.subreddit._reddit.post( + API_PATH["leavecontributor"], data={"id": self.subreddit.fullname} + ) + + +class ModeratorRelationship(SubredditRelationship): + r"""Provides methods to interact with a :class:`.Subreddit`'s moderators. + + Moderators of a subreddit can be iterated through like so: + + .. code-block:: python + + for moderator in reddit.subreddit("test").moderator(): + print(moderator) + + """ + + PERMISSIONS = { + "access", + "chat_config", + "chat_operator", + "config", + "flair", + "mail", + "posts", + "wiki", + } + + @staticmethod + def _handle_permissions( *, - other_subreddits: list[praw.models.Subreddit] | None = None, - sort: str | None = None, - state: str | None = None, - **stream_options: Any, - ) -> Generator[ModmailConversation, None, None]: - """Yield new-modmail conversations as they become available. + other_settings: dict | None = None, + permissions: list[str] | None = None, + ) -> dict[str, Any]: + other_settings = deepcopy(other_settings) if other_settings else {} + other_settings["permissions"] = permissions_string( + known_permissions=ModeratorRelationship.PERMISSIONS, permissions=permissions + ) + return other_settings - :param other_subreddits: A list of :class:`.Subreddit` instances for which to - fetch conversations (default: ``None``). - :param sort: Can be one of: ``"mod"``, ``"recent"``, ``"unread"``, or ``"user"`` - (default: ``"recent"``). - :param state: Can be one of: ``"all"``, ``"appeals"``, ``"archived"``, - ``"default"``, ``"highlighted"``, ``"inbox"``, ``"inprogress"``, - ``"join_requests"``, ``"mod"``, ``"new"``, or ``"notifications"`` (default: - ``"all"``). ``"all"`` does not include mod or archived conversations. - ``"inbox"`` does not include appeals conversations. + def __call__( + self, redditor: str | praw.models.Redditor | None = None + ) -> list[praw.models.Redditor]: + r"""Return a list of :class:`.Redditor`\ s who are moderators. - Keyword arguments are passed to :func:`.stream_generator`. + :param redditor: When provided, return a list containing at most one + :class:`.Redditor` instance. This is useful to confirm if a relationship + exists, or to fetch the metadata associated with a particular relationship + (default: ``None``). - To print new mail in the unread modmail queue try: + .. note:: - .. code-block:: python + To help mitigate targeted moderator harassment, this call requires the + :class:`.Reddit` instance to be authenticated i.e., :attr:`.read_only` must + return ``False``. This call, however, only makes use of the ``read`` scope. + For more information on why the moderator list is hidden can be found here: + https://reddit.zendesk.com/hc/en-us/articles/360049499032-Why-is-the-moderator-list-hidden- - subreddit = reddit.subreddit("all") - for message in subreddit.mod.stream.modmail_conversations(): - print(f"From: {message.owner}, To: {message.participant}") + .. note:: - """ # noqa: E501 - if self.subreddit == "mod": - self.subreddit = self.subreddit._reddit.subreddit("all") - return stream_generator( - self.subreddit.modmail.conversations, - attribute_name="id", - exclude_before=True, - other_subreddits=other_subreddits, - sort=sort, - state=state, - **stream_options, - ) + Unlike other relationship callables, this relationship is not paginated. + Thus, it simply returns the full list, rather than an iterator for the + results. - @_deprecate_args("only") - def modqueue( - self, *, only: str | None = None, **stream_options: Any - ) -> Generator[praw.models.Comment | praw.models.Submission, None, None]: - r"""Yield :class:`.Comment`\ s and :class:`.Submission`\ s in the modqueue as they become available. + To be used like: - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + .. code-block:: python - Keyword arguments are passed to :func:`.stream_generator`. + moderators = reddit.subreddit("test").moderator() - To print all new modqueue items try: + For example, to list the moderators along with their permissions try: .. code-block:: python - for item in reddit.subreddit("mod").mod.stream.modqueue(): - print(item) + for moderator in reddit.subreddit("test").moderator(): + print(f"{moderator}: {moderator.mod_permissions}") """ - return stream_generator( - self.subreddit.mod.modqueue, only=only, **stream_options - ) + params = {} if redditor is None else {"user": redditor} + url = API_PATH[f"list_{self.relationship}"].format(subreddit=self.subreddit) + return self.subreddit._reddit.get(url, params=params) - @_deprecate_args("only") - def reports( - self, *, only: str | None = None, **stream_options: Any - ) -> Generator[praw.models.Comment | praw.models.Submission, None, None]: - r"""Yield reported :class:`.Comment`\ s and :class:`.Submission`\ s as they become available. + @_deprecate_args("redditor", "permissions") + def add( + self, + redditor: str | praw.models.Redditor, + *, + permissions: list[str] | None = None, + **other_settings: Any, + ): + """Add or invite ``redditor`` to be a moderator of the :class:`.Subreddit`. - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + :param redditor: A redditor name or :class:`.Redditor` instance. + :param permissions: When provided (not ``None``), permissions should be a list + of strings specifying which subset of permissions to grant. An empty list + ``[]`` indicates no permissions, and when not provided ``None``, indicates + full permissions (default: ``None``). - Keyword arguments are passed to :func:`.stream_generator`. + An invite will be sent unless the user making this call is an admin user. - To print new user and mod report reasons in the report queue try: + For example, to invite u/spez with ``"posts"`` and ``"mail"`` permissions to + r/test, try: .. code-block:: python - for item in reddit.subreddit("mod").mod.stream.reports(): - print(item) + reddit.subreddit("test").moderator.add("spez", permissions=["posts", "mail"]) """ - return stream_generator(self.subreddit.mod.reports, only=only, **stream_options) - - @_deprecate_args("only") - def spam( - self, *, only: str | None = None, **stream_options: Any - ) -> Generator[praw.models.Comment | praw.models.Submission, None, None]: - r"""Yield spam :class:`.Comment`\ s and :class:`.Submission`\ s as they become available. + other_settings = self._handle_permissions( + other_settings=other_settings, permissions=permissions + ) + super().add(redditor, **other_settings) - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + @_deprecate_args("redditor", "permissions") + def invite( + self, + redditor: str | praw.models.Redditor, + *, + permissions: list[str] | None = None, + **other_settings: Any, + ): + """Invite ``redditor`` to be a moderator of the :class:`.Subreddit`. - Keyword arguments are passed to :func:`.stream_generator`. + :param redditor: A redditor name or :class:`.Redditor` instance. + :param permissions: When provided (not ``None``), permissions should be a list + of strings specifying which subset of permissions to grant. An empty list + ``[]`` indicates no permissions, and when not provided ``None``, indicates + full permissions (default: ``None``). - To print new items in the spam queue try: + For example, to invite u/spez with ``"posts"`` and ``"mail"`` permissions to + r/test, try: .. code-block:: python - for item in reddit.subreddit("mod").mod.stream.spam(): - print(item) + reddit.subreddit("test").moderator.invite("spez", permissions=["posts", "mail"]) """ - return stream_generator(self.subreddit.mod.spam, only=only, **stream_options) + data = self._handle_permissions( + other_settings=other_settings, permissions=permissions + ) + data.update({"name": str(redditor), "type": "moderator_invite"}) + url = API_PATH["friend"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url, data=data) - def unmoderated( - self, **stream_options: Any - ) -> Generator[praw.models.Submission, None, None]: - r"""Yield unmoderated :class:`.Submission`\ s as they become available. + @_deprecate_args("redditor") + def invited( + self, + *, + redditor: str | praw.models.Redditor | None = None, + **generator_kwargs: Any, + ) -> Iterator[praw.models.Redditor]: + r"""Return a :class:`.ListingGenerator` for :class:`.Redditor`\ s invited to be moderators. - Keyword arguments are passed to :func:`.stream_generator`. + :param redditor: When provided, return a list containing at most one + :class:`.Redditor` instance. This is useful to confirm if a relationship + exists, or to fetch the metadata associated with a particular relationship + (default: ``None``). - To print new items in the unmoderated queue try: + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. - .. code-block:: python + .. note:: - for item in reddit.subreddit("mod").mod.stream.unmoderated(): - print(item) + Unlike other usages of :class:`.ListingGenerator`, ``limit`` has no effect + in the quantity returned. This endpoint always returns moderators in batches + of 25 at a time regardless of what ``limit`` is set to. - """ - return stream_generator(self.subreddit.mod.unmoderated, **stream_options) + Usage: - def unread( - self, **stream_options: Any - ) -> Generator[praw.models.SubredditMessage, None, None]: - """Yield unread old modmail messages as they become available. + .. code-block:: python - Keyword arguments are passed to :func:`.stream_generator`. + for invited_mod in reddit.subreddit("test").moderator.invited(): + print(invited_mod) - .. seealso:: + """ + generator_kwargs["params"] = {"username": redditor} if redditor else None + url = API_PATH["list_invited_moderator"].format(subreddit=self.subreddit) + return ListingGenerator(self.subreddit._reddit, url, **generator_kwargs) - :meth:`.SubredditModeration.inbox` for all messages. + def leave(self): + """Abdicate the moderator position (use with care). - To print new mail in the unread modmail queue try: + For example: .. code-block:: python - for message in reddit.subreddit("mod").mod.stream.unread(): - print(f"From: {message.author}, To: {message.dest}") + reddit.subreddit("test").moderator.leave() """ - return stream_generator(self.subreddit.mod.unread, **stream_options) + self.remove( + self.subreddit._reddit.config.username or self.subreddit._reddit.user.me() + ) + def remove_invite(self, redditor: str | praw.models.Redditor): + """Remove the moderator invite for ``redditor``. -class SubredditQuarantine: - """Provides subreddit quarantine related methods. + :param redditor: A redditor name or :class:`.Redditor` instance. - To opt-in into a quarantined subreddit: + For example: - .. code-block:: python + .. code-block:: python - reddit.subreddit("test").quaran.opt_in() + reddit.subreddit("test").moderator.remove_invite("spez") - """ + """ + data = {"name": str(redditor), "type": "moderator_invite"} + url = API_PATH["unfriend"].format(subreddit=self.subreddit) + self.subreddit._reddit.post(url, data=data) - def __init__(self, subreddit: praw.models.Subreddit): - """Initialize a :class:`.SubredditQuarantine` instance. + @_deprecate_args("redditor", "permissions") + def update( + self, + redditor: str | praw.models.Redditor, + *, + permissions: list[str] | None = None, + ): + """Update the moderator permissions for ``redditor``. - :param subreddit: The :class:`.Subreddit` associated with the quarantine. + :param redditor: A redditor name or :class:`.Redditor` instance. + :param permissions: When provided (not ``None``), permissions should be a list + of strings specifying which subset of permissions to grant. An empty list + ``[]`` indicates no permissions, and when not provided, ``None``, indicates + full permissions (default: ``None``). - """ - self.subreddit = subreddit + For example, to add all permissions to the moderator, try: - def opt_in(self): - """Permit your user access to the quarantined subreddit. + .. code-block:: python - Usage: + subreddit.moderator.update("spez") - .. code-block:: python + To remove all permissions from the moderator, try: - subreddit = reddit.subreddit("QUESTIONABLE") - next(subreddit.hot()) # Raises prawcore.Forbidden + .. code-block:: python - subreddit.quaran.opt_in() - next(subreddit.hot()) # Returns Submission + subreddit.moderator.update("spez", permissions=[]) """ - data = {"sr_name": self.subreddit} - with contextlib.suppress(Redirect): - self.subreddit._reddit.post(API_PATH["quarantine_opt_in"], data=data) + url = API_PATH["setpermissions"].format(subreddit=self.subreddit) + data = self._handle_permissions( + other_settings={"name": str(redditor), "type": "moderator"}, + permissions=permissions, + ) + self.subreddit._reddit.post(url, data=data) - def opt_out(self): - """Remove access to the quarantined subreddit. + @_deprecate_args("redditor", "permissions") + def update_invite( + self, + redditor: str | praw.models.Redditor, + *, + permissions: list[str] | None = None, + ): + """Update the moderator invite permissions for ``redditor``. - Usage: + :param redditor: A redditor name or :class:`.Redditor` instance. + :param permissions: When provided (not ``None``), permissions should be a list + of strings specifying which subset of permissions to grant. An empty list + ``[]`` indicates no permissions, and when not provided, ``None``, indicates + full permissions (default: ``None``). + + For example, to grant the ``"flair"`` and ``"mail"`` permissions to the + moderator invite, try: .. code-block:: python - subreddit = reddit.subreddit("QUESTIONABLE") - next(subreddit.hot()) # Returns Submission - - subreddit.quaran.opt_out() - next(subreddit.hot()) # Raises prawcore.Forbidden + subreddit.moderator.update_invite("spez", permissions=["flair", "mail"]) """ - data = {"sr_name": self.subreddit} - with contextlib.suppress(Redirect): - self.subreddit._reddit.post(API_PATH["quarantine_opt_out"], data=data) - + url = API_PATH["setpermissions"].format(subreddit=self.subreddit) + data = self._handle_permissions( + other_settings={"name": str(redditor), "type": "moderator_invite"}, + permissions=permissions, + ) + self.subreddit._reddit.post(url, data=data) -class SubredditRelationship: - """Represents a relationship between a :class:`.Redditor` and :class:`.Subreddit`. - Instances of this class can be iterated through in order to discover the Redditors - that make up the relationship. +class Subreddit(MessageableMixin, SubredditListingMixin, FullnameMixin, RedditBase): + """A class for Subreddits. - For example, banned users of a subreddit can be iterated through like so: + To obtain an instance of this class for r/test execute: .. code-block:: python - for ban in reddit.subreddit("test").banned(): - print(f"{ban}: {ban.note}") + subreddit = reddit.subreddit("test") - """ + While r/all is not a real subreddit, it can still be treated like one. The following + outputs the titles of the 25 hottest submissions in r/all: - def __call__( - self, - redditor: str | praw.models.Redditor | None = None, - **generator_kwargs: Any, - ) -> Iterator[praw.models.Redditor]: - r"""Return a :class:`.ListingGenerator` for :class:`.Redditor`\ s in the relationship. + .. code-block:: python - :param redditor: When provided, yield at most a single :class:`.Redditor` - instance. This is useful to confirm if a relationship exists, or to fetch - the metadata associated with a particular relationship (default: ``None``). + for submission in reddit.subreddit("all").hot(limit=25): + print(submission.title) - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + Multiple subreddits can be combined with a ``+`` like so: - """ - Subreddit._safely_add_arguments( - arguments=generator_kwargs, key="params", user=redditor - ) - url = API_PATH[f"list_{self.relationship}"].format(subreddit=self.subreddit) - return ListingGenerator(self.subreddit._reddit, url, **generator_kwargs) + .. code-block:: python - def __init__(self, subreddit: praw.models.Subreddit, relationship: str): - """Initialize a :class:`.SubredditRelationship` instance. + for submission in reddit.subreddit("redditdev+learnpython").top(time_filter="all"): + print(submission) - :param subreddit: The :class:`.Subreddit` for the relationship. - :param relationship: The name of the relationship. + Subreddits can be filtered from combined listings as follows. - """ - self.relationship = relationship - self.subreddit = subreddit + .. note:: - def add(self, redditor: str | praw.models.Redditor, **other_settings: Any): - """Add ``redditor`` to this relationship. + These filters are ignored by certain methods, including :attr:`.comments`, + :meth:`.gilded`, and :meth:`.SubredditStream.comments`. - :param redditor: A redditor name or :class:`.Redditor` instance. + .. code-block:: python - """ - data = {"name": str(redditor), "type": self.relationship} - data.update(other_settings) - url = API_PATH["friend"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url, data=data) + for submission in reddit.subreddit("all-redditdev").new(): + print(submission) - def remove(self, redditor: str | praw.models.Redditor): - """Remove ``redditor`` from this relationship. + .. include:: ../../typical_attributes.rst - :param redditor: A redditor name or :class:`.Redditor` instance. + ========================= ========================================================== + Attribute Description + ========================= ========================================================== + ``can_assign_link_flair`` Whether users can assign their own link flair. + ``can_assign_user_flair`` Whether users can assign their own user flair. + ``created_utc`` Time the subreddit was created, represented in `Unix + Time`_. + ``description`` Subreddit description, in Markdown. + ``description_html`` Subreddit description, in HTML. + ``display_name`` Name of the subreddit. + ``icon_img`` The URL of the subreddit icon image. + ``id`` ID of the subreddit. + ``name`` Fullname of the subreddit. + ``over18`` Whether the subreddit is NSFW. + ``public_description`` Description of the subreddit, shown in searches and on the + "You must be invited to visit this community" page (if + applicable). + ``spoilers_enabled`` Whether the spoiler tag feature is enabled. + ``subscribers`` Count of subscribers. + ``user_is_banned`` Whether the authenticated user is banned. + ``user_is_moderator`` Whether the authenticated user is a moderator. + ``user_is_subscriber`` Whether the authenticated user is subscribed. + ========================= ========================================================== - """ - data = {"name": str(redditor), "type": self.relationship} - url = API_PATH["unfriend"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url, data=data) + .. note:: + Trying to retrieve attributes of quarantined or private subreddits will result + in a 403 error. Trying to retrieve attributes of a banned subreddit will result + in a 404 error. -class SubredditStream: - """Provides submission and comment streams.""" + .. _unix time: https://en.wikipedia.org/wiki/Unix_time - def __init__(self, subreddit: praw.models.Subreddit): - """Initialize a :class:`.SubredditStream` instance. + """ - :param subreddit: The subreddit associated with the streams. + STR_FIELD = "display_name" + MESSAGE_PREFIX = "#" - """ - self.subreddit = subreddit + @staticmethod + def _create_or_update( + *, + _reddit: praw.Reddit, + allow_images: bool | None = None, + allow_post_crossposts: bool | None = None, + allow_top: bool | None = None, + collapse_deleted_comments: bool | None = None, + comment_score_hide_mins: int | None = None, + description: str | None = None, + domain: str | None = None, + exclude_banned_modqueue: bool | None = None, + header_hover_text: str | None = None, + hide_ads: bool | None = None, + lang: str | None = None, + key_color: str | None = None, + link_type: str | None = None, + name: str | None = None, + over_18: bool | None = None, + public_description: str | None = None, + public_traffic: bool | None = None, + show_media: bool | None = None, + show_media_preview: bool | None = None, + spam_comments: bool | None = None, + spam_links: bool | None = None, + spam_selfposts: bool | None = None, + spoilers_enabled: bool | None = None, + sr: str | None = None, + submit_link_label: str | None = None, + submit_text: str | None = None, + submit_text_label: str | None = None, + subreddit_type: str | None = None, + suggested_comment_sort: str | None = None, + title: str | None = None, + wiki_edit_age: int | None = None, + wiki_edit_karma: int | None = None, + wikimode: str | None = None, + **other_settings: Any, + ): + model = { + "allow_images": allow_images, + "allow_post_crossposts": allow_post_crossposts, + "allow_top": allow_top, + "collapse_deleted_comments": collapse_deleted_comments, + "comment_score_hide_mins": comment_score_hide_mins, + "description": description, + "domain": domain, + "exclude_banned_modqueue": exclude_banned_modqueue, + "header-title": header_hover_text, # Remap here - better name + "hide_ads": hide_ads, + "key_color": key_color, + "lang": lang, + "link_type": link_type, + "name": name, + "over_18": over_18, + "public_description": public_description, + "public_traffic": public_traffic, + "show_media": show_media, + "show_media_preview": show_media_preview, + "spam_comments": spam_comments, + "spam_links": spam_links, + "spam_selfposts": spam_selfposts, + "spoilers_enabled": spoilers_enabled, + "sr": sr, + "submit_link_label": submit_link_label, + "submit_text": submit_text, + "submit_text_label": submit_text_label, + "suggested_comment_sort": suggested_comment_sort, + "title": title, + "type": subreddit_type, + "wiki_edit_age": wiki_edit_age, + "wiki_edit_karma": wiki_edit_karma, + "wikimode": wikimode, + } - def comments( - self, **stream_options: Any - ) -> Generator[praw.models.Comment, None, None]: - """Yield new comments as they become available. + model.update(other_settings) - Comments are yielded oldest first. Up to 100 historical comments will initially - be returned. + _reddit.post(API_PATH["site_admin"], data=model) - Keyword arguments are passed to :func:`.stream_generator`. + @staticmethod + def _subreddit_list( + *, + other_subreddits: list[str | praw.models.Subreddit], + subreddit: praw.models.Subreddit, + ) -> str: + if other_subreddits: + return ",".join([str(subreddit)] + [str(x) for x in other_subreddits]) + return str(subreddit) - .. note:: + @staticmethod + def _validate_gallery(images: list[dict[str, str]]): + for image in images: + image_path = image.get("image_path", "") + if image_path: + if not Path(image_path).is_file(): + msg = f"{image_path!r} is not a valid image path." + raise TypeError(msg) + else: + msg = "'image_path' is required." + raise TypeError(msg) + if not len(image.get("caption", "")) <= 180: + msg = "Caption must be 180 characters or less." + raise TypeError(msg) - While PRAW tries to catch all new comments, some high-volume streams, - especially the r/all stream, may drop some comments. + @staticmethod + def _validate_inline_media(inline_media: praw.models.InlineMedia): + if not Path(inline_media.path).is_file(): + msg = f"{inline_media.path!r} is not a valid file path." + raise ValueError(msg) - For example, to retrieve all new comments made to r/test, try: + @cachedproperty + def banned(self) -> praw.models.reddit.subreddit.SubredditRelationship: + """Provide an instance of :class:`.SubredditRelationship`. + + For example, to ban a user try: .. code-block:: python - for comment in reddit.subreddit("test").stream.comments(): - print(comment) + reddit.subreddit("test").banned.add("spez", ban_reason="...") - To only retrieve new submissions starting when the stream is created, pass - ``skip_existing=True``: + To list the banned users along with any notes, try: .. code-block:: python - subreddit = reddit.subreddit("test") - for comment in subreddit.stream.comments(skip_existing=True): - print(comment) + for ban in reddit.subreddit("test").banned(): + print(f"{ban}: {ban.note}") """ - return stream_generator(self.subreddit.comments, **stream_options) + return SubredditRelationship(self, "banned") - def submissions( - self, **stream_options: Any - ) -> Generator[praw.models.Submission, None, None]: - r"""Yield new :class:`.Submission`\ s as they become available. + @cachedproperty + def collections(self) -> praw.models.reddit.collections.SubredditCollections: + r"""Provide an instance of :class:`.SubredditCollections`. - Submissions are yielded oldest first. Up to 100 historical submissions will - initially be returned. + To see the permalinks of all :class:`.Collection`\ s that belong to a subreddit, + try: - Keyword arguments are passed to :func:`.stream_generator`. + .. code-block:: python - .. note:: + for collection in reddit.subreddit("test").collections: + print(collection.permalink) - While PRAW tries to catch all new submissions, some high-volume streams, - especially the r/all stream, may drop some submissions. + To get a specific :class:`.Collection` by its UUID or permalink, use one of the + following: - For example, to retrieve all new submissions made to all of Reddit, try: + .. code-block:: python + + collection = reddit.subreddit("test").collections("some_uuid") + collection = reddit.subreddit("test").collections( + permalink="https://reddit.com/r/SUBREDDIT/collection/some_uuid" + ) + + """ + return self._subreddit_collections_class(self._reddit, self) + + @cachedproperty + def contributor(self) -> praw.models.reddit.subreddit.ContributorRelationship: + """Provide an instance of :class:`.ContributorRelationship`. + + Contributors are also known as approved submitters. + + To add a contributor try: .. code-block:: python - for submission in reddit.subreddit("all").stream.submissions(): - print(submission) + reddit.subreddit("test").contributor.add("spez") """ - return stream_generator(self.subreddit.new, **stream_options) - + return ContributorRelationship(self, "contributor") -class SubredditStylesheet: - """Provides a set of stylesheet functions to a :class:`.Subreddit`. + @cachedproperty + def emoji(self) -> SubredditEmoji: + """Provide an instance of :class:`.SubredditEmoji`. - For example, to add the css data ``.test{color:blue}`` to the existing stylesheet: + This attribute can be used to discover all emoji for a subreddit: - .. code-block:: python + .. code-block:: python - subreddit = reddit.subreddit("test") - stylesheet = subreddit.stylesheet() - stylesheet.stylesheet += ".test{color:blue}" - subreddit.stylesheet.update(stylesheet.stylesheet) + for emoji in reddit.subreddit("test").emoji: + print(emoji) - """ + A single emoji can be lazily retrieved via: - def __call__(self) -> praw.models.Stylesheet: - """Return the :class:`.Subreddit`'s stylesheet. + .. code-block:: python - To be used as: + reddit.subreddit("test").emoji["emoji_name"] - .. code-block:: python + .. note:: - stylesheet = reddit.subreddit("test").stylesheet() + Attempting to access attributes of a nonexistent emoji will result in a + :class:`.ClientException`. """ - url = API_PATH["about_stylesheet"].format(subreddit=self.subreddit) - return self.subreddit._reddit.get(url) - - def __init__(self, subreddit: praw.models.Subreddit): - """Initialize a :class:`.SubredditStylesheet` instance. + return SubredditEmoji(self) - :param subreddit: The :class:`.Subreddit` associated with the stylesheet. + @cachedproperty + def filters(self) -> praw.models.reddit.subreddit.SubredditFilters: + """Provide an instance of :class:`.SubredditFilters`. - An instance of this class is provided as: + For example, to add a filter, run: .. code-block:: python - reddit.subreddit("test").stylesheet + reddit.subreddit("all").filters.add("test") """ - self.subreddit = subreddit + return SubredditFilters(self) - def _update_structured_styles( - self, style_data: dict[str, str | Any] - ): # noqa: ANN001 - url = API_PATH["structured_styles"].format(subreddit=self.subreddit) - self.subreddit._reddit.patch(url, data=style_data) + @cachedproperty + def flair(self) -> praw.models.reddit.subreddit.SubredditFlair: + """Provide an instance of :class:`.SubredditFlair`. - def _upload_image( - self, *, data: dict[str, str | Any], image_path: str - ) -> dict[str, Any]: - with Path(image_path).open("rb") as image: - header = image.read(len(JPEG_HEADER)) - image.seek(0) - data["img_type"] = "jpg" if header == JPEG_HEADER else "png" - url = API_PATH["upload_image"].format(subreddit=self.subreddit) - response = self.subreddit._reddit.post( - url, data=data, files={"file": image} - ) - if response["errors"]: - error_type = response["errors"][0] - error_value = response.get("errors_values", [""])[0] - assert error_type in [ - "BAD_CSS_NAME", - "IMAGE_ERROR", - ], "Please file a bug with PRAW." - raise RedditAPIException([[error_type, error_value, None]]) - return response + Use this attribute for interacting with a :class:`.Subreddit`'s flair. For + example, to list all the flair for a subreddit which you have the ``flair`` + moderator permission on try: - def _upload_style_asset( - self, *, image_path: str, image_type: str - ) -> str: # noqa: ANN001 - file = Path(image_path) - data = {"imagetype": image_type, "filepath": file.name} - data["mimetype"] = "image/jpeg" - if image_path.lower().endswith(".png"): - data["mimetype"] = "image/png" - url = API_PATH["style_asset_lease"].format(subreddit=self.subreddit) + .. code-block:: python - upload_lease = self.subreddit._reddit.post(url, data=data)["s3UploadLease"] - upload_data = {item["name"]: item["value"] for item in upload_lease["fields"]} - upload_url = f"https:{upload_lease['action']}" + for flair in reddit.subreddit("test").flair(): + print(flair) - with file.open("rb") as image: - response = self.subreddit._reddit._core._requestor._http.post( - upload_url, data=upload_data, files={"file": image} - ) - response.raise_for_status() + Flair templates can be interacted with through this attribute via: - return f"{upload_url}/{upload_data['key']}" + .. code-block:: python - def delete_banner(self): - """Remove the current :class:`.Subreddit` (redesign) banner image. + for template in reddit.subreddit("test").flair.templates: + print(template) - Succeeds even if there is no banner image. + """ + return SubredditFlair(self) - For example: + @cachedproperty + def mod(self) -> SubredditModeration: + """Provide an instance of :class:`.SubredditModeration`. + + For example, to accept a moderation invite from r/test: .. code-block:: python - reddit.subreddit("test").stylesheet.delete_banner() + reddit.subreddit("test").mod.accept_invite() """ - data = {"bannerBackgroundImage": ""} - self._update_structured_styles(data) + return SubredditModeration(self) - def delete_banner_additional_image(self): - """Remove the current :class:`.Subreddit` (redesign) banner additional image. + @cachedproperty + def moderator(self) -> praw.models.reddit.subreddit.ModeratorRelationship: + """Provide an instance of :class:`.ModeratorRelationship`. - Succeeds even if there is no additional image. Will also delete any configured - hover image. + For example, to add a moderator try: - For example: + .. code-block:: python + + reddit.subreddit("test").moderator.add("spez") + + To list the moderators along with their permissions try: .. code-block:: python - reddit.subreddit("test").stylesheet.delete_banner_additional_image() + for moderator in reddit.subreddit("test").moderator(): + print(f"{moderator}: {moderator.mod_permissions}") """ - data = {"bannerPositionedImage": "", "secondaryBannerPositionedImage": ""} - self._update_structured_styles(data) - - def delete_banner_hover_image(self): - """Remove the current :class:`.Subreddit` (redesign) banner hover image. + return ModeratorRelationship(self, "moderator") - Succeeds even if there is no hover image. + @cachedproperty + def modmail(self) -> praw.models.reddit.subreddit.Modmail: + """Provide an instance of :class:`.Modmail`. - For example: + For example, to send a new modmail from r/test to u/spez with the subject + ``"test"`` along with a message body of ``"hello"``: .. code-block:: python - reddit.subreddit("test").stylesheet.delete_banner_hover_image() + reddit.subreddit("test").modmail.create(subject="test", body="hello", recipient="spez") """ - data = {"secondaryBannerPositionedImage": ""} - self._update_structured_styles(data) - - def delete_header(self): - """Remove the current :class:`.Subreddit` header image. + return Modmail(self) - Succeeds even if there is no header image. + @cachedproperty + def muted(self) -> praw.models.reddit.subreddit.SubredditRelationship: + """Provide an instance of :class:`.SubredditRelationship`. - For example: + For example, muted users can be iterated through like so: .. code-block:: python - reddit.subreddit("test").stylesheet.delete_header() + for mute in reddit.subreddit("test").muted(): + print(f"{mute}: {mute.date}") """ - url = API_PATH["delete_sr_header"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url) + return SubredditRelationship(self, "muted") - def delete_image(self, name: str): - """Remove the named image from the :class:`.Subreddit`. + @cachedproperty + def quaran(self) -> praw.models.reddit.subreddit.SubredditQuarantine: + """Provide an instance of :class:`.SubredditQuarantine`. - Succeeds even if the named image does not exist. + This property is named ``quaran`` because ``quarantine`` is a subreddit + attribute returned by Reddit to indicate whether or not a subreddit is + quarantined. - For example: + To opt-in into a quarantined subreddit: .. code-block:: python - reddit.subreddit("test").stylesheet.delete_image("smile") + reddit.subreddit("test").quaran.opt_in() """ - url = API_PATH["delete_sr_image"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url, data={"img_name": name}) + return SubredditQuarantine(self) - def delete_mobile_banner(self): - """Remove the current :class:`.Subreddit` (redesign) mobile banner. + @cachedproperty + def rules(self) -> SubredditRules: + """Provide an instance of :class:`.SubredditRules`. - Succeeds even if there is no mobile banner. + Use this attribute for interacting with a :class:`.Subreddit`'s rules. - For example: + For example, to list all the rules for a subreddit: .. code-block:: python - subreddit = reddit.subreddit("test") - subreddit.stylesheet.delete_banner_hover_image() - - """ - data = {"mobileBannerImage": ""} - self._update_structured_styles(data) - - def delete_mobile_header(self): - """Remove the current :class:`.Subreddit` mobile header. - - Succeeds even if there is no mobile header. + for rule in reddit.subreddit("test").rules: + print(rule) - For example: + Moderators can also add rules to the subreddit. For example, to make a rule + called ``"No spam"`` in r/test: .. code-block:: python - reddit.subreddit("test").stylesheet.delete_mobile_header() + reddit.subreddit("test").rules.mod.add( + short_name="No spam", kind="all", description="Do not spam. Spam bad" + ) """ - url = API_PATH["delete_sr_header"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url) + return SubredditRules(self) - def delete_mobile_icon(self): - """Remove the current :class:`.Subreddit` mobile icon. + @cachedproperty + def stream(self) -> praw.models.reddit.subreddit.SubredditStream: + """Provide an instance of :class:`.SubredditStream`. - Succeeds even if there is no mobile icon. + Streams can be used to indefinitely retrieve new comments made to a subreddit, + like: - For example: + .. code-block:: python + + for comment in reddit.subreddit("test").stream.comments(): + print(comment) + + Additionally, new submissions can be retrieved via the stream. In the following + example all submissions are fetched via the special r/all: .. code-block:: python - reddit.subreddit("test").stylesheet.delete_mobile_icon() + for submission in reddit.subreddit("all").stream.submissions(): + print(submission) """ - url = API_PATH["delete_sr_icon"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url) - - @_deprecate_args("stylesheet", "reason") - def update(self, stylesheet: str, *, reason: str | None = None): - """Update the :class:`.Subreddit`'s stylesheet. + return SubredditStream(self) - :param stylesheet: The CSS for the new stylesheet. - :param reason: The reason for updating the stylesheet. + @cachedproperty + def stylesheet(self) -> praw.models.reddit.subreddit.SubredditStylesheet: + """Provide an instance of :class:`.SubredditStylesheet`. - For example: + For example, to add the css data ``.test{color:blue}`` to the existing + stylesheet: .. code-block:: python - reddit.subreddit("test").stylesheet.update( - "p { color: green; }", reason="color text green" - ) - - """ - data = {"op": "save", "reason": reason, "stylesheet_contents": stylesheet} - url = API_PATH["subreddit_stylesheet"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url, data=data) + subreddit = reddit.subreddit("test") + stylesheet = subreddit.stylesheet() + stylesheet.stylesheet += ".test{color:blue}" + subreddit.stylesheet.update(stylesheet.stylesheet) - @_deprecate_args("name", "image_path") - def upload(self, *, image_path: str, name: str) -> dict[str, str]: - """Upload an image to the :class:`.Subreddit`. + """ + return SubredditStylesheet(self) - :param image_path: A path to a jpeg or png image. - :param name: The name to use for the image. If an image already exists with the - same name, it will be replaced. + @cachedproperty + def widgets(self) -> praw.models.SubredditWidgets: + """Provide an instance of :class:`.SubredditWidgets`. - :returns: A dictionary containing a link to the uploaded image under the key - ``img_src``. + **Example usage** - :raises: ``prawcore.TooLarge`` if the overall request body is too large. + Get all sidebar widgets: - :raises: :class:`.RedditAPIException` if there are other issues with the - uploaded image. Unfortunately the exception info might not be very specific, - so try through the website with the same image to see what the problem - actually might be. + .. code-block:: python - For example: + for widget in reddit.subreddit("test").widgets.sidebar: + print(widget) + + Get ID card widget: .. code-block:: python - reddit.subreddit("test").stylesheet.upload(name="smile", image_path="img.png") + print(reddit.subreddit("test").widgets.id_card) """ - return self._upload_image( - data={"name": name, "upload_type": "img"}, image_path=image_path - ) + return SubredditWidgets(self) - def upload_banner(self, image_path: str): - """Upload an image for the :class:`.Subreddit`'s (redesign) banner image. + @cachedproperty + def wiki(self) -> praw.models.reddit.subreddit.SubredditWiki: + """Provide an instance of :class:`.SubredditWiki`. - :param image_path: A path to a jpeg or png image. + This attribute can be used to discover all wikipages for a subreddit: - :raises: ``prawcore.TooLarge`` if the overall request body is too large. + .. code-block:: python - :raises: :class:`.RedditAPIException` if there are other issues with the - uploaded image. Unfortunately the exception info might not be very specific, - so try through the website with the same image to see what the problem - actually might be. + for wikipage in reddit.subreddit("test").wiki: + print(wikipage) - For example: + To fetch the content for a given wikipage try: .. code-block:: python - reddit.subreddit("test").stylesheet.upload_banner("banner.png") + wikipage = reddit.subreddit("test").wiki["proof"] + print(wikipage.content_md) """ - image_type = "bannerBackgroundImage" - image_url = self._upload_style_asset( - image_path=image_path, image_type=image_type - ) - self._update_structured_styles({image_type: image_url}) + return SubredditWiki(self) - @_deprecate_args("image_path", "align") - def upload_banner_additional_image( + @property + def _kind(self) -> str: + """Return the class's kind.""" + return self._reddit.config.kinds["subreddit"] + + def __init__( self, - image_path: str, - *, - align: str | None = None, + reddit: praw.Reddit, + display_name: str | None = None, + _data: dict[str, Any] | None = None, ): - """Upload an image for the :class:`.Subreddit`'s (redesign) additional image. - - :param image_path: A path to a jpeg or png image. - :param align: Either ``"left"``, ``"centered"``, or ``"right"``. (default: - ``"left"``). + """Initialize a :class:`.Subreddit` instance. - :raises: ``prawcore.TooLarge`` if the overall request body is too large. + :param reddit: An instance of :class:`.Reddit`. + :param display_name: The name of the subreddit. - :raises: :class:`.RedditAPIException` if there are other issues with the - uploaded image. Unfortunately the exception info might not be very specific, - so try through the website with the same image to see what the problem - actually might be. + .. note:: - For example: + This class should not be initialized directly. Instead, obtain an instance + via: - .. code-block:: python + .. code-block:: python - subreddit = reddit.subreddit("test") - subreddit.stylesheet.upload_banner_additional_image("banner.png") + subreddit = reddit.subreddit("test") """ - alignment = {} - if align is not None: - if align not in {"left", "centered", "right"}: - msg = "'align' argument must be either 'left', 'centered', or 'right'" - raise ValueError(msg) - alignment["bannerPositionedImagePosition"] = align - - image_type = "bannerPositionedImage" - image_url = self._upload_style_asset( - image_path=image_path, image_type=image_type - ) - style_data = {image_type: image_url} - if alignment: - style_data.update(alignment) - self._update_structured_styles(style_data) - - def upload_banner_hover_image(self, image_path: str): - """Upload an image for the :class:`.Subreddit`'s (redesign) additional image. - - :param image_path: A path to a jpeg or png image. + if (display_name, _data).count(None) != 1: + msg = "Either 'display_name' or '_data' must be provided." + raise TypeError(msg) + if display_name: + self.display_name = display_name + super().__init__(reddit, _data=_data) + self._path = API_PATH["subreddit"].format(subreddit=self) - Fails if the :class:`.Subreddit` does not have an additional image defined. + def _convert_to_fancypants(self, markdown_text: str) -> dict: + """Convert a Markdown string to a dict for use with the ``richtext_json`` param. - :raises: ``prawcore.TooLarge`` if the overall request body is too large. + :param markdown_text: A Markdown string to convert. - :raises: :class:`.RedditAPIException` if there are other issues with the - uploaded image. Unfortunately the exception info might not be very specific, - so try through the website with the same image to see what the problem - actually might be. + :returns: A dict in ``richtext_json`` format. - For example: + """ + text_data = {"output_mode": "rtjson", "markdown_text": markdown_text} + return self._reddit.post(API_PATH["convert_rte_body"], data=text_data)["output"] - .. code-block:: python + def _fetch(self): + data = self._fetch_data() + data = data["data"] + other = type(self)(self._reddit, _data=data) + self.__dict__.update(other.__dict__) + super()._fetch() - subreddit = reddit.subreddit("test") - subreddit.stylesheet.upload_banner_hover_image("banner.png") + def _fetch_info(self): + return "subreddit_about", {"subreddit": self}, None - """ - image_type = "secondaryBannerPositionedImage" - image_url = self._upload_style_asset( - image_path=image_path, image_type=image_type - ) - self._update_structured_styles({image_type: image_url}) + def _parse_xml_response(self, response: Response): + """Parse the XML from a response and raise any errors found.""" + xml = response.text + root = XML(xml) + tags = [element.tag for element in root] + if tags[:4] == ["Code", "Message", "ProposedSize", "MaxSizeAllowed"]: + # Returned if image is too big + code, message, actual, maximum_size = (element.text for element in root[:4]) + raise TooLargeMediaException( + actual=int(actual), maximum_size=int(maximum_size) + ) - def upload_header(self, image_path: str) -> dict[str, str]: - """Upload an image to be used as the :class:`.Subreddit`'s header image. + def _read_and_post_media( + self, media_path: str, upload_url: str, upload_data: dict[str, Any] + ) -> Response: + with media_path.open("rb") as media: + return self._reddit._core._requestor._http.post( + upload_url, data=upload_data, files={"file": media} + ) - :param image_path: A path to a jpeg or png image. + def _submit_media( + self, *, data: dict[Any, Any], timeout: int, without_websockets: bool + ): + """Submit and return an ``image``, ``video``, or ``videogif``. - :returns: A dictionary containing a link to the uploaded image under the key - ``img_src``. + This is a helper method for submitting posts that are not link posts or self + posts. - :raises: ``prawcore.TooLarge`` if the overall request body is too large. + """ + response = self._reddit.post(API_PATH["submit"], data=data) + websocket_url = response["json"]["data"]["websocket_url"] + connection = None + if websocket_url is not None and not without_websockets: + try: + connection = websocket.create_connection(websocket_url, timeout=timeout) + except ( + OSError, + websocket.WebSocketException, + BlockingIOError, + ) as ws_exception: + msg = "Error establishing websocket connection." + raise WebSocketException(msg, ws_exception) from None - :raises: :class:`.RedditAPIException` if there are other issues with the - uploaded image. Unfortunately the exception info might not be very specific, - so try through the website with the same image to see what the problem - actually might be. + if connection is None: + return None - For example: + try: + ws_update = loads(connection.recv()) + connection.close() + except (OSError, websocket.WebSocketException, BlockingIOError) as ws_exception: + msg = "Websocket error. Check your media file. Your post may still have been created." + raise WebSocketException( + msg, + ws_exception, + ) from None + if ws_update.get("type") == "failed": + raise MediaPostFailed + url = ws_update["payload"]["redirect"] + return self._reddit.submission(url=url) - .. code-block:: python + def _upload_inline_media(self, inline_media: praw.models.InlineMedia): + """Upload media for use in self posts and return ``inline_media``. - reddit.subreddit("test").stylesheet.upload_header("header.png") + :param inline_media: An :class:`.InlineMedia` object to validate and upload. """ - return self._upload_image(data={"upload_type": "header"}, image_path=image_path) - - def upload_mobile_banner(self, image_path: str): - """Upload an image for the :class:`.Subreddit`'s (redesign) mobile banner. + self._validate_inline_media(inline_media) + inline_media.media_id = self._upload_media( + media_path=inline_media.path, upload_type="selfpost" + ) + return inline_media - :param image_path: A path to a JPEG or PNG image. + def _upload_media( + self, + *, + expected_mime_prefix: str | None = None, + media_path: str, + upload_type: str = "link", + ): + """Upload media and return its URL and a websocket (Undocumented endpoint). - For example: + :param expected_mime_prefix: If provided, enforce that the media has a mime type + that starts with the provided prefix. + :param upload_type: One of ``"link"``, ``"gallery"'', or ``"selfpost"`` + (default: ``"link"``). - .. code-block:: python + :returns: A tuple containing ``(media_url, websocket_url)`` for the piece of + media. The websocket URL can be used to determine when media processing is + finished, or it can be ignored. - subreddit = reddit.subreddit("test") - subreddit.stylesheet.upload_mobile_banner("banner.png") + """ + if media_path is None: + file = Path(__file__).absolute() + media_path = file.parent.parent.parent / "images" / "PRAW logo.png" + else: + file = Path(media_path) - Fails if the :class:`.Subreddit` does not have an additional image defined. + file_name = file.name.lower() + file_extension = file_name.rpartition(".")[2] + mime_type = { + "png": "image/png", + "mov": "video/quicktime", + "mp4": "video/mp4", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + }.get( + file_extension, "image/jpeg" + ) # default to JPEG + if ( + expected_mime_prefix is not None + and mime_type.partition("/")[0] != expected_mime_prefix + ): + msg = f"Expected a mimetype starting with {expected_mime_prefix!r} but got mimetype {mime_type!r} (from file extension {file_extension!r})." + raise ClientException(msg) + img_data = {"filepath": file_name, "mimetype": mime_type} - :raises: ``prawcore.TooLarge`` if the overall request body is too large. + url = API_PATH["media_asset"] + # until we learn otherwise, assume this request always succeeds + upload_response = self._reddit.post(url, data=img_data) + upload_lease = upload_response["args"] + upload_url = f"https:{upload_lease['action']}" + upload_data = {item["name"]: item["value"] for item in upload_lease["fields"]} - :raises: :class:`.RedditAPIException` if there are other issues with the - uploaded image. Unfortunately the exception info might not be very specific, - so try through the website with the same image to see what the problem - actually might be. + response = self._read_and_post_media(file, upload_url, upload_data) + if not response.ok: + self._parse_xml_response(response) + try: + response.raise_for_status() + except HTTPError as err: + raise ServerError(response=err.response) from None - """ - image_type = "mobileBannerImage" - image_url = self._upload_style_asset( - image_path=image_path, image_type=image_type - ) - self._update_structured_styles({image_type: image_url}) + upload_response["asset"]["websocket_url"] - def upload_mobile_header(self, image_path: str) -> dict[str, str]: - """Upload an image to be used as the :class:`.Subreddit`'s mobile header. + if upload_type == "link": + return f"{upload_url}/{upload_data['key']}" + return upload_response["asset"]["asset_id"] - :param image_path: A path to a jpeg or png image. + def post_requirements(self) -> dict[str, str | int | bool]: + """Get the post requirements for a subreddit. - :returns: A dictionary containing a link to the uploaded image under the key - ``img_src``. + :returns: A dict with the various requirements. - :raises: ``prawcore.TooLarge`` if the overall request body is too large. + The returned dict contains the following keys: - :raises: :class:`.RedditAPIException` if there are other issues with the - uploaded image. Unfortunately the exception info might not be very specific, - so try through the website with the same image to see what the problem - actually might be. + - ``domain_blacklist`` + - ``body_restriction_policy`` + - ``domain_whitelist`` + - ``title_regexes`` + - ``body_blacklisted_strings`` + - ``body_required_strings`` + - ``title_text_min_length`` + - ``is_flair_required`` + - ``title_text_max_length`` + - ``body_regexes`` + - ``link_repost_age`` + - ``body_text_min_length`` + - ``link_restriction_policy`` + - ``body_text_max_length`` + - ``title_required_strings`` + - ``title_blacklisted_strings`` + - ``guidelines_text`` + - ``guidelines_display_policy`` - For example: + For example, to fetch the post requirements for r/test: .. code-block:: python - reddit.subreddit("test").stylesheet.upload_mobile_header("header.png") + print(reddit.subreddit("test").post_requirements) """ - return self._upload_image(data={"upload_type": "banner"}, image_path=image_path) - - def upload_mobile_icon(self, image_path: str) -> dict[str, str]: - """Upload an image to be used as the :class:`.Subreddit`'s mobile icon. - - :param image_path: A path to a jpeg or png image. - - :returns: A dictionary containing a link to the uploaded image under the key - ``img_src``. + return self._reddit.get( + API_PATH["post_requirements"].format(subreddit=str(self)) + ) - :raises: ``prawcore.TooLarge`` if the overall request body is too large. + def random(self) -> praw.models.Submission | None: + """Return a random :class:`.Submission`. - :raises: :class:`.RedditAPIException` if there are other issues with the - uploaded image. Unfortunately the exception info might not be very specific, - so try through the website with the same image to see what the problem - actually might be. + Returns ``None`` on subreddits that do not support the random feature. One + example, at the time of writing, is r/wallpapers. - For example: + For example, to get a random submission off of r/AskReddit: .. code-block:: python - reddit.subreddit("test").stylesheet.upload_mobile_icon("icon.png") + submission = reddit.subreddit("AskReddit").random() + print(submission.title) """ - return self._upload_image(data={"upload_type": "icon"}, image_path=image_path) + url = API_PATH["subreddit_random"].format(subreddit=self) + try: + self._reddit.get(url, params={"unique": self._reddit._next_unique}) + except Redirect as redirect: + path = redirect.path + try: + return self._submission_class( + self._reddit, url=urljoin(self._reddit.config.reddit_url, path) + ) + except ClientException: + return None + @_deprecate_args("query", "sort", "syntax", "time_filter") + def search( + self, + query: str, + *, + sort: str = "relevance", + syntax: str = "lucene", + time_filter: str = "all", + **generator_kwargs: Any, + ) -> Iterator[praw.models.Submission]: + """Return a :class:`.ListingGenerator` for items that match ``query``. -class SubredditWiki: - """Provides a set of wiki functions to a :class:`.Subreddit`.""" + :param query: The query string to search for. + :param sort: Can be one of: ``"relevance"``, ``"hot"``, ``"top"``, ``"new"``, or + ``"comments"``. (default: ``"relevance"``). + :param syntax: Can be one of: ``"cloudsearch"``, ``"lucene"``, or ``"plain"`` + (default: ``"lucene"``). + :param time_filter: Can be one of: ``"all"``, ``"day"``, ``"hour"``, + ``"month"``, ``"week"``, or ``"year"`` (default: ``"all"``). - def __getitem__(self, page_name: str) -> WikiPage: - """Lazily return the :class:`.WikiPage` for the :class:`.Subreddit` named ``page_name``. + For more information on building a search query see: + https://www.reddit.com/wiki/search - This method is to be used to fetch a specific wikipage, like so: + For example, to search all subreddits for ``"praw"`` try: .. code-block:: python - wikipage = reddit.subreddit("test").wiki["proof"] - print(wikipage.content_md) + for submission in reddit.subreddit("all").search("praw"): + print(submission.title) """ - return WikiPage(self.subreddit._reddit, self.subreddit, page_name.lower()) - - def __init__(self, subreddit: praw.models.Subreddit): - """Initialize a :class:`.SubredditWiki` instance. + self._validate_time_filter(time_filter) + not_all = self.display_name.lower() != "all" + self._safely_add_arguments( + arguments=generator_kwargs, + key="params", + q=query, + restrict_sr=not_all, + sort=sort, + syntax=syntax, + t=time_filter, + ) + url = API_PATH["search"].format(subreddit=self) + return ListingGenerator(self._reddit, url, **generator_kwargs) - :param subreddit: The subreddit whose wiki to work with. + @_deprecate_args("number") + def sticky(self, *, number: int = 1) -> praw.models.Submission: + """Return a :class:`.Submission` object for a sticky of the subreddit. - """ - self.banned = SubredditRelationship(subreddit, "wikibanned") - self.contributor = SubredditRelationship(subreddit, "wikicontributor") - self.subreddit = subreddit + :param number: Specify which sticky to return. 1 appears at the top (default: + ``1``). - def __iter__(self) -> Generator[WikiPage, None, None]: - """Iterate through the pages of the wiki. + :raises: ``prawcore.NotFound`` if the sticky does not exist. - This method is to be used to discover all wikipages for a subreddit: + For example, to get the stickied post on r/test: .. code-block:: python - for wikipage in reddit.subreddit("test").wiki: - print(wikipage) + reddit.subreddit("test").sticky() """ - response = self.subreddit._reddit.get( - API_PATH["wiki_pages"].format(subreddit=self.subreddit), - params={"unique": self.subreddit._reddit._next_unique}, + url = API_PATH["about_sticky"].format(subreddit=self) + try: + self._reddit.get(url, params={"num": number}) + except Redirect as redirect: + path = redirect.path + return self._submission_class( + self._reddit, url=urljoin(self._reddit.config.reddit_url, path) ) - for page_name in response["data"]: - yield WikiPage(self.subreddit._reddit, self.subreddit, page_name) - @_deprecate_args("name", "content", "reason") - def create( + @_deprecate_args( + "title", + "selftext", + "url", + "flair_id", + "flair_text", + "resubmit", + "send_replies", + "nsfw", + "spoiler", + "collection_id", + "discussion_type", + "inline_media", + "draft_id", + ) + def submit( self, + title: str, *, - content: str, - name: str, - reason: str | None = None, - **other_settings: Any, - ) -> WikiPage: - """Create a new :class:`.WikiPage`. + collection_id: str | None = None, + discussion_type: str | None = None, + draft_id: str | None = None, + flair_id: str | None = None, + flair_text: str | None = None, + inline_media: dict[str, praw.models.InlineMedia] | None = None, + nsfw: bool = False, + resubmit: bool = True, + selftext: str | None = None, + send_replies: bool = True, + spoiler: bool = False, + url: str | None = None, + ) -> praw.models.Submission: + r"""Add a submission to the :class:`.Subreddit`. - :param name: The name of the new :class:`.WikiPage`. This name will be - normalized. - :param content: The content of the new :class:`.WikiPage`. - :param reason: The reason for the creation. - :param other_settings: Additional keyword arguments to pass. + :param title: The title of the submission. + :param collection_id: The UUID of a :class:`.Collection` to add the + newly-submitted post to. + :param discussion_type: Set to ``"CHAT"`` to enable live discussion instead of + traditional comments (default: ``None``). + :param draft_id: The ID of a draft to submit. + :param flair_id: The flair template to select (default: ``None``). + :param flair_text: If the template's ``flair_text_editable`` value is ``True``, + this value will set a custom text (default: ``None``). ``flair_id`` is + required when ``flair_text`` is provided. + :param inline_media: A dict of :class:`.InlineMedia` objects where the key is + the placeholder name in ``selftext``. + :param nsfw: Whether the submission should be marked NSFW (default: ``False``). + :param resubmit: When ``False``, an error will occur if the URL has already been + submitted (default: ``True``). + :param selftext: The Markdown formatted content for a ``text`` submission. Use + an empty string, ``""``, to make a title-only submission. + :param send_replies: When ``True``, messages will be sent to the submission + author when comments are made to the submission (default: ``True``). + :param spoiler: Whether the submission should be marked as a spoiler (default: + ``False``). + :param url: The URL for a ``link`` submission. + + :returns: A :class:`.Submission` object for the newly created submission. + + Either ``selftext`` or ``url`` can be provided, but not both. - To create the wiki page ``"praw_test"`` in r/test try: + For example, to submit a URL to r/test do: .. code-block:: python - reddit.subreddit("test").wiki.create( - name="praw_test", content="wiki body text", reason="PRAW Test Creation" - ) + title = "PRAW documentation" + url = "https://praw.readthedocs.io" + reddit.subreddit("test").submit(title, url=url) - """ - name = name.replace(" ", "_").lower() - new = WikiPage(self.subreddit._reddit, self.subreddit, name) - new.edit(content=content, reason=reason, **other_settings) - return new + For example, to submit a self post with inline media do: - def revisions( - self, **generator_kwargs: Any - ) -> Generator[ - dict[str, praw.models.Redditor | WikiPage | str | int | bool | None], - None, - None, - ]: - """Return a :class:`.ListingGenerator` for recent wiki revisions. + .. code-block:: python - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + from praw.models import InlineGif, InlineImage, InlineVideo - To view the wiki revisions for ``"praw_test"`` in r/test try: + gif = InlineGif(path="path/to/image.gif", caption="optional caption") + image = InlineImage(path="path/to/image.jpg", caption="optional caption") + video = InlineVideo(path="path/to/video.mp4", caption="optional caption") + selftext = "Text with a gif {gif1} an image {image1} and a video {video1} inline" + media = {"gif1": gif, "image1": image, "video1": video} + reddit.subreddit("test").submit("title", inline_media=media, selftext=selftext) - .. code-block:: python + .. note:: - for item in reddit.subreddit("test").wiki["praw_test"].revisions(): - print(item) + Inserted media will have a padding of ``\\n\\n`` automatically added. This + is due to the weirdness with Reddit's API. Using the example above, the + result selftext body will look like so: - """ - url = API_PATH["wiki_revisions"].format(subreddit=self.subreddit) - return WikiPage._revision_generator( - generator_kwargs=generator_kwargs, subreddit=self.subreddit, url=url - ) + .. code-block:: + Text with a gif -class ContributorRelationship(SubredditRelationship): - r"""Provides methods to interact with a :class:`.Subreddit`'s contributors. + ![gif](u1rchuphryq51 "optional caption") - Contributors are also known as approved submitters. + an image - Contributors of a subreddit can be iterated through like so: + ![img](srnr8tshryq51 "optional caption") - .. code-block:: python + and video - for contributor in reddit.subreddit("test").contributor(): - print(contributor) + ![video](gmc7rvthryq51 "optional caption") - """ + inline - def leave(self): - """Abdicate the contributor position.""" - self.subreddit._reddit.post( - API_PATH["leavecontributor"], data={"id": self.subreddit.fullname} - ) + .. note:: + To submit a post to a subreddit with the ``"news"`` flair, you can get the + flair id like this: -class ModeratorRelationship(SubredditRelationship): - r"""Provides methods to interact with a :class:`.Subreddit`'s moderators. + .. code-block:: - Moderators of a subreddit can be iterated through like so: + choices = list(subreddit.flair.link_templates.user_selectable()) + template_id = next(x for x in choices if x["flair_text"] == "news")["flair_template_id"] + subreddit.submit("title", flair_id=template_id, url="https://www.news.com/") - .. code-block:: python + .. seealso:: - for moderator in reddit.subreddit("test").moderator(): - print(moderator) + - :meth:`~.Subreddit.submit_gallery` to submit more than one image in the + same post + - :meth:`~.Subreddit.submit_image` to submit images + - :meth:`~.Subreddit.submit_poll` to submit polls + - :meth:`~.Subreddit.submit_video` to submit videos and videogifs - """ + """ + if (bool(selftext) or selftext == "") == bool(url): + msg = "Either 'selftext' or 'url' must be provided." + raise TypeError(msg) - PERMISSIONS = { - "access", - "chat_config", - "chat_operator", - "config", - "flair", - "mail", - "posts", - "wiki", - } + data = { + "sr": str(self), + "resubmit": bool(resubmit), + "sendreplies": bool(send_replies), + "title": title, + "nsfw": bool(nsfw), + "spoiler": bool(spoiler), + "validate_on_submit": self._reddit.validate_on_submit, + } + for key, value in ( + ("flair_id", flair_id), + ("flair_text", flair_text), + ("collection_id", collection_id), + ("discussion_type", discussion_type), + ("draft_id", draft_id), + ): + if value is not None: + data[key] = value + if selftext is not None: + data.update(kind="self") + if inline_media: + body = selftext.format( + **{ + placeholder: self._upload_inline_media(media) + for placeholder, media in inline_media.items() + } + ) + converted = self._convert_to_fancypants(body) + data.update(richtext_json=dumps(converted)) + else: + data.update(text=selftext) + else: + data.update(kind="link", url=url) - @staticmethod - def _handle_permissions( # noqa: ANN205 + return self._reddit.post(API_PATH["submit"], data=data) + + @_deprecate_args( + "title", + "images", + "collection_id", + "discussion_type", + "flair_id", + "flair_text", + "nsfw", + "send_replies", + "spoiler", + ) + def submit_gallery( + self, + title: str, + images: list[dict[str, str]], *, - other_settings: dict | None = None, - permissions: list[str] | None = None, - ): - other_settings = deepcopy(other_settings) if other_settings else {} - other_settings["permissions"] = permissions_string( - known_permissions=ModeratorRelationship.PERMISSIONS, permissions=permissions - ) - return other_settings + collection_id: str | None = None, + discussion_type: str | None = None, + flair_id: str | None = None, + flair_text: str | None = None, + nsfw: bool = False, + send_replies: bool = True, + spoiler: bool = False, + ) -> praw.models.Submission: + """Add an image gallery submission to the subreddit. + + :param title: The title of the submission. + :param images: The images to post in dict with the following structure: + ``{"image_path": "path", "caption": "caption", "outbound_url": "url"}``, + only ``image_path`` is required. + :param collection_id: The UUID of a :class:`.Collection` to add the + newly-submitted post to. + :param discussion_type: Set to ``"CHAT"`` to enable live discussion instead of + traditional comments (default: ``None``). + :param flair_id: The flair template to select (default: ``None``). + :param flair_text: If the template's ``flair_text_editable`` value is ``True``, + this value will set a custom text (default: ``None``). ``flair_id`` is + required when ``flair_text`` is provided. + :param nsfw: Whether the submission should be marked NSFW (default: ``False``). + :param send_replies: When ``True``, messages will be sent to the submission + author when comments are made to the submission (default: ``True``). + :param spoiler: Whether the submission should be marked asa spoiler (default: + ``False``). + + :returns: A :class:`.Submission` object for the newly created submission. + + :raises: :class:`.ClientException` if ``image_path`` in ``images`` refers to a + file that is not an image. + + For example, to submit an image gallery to r/test do: + + .. code-block:: python + + title = "My favorite pictures" + image = "/path/to/image.png" + image2 = "/path/to/image2.png" + image3 = "/path/to/image3.png" + images = [ + {"image_path": image}, + { + "image_path": image2, + "caption": "Image caption 2", + }, + { + "image_path": image3, + "caption": "Image caption 3", + "outbound_url": "https://example.com/link3", + }, + ] + reddit.subreddit("test").submit_gallery(title, images) + + .. seealso:: + + - :meth:`~.Subreddit.submit` to submit url posts and selftexts + - :meth:`~.Subreddit.submit_image` to submit single images + - :meth:`~.Subreddit.submit_poll` to submit polls + - :meth:`~.Subreddit.submit_video` to submit videos and videogifs + + """ + self._validate_gallery(images) + data = { + "api_type": "json", + "items": [], + "nsfw": bool(nsfw), + "sendreplies": bool(send_replies), + "show_error_list": True, + "spoiler": bool(spoiler), + "sr": str(self), + "title": title, + "validate_on_submit": self._reddit.validate_on_submit, + } + for key, value in ( + ("flair_id", flair_id), + ("flair_text", flair_text), + ("collection_id", collection_id), + ("discussion_type", discussion_type), + ): + if value is not None: + data[key] = value + for image in images: + data["items"].append( + { + "caption": image.get("caption", ""), + "outbound_url": image.get("outbound_url", ""), + "media_id": self._upload_media( + expected_mime_prefix="image", + media_path=image["image_path"], + upload_type="gallery", + ), + } + ) + response = self._reddit.request( + json=data, method="POST", path=API_PATH["submit_gallery_post"] + )["json"] + if response["errors"]: + raise RedditAPIException(response["errors"]) + return self._reddit.submission(url=response["data"]["url"]) - def __call__( - self, redditor: str | praw.models.Redditor | None = None - ) -> list[praw.models.Redditor]: # pylint: disable=arguments-differ - r"""Return a list of :class:`.Redditor`\ s who are moderators. + @_deprecate_args( + "title", + "image_path", + "flair_id", + "flair_text", + "resubmit", + "send_replies", + "nsfw", + "spoiler", + "timeout", + "collection_id", + "without_websockets", + "discussion_type", + ) + def submit_image( + self, + title: str, + image_path: str, + *, + collection_id: str | None = None, + discussion_type: str | None = None, + flair_id: str | None = None, + flair_text: str | None = None, + nsfw: bool = False, + resubmit: bool = True, + send_replies: bool = True, + spoiler: bool = False, + timeout: int = 10, + without_websockets: bool = False, + ) -> praw.models.Submission | None: + """Add an image submission to the subreddit. - :param redditor: When provided, return a list containing at most one - :class:`.Redditor` instance. This is useful to confirm if a relationship - exists, or to fetch the metadata associated with a particular relationship - (default: ``None``). + :param collection_id: The UUID of a :class:`.Collection` to add the + newly-submitted post to. + :param discussion_type: Set to ``"CHAT"`` to enable live discussion instead of + traditional comments (default: ``None``). + :param flair_id: The flair template to select (default: ``None``). + :param flair_text: If the template's ``flair_text_editable`` value is ``True``, + this value will set a custom text (default: ``None``). ``flair_id`` is + required when ``flair_text`` is provided. + :param image_path: The path to an image, to upload and post. + :param nsfw: Whether the submission should be marked NSFW (default: ``False``). + :param resubmit: When ``False``, an error will occur if the URL has already been + submitted (default: ``True``). + :param send_replies: When ``True``, messages will be sent to the submission + author when comments are made to the submission (default: ``True``). + :param spoiler: Whether the submission should be marked as a spoiler (default: + ``False``). + :param timeout: Specifies a particular timeout, in seconds. Use to avoid + "Websocket error" exceptions (default: ``10``). + :param title: The title of the submission. + :param without_websockets: Set to ``True`` to disable use of WebSockets (see + note below for an explanation). If ``True``, this method doesn't return + anything (default: ``False``). - .. note:: + :returns: A :class:`.Submission` object for the newly created submission, unless + ``without_websockets`` is ``True``. - To help mitigate targeted moderator harassment, this call requires the - :class:`.Reddit` instance to be authenticated i.e., :attr:`.read_only` must - return ``False``. This call, however, only makes use of the ``read`` scope. - For more information on why the moderator list is hidden can be found here: - https://reddit.zendesk.com/hc/en-us/articles/360049499032-Why-is-the-moderator-list-hidden- + :raises: :class:`.ClientException` if ``image_path`` refers to a file that is + not an image. .. note:: - Unlike other relationship callables, this relationship is not paginated. - Thus it simply returns the full list, rather than an iterator for the - results. + Reddit's API uses WebSockets to respond with the link of the newly created + post. If this fails, the method will raise :class:`.WebSocketException`. + Occasionally, the Reddit post will still be created. More often, there is an + error with the image file. If you frequently get exceptions but successfully + created posts, try setting the ``timeout`` parameter to a value above 10. - To be used like: + To disable the use of WebSockets, set ``without_websockets=True``. This will + make the method return ``None``, though the post will still be created. You + may wish to do this if you are running your program in a restricted network + environment, or using a proxy that doesn't support WebSockets connections. - .. code-block:: python + For example, to submit an image to r/test do: - moderators = reddit.subreddit("test").moderator() + .. code-block:: python - For example, to list the moderators along with their permissions try: + title = "My favorite picture" + image = "/path/to/image.png" + reddit.subreddit("test").submit_image(title, image) - .. code-block:: python + .. seealso:: - for moderator in reddit.subreddit("test").moderator(): - print(f"{moderator}: {moderator.mod_permissions}") + - :meth:`~.Subreddit.submit` to submit url posts and selftexts + - :meth:`~.Subreddit.submit_gallery` to submit more than one image in the + same post + - :meth:`~.Subreddit.submit_poll` to submit polls + - :meth:`~.Subreddit.submit_video` to submit videos and videogifs """ - params = {} if redditor is None else {"user": redditor} - url = API_PATH[f"list_{self.relationship}"].format(subreddit=self.subreddit) - return self.subreddit._reddit.get(url, params=params) + data = { + "sr": str(self), + "resubmit": bool(resubmit), + "sendreplies": bool(send_replies), + "title": title, + "nsfw": bool(nsfw), + "spoiler": bool(spoiler), + "validate_on_submit": self._reddit.validate_on_submit, + } + for key, value in ( + ("flair_id", flair_id), + ("flair_text", flair_text), + ("collection_id", collection_id), + ("discussion_type", discussion_type), + ): + if value is not None: + data[key] = value - # pylint: disable=arguments-differ - @_deprecate_args("redditor", "permissions") - def add( + image_url = self._upload_media( + expected_mime_prefix="image", media_path=image_path + ) + data.update(kind="image", url=image_url) + return self._submit_media( + data=data, timeout=timeout, without_websockets=without_websockets + ) + + @_deprecate_args( + "title", + "selftext", + "options", + "duration", + "flair_id", + "flair_text", + "resubmit", + "send_replies", + "nsfw", + "spoiler", + "collection_id", + "discussion_type", + ) + def submit_poll( self, - redditor: str | praw.models.Redditor, + title: str, *, - permissions: list[str] | None = None, - **other_settings: Any, - ): - """Add or invite ``redditor`` to be a moderator of the :class:`.Subreddit`. + collection_id: str | None = None, + discussion_type: str | None = None, + duration: int, + flair_id: str | None = None, + flair_text: str | None = None, + nsfw: bool = False, + options: list[str], + resubmit: bool = True, + selftext: str, + send_replies: bool = True, + spoiler: bool = False, + ) -> praw.models.Submission: + """Add a poll submission to the subreddit. - :param redditor: A redditor name or :class:`.Redditor` instance. - :param permissions: When provided (not ``None``), permissions should be a list - of strings specifying which subset of permissions to grant. An empty list - ``[]`` indicates no permissions, and when not provided ``None``, indicates - full permissions (default: ``None``). + :param title: The title of the submission. + :param collection_id: The UUID of a :class:`.Collection` to add the + newly-submitted post to. + :param discussion_type: Set to ``"CHAT"`` to enable live discussion instead of + traditional comments (default: ``None``). + :param duration: The number of days the poll should accept votes, as an ``int``. + Valid values are between ``1`` and ``7``, inclusive. + :param flair_id: The flair template to select (default: ``None``). + :param flair_text: If the template's ``flair_text_editable`` value is ``True``, + this value will set a custom text (default: ``None``). ``flair_id`` is + required when ``flair_text`` is provided. + :param nsfw: Whether the submission should be marked NSFW (default: ``False``). + :param options: A list of two to six poll options as ``str``. + :param resubmit: When ``False``, an error will occur if the URL has already been + submitted (default: ``True``). + :param selftext: The Markdown formatted content for the submission. Use an empty + string, ``""``, to make a submission with no text contents. + :param send_replies: When ``True``, messages will be sent to the submission + author when comments are made to the submission (default: ``True``). + :param spoiler: Whether the submission should be marked as a spoiler (default: + ``False``). - An invite will be sent unless the user making this call is an admin user. + :returns: A :class:`.Submission` object for the newly created submission. - For example, to invite u/spez with ``"posts"`` and ``"mail"`` permissions to - r/test, try: + For example, to submit a poll to r/test do: .. code-block:: python - reddit.subreddit("test").moderator.add("spez", permissions=["posts", "mail"]) - - """ - other_settings = self._handle_permissions( - other_settings=other_settings, permissions=permissions - ) - super().add(redditor, **other_settings) - - # pylint: enable=arguments-differ - @_deprecate_args("redditor", "permissions") - def invite( - self, - redditor: str | praw.models.Redditor, - *, - permissions: list[str] | None = None, - **other_settings: Any, - ): - """Invite ``redditor`` to be a moderator of the :class:`.Subreddit`. - - :param redditor: A redditor name or :class:`.Redditor` instance. - :param permissions: When provided (not ``None``), permissions should be a list - of strings specifying which subset of permissions to grant. An empty list - ``[]`` indicates no permissions, and when not provided ``None``, indicates - full permissions (default: ``None``). + title = "Do you like PRAW?" + reddit.subreddit("test").submit_poll( + title, selftext="", options=["Yes", "No"], duration=3 + ) - For example, to invite u/spez with ``"posts"`` and ``"mail"`` permissions to - r/test, try: + .. seealso:: - .. code-block:: python + - :meth:`~.Subreddit.submit` to submit url posts and selftexts + - :meth:`~.Subreddit.submit_gallery` to submit more than one image in the + same post + - :meth:`~.Subreddit.submit_image` to submit single images + - :meth:`~.Subreddit.submit_video` to submit videos and videogifs - reddit.subreddit("test").moderator.invite("spez", permissions=["posts", "mail"]) + """ + data = { + "sr": str(self), + "text": selftext, + "options": options, + "duration": duration, + "resubmit": bool(resubmit), + "sendreplies": bool(send_replies), + "title": title, + "nsfw": bool(nsfw), + "spoiler": bool(spoiler), + "validate_on_submit": self._reddit.validate_on_submit, + } + for key, value in ( + ("flair_id", flair_id), + ("flair_text", flair_text), + ("collection_id", collection_id), + ("discussion_type", discussion_type), + ): + if value is not None: + data[key] = value - """ - data = self._handle_permissions( - other_settings=other_settings, permissions=permissions - ) - data.update({"name": str(redditor), "type": "moderator_invite"}) - url = API_PATH["friend"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url, data=data) + return self._reddit.post(API_PATH["submit_poll_post"], json=data) - @_deprecate_args("redditor") - def invited( + @_deprecate_args( + "title", + "video_path", + "videogif", + "thumbnail_path", + "flair_id", + "flair_text", + "resubmit", + "send_replies", + "nsfw", + "spoiler", + "timeout", + "collection_id", + "without_websockets", + "discussion_type", + ) + def submit_video( self, + title: str, + video_path: str, *, - redditor: str | praw.models.Redditor | None = None, - **generator_kwargs: Any, - ) -> Iterator[praw.models.Redditor]: - r"""Return a :class:`.ListingGenerator` for :class:`.Redditor`\ s invited to be moderators. - - :param redditor: When provided, return a list containing at most one - :class:`.Redditor` instance. This is useful to confirm if a relationship - exists, or to fetch the metadata associated with a particular relationship - (default: ``None``). + collection_id: str | None = None, + discussion_type: str | None = None, + flair_id: str | None = None, + flair_text: str | None = None, + nsfw: bool = False, + resubmit: bool = True, + send_replies: bool = True, + spoiler: bool = False, + thumbnail_path: str | None = None, + timeout: int = 10, + videogif: bool = False, + without_websockets: bool = False, + ) -> praw.models.Submission | None: + """Add a video or videogif submission to the subreddit. - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + :param title: The title of the submission. + :param video_path: The path to a video, to upload and post. + :param collection_id: The UUID of a :class:`.Collection` to add the + newly-submitted post to. + :param discussion_type: Set to ``"CHAT"`` to enable live discussion instead of + traditional comments (default: ``None``). + :param flair_id: The flair template to select (default: ``None``). + :param flair_text: If the template's ``flair_text_editable`` value is ``True``, + this value will set a custom text (default: ``None``). ``flair_id`` is + required when ``flair_text`` is provided. + :param nsfw: Whether the submission should be marked NSFW (default: ``False``). + :param resubmit: When ``False``, an error will occur if the URL has already been + submitted (default: ``True``). + :param send_replies: When ``True``, messages will be sent to the submission + author when comments are made to the submission (default: ``True``). + :param spoiler: Whether the submission should be marked as a spoiler (default: + ``False``). + :param thumbnail_path: The path to an image, to be uploaded and used as the + thumbnail for this video. If not provided, the PRAW logo will be used as the + thumbnail. + :param timeout: Specifies a particular timeout, in seconds. Use to avoid + "Websocket error" exceptions (default: ``10``). + :param videogif: If ``True``, the video is uploaded as a videogif, which is + essentially a silent video (default: ``False``). + :param without_websockets: Set to ``True`` to disable use of WebSockets (see + note below for an explanation). If ``True``, this method doesn't return + anything (default: ``False``). - .. note:: + :returns: A :class:`.Submission` object for the newly created submission, unless + ``without_websockets`` is ``True``. - Unlike other usages of :class:`.ListingGenerator`, ``limit`` has no effect - in the quantity returned. This endpoint always returns moderators in batches - of 25 at a time regardless of what ``limit`` is set to. + :raises: :class:`.ClientException` if ``video_path`` refers to a file that is + not a video. - Usage: + .. note:: - .. code-block:: python + Reddit's API uses WebSockets to respond with the link of the newly created + post. If this fails, the method will raise :class:`.WebSocketException`. + Occasionally, the Reddit post will still be created. More often, there is an + error with the image file. If you frequently get exceptions but successfully + created posts, try setting the ``timeout`` parameter to a value above 10. - for invited_mod in reddit.subreddit("test").moderator.invited(): - print(invited_mod) + To disable the use of WebSockets, set ``without_websockets=True``. This will + make the method return ``None``, though the post will still be created. You + may wish to do this if you are running your program in a restricted network + environment, or using a proxy that doesn't support WebSockets connections. - """ - generator_kwargs["params"] = {"username": redditor} if redditor else None - url = API_PATH["list_invited_moderator"].format(subreddit=self.subreddit) - return ListingGenerator(self.subreddit._reddit, url, **generator_kwargs) + For example, to submit a video to r/test do: - def leave(self): - """Abdicate the moderator position (use with care). + .. code-block:: python - For example: + title = "My favorite movie" + video = "/path/to/video.mp4" + reddit.subreddit("test").submit_video(title, video) - .. code-block:: python + .. seealso:: - reddit.subreddit("test").moderator.leave() + - :meth:`~.Subreddit.submit` to submit url posts and selftexts + - :meth:`~.Subreddit.submit_image` to submit images + - :meth:`~.Subreddit.submit_gallery` to submit more than one image in the + same post + - :meth:`~.Subreddit.submit_poll` to submit polls """ - self.remove( - self.subreddit._reddit.config.username or self.subreddit._reddit.user.me() + data = { + "sr": str(self), + "resubmit": bool(resubmit), + "sendreplies": bool(send_replies), + "title": title, + "nsfw": bool(nsfw), + "spoiler": bool(spoiler), + "validate_on_submit": self._reddit.validate_on_submit, + } + for key, value in ( + ("flair_id", flair_id), + ("flair_text", flair_text), + ("collection_id", collection_id), + ("discussion_type", discussion_type), + ): + if value is not None: + data[key] = value + + video_url = self._upload_media( + expected_mime_prefix="video", media_path=video_path + ) + data.update( + kind="videogif" if videogif else "video", + url=video_url, + # if thumbnail_path is None, it uploads the PRAW logo + video_poster_url=self._upload_media(media_path=thumbnail_path), + ) + return self._submit_media( + data=data, timeout=timeout, without_websockets=without_websockets ) - def remove_invite(self, redditor: str | praw.models.Redditor): - """Remove the moderator invite for ``redditor``. + @_deprecate_args("other_subreddits") + def subscribe(self, *, other_subreddits: list[praw.models.Subreddit] | None = None): + """Subscribe to the subreddit. - :param redditor: A redditor name or :class:`.Redditor` instance. + :param other_subreddits: When provided, also subscribe to the provided list of + subreddits. - For example: + For example, to subscribe to r/test: .. code-block:: python - reddit.subreddit("test").moderator.remove_invite("spez") + reddit.subreddit("test").subscribe() """ - data = {"name": str(redditor), "type": "moderator_invite"} - url = API_PATH["unfriend"].format(subreddit=self.subreddit) - self.subreddit._reddit.post(url, data=data) + data = { + "action": "sub", + "skip_inital_defaults": True, + "sr_name": self._subreddit_list( + other_subreddits=other_subreddits, subreddit=self + ), + } + self._reddit.post(API_PATH["subscribe"], data=data) - @_deprecate_args("redditor", "permissions") - def update( - self, - redditor: str | praw.models.Redditor, - *, - permissions: list[str] | None = None, - ): - """Update the moderator permissions for ``redditor``. + def traffic(self) -> dict[str, list[list[int]]]: + """Return a dictionary of the :class:`.Subreddit`'s traffic statistics. - :param redditor: A redditor name or :class:`.Redditor` instance. - :param permissions: When provided (not ``None``), permissions should be a list - of strings specifying which subset of permissions to grant. An empty list - ``[]`` indicates no permissions, and when not provided, ``None``, indicates - full permissions (default: ``None``). + :raises: ``prawcore.NotFound`` when the traffic stats aren't available to the + authenticated user, that is, they are not public and the authenticated user + is not a moderator of the subreddit. - For example, to add all permissions to the moderator, try: + The traffic method returns a dict with three keys. The keys are ``day``, + ``hour`` and ``month``. Each key contains a list of lists with 3 or 4 values. + The first value is a timestamp indicating the start of the category (start of + the day for the ``day`` key, start of the hour for the ``hour`` key, etc.). The + second, third, and fourth values indicate the unique pageviews, total pageviews, + and subscribers, respectively. - .. code-block:: python + .. note:: - subreddit.moderator.update("spez") + The ``hour`` key does not contain subscribers, and therefore each sub-list + contains three values. - To remove all permissions from the moderator, try: + For example, to get the traffic stats for r/test: .. code-block:: python - subreddit.moderator.update("spez", permissions=[]) + stats = reddit.subreddit("test").traffic() """ - url = API_PATH["setpermissions"].format(subreddit=self.subreddit) - data = self._handle_permissions( - other_settings={"name": str(redditor), "type": "moderator"}, - permissions=permissions, - ) - self.subreddit._reddit.post(url, data=data) + return self._reddit.get(API_PATH["about_traffic"].format(subreddit=self)) - @_deprecate_args("redditor", "permissions") - def update_invite( - self, - redditor: str | praw.models.Redditor, - *, - permissions: list[str] | None = None, + @_deprecate_args("other_subreddits") + def unsubscribe( + self, *, other_subreddits: list[praw.models.Subreddit] | None = None ): - """Update the moderator invite permissions for ``redditor``. + """Unsubscribe from the subreddit. - :param redditor: A redditor name or :class:`.Redditor` instance. - :param permissions: When provided (not ``None``), permissions should be a list - of strings specifying which subset of permissions to grant. An empty list - ``[]`` indicates no permissions, and when not provided, ``None``, indicates - full permissions (default: ``None``). + :param other_subreddits: When provided, also unsubscribe from the provided list + of subreddits. - For example, to grant the ``"flair"`` and ``"mail"`` permissions to the - moderator invite, try: + To unsubscribe from r/test: .. code-block:: python - subreddit.moderator.update_invite("spez", permissions=["flair", "mail"]) + reddit.subreddit("test").unsubscribe() """ - url = API_PATH["setpermissions"].format(subreddit=self.subreddit) - data = self._handle_permissions( - other_settings={"name": str(redditor), "type": "moderator_invite"}, - permissions=permissions, - ) - self.subreddit._reddit.post(url, data=data) + data = { + "action": "unsub", + "sr_name": self._subreddit_list( + other_subreddits=other_subreddits, subreddit=self + ), + } + self._reddit.post(API_PATH["subscribe"], data=data) + + +WidgetEncoder._subreddit_class = Subreddit class SubredditLinkFlairTemplates(SubredditFlairTemplates): diff --git a/praw/models/reddit/user_subreddit.py b/praw/models/reddit/user_subreddit.py index 47763d389..d99e3b056 100644 --- a/praw/models/reddit/user_subreddit.py +++ b/praw/models/reddit/user_subreddit.py @@ -2,7 +2,7 @@ from __future__ import annotations import inspect -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Callable from warnings import warn from ...util.cache import cachedproperty @@ -52,7 +52,7 @@ class UserSubreddit(Subreddit): """ @staticmethod - def _dict_depreciated_wrapper(func): # noqa: ANN001,ANN205 + def _dict_deprecated_wrapper(func: Callable) -> Callable: """Show deprecation notice for dict only methods.""" def wrapper(*args: Any, **kwargs: Any): @@ -112,7 +112,7 @@ def predicate(item: str): setattr( self, name, - self._dict_depreciated_wrapper(getattr(self.__dict__, name)), + self._dict_deprecated_wrapper(getattr(self.__dict__, name)), ) super().__init__(reddit, *args, **kwargs) diff --git a/praw/models/reddit/widgets.py b/praw/models/reddit/widgets.py index 79a8ba8ad..48230580a 100644 --- a/praw/models/reddit/widgets.py +++ b/praw/models/reddit/widgets.py @@ -301,12 +301,12 @@ def topbar(self) -> list[praw.models.Menu]: self.items[widget_name] for widget_name in self.layout["topbar"]["order"] ] - def __getattr__(self, attribute: str) -> Any: + def __getattr__(self, attr: str) -> Any: """Return the value of ``attr``.""" - if not attribute.startswith("_") and not self._fetched: + if not attr.startswith("_") and not self._fetched: self._fetch() - return getattr(self, attribute) - msg = f"{self.__class__.__name__!r} object has no attribute {attribute!r}" + return getattr(self, attr) + msg = f"{self.__class__.__name__!r} object has no attribute {attr!r}" raise AttributeError(msg) def __init__(self, subreddit: praw.models.Subreddit): @@ -326,7 +326,7 @@ def __repr__(self) -> str: """Return an object initialization representation of the instance.""" return f"SubredditWidgets(subreddit={self.subreddit!r})" - def _fetch(self): # noqa: ANN001 + def _fetch(self): data = self._reddit.get( API_PATH["widgets"].format(subreddit=self.subreddit), params={"progressive_images": self.progressive_images}, @@ -387,7 +387,6 @@ def __eq__(self, other: object) -> bool: return self.id.lower() == other.id.lower() return str(other).lower() == self.id.lower() - # pylint: disable=invalid-name def __init__(self, reddit: praw.Reddit, _data: dict[str, Any]): """Initialize a :class:`.Widget` instance.""" self.subreddit = "" # in case it isn't in _data @@ -399,7 +398,7 @@ def __init__(self, reddit: praw.Reddit, _data: dict[str, Any]): class WidgetEncoder(JSONEncoder): """Class to encode widget-related objects.""" - def default(self, o: Any) -> Any: # pylint: disable=E0202 + def default(self, o: Any) -> Any: """Serialize ``PRAWBase`` objects.""" if isinstance(o, self._subreddit_class): return str(o) @@ -1109,6 +1108,85 @@ class TextArea(Widget): """ +class WidgetModeration: + """Class for moderating a particular widget. + + Example usage: + + .. code-block:: python + + widget = reddit.subreddit("test").widgets.sidebar[0] + widget.mod.update(shortName="My new title") + widget.mod.delete() + + """ + + def __init__( + self, + widget: Widget, + subreddit: praw.models.Subreddit | str, + reddit: praw.Reddit, + ): + """Initialize a :class:`.WidgetModeration` instance.""" + self.widget = widget + self._reddit = reddit + self._subreddit = subreddit + + def delete(self): + """Delete the widget. + + Example usage: + + .. code-block:: python + + widget.mod.delete() + + """ + path = API_PATH["widget_modify"].format( + widget_id=self.widget.id, subreddit=self._subreddit + ) + self._reddit.delete(path) + + def update(self, **kwargs: Any) -> Widget: + """Update the widget. Returns the updated widget. + + Parameters differ based on the type of widget. See `Reddit documentation + `_ or the document of + the particular type of widget. + + :returns: The updated :class:`.Widget`. + + For example, update a text widget like so: + + .. code-block:: python + + text_widget.mod.update(shortName="New text area", text="Hello!") + + .. note:: + + Most parameters follow the ``lowerCamelCase`` convention. When in doubt, + check the Reddit documentation linked above. + + """ + path = API_PATH["widget_modify"].format( + widget_id=self.widget.id, subreddit=self._subreddit + ) + payload = { + key: value + for key, value in vars(self.widget).items() + if not key.startswith("_") + } + del payload["subreddit"] # not JSON serializable + if "mod" in payload: + del payload["mod"] + payload.update(kwargs) + widget = self._reddit.put( + path, data={"json": dumps(payload, cls=WidgetEncoder)} + ) + widget.subreddit = self._subreddit + return widget + + class SubredditWidgetsModeration: """Class for moderating a :class:`.Subreddit`'s widgets. @@ -1135,7 +1213,7 @@ def __init__(self, subreddit: praw.models.Subreddit, reddit: praw.Reddit): self._subreddit = subreddit self._reddit = reddit - def _create_widget(self, payload: dict[str, Any]) -> WidgetType: # noqa: ANN001 + def _create_widget(self, payload: dict[str, Any]) -> WidgetType: path = API_PATH["widget_create"].format(subreddit=self._subreddit) widget = self._reddit.post( path, data={"json": dumps(payload, cls=WidgetEncoder)} @@ -1147,7 +1225,7 @@ def _create_widget(self, payload: dict[str, Any]) -> WidgetType: # noqa: ANN001 def add_button_widget( self, *, - buttons: list[dict[str, dict[str, str] | str | int | dict[str, str | int]]], + buttons: list[dict[str, dict[str, str | int] | str | int]], description: str, short_name: str, styles: dict[str, str], @@ -1790,82 +1868,3 @@ def upload_image(self, file_path: str) -> str: response.raise_for_status() return f"{upload_url}/{upload_data['key']}" - - -class WidgetModeration: - """Class for moderating a particular widget. - - Example usage: - - .. code-block:: python - - widget = reddit.subreddit("test").widgets.sidebar[0] - widget.mod.update(shortName="My new title") - widget.mod.delete() - - """ - - def __init__( - self, - widget: Widget, - subreddit: praw.models.Subreddit | str, - reddit: praw.Reddit, - ): - """Initialize a :class:`.WidgetModeration` instance.""" - self.widget = widget - self._reddit = reddit - self._subreddit = subreddit - - def delete(self): - """Delete the widget. - - Example usage: - - .. code-block:: python - - widget.mod.delete() - - """ - path = API_PATH["widget_modify"].format( - widget_id=self.widget.id, subreddit=self._subreddit - ) - self._reddit.delete(path) - - def update(self, **kwargs: Any) -> Widget: - """Update the widget. Returns the updated widget. - - Parameters differ based on the type of widget. See `Reddit documentation - `_ or the document of - the particular type of widget. - - :returns: The updated :class:`.Widget`. - - For example, update a text widget like so: - - .. code-block:: python - - text_widget.mod.update(shortName="New text area", text="Hello!") - - .. note:: - - Most parameters follow the ``lowerCamelCase`` convention. When in doubt, - check the Reddit documentation linked above. - - """ - path = API_PATH["widget_modify"].format( - widget_id=self.widget.id, subreddit=self._subreddit - ) - payload = { - key: value - for key, value in vars(self.widget).items() - if not key.startswith("_") - } - del payload["subreddit"] # not JSON serializable - if "mod" in payload: - del payload["mod"] - payload.update(kwargs) - widget = self._reddit.put( - path, data={"json": dumps(payload, cls=WidgetEncoder)} - ) - widget.subreddit = self._subreddit - return widget diff --git a/praw/models/reddit/wikipage.py b/praw/models/reddit/wikipage.py index 18ac295a7..1bb46e897 100644 --- a/praw/models/reddit/wikipage.py +++ b/praw/models/reddit/wikipage.py @@ -239,7 +239,7 @@ def __str__(self) -> str: """Return a string representation of the instance.""" return f"{self.subreddit}/{self.name}" - def _fetch(self): # noqa: ANN001 + def _fetch(self): data = self._fetch_data() data = data["data"] if data["revision_by"] is not None: @@ -247,9 +247,9 @@ def _fetch(self): # noqa: ANN001 self._reddit, _data=data["revision_by"]["data"] ) self.__dict__.update(data) - self._fetched = True + super()._fetch() - def _fetch_info(self): # noqa: ANN001 + def _fetch_info(self): return ( "wiki_page", {"subreddit": self.subreddit, "page": self.name}, diff --git a/praw/models/subreddits.py b/praw/models/subreddits.py index 715e603bf..e6306fd3b 100644 --- a/praw/models/subreddits.py +++ b/praw/models/subreddits.py @@ -19,7 +19,7 @@ class Subreddits(PRAWBase): """Subreddits is a Listing class that provides various subreddit lists.""" @staticmethod - def _to_list(subreddit_list): # noqa: ANN001,ANN003,ANN205 + def _to_list(subreddit_list: list[str | praw.models.Subreddit]) -> str: return ",".join([str(x) for x in subreddit_list]) def default( diff --git a/praw/models/trophy.py b/praw/models/trophy.py index 8e8662de4..fc86671b0 100644 --- a/praw/models/trophy.py +++ b/praw/models/trophy.py @@ -54,4 +54,4 @@ def __repr__(self) -> str: def __str__(self) -> str: """Return a name of the trophy.""" - return self.name # pylint: disable=no-member + return self.name diff --git a/praw/models/user.py b/praw/models/user.py index 5e3a15e62..4ae5d5b9c 100644 --- a/praw/models/user.py +++ b/praw/models/user.py @@ -133,9 +133,7 @@ def karma(self) -> dict[praw.models.Subreddit, dict[str, int]]: return karma_map @_deprecate_args("use_cache") - def me( - self, *, use_cache: bool = True - ) -> praw.models.Redditor | None: # pylint: disable=invalid-name + def me(self, *, use_cache: bool = True) -> praw.models.Redditor | None: """Return a :class:`.Redditor` instance for the authenticated user. :param use_cache: When ``True``, and if this function has been previously diff --git a/praw/models/util.py b/praw/models/util.py index 498898d08..d0907974e 100644 --- a/praw/models/util.py +++ b/praw/models/util.py @@ -189,7 +189,7 @@ def __init__(self, max_items: int): self.max_items = max_items self._set = OrderedDict() - def _access(self, item: Any): # noqa: ANN001 + def _access(self, item: Any): if item in self._set: self._set.move_to_end(item) diff --git a/praw/objector.py b/praw/objector.py index 3352d1777..68b3b29c4 100644 --- a/praw/objector.py +++ b/praw/objector.py @@ -59,7 +59,9 @@ def __init__(self, reddit: praw.Reddit, parsers: dict[str, Any] | None = None): self.parsers = {} if parsers is None else parsers self._reddit = reddit - def _objectify_dict(self, data): # noqa: ANN001,PLR0912,PLR0915 + def _objectify_dict( # noqa: PLR0912,PLR0915 + self, data: dict[str:Any] + ) -> RedditBase: """Create :class:`.RedditBase` objects from dicts. :param data: The structured data, assumed to be a dict. @@ -223,7 +225,6 @@ def objectify( # noqa: PLR0911,PLR0912,PLR0915 ``None``. """ - # pylint: disable=too-many-return-statements if data is None: # 204 no content return None if isinstance(data, list): diff --git a/praw/reddit.py b/praw/reddit.py index ab3e0f200..9528d2a2d 100644 --- a/praw/reddit.py +++ b/praw/reddit.py @@ -82,7 +82,7 @@ class Reddit: _ratelimit_regex = re.compile(r"([0-9]{1,3}) (milliseconds?|seconds?|minutes?)") @property - def _next_unique(self) -> int: # noqa: ANN001 + def _next_unique(self) -> int: value = self._unique_counter self._unique_counter += 1 return value @@ -139,7 +139,7 @@ def __enter__(self): # noqa: ANN204 """Handle the context manager open.""" return self - def __exit__(self, *_args): + def __exit__(self, *_: object): """Handle the context manager close.""" @_deprecate_args( @@ -158,7 +158,7 @@ def __init__( requestor_kwargs: dict[str, Any] | None = None, token_manager: BaseTokenManager | None = None, **config_settings: str | bool | int | None, - ): # noqa: D207, D301 + ): """Initialize a :class:`.Reddit` instance. :param site_name: The name of a section in your ``praw.ini`` file from which to @@ -436,7 +436,7 @@ def request(self, *args, **kwargs): """ - def _check_for_async(self): # noqa: ANN001 + def _check_for_async(self): if self.config.check_for_async: # pragma: no cover try: shell = get_ipython().__class__.__name__ @@ -448,10 +448,8 @@ def _check_for_async(self): # noqa: ANN001 try: asyncio.get_running_loop() in_async = True - except ( # noqa: S110: Quietly fail if any exception occurs during the check - Exception # noqa: BLE001 - ): - pass + except Exception: # noqa: BLE001,S110 + pass # Quietly fail if any exception occurs during the check if in_async: logger.warning( "It appears that you are using PRAW in an asynchronous" @@ -462,7 +460,7 @@ def _check_for_async(self): # noqa: ANN001 " for more info.\n", ) - def _check_for_update(self): # noqa: ANN001 + def _check_for_update(self): if UPDATE_CHECKER_MISSING: return if not Reddit.update_checked and self.config.check_for_updates: @@ -520,7 +518,9 @@ def _objectify_request( ) ) - def _prepare_common_authorizer(self, authenticator): # noqa: ANN001 + def _prepare_common_authorizer( + self, authenticator: prawcore.auth.BaseAuthenticator + ): if self._token_manager is not None: warn( "Token managers have been deprecated and will be removed in the near" @@ -548,7 +548,7 @@ def _prepare_common_authorizer(self, authenticator): # noqa: ANN001 return self._core = self._authorized_core = session(authorizer) - def _prepare_objector(self): # noqa: ANN001 + def _prepare_objector(self): mappings = { self.config.kinds["comment"]: models.Comment, self.config.kinds["message"]: models.Message, @@ -597,7 +597,10 @@ def _prepare_objector(self): # noqa: ANN001 self._objector = Objector(self, mappings) def _prepare_prawcore( - self, *, requestor_class=None, requestor_kwargs=None # noqa: ANN001 + self, + *, + requestor_class: type[prawcore.requestor.Requestor] = None, + requestor_kwargs: Any | None = None, ): requestor_class = requestor_class or Requestor requestor_kwargs = requestor_kwargs or {} @@ -614,7 +617,7 @@ def _prepare_prawcore( else: self._prepare_untrusted_prawcore(requestor) - def _prepare_trusted_prawcore(self, requestor): # noqa: ANN001 + def _prepare_trusted_prawcore(self, requestor: prawcore.requestor.Requestor): authenticator = TrustedAuthenticator( requestor, self.config.client_id, @@ -632,7 +635,7 @@ def _prepare_trusted_prawcore(self, requestor): # noqa: ANN001 else: self._prepare_common_authorizer(authenticator) - def _prepare_untrusted_prawcore(self, requestor): # noqa: ANN001 + def _prepare_untrusted_prawcore(self, requestor: prawcore.requestor.Requestor): authenticator = UntrustedAuthenticator( requestor, self.config.client_id, self.config.redirect_uri ) @@ -642,10 +645,7 @@ def _prepare_untrusted_prawcore(self, requestor): # noqa: ANN001 @_deprecate_args("id", "url") def comment( - self, # pylint: disable=invalid-name - id: str | None = None, # pylint: disable=redefined-builtin noqa: A002 - *, - url: str | None = None, + self, id: str | None = None, *, url: str | None = None ) -> models.Comment: """Return a lazy instance of :class:`.Comment`. @@ -849,8 +849,9 @@ def post( if seconds is None: break second_string = "second" if seconds == 1 else "seconds" - stmt = f"Rate limit hit, sleeping for {seconds} {second_string}" - logger.debug(stmt) + logger.debug( + "Rate limit hit, sleeping for %d %s", seconds, second_string + ) time.sleep(seconds) raise last_exception @@ -965,7 +966,7 @@ def request( ) from exception @_deprecate_args("id", "url") - def submission( # pylint: disable=invalid-name,redefined-builtin + def submission( self, id: str | None = None, *, url: str | None = None ) -> praw.models.Submission: """Return a lazy instance of :class:`.Submission`. diff --git a/praw/util/__init__.py b/praw/util/__init__.py index 5b7891699..064b8ff73 100644 --- a/praw/util/__init__.py +++ b/praw/util/__init__.py @@ -1,5 +1,5 @@ """Package imports for utilities.""" -from .cache import cachedproperty # noqa: F401 -from .deprecate_args import _deprecate_args # noqa: F401 -from .snake import camel_to_snake, snake_case_keys # noqa: F401 +from .cache import cachedproperty +from .deprecate_args import _deprecate_args +from .snake import camel_to_snake, snake_case_keys diff --git a/praw/util/cache.py b/praw/util/cache.py index bf771b5b7..acd777617 100644 --- a/praw/util/cache.py +++ b/praw/util/cache.py @@ -22,13 +22,10 @@ class cachedproperty: # noqa: N801 """ # This to make sphinx run properly - # noqa: D102 def __call__(self, *args: Any, **kwargs: Any): # pragma: no cover """Empty method to make sphinx run properly.""" - def __get__( - self, obj: Any | None, objtype: Any | None = None - ) -> Any: # noqa: ANN401 + def __get__(self, obj: Any | None, objtype: Any | None = None) -> Any: """Implement descriptor getter. Calculate the property's value and then store it in the associated object's diff --git a/praw/util/deprecate_args.py b/praw/util/deprecate_args.py index e9be7c50c..283991d09 100644 --- a/praw/util/deprecate_args.py +++ b/praw/util/deprecate_args.py @@ -7,8 +7,8 @@ from warnings import warn -def _deprecate_args(*old_args: str): # noqa: ANN001 - def _generate_arg_string(used_args: tuple[str, ...]) -> str: # noqa: ANN001 +def _deprecate_args(*old_args: str) -> Callable: + def _generate_arg_string(used_args: tuple[str, ...]) -> str: used_args = list(map(repr, used_args)) arg_count = len(used_args) arg_string = ( diff --git a/praw/util/token_manager.py b/praw/util/token_manager.py index 8b49a2571..843b06892 100644 --- a/praw/util/token_manager.py +++ b/praw/util/token_manager.py @@ -64,7 +64,7 @@ def reddit(self, value: praw.Reddit): raise RuntimeError(msg) self._reddit = value - def __init__(self) -> None: + def __init__(self): """Initialize a :class:`.BaseTokenManager` instance.""" self._reddit = None @@ -120,7 +120,7 @@ class SQLiteTokenManager(BaseTokenManager): """ @_deprecate_args("database", "key") - def __init__(self, *, database: str, key: str) -> None: + def __init__(self, *, database: str, key: str): """Initialize a :class:`.SQLiteTokenManager` instance. :param database: The path to the SQLite database. @@ -143,7 +143,7 @@ def __init__(self, *, database: str, key: str) -> None: self._connection.commit() self.key = key - def _get(self): # noqa: ANN001 + def _get(self): cursor = self._connection.execute( "SELECT refresh_token FROM tokens WHERE id=?", (self.key,) ) @@ -152,7 +152,7 @@ def _get(self): # noqa: ANN001 raise KeyError return result[0] - def _set(self, refresh_token): # noqa: ANN001 + def _set(self, refresh_token: str): """Set the refresh token in the database. This function will overwrite an existing value if the corresponding ``key`` @@ -166,7 +166,7 @@ def _set(self, refresh_token): # noqa: ANN001 self._connection.commit() def is_registered(self) -> bool: - """Return whether or not ``key`` already has a ``refresh_token``.""" + """Return whether ``key`` already has a ``refresh_token``.""" cursor = self._connection.execute( "SELECT refresh_token FROM tokens WHERE id=?", (self.key,) ) diff --git a/pyproject.toml b/pyproject.toml index a38b70149..38a7ae3b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ ignore = [ "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line "E501", # line-length + "N818", # exception name should be named with an Error suffix "PLR0913", # too many arguments "PLR2004", # Magic value used in comparison, "S101" # use of assert diff --git a/tests/integration/models/reddit/test_submission.py b/tests/integration/models/reddit/test_submission.py index e9ee13a41..81abd61fb 100644 --- a/tests/integration/models/reddit/test_submission.py +++ b/tests/integration/models/reddit/test_submission.py @@ -314,14 +314,14 @@ def test_choices(self, reddit): expected = [ { "flair_text": "SATISFIED", - "flair_template_id": "680f43b8-1fec-11e3-80d1-12313b0b80bc", # noqa:E501 + "flair_template_id": "680f43b8-1fec-11e3-80d1-12313b0b80bc", "flair_text_editable": False, "flair_position": "left", "flair_css_class": "", }, { "flair_text": "STATS", - "flair_template_id": "16cabd0a-a68d-11e5-8349-0e8ff96e6679", # noqa:E501 + "flair_template_id": "16cabd0a-a68d-11e5-8349-0e8ff96e6679", "flair_text_editable": False, "flair_position": "left", "flair_css_class": "", diff --git a/tools/set_version.py b/tools/set_version.py index d870b0d4e..4de0431fb 100755 --- a/tools/set_version.py +++ b/tools/set_version.py @@ -7,7 +7,7 @@ CHANGELOG_HEADER = ( "Change Log\n==========\n\n" - "PRAW follows `semantic versioning `_.\n\n" + "PRAW follows `semantic versioning `_.\n\n" ) UNRELEASED_HEADER = "Unreleased\n----------\n\n" diff --git a/tox.ini b/tox.ini index 5f14cf811..f5d620c45 100644 --- a/tox.ini +++ b/tox.ini @@ -7,11 +7,3 @@ deps = .[test] commands = pytest -passenv = - prawtest_client_id - prawtest_client_secret - prawtest_password - prawtest_refresh_token - prawtest_test_subreddit - prawtest_username - prawtest_user_agent