diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 1f2218ec9..000000000 --- a/.flake8 +++ /dev/null @@ -1,7 +0,0 @@ -[flake8] -exclude = .eggs,build,docs,.venv* -ignore = E203 E501 W503 W504 -per-file-ignores = - asyncpraw/models/__init__.py:F401 - asyncpraw/models/listing/mixins/__init__.py:F401 - asyncpraw/models/reddit/mixins/__init__.py:F401 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index feaa14dde..4b61ba826 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -28,7 +28,7 @@ body: - id: credential-check attributes: label: | - The `Reddit()` initialization in my code example does not include the following parameters to prevent credential leakage: + The `Reddit()` initialization in my code example does not include the following parameters to prevent credential leakage: `client_secret`, `password`, or `refresh_token`. options: - label: "Yes" diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 717fc83ab..f04017ded 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,4 +6,4 @@ This pull request provides ... ## References -- +- diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 811e641b8..83bc298e7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,6 @@ +default_language_version: + python: python3.11 repos: - - - repo: https://github.com/pre-commit/pre-commit-hooks - hooks: - - id: end-of-file-fixer - exclude: .*\.txt - rev: v4.5.0 - - repo: local hooks: - id: static_word_checks @@ -24,36 +19,43 @@ repos: pass_filenames: false types: [ python ] - - repo: https://github.com/psf/black - hooks: - - id: black - rev: 23.9.1 - - - repo: https://github.com/LilSpazJoekp/docstrfmt + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 hooks: - - id: docstrfmt - rev: v1.5.1 + - id: check-added-large-files + - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: mixed-line-ending + args: [ --fix=no ] + - id: name-tests-test + args: [ --pytest-test-first ] + files: ^tests/integration/.*\.py|tests/unit/.*\.py$ + - id: sort-simple-yaml + files: ^(\.github/workflows/.*\.ya?ml|\.readthedocs.ya?ml)$ + - id: trailing-whitespace - - repo: https://github.com/pycqa/flake8 + - repo: https://github.com/pappasam/toml-sort + rev: v0.23.1 hooks: - - id: flake8 - rev: 6.1.0 + - id: toml-sort-fix + files: ^(.*\.toml)$ - - repo: https://github.com/ikamensh/flynt/ + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.292 hooks: - - id: flynt - args: - - '-ll' - - '1000' - rev: '1.0.1' + - id: ruff + args: [ --exit-non-zero-on-fix, --fix ] + files: ^(asyncpraw/.*.py)$ - - repo: https://github.com/pycqa/isort + - repo: https://github.com/psf/black hooks: - - id: isort - rev: 5.12.0 + - id: black + rev: 23.9.1 - - repo: https://github.com/pycqa/pydocstyle + - repo: https://github.com/LilSpazJoekp/docstrfmt hooks: - - id: pydocstyle - files: asyncpraw/.* - rev: 6.3.0 + - id: docstrfmt + rev: v1.5.1 diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 609704a46..000000000 --- a/.pylintrc +++ /dev/null @@ -1,245 +0,0 @@ -[MASTER] - -# Specify a configuration file. -#rcfile= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Profiled execution. -profile=no - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Pickle collected data for later comparisons. -persistent=yes - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - - -[MESSAGES CONTROL] - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time. -#enable= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). -#disable=C0111,I0011,I0012,W0142,W0212,R0205,R1705 -disable=C0111,C0330,R0205,W0212 - - -[REPORTS] - -# Set the output format. Available formats are text, parseable, colorized, msvs -# (visual studio) and html -output-format=colorized - -msg-template="{msg_id}:{line:4d},{column:2d}: {msg} ({symbol})" - -# Include message's id in output -include-ids=yes - -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no - -# Tells whether to display a full report or only the messages -reports=no - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Add a comment according to your evaluation note. This is used by the global -# evaluation report (RP0004). -comment=no - - -[BASIC] - -# List of builtins function names that should not be used, separated by a comma -bad-functions=map,filter,apply,input - -# Regular expression which should only match correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Regular expression which should only match correct module level names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Regular expression which should only match correct class names -class-rgx=[A-Z_][a-zA-Z0-9]+$ - -# Regular expression which should only match correct function names -function-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct method names -method-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct instance attribute names -attr-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct argument names -argument-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct variable names -variable-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct list comprehension / -# generator expression variable names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Good variable names which should always be accepted, separated by a comma -good-names=i,id,_ - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# Regular expression which should only match functions or classes name which do -# not require a docstring -no-docstring-rgx=__.*__ - - -[FORMAT] - -# Maximum number of characters on a single line. -max-line-length=80 - -# Maximum number of lines in a module -max-module-lines=2304 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - - -[VARIABLES] - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# A regular expression matching the beginning of the name of dummy variables -# (i.e. not used). -dummy-variables-rgx=_|dummy - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= - - -[TYPECHECK] - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# List of classes names for which member attributes should not be checked -# (useful for classes with attributes dynamically set). -ignored-classes=SQLObject - -# When zope mode is activated, add a predefined set of Zope acquired attributes -# to generated-members. -zope=no - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E0201 when accessed. Python regular -# expressions are accepted. -generated-members=REQUEST,acl_users,aq_parent - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME,XXX,TODO - - -[SIMILARITIES] - -# Minimum lines number of a similarity. -min-similarity-lines=4 - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - - -[IMPORTS] - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=regsub,string,TERMIOS,Bastion,rexec - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= - - -[DESIGN] - -# Maximum number of arguments for function / method -max-args=32 - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.* - -# Maximum number of locals for function / method body -max-locals=32 - -# Maximum number of return / yield for function / method body -# max-returns=0 - -# Maximum number of branch for function / method body -max-branches=32 - -# Maximum number of statements in function / method body -# max-statements=0 - -# Maximum number of parents for a class (see R0901). -max-parents=16 - -# Maximum number of attributes for a class (see R0902). -max-attributes=32 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=0 - -# Maximum number of public methods for a class (see R0904). -# max-public-methods=18 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception diff --git a/.readthedocs.yml b/.readthedocs.yml index 1c267e2f6..265656b33 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -6,5 +6,5 @@ python: extra_requirements: - readthedocs path: . - version: '3.8' + version: 3.8 version: 2 diff --git a/asyncpraw/__init__.py b/asyncpraw/__init__.py index 21bd1796c..5da248bd4 100644 --- a/asyncpraw/__init__.py +++ b/asyncpraw/__init__.py @@ -2,13 +2,13 @@ Async PRAW, an abbreviation for "Asynchronous Python Reddit API Wrapper", is a python package that allows for simple access to Reddit's API. Async PRAW aims to be as easy to -use as possible and is designed to follow all of Reddit's API rules. You have to give an +use as possible and is designed to follow all of Reddit's API rules. You have to give a useragent, everything else is handled by Async PRAW so you needn't worry about violating them. -More information about Async PRAW can be found at https://github.com/praw-dev/asyncpraw +More information about Async PRAW can be found at https://github.com/praw-dev/asyncpraw. """ -from .const import __version__ # NOQA -from .reddit import Reddit # NOQA +from .const import __version__ +from .reddit import Reddit diff --git a/asyncpraw/config.py b/asyncpraw/config.py index 1fc196e55..13a6f6f24 100644 --- a/asyncpraw/config.py +++ b/asyncpraw/config.py @@ -1,20 +1,23 @@ """Provides the code to load Async PRAW's configuration file ``praw.ini``.""" +from __future__ import annotations + import configparser import os import sys +from pathlib import Path from threading import Lock -from typing import Optional +from typing import Any from .exceptions import ClientException class _NotSet: - def __bool__(self): + def __bool__(self) -> bool: return False __nonzero__ = __bool__ - def __str__(self): + def __str__(self) -> str: return "NotSet" @@ -30,31 +33,36 @@ class Config: } @staticmethod - def _config_boolean(item): + 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: Optional[str] = None): + 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]() else: interpolator_class = None + config = configparser.ConfigParser(interpolation=interpolator_class) - module_dir = os.path.dirname(sys.modules[__name__].__file__) + module_dir = Path(sys.modules[__name__].__file__).parent + if "APPDATA" in os.environ: # Windows - os_config_path = os.environ["APPDATA"] + os_config_path = Path(os.environ["APPDATA"]) elif "XDG_CONFIG_HOME" in os.environ: # Modern Linux - os_config_path = os.environ["XDG_CONFIG_HOME"] + os_config_path = Path(os.environ["XDG_CONFIG_HOME"]) elif "HOME" in os.environ: # Legacy Linux - os_config_path = os.path.join(os.environ["HOME"], ".config") + os_config_path = Path(os.environ["HOME"]) / ".config" else: os_config_path = None - locations = [os.path.join(module_dir, "praw.ini"), "praw.ini"] + + locations = [str(module_dir / "praw.ini"), "praw.ini"] + if os_config_path is not None: - locations.insert(1, os.path.join(os_config_path, "praw.ini")) + locations.insert(1, str(os_config_path / "praw.ini")) + config.read(locations) cls.CONFIG = config @@ -66,13 +74,14 @@ def short_url(self) -> str: """ if self._short_url is self.CONFIG_NOT_SET: - raise ClientException("No short domain specified.") + msg = "No short domain specified." + raise ClientException(msg) return self._short_url def __init__( self, site_name: str, - config_interpolation: Optional[str] = None, + config_interpolation: str | None = None, **settings: str, ): """Initialize a :class:`.Config` instance.""" @@ -89,17 +98,19 @@ def __init__( self._initialize_attributes() - def _fetch(self, key): + def _fetch(self, key: str) -> Any: value = self.custom[key] del self.custom[key] return value - def _fetch_default(self, key, *, default=None): + 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): + 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) @@ -158,8 +169,5 @@ def _initialize_attributes(self): try: setattr(self, attribute, conversion(getattr(self, attribute))) except ValueError: - raise ValueError( - f"An incorrect config type was given for option {attribute}. The" - f" expected type is {conversion.__name__}, but the given value is" - f" {getattr(self, attribute)}." - ) + msg = f"An incorrect config type was given for option {attribute}. The expected type is {conversion.__name__}, but the given value is {getattr(self, attribute)}." + raise ValueError(msg) from None diff --git a/asyncpraw/endpoints.py b/asyncpraw/endpoints.py index eaef33a6d..1c4f12df1 100644 --- a/asyncpraw/endpoints.py +++ b/asyncpraw/endpoints.py @@ -1,6 +1,4 @@ """List of API endpoints PRAW knows about.""" - -# flake8: noqa # fmt: off API_PATH = { "about_edited": "r/{subreddit}/about/edited/", diff --git a/asyncpraw/exceptions.py b/asyncpraw/exceptions.py index 1e74b1429..02131d0e2 100644 --- a/asyncpraw/exceptions.py +++ b/asyncpraw/exceptions.py @@ -7,8 +7,10 @@ All other exceptions are subclassed from :class:`.ClientException`. """ +from __future__ import annotations + import sys -from typing import List, Optional, Union +from typing import Any from warnings import warn from .util import _deprecate_args @@ -22,10 +24,10 @@ class AsyncPRAWException(Exception): # Adapted from https://stackoverflow.com/a/40546615 -class ExceptionWrapper(object): +class ExceptionWrapper: """Wrapper to facilitate showing depreciation for PRAWException class rename.""" - def __getattr__(self, attribute): + def __getattr__(self, attribute: str) -> Any: """Return the value of `attribute`.""" if attribute == "PRAWException": warn( @@ -36,7 +38,7 @@ def __getattr__(self, attribute): ) return getattr(self.wrapped, attribute) - def __init__(self, wrapped): + def __init__(self, wrapped: Any): """Initialize Wrapper instance.""" self.wrapped = wrapped @@ -54,7 +56,7 @@ def error_message(self) -> str: error_str += f" on field {self.field!r}" return error_str - def __eq__(self, other: Union["RedditErrorItem", List[str]]): + def __eq__(self, other: RedditErrorItem | list[str]) -> bool: """Check for equality.""" if isinstance(other, RedditErrorItem): return (self.error_type, self.message, self.field) == ( @@ -69,8 +71,8 @@ def __init__( self, error_type: str, *, - field: Optional[str] = None, - message: Optional[str] = None, + field: str | None = None, + message: str | None = None, ): """Initialize a :class:`.RedditErrorItem` instance. @@ -90,7 +92,7 @@ def __repr__(self) -> str: f" message={self.message!r}, field={self.field!r})" ) - def __str__(self): + def __str__(self) -> str: """Get the message returned from str(self).""" return self.error_message @@ -194,7 +196,7 @@ def original_exception(self, value: Exception): def original_exception(self): del self._original_exception - def __init__(self, message: str, exception: Optional[Exception]): + def __init__(self, message: str, exception: Exception | None): """Initialize a :class:`.WebSocketException` instance. :param message: The exception message. @@ -233,7 +235,9 @@ class APIException(AsyncPRAWException): """ @staticmethod - def parse_exception_list(exceptions: List[Union[RedditErrorItem, List[str]]]): + def parse_exception_list( + exceptions: list[RedditErrorItem | list[str]], + ) -> list[RedditErrorItem]: """Covert an exception list into a :class:`.RedditErrorItem` list.""" return [ exception @@ -290,7 +294,7 @@ def message(self) -> str: def __init__( self, - items: Union[List[Union[RedditErrorItem, List[str], str]], str], + items: list[RedditErrorItem | list[str] | str] | str, *optional_args: str, ): """Initialize a :class:`.RedditAPIException` instance. @@ -308,7 +312,7 @@ def __init__( self.items = self.parse_exception_list(items) super().__init__(*self.items) - def _get_old_attr(self, attrname): + def _get_old_attr(self, attrname: str) -> Any: warn( f"Accessing attribute '{attrname}' through APIException is deprecated." " This behavior will be removed in Async PRAW 8.0. Check out" diff --git a/asyncpraw/models/auth.py b/asyncpraw/models/auth.py index 4ac468f2d..a9404f211 100644 --- a/asyncpraw/models/auth.py +++ b/asyncpraw/models/auth.py @@ -1,5 +1,5 @@ """Provide the Auth class.""" -from typing import Dict, List, Optional, Set, Union +from __future__ import annotations from asyncprawcore import ( Authorizer, @@ -17,7 +17,7 @@ class Auth(AsyncPRAWBase): """Auth provides an interface to Reddit's authorization.""" @property - def limits(self) -> Dict[str, Optional[Union[str, int]]]: + def limits(self) -> dict[str, str | int | None]: """Return a dictionary containing the rate limit info. The keys are: @@ -44,7 +44,7 @@ def limits(self) -> Dict[str, Optional[Union[str, int]]]: "used": data.used, } - async def authorize(self, code: str) -> Optional[str]: + async def authorize(self, code: str) -> str | None: """Complete the web authorization flow and return the refresh token. :param code: The code obtained through the request to the redirect uri. @@ -86,7 +86,7 @@ def implicit(self, *, access_token: str, expires_in: int, scope: str) -> None: ) self._reddit._core = self._reddit._authorized_core = implicit_session - async def scopes(self) -> Set[str]: + async def scopes(self) -> set[str]: """Return a set of scopes included in the current authorization. For read-only authorizations this should return ``{"*"}``. @@ -103,17 +103,17 @@ def url( *, duration: str = "permanent", implicit: bool = False, - scopes: List[str], + scopes: list[str], state: str, ) -> str: """Return the URL used out-of-band to grant access to your application. :param duration: Either ``"permanent"`` or ``"temporary"`` (default: ``"permanent"``). ``"temporary"`` authorizations generate access tokens that - last only 1 hour. ``"permanent"`` authorizations additionally ``permanent`` - authorizations additionally generate a refresh token that expires 1 year - after the last use and can be used indefinitelyto generate new hour-long - access tokens. This value is ignored when ``implicit=True``. + last only 1 hour. ``"permanent"`` authorizations additionally generate a + refresh token that expires 1 year after the last use and can be used + indefinitely to generate new hour-long access tokens. This value is ignored + when ``implicit=True``. :param implicit: For **installed** applications, this value can be set to use the implicit, rather than the code flow. When ``True``, the ``duration`` argument has no effect as only temporary tokens can be retrieved. @@ -125,7 +125,8 @@ def url( """ authenticator = self._reddit._read_only_core._authorizer._authenticator if authenticator.redirect_uri is self._reddit.config.CONFIG_NOT_SET: - raise MissingRequiredAttributeException("redirect_uri must be provided") + msg = "redirect_uri must be provided" + raise MissingRequiredAttributeException(msg) if isinstance(authenticator, UntrustedAuthenticator): return authenticator.authorize_url( "temporary" if implicit else duration, diff --git a/asyncpraw/models/base.py b/asyncpraw/models/base.py index 030d60ee9..a7bf84ac1 100644 --- a/asyncpraw/models/base.py +++ b/asyncpraw/models/base.py @@ -1,6 +1,8 @@ """Provide the AsyncPRAWBase superclass.""" +from __future__ import annotations + from copy import deepcopy -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover import asyncpraw @@ -10,7 +12,9 @@ class AsyncPRAWBase: """Superclass for all models in Async PRAW.""" @staticmethod - def _safely_add_arguments(*, arguments, key, **new_arguments): + def _safely_add_arguments( + *, arguments: dict[str, Any], key: str, **new_arguments: Any + ): """Replace arguments[key] with a deepcopy and update. This method is often called when new parameters need to be added to a request. @@ -23,7 +27,7 @@ def _safely_add_arguments(*, arguments, key, **new_arguments): arguments[key] = value @classmethod - def parse(cls, data: Dict[str, Any], reddit: "asyncpraw.Reddit") -> Any: + def parse(cls, data: dict[str, Any], reddit: asyncpraw.Reddit) -> AsyncPRAWBase: """Return an instance of ``cls`` from ``data``. :param data: The structured data. @@ -32,7 +36,7 @@ def parse(cls, data: Dict[str, Any], reddit: "asyncpraw.Reddit") -> Any: """ return cls(reddit, _data=data) - def __init__(self, reddit: "asyncpraw.Reddit", _data: Optional[Dict[str, Any]]): + def __init__(self, reddit: asyncpraw.Reddit, _data: dict[str, Any] | None): """Initialize a :class:`.AsyncPRAWBase` instance. :param reddit: An instance of :class:`.Reddit`. diff --git a/asyncpraw/models/comment_forest.py b/asyncpraw/models/comment_forest.py index f9747decc..31ee0ef8a 100644 --- a/asyncpraw/models/comment_forest.py +++ b/asyncpraw/models/comment_forest.py @@ -1,7 +1,9 @@ """Provide CommentForest for submission comments.""" +from __future__ import annotations + import inspect from heapq import heappop, heappush -from typing import TYPE_CHECKING, Any, AsyncIterator, Coroutine, List, Optional, Union +from typing import TYPE_CHECKING, Any, AsyncIterator, Coroutine from warnings import warn from ..exceptions import DuplicateReplaceException @@ -20,7 +22,11 @@ class CommentForest: """ @staticmethod - def _gather_more_comments(tree, *, parent_tree=None): + def _gather_more_comments( + tree: list[asyncpraw.models.MoreComments], + *, + parent_tree: list[asyncpraw.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] @@ -37,7 +43,7 @@ def _gather_more_comments(tree, *, parent_tree=None): queue.append((comment, item)) return more_comments - async def __aiter__(self) -> AsyncIterator["asyncpraw.models.Comment"]: + async def __aiter__(self) -> AsyncIterator[asyncpraw.models.Comment]: """Allow CommentForest to be used as an AsyncIterator. This method enables one to iterate over all top_level comments, like so: @@ -58,7 +64,12 @@ async def __aiter__(self) -> AsyncIterator["asyncpraw.models.Comment"]: for comment in self: yield comment - async def __call__(self): # noqa: D102 + async def __call__(self) -> CommentForest: + """Return the instance. + + This method is deprecated and will be removed in a future version. + + """ warn( "`Submission.comments` is now a property and no longer needs to be awaited. This" " will raise an error in a future version of Async PRAW.", @@ -70,7 +81,7 @@ async def __call__(self): # noqa: D102 self._comments = self._submission.comments._comments return self - def __getitem__(self, index: int): + def __getitem__(self, index: int) -> asyncpraw.models.Comment: """Return the comment at position ``index`` in the list. This method is to be used like an array access, such as: @@ -89,32 +100,15 @@ def __getitem__(self, index: int): """ if not (self._comments is not None or self._submission._fetched): - raise TypeError( - "Submission must be fetched before comments are accessible. Call `.load()` to fetch." - ) + msg = "Submission must be fetched before comments are accessible. Call `.load()` to fetch." + raise TypeError(msg) return self._comments[index] - def __init__( - self, - submission: "asyncpraw.models.Submission", - comments: Optional[List["asyncpraw.models.Comment"]] = 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 or []) - def _insert_comment(self, comment): + def _insert_comment(self, comment: asyncpraw.models.Comment): if comment.name in self._submission._comments_by_id: raise DuplicateReplaceException comment.submission = self._submission @@ -128,22 +122,20 @@ def _insert_comment(self, comment): parent = self._submission._comments_by_id[comment.parent_id] parent.replies._comments.append(comment) - def _update(self, comments): + def _update(self, comments: list[asyncpraw.models.Comment]): self._comments = comments for comment in comments: comment.submission = self._submission - def list( + def list( # noqa: A003 self, - ) -> Union[ - List[Union["asyncpraw.models.Comment", "asyncpraw.models.MoreComments"]], - Coroutine[ - Any, - Any, - List[Union["asyncpraw.models.Comment", "asyncpraw.models.MoreComments"]], - ], - ]: - """Return a flattened list of all Comments. + ) -> ( + list[asyncpraw.models.Comment | asyncpraw.models.MoreComments] + | Coroutine[ + Any, Any, list[asyncpraw.models.Comment | asyncpraw.models.MoreComments] + ] + ): + """Return a flattened list of all comments. This list may contain :class:`.MoreComments` instances if :meth:`.replace_more` was not called first. @@ -159,12 +151,10 @@ def list( # check if this got called with await # I'm so sorry this is really gross if any( - [ - "await" in context - for context in inspect.getframeinfo( - inspect.currentframe().f_back - ).code_context - ] + "await" in context + for context in inspect.getframeinfo( + inspect.currentframe().f_back + ).code_context ): async def async_func(): @@ -179,10 +169,26 @@ async def async_func(): return async_func() return comments + def __init__( + self, + submission: asyncpraw.models.Submission, + comments: list[asyncpraw.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") async def replace_more( - self, *, limit: Optional[int] = 32, threshold: int = 0 - ) -> List["asyncpraw.models.MoreComments"]: + self, *, limit: int | None = 32, threshold: int = 0 + ) -> list[asyncpraw.models.MoreComments]: """Update the comment forest by resolving instances of :class:`.MoreComments`. :param limit: The maximum number of :class:`.MoreComments` instances to replace. diff --git a/asyncpraw/models/front.py b/asyncpraw/models/front.py index 09932f1ee..5ad5ac242 100644 --- a/asyncpraw/models/front.py +++ b/asyncpraw/models/front.py @@ -1,5 +1,7 @@ """Provide the Front class.""" -from typing import TYPE_CHECKING, AsyncIterator, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, AsyncIterator from urllib.parse import urljoin from .listing.generator import ListingGenerator @@ -12,14 +14,14 @@ class Front(SubredditListingMixin): """Front is a Listing class that represents the front page.""" - def __init__(self, reddit: "asyncpraw.Reddit"): + def __init__(self, reddit: asyncpraw.Reddit): """Initialize a :class:`.Front` instance.""" super().__init__(reddit, _data=None) self._path = "/" def best( - self, **generator_kwargs: Union[str, int] - ) -> AsyncIterator["asyncpraw.models.Submission"]: + self, **generator_kwargs: str | int + ) -> AsyncIterator[asyncpraw.models.Submission]: """Return a :class:`.ListingGenerator` for best items. Additional keyword arguments are passed in the initialization of diff --git a/asyncpraw/models/helpers.py b/asyncpraw/models/helpers.py index fd02cc57f..7d5c3ced0 100644 --- a/asyncpraw/models/helpers.py +++ b/asyncpraw/models/helpers.py @@ -1,6 +1,8 @@ """Provide the helper classes.""" +from __future__ import annotations + from json import dumps -from typing import TYPE_CHECKING, AsyncGenerator, List, Optional, Union +from typing import TYPE_CHECKING, Any, AsyncGenerator from ..const import API_PATH from ..util import _deprecate_args @@ -40,8 +42,8 @@ async def __aiter__(self): yield draft async def __call__( - self, draft_id: Optional[str] = None, fetch: bool = True - ) -> Union[List["asyncpraw.models.Draft"], "asyncpraw.models.Draft"]: + self, draft_id: str | None = None, fetch: bool = True + ) -> list[asyncpraw.models.Draft] | asyncpraw.models.Draft: """Return a list of :class:`.Draft` instances. :param draft_id: When provided, this returns a :class:`.Draft` instance @@ -68,7 +70,7 @@ async def __call__( return draft return await self._draft_list() - async def _draft_list(self) -> List["asyncpraw.models.Draft"]: + async def _draft_list(self) -> list[asyncpraw.models.Draft]: """Get a list of :class:`.Draft` instances. :returns: A list of :class:`.Draft` instances. @@ -79,21 +81,22 @@ async def _draft_list(self) -> List["asyncpraw.models.Draft"]: async def create( self, *, - flair_id: Optional[str] = None, - flair_text: Optional[str] = None, + flair_id: str | None = None, + flair_text: str | None = None, is_public_link: bool = False, nsfw: bool = False, original_content: bool = False, - selftext: Optional[str] = None, + selftext: str | None = None, send_replies: bool = True, spoiler: bool = False, - subreddit: Optional[ - Union[str, "asyncpraw.models.Subreddit", "asyncpraw.models.UserSubreddit"] - ] = None, - title: Optional[str] = None, - url: Optional[str] = None, - **draft_kwargs, - ) -> "asyncpraw.models.Draft": + subreddit: str + | asyncpraw.models.Subreddit + | asyncpraw.models.UserSubreddit + | None = None, + title: str | None = None, + url: str | None = None, + **draft_kwargs: Any, + ) -> asyncpraw.models.Draft: """Create a new :class:`.Draft`. :param flair_id: The flair template to select (default: ``None``). @@ -127,7 +130,8 @@ async def create( """ if selftext and url: - raise TypeError("Exactly one of 'selftext' or 'url' must be provided.") + msg = "Exactly one of 'selftext' or 'url' must be provided." + raise TypeError(msg) if isinstance(subreddit, str): subreddit = await self._reddit.subreddit(subreddit) @@ -153,7 +157,7 @@ class LiveHelper(AsyncPRAWBase): async def __call__( self, id: str, fetch: bool = False - ) -> "asyncpraw.models.LiveThread": # pylint: disable=invalid-name,redefined-builtin + ) -> asyncpraw.models.LiveThread: """Return a new instance of :class:`.LiveThread`. This method is intended to be used as: @@ -185,10 +189,10 @@ async def create( self, title: str, *, - description: Optional[str] = None, + description: str | None = None, nsfw: bool = False, resources: str = None, - ) -> "asyncpraw.models.LiveThread": + ) -> asyncpraw.models.LiveThread: """Create a new :class:`.LiveThread`. :param title: The title of the new :class:`.LiveThread`. @@ -211,9 +215,7 @@ async def create( }, ) - def info( - self, ids: List[str] - ) -> AsyncGenerator["asyncpraw.models.LiveThread", None]: + def info(self, ids: list[str]) -> AsyncGenerator[asyncpraw.models.LiveThread, None]: """Fetch information about each live thread in ``ids``. :param ids: A list of IDs for a live thread. @@ -243,7 +245,8 @@ def info( """ if not isinstance(ids, list): - raise TypeError("ids must be a list") + msg = "ids must be a list" + raise TypeError(msg) async def generator(): for position in range(0, len(ids), 100): @@ -255,7 +258,7 @@ async def generator(): return generator() - async def now(self) -> Optional["asyncpraw.models.LiveThread"]: + async def now(self) -> asyncpraw.models.LiveThread | None: """Get the currently featured live thread. :returns: The :class:`.LiveThread` object, or ``None`` if there is no currently @@ -279,9 +282,9 @@ async def __call__( self, *, name: str, - redditor: Union[str, "asyncpraw.models.Redditor"], + redditor: str | asyncpraw.models.Redditor, fetch: bool = False, - ) -> "asyncpraw.models.Multireddit": + ) -> asyncpraw.models.Multireddit: """Return a lazy instance of :class:`.Multireddit`. If you need the object fetched right away (e.g., to access an attribute) you can @@ -319,14 +322,14 @@ async def __call__( async def create( self, *, - description_md: Optional[str] = None, + description_md: str | None = None, display_name: str, - icon_name: Optional[str] = None, - key_color: Optional[str] = None, - subreddits: Union[str, "asyncpraw.models.Subreddit"], + icon_name: str | None = None, + key_color: str | None = None, + subreddits: str | asyncpraw.models.Subreddit, visibility: str = "private", weighting_scheme: str = "classic", - ) -> "asyncpraw.models.Multireddit": + ) -> asyncpraw.models.Multireddit: """Create a new :class:`.Multireddit`. :param display_name: The display name for the new multireddit. @@ -369,7 +372,7 @@ class SubredditHelper(AsyncPRAWBase): async def __call__( self, display_name: str, fetch: bool = False - ) -> "asyncpraw.models.Subreddit": + ) -> asyncpraw.models.Subreddit: """Return an instance of :class:`.Subreddit`. :param display_name: The name of the subreddit. @@ -403,10 +406,10 @@ async def create( *, link_type: str = "any", subreddit_type: str = "public", - title: Optional[str] = None, + title: str | None = None, wikimode: str = "disabled", - **other_settings: Optional[str], - ) -> "asyncpraw.models.Subreddit": + **other_settings: str | None, + ) -> asyncpraw.models.Subreddit: """Create a new :class:`.Subreddit`. :param name: The name for the new subreddit. @@ -438,5 +441,4 @@ async def create( wikimode=wikimode, **other_settings, ) - subreddit = await self(name, fetch=True) - return subreddit + return await self(name, fetch=True) diff --git a/asyncpraw/models/inbox.py b/asyncpraw/models/inbox.py index 046ad93f6..bc52d41b0 100644 --- a/asyncpraw/models/inbox.py +++ b/asyncpraw/models/inbox.py @@ -1,5 +1,7 @@ """Provide the Front class.""" -from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, AsyncIterator from ..const import API_PATH from ..util import _deprecate_args @@ -14,9 +16,9 @@ class Inbox(AsyncPRAWBase): """Inbox is a Listing class that represents the inbox.""" - def all( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator[Union["asyncpraw.models.Message", "asyncpraw.models.Comment"]]: + def all( # noqa: A003 + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Message | asyncpraw.models.Comment]: """Return a :class:`.ListingGenerator` for all inbox comments and messages. Additional keyword arguments are passed in the initialization of @@ -32,7 +34,7 @@ def all( """ return ListingGenerator(self._reddit, API_PATH["inbox"], **generator_kwargs) - async def collapse(self, items: List["asyncpraw.models.Message"]): + async def collapse(self, items: list[asyncpraw.models.Message]): """Mark an inbox message as collapsed. :param items: A list containing instances of :class:`.Message`. @@ -62,8 +64,8 @@ async def collapse(self, items: List["asyncpraw.models.Message"]): items = items[25:] def comment_replies( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Comment"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Comment]: """Return a :class:`.ListingGenerator` for comment replies. Additional keyword arguments are passed in the initialization of @@ -99,7 +101,7 @@ async def mark_all_read(self): await self._reddit.post(API_PATH["read_all_messages"]) async def mark_read( - self, items: List[Union["asyncpraw.models.Comment", "asyncpraw.models.Message"]] + self, items: list[asyncpraw.models.Comment | asyncpraw.models.Message] ): """Mark Comments or Messages as read. @@ -133,7 +135,7 @@ async def mark_read( items = items[25:] async def mark_unread( - self, items: List[Union["asyncpraw.models.Comment", "asyncpraw.models.Message"]] + self, items: list[asyncpraw.models.Comment | asyncpraw.models.Message] ): """Unmark Comments or Messages as read. @@ -162,8 +164,8 @@ async def mark_unread( items = items[25:] def mentions( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Comment"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Comment]: r"""Return a :class:`.ListingGenerator` for mentions. A mention is :class:`.Comment` in which the authorized redditor is named in its @@ -182,7 +184,7 @@ def mentions( """ return ListingGenerator(self._reddit, API_PATH["mentions"], **generator_kwargs) - async def message(self, message_id: str) -> "asyncpraw.models.Message": + async def message(self, message_id: str) -> asyncpraw.models.Message: """Return a :class:`.Message` corresponding to ``message_id``. :param message_id: The base36 ID of a message. @@ -198,13 +200,13 @@ async def message(self, message_id: str) -> "asyncpraw.models.Message": messages = { message.fullname: message for message in [listing[0]] + listing[0].replies } - for fullname, message in messages.items(): + for _fullname, message in messages.items(): message.parent = messages.get(message.parent_id, None) return messages[f"t4_{message_id.lower()}"] def messages( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Message"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Message]: """Return a :class:`.ListingGenerator` for inbox messages. Additional keyword arguments are passed in the initialization of @@ -221,8 +223,8 @@ def messages( return ListingGenerator(self._reddit, API_PATH["messages"], **generator_kwargs) def sent( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Message"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Message]: """Return a :class:`.ListingGenerator` for sent messages. Additional keyword arguments are passed in the initialization of @@ -239,8 +241,8 @@ def sent( return ListingGenerator(self._reddit, API_PATH["sent"], **generator_kwargs) def stream( - self, **stream_options: Union[str, int, Dict[str, str]] - ) -> AsyncIterator[Union["asyncpraw.models.Comment", "asyncpraw.models.Message"]]: + self, **stream_options: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Comment | asyncpraw.models.Message]: """Yield new inbox items as they become available. Items are yielded oldest first. Up to 100 historical items will initially be @@ -259,8 +261,8 @@ def stream( return stream_generator(self.unread, **stream_options) def submission_replies( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Comment"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Comment]: """Return a :class:`.ListingGenerator` for submission replies. Additional keyword arguments are passed in the initialization of @@ -278,7 +280,7 @@ def submission_replies( self._reddit, API_PATH["submission_replies"], **generator_kwargs ) - async def uncollapse(self, items: List["asyncpraw.models.Message"]): + async def uncollapse(self, items: list[asyncpraw.models.Message]): """Mark an inbox message as uncollapsed. :param items: A list containing instances of :class:`.Message`. @@ -312,8 +314,8 @@ def unread( self, *, mark_read: bool = False, - **generator_kwargs: Union[str, int, Dict[str, str]], - ) -> AsyncIterator[Union["asyncpraw.models.Comment", "asyncpraw.models.Message"]]: + **generator_kwargs: str | int | dict[str, str], + ) -> AsyncIterator[asyncpraw.models.Comment | asyncpraw.models.Message]: """Return a :class:`.ListingGenerator` for unread comments and messages. :param mark_read: Marks the inbox as read (default: ``False``). diff --git a/asyncpraw/models/list/base.py b/asyncpraw/models/list/base.py index a25bb99d3..454a1cf52 100644 --- a/asyncpraw/models/list/base.py +++ b/asyncpraw/models/list/base.py @@ -1,5 +1,7 @@ """Provide the BaseList class.""" -from typing import TYPE_CHECKING, Any, Dict, Iterator +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Iterator from ..base import AsyncPRAWBase @@ -20,7 +22,7 @@ def __getitem__(self, index: int) -> Any: """Return the item at position index in the list.""" return getattr(self, self.CHILD_ATTRIBUTE)[index] - def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): + def __init__(self, reddit: asyncpraw.Reddit, _data: dict[str, Any]): """Initialize a :class:`.BaseList` instance. :param reddit: An instance of :class:`.Reddit`. @@ -29,7 +31,8 @@ def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): super().__init__(reddit, _data=_data) if self.CHILD_ATTRIBUTE is None: - raise NotImplementedError("BaseList must be extended.") + msg = "BaseList must be extended." + raise NotImplementedError(msg) child_list = getattr(self, self.CHILD_ATTRIBUTE) for index, item in enumerate(child_list): diff --git a/asyncpraw/models/list/draft.py b/asyncpraw/models/list/draft.py index 1bdc6bada..b54ee2118 100644 --- a/asyncpraw/models/list/draft.py +++ b/asyncpraw/models/list/draft.py @@ -1,5 +1,4 @@ """Provide the DraftList class.""" - from .base import BaseList diff --git a/asyncpraw/models/listing/domain.py b/asyncpraw/models/listing/domain.py index 0950c1de8..5d2a21640 100644 --- a/asyncpraw/models/listing/domain.py +++ b/asyncpraw/models/listing/domain.py @@ -1,4 +1,6 @@ """Provide the DomainListing class.""" +from __future__ import annotations + from typing import TYPE_CHECKING from ...const import API_PATH @@ -11,7 +13,7 @@ class DomainListing(BaseListingMixin, RisingListingMixin): """Provide a set of functions to interact with domain listings.""" - def __init__(self, reddit: "asyncpraw.Reddit", domain: str): + def __init__(self, reddit: asyncpraw.Reddit, domain: str): """Initialize a :class:`.DomainListing` instance. :param reddit: An instance of :class:`.Reddit`. diff --git a/asyncpraw/models/listing/generator.py b/asyncpraw/models/listing/generator.py index b263af847..9cfb81d83 100644 --- a/asyncpraw/models/listing/generator.py +++ b/asyncpraw/models/listing/generator.py @@ -1,6 +1,8 @@ """Provide the ListingGenerator class.""" +from __future__ import annotations + from copy import deepcopy -from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, Optional, Union +from typing import TYPE_CHECKING, Any, AsyncIterator from ..base import AsyncPRAWBase from .listing import FlairListing, ModNoteListing @@ -21,14 +23,14 @@ class ListingGenerator(AsyncPRAWBase, AsyncIterator): """ - def __aiter__(self) -> AsyncIterator[Any]: + def __aiter__(self) -> Any: """Permit :class:`.ListingGenerator` to operate as an async iterator.""" return self async def __anext__(self) -> Any: """Permit :class:`.ListingGenerator` to operate as a async generator.""" if self.limit is not None and self.yielded >= self.limit: - raise StopAsyncIteration() + raise StopAsyncIteration if self._listing is None or self._list_index >= len(self._listing): await self._next_batch() @@ -39,10 +41,10 @@ async def __anext__(self) -> Any: def __init__( self, - reddit: "asyncpraw.Reddit", + reddit: asyncpraw.Reddit, url: str, limit: int = 100, - params: Optional[Dict[str, Union[str, int]]] = None, + params: dict[str, str | int] | None = None, ): """Initialize a :class:`.ListingGenerator` instance. @@ -66,32 +68,30 @@ def __init__( self.url = url self.yielded = 0 - def _extract_sublist(self, listing): + def _extract_sublist(self, listing: dict[str, Any] | list[Any]): if isinstance(listing, list): return listing[1] # for submission duplicates - elif isinstance(listing, dict): + if isinstance(listing, dict): classes = [FlairListing, ModNoteListing] for listing_type in classes: if listing_type.CHILD_ATTRIBUTE in listing: return listing_type(self._reddit, listing) - else: - raise ValueError( - "The generator returned a dictionary Async PRAW didn't" - " recognize. File a bug report at Async PRAW." - ) + else: # noqa: PLW0120 + msg = "The generator returned a dictionary Async PRAW didn't recognize. File a bug report at Async PRAW." + raise ValueError(msg) return listing async def _next_batch(self): if self._exhausted: - raise StopAsyncIteration() + raise StopAsyncIteration self._listing = await self._reddit.get(self.url, params=self.params) self._listing = self._extract_sublist(self._listing) self._list_index = 0 if not self._listing: - raise StopAsyncIteration() + raise StopAsyncIteration if self._listing.after and self._listing.after != self.params.get( self._listing.AFTER_PARAM diff --git a/asyncpraw/models/listing/listing.py b/asyncpraw/models/listing/listing.py index 62bc19120..3e2e51dc8 100644 --- a/asyncpraw/models/listing/listing.py +++ b/asyncpraw/models/listing/listing.py @@ -1,5 +1,7 @@ """Provide the Listing class.""" -from typing import Any, Optional +from __future__ import annotations + +from typing import Any from ..base import AsyncPRAWBase @@ -18,7 +20,7 @@ def __len__(self) -> int: """Return the number of items in the Listing.""" return len(getattr(self, self.CHILD_ATTRIBUTE)) - def __setattr__(self, attribute: str, value: Any): + def __setattr__(self, attribute: str, value: Any) -> None: """Objectify the ``CHILD_ATTRIBUTE`` attribute.""" if attribute == self.CHILD_ATTRIBUTE: value = self._reddit._objector.objectify(value) @@ -31,7 +33,7 @@ class FlairListing(Listing): CHILD_ATTRIBUTE = "users" @property - def after(self) -> Optional[Any]: + def after(self) -> Any | None: """Return the next attribute or ``None``.""" return getattr(self, "next", None) @@ -43,7 +45,7 @@ class ModNoteListing(Listing): CHILD_ATTRIBUTE = "mod_notes" @property - def after(self) -> Optional[Any]: + def after(self) -> Any | None: """Return the next attribute or None.""" if not getattr(self, "has_next_page", True): return None @@ -62,7 +64,7 @@ class ModmailConversationsListing(Listing): CHILD_ATTRIBUTE = "conversations" @property - def after(self) -> Optional[str]: + def after(self) -> str | None: """Return the next attribute or ``None``.""" try: return self.conversations[-1].id diff --git a/asyncpraw/models/listing/mixins/base.py b/asyncpraw/models/listing/mixins/base.py index 9981efa84..da1cff948 100644 --- a/asyncpraw/models/listing/mixins/base.py +++ b/asyncpraw/models/listing/mixins/base.py @@ -1,5 +1,7 @@ """Provide the BaseListingMixin class.""" -from typing import Any, AsyncIterator, Dict, Union +from __future__ import annotations + +from typing import Any, AsyncIterator from urllib.parse import urljoin from ....util import _deprecate_args @@ -13,19 +15,20 @@ class BaseListingMixin(AsyncPRAWBase): VALID_TIME_FILTERS = {"all", "day", "hour", "month", "week", "year"} @staticmethod - def _validate_time_filter(time_filter): + def _validate_time_filter(time_filter: str): """Validate ``time_filter``. - :raises: :py:class:`.ValueError` if ``time_filter`` is not valid. + :raises: :py:class:`ValueError` if ``time_filter`` is not valid. """ if time_filter not in BaseListingMixin.VALID_TIME_FILTERS: valid_time_filters = ", ".join( map("{!r}".format, BaseListingMixin.VALID_TIME_FILTERS) ) - raise ValueError(f"'time_filter' must be one of: {valid_time_filters}") + msg = f"'time_filter' must be one of: {valid_time_filters}" + raise ValueError(msg) - def _prepare(self, *, arguments, sort): + 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) @@ -37,7 +40,7 @@ def controversial( self, *, time_filter: str = "all", - **generator_kwargs: Union[str, int, Dict[str, str]], + **generator_kwargs: str | int | dict[str, str], ) -> AsyncIterator[Any]: """Return a :class:`.ListingGenerator` for controversial items. @@ -78,9 +81,7 @@ def controversial( url = self._prepare(arguments=generator_kwargs, sort="controversial") return ListingGenerator(self._reddit, url, **generator_kwargs) - def hot( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator[Any]: + def hot(self, **generator_kwargs: str | int | dict[str, str]) -> AsyncIterator[Any]: """Return a :class:`.ListingGenerator` for hot items. Additional keyword arguments are passed in the initialization of @@ -112,9 +113,7 @@ def hot( url = self._prepare(arguments=generator_kwargs, sort="hot") return ListingGenerator(self._reddit, url, **generator_kwargs) - def new( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator[Any]: + def new(self, **generator_kwargs: str | int | dict[str, str]) -> AsyncIterator[Any]: """Return a :class:`.ListingGenerator` for new items. Additional keyword arguments are passed in the initialization of @@ -151,7 +150,7 @@ def top( self, *, time_filter: str = "all", - **generator_kwargs: Union[str, int, Dict[str, str]], + **generator_kwargs: str | int | dict[str, str], ) -> AsyncIterator[Any]: """Return a :class:`.ListingGenerator` for top items. diff --git a/asyncpraw/models/listing/mixins/gilded.py b/asyncpraw/models/listing/mixins/gilded.py index 6e35b93ec..7155e1595 100644 --- a/asyncpraw/models/listing/mixins/gilded.py +++ b/asyncpraw/models/listing/mixins/gilded.py @@ -1,5 +1,7 @@ """Provide the GildedListingMixin class.""" -from typing import Any, AsyncIterator, Dict, Union +from __future__ import annotations + +from typing import Any, AsyncIterator from urllib.parse import urljoin from ...base import AsyncPRAWBase @@ -10,7 +12,7 @@ class GildedListingMixin(AsyncPRAWBase): """Mixes in the gilded method.""" def gilded( - self, **generator_kwargs: Union[str, int, Dict[str, str]] + self, **generator_kwargs: str | int | dict[str, str] ) -> AsyncIterator[Any]: """Return a :class:`.ListingGenerator` for gilded items. diff --git a/asyncpraw/models/listing/mixins/redditor.py b/asyncpraw/models/listing/mixins/redditor.py index 5e220e1fa..c023c10a7 100644 --- a/asyncpraw/models/listing/mixins/redditor.py +++ b/asyncpraw/models/listing/mixins/redditor.py @@ -1,5 +1,7 @@ """Provide the RedditorListingMixin class.""" -from typing import TYPE_CHECKING, AsyncIterator, Dict, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, AsyncIterator from urllib.parse import urljoin from ....util.cache import cachedproperty @@ -14,7 +16,7 @@ class SubListing(BaseListingMixin): """Helper class for generating :class:`.ListingGenerator` objects.""" - def __init__(self, reddit: "asyncpraw.Reddit", base_path: str, subpath: str): + def __init__(self, reddit: asyncpraw.Reddit, base_path: str, subpath: str): """Initialize a :class:`.SubListing` instance. :param reddit: An instance of :class:`.Reddit`. @@ -63,10 +65,8 @@ def submissions(self) -> SubListing: return SubListing(self._reddit, self._path, "submitted") def downvoted( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator[ - Union["asyncpraw.models.Comment", "asyncpraw.models.Submission"] - ]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Comment | asyncpraw.models.Submission]: """Return a :class:`.ListingGenerator` for items the user has downvoted. :returns: A :class:`.ListingGenerator` object which yields :class:`.Comment` or @@ -98,10 +98,8 @@ def downvoted( ) def gildings( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator[ - Union["asyncpraw.models.Comment", "asyncpraw.models.Submission"] - ]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Comment | asyncpraw.models.Submission]: """Return a :class:`.ListingGenerator` for items the user has gilded. :returns: A :class:`.ListingGenerator` object which yields :class:`.Comment` or @@ -133,10 +131,8 @@ def gildings( ) def hidden( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator[ - Union["asyncpraw.models.Comment", "asyncpraw.models.Submission"] - ]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Comment | asyncpraw.models.Submission]: """Return a :class:`.ListingGenerator` for items the user has hidden. :returns: A :class:`.ListingGenerator` object which yields :class:`.Comment` or @@ -168,10 +164,8 @@ def hidden( ) def saved( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator[ - Union["asyncpraw.models.Comment", "asyncpraw.models.Submission"] - ]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Comment | asyncpraw.models.Submission]: """Return a :class:`.ListingGenerator` for items the user has saved. :returns: A :class:`.ListingGenerator` object which yields :class:`.Comment` or @@ -203,10 +197,8 @@ def saved( ) def upvoted( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator[ - Union["asyncpraw.models.Comment", "asyncpraw.models.Submission"] - ]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Comment | asyncpraw.models.Submission]: """Return a :class:`.ListingGenerator` for items the user has upvoted. :returns: A :class:`.ListingGenerator` object which yields :class:`.Comment` or diff --git a/asyncpraw/models/listing/mixins/rising.py b/asyncpraw/models/listing/mixins/rising.py index 987ace944..0784d0dd1 100644 --- a/asyncpraw/models/listing/mixins/rising.py +++ b/asyncpraw/models/listing/mixins/rising.py @@ -1,5 +1,7 @@ """Provide the RisingListingMixin class.""" -from typing import TYPE_CHECKING, AsyncIterator, Dict, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, AsyncIterator from urllib.parse import urljoin from ...base import AsyncPRAWBase @@ -13,8 +15,8 @@ class RisingListingMixin(AsyncPRAWBase): """Mixes in the rising methods.""" def random_rising( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Submission"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Submission]: """Return a :class:`.ListingGenerator` for random rising submissions. Additional keyword arguments are passed in the initialization of @@ -34,8 +36,8 @@ def random_rising( ) def rising( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Submission"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Submission]: """Return a :class:`.ListingGenerator` for rising submissions. Additional keyword arguments are passed in the initialization of diff --git a/asyncpraw/models/listing/mixins/submission.py b/asyncpraw/models/listing/mixins/submission.py index e02d33882..ea1c5dc8a 100644 --- a/asyncpraw/models/listing/mixins/submission.py +++ b/asyncpraw/models/listing/mixins/submission.py @@ -1,5 +1,7 @@ """Provide the SubmissionListingMixin class.""" -from typing import TYPE_CHECKING, AsyncIterator, Dict, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, AsyncIterator from ....const import API_PATH from ...base import AsyncPRAWBase @@ -13,8 +15,8 @@ class SubmissionListingMixin(AsyncPRAWBase): """Adds additional methods pertaining to :class:`.Submission` instances.""" def duplicates( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Submission"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Submission]: """Return a :class:`.ListingGenerator` for the submission's duplicates. Additional keyword arguments are passed in the initialization of diff --git a/asyncpraw/models/listing/mixins/subreddit.py b/asyncpraw/models/listing/mixins/subreddit.py index 5aa0f4f5e..7444eb8ba 100644 --- a/asyncpraw/models/listing/mixins/subreddit.py +++ b/asyncpraw/models/listing/mixins/subreddit.py @@ -1,5 +1,7 @@ """Provide the SubredditListingMixin class.""" -from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, AsyncIterator from urllib.parse import urljoin from ....util.cache import cachedproperty @@ -21,8 +23,8 @@ def _path(self) -> str: return urljoin(self.subreddit._path, "comments/") def __call__( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Comment"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Comment]: """Return a :class:`.ListingGenerator` for the :class:`.Subreddit`'s comments. Additional keyword arguments are passed in the initialization of @@ -39,7 +41,7 @@ def __call__( """ return ListingGenerator(self._reddit, self._path, **generator_kwargs) - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): + def __init__(self, subreddit: asyncpraw.models.Subreddit | SubredditListingMixin): """Initialize a :class:`.CommentHelper` instance.""" super().__init__(subreddit._reddit, _data=None) self.subreddit = subreddit @@ -64,7 +66,7 @@ def comments(self) -> CommentHelper: """ return CommentHelper(self) - def __init__(self, reddit: "asyncpraw.Reddit", _data: Optional[Dict[str, Any]]): + def __init__(self, reddit: asyncpraw.Reddit, _data: dict[str, Any] | None): """Initialize a :class:`.SubredditListingMixin` instance. :param reddit: An instance of :class:`.Reddit`. diff --git a/asyncpraw/models/mod_action.py b/asyncpraw/models/mod_action.py index 4dad804b4..3d22e61fb 100644 --- a/asyncpraw/models/mod_action.py +++ b/asyncpraw/models/mod_action.py @@ -1,5 +1,7 @@ """Provide the ModAction class.""" -from typing import TYPE_CHECKING, Union +from __future__ import annotations + +from typing import TYPE_CHECKING from .base import AsyncPRAWBase from .reddit.redditor import Redditor @@ -12,10 +14,10 @@ class ModAction(AsyncPRAWBase): """Represent a moderator action.""" @property - def mod(self) -> "asyncpraw.models.Redditor": + def mod(self) -> asyncpraw.models.Redditor: """Return the :class:`.Redditor` who the action was issued by.""" - return Redditor(self._reddit, name=self._mod) # pylint: disable=no-member + return Redditor(self._reddit, name=self._mod) @mod.setter - def mod(self, value: Union[str, "asyncpraw.models.Redditor"]): - self._mod = value # pylint: disable=attribute-defined-outside-init + def mod(self, value: str | asyncpraw.models.Redditor): + self._mod = value diff --git a/asyncpraw/models/mod_note.py b/asyncpraw/models/mod_note.py index 6ec44e9c6..4cd833b35 100644 --- a/asyncpraw/models/mod_note.py +++ b/asyncpraw/models/mod_note.py @@ -1,4 +1,5 @@ """Provide the ModNote class.""" +from __future__ import annotations from ..endpoints import API_PATH from .base import AsyncPRAWBase @@ -43,7 +44,7 @@ class ModNote(AsyncPRAWBase): """ - def __eq__(self, other): + def __eq__(self, other: ModNote) -> bool: """Return whether the other instance equals the current.""" if isinstance(other, self.__class__): return self.id == other.id diff --git a/asyncpraw/models/mod_notes.py b/asyncpraw/models/mod_notes.py index b5fcda8dd..fd6eb444c 100644 --- a/asyncpraw/models/mod_notes.py +++ b/asyncpraw/models/mod_notes.py @@ -3,8 +3,8 @@ from typing import TYPE_CHECKING, Any, AsyncGenerator, List, Optional, Tuple, Union from ..const import API_PATH -from ..models.base import AsyncPRAWBase -from ..models.listing.generator import ListingGenerator +from .base import AsyncPRAWBase +from .listing.generator import ListingGenerator from .reddit.comment import Comment from .reddit.redditor import Redditor from .reddit.submission import Submission @@ -63,7 +63,7 @@ async def _bulk_generator( for note_dict in response["mod_notes"]: yield self._reddit._objector.objectify(note_dict) - def _ensure_attribute(self, error_message, **attributes: Any): + 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: @@ -269,7 +269,8 @@ async def delete( subreddit=subreddit, ) if not delete_all and note_id is None: - raise TypeError("Either 'note_id' or 'delete_all' must be provided.") + msg = "Either 'note_id' or 'delete_all' must be provided." + raise TypeError(msg) if delete_all: async for note in self._notes(True, [redditor], [subreddit]): await note.delete() @@ -366,7 +367,8 @@ def subreddits( """ if len(subreddits) == 0: - raise ValueError("At least 1 subreddit must be provided.") + msg = "At least 1 subreddit must be provided." + raise ValueError(msg) if all_notes is None: all_notes = len(subreddits) == 1 return self._notes( @@ -461,7 +463,8 @@ def redditors( """ if len(redditors) == 0: - raise ValueError("At least 1 redditor must be provided.") + msg = "At least 1 redditor must be provided." + raise ValueError(msg) if all_notes is None: all_notes = len(redditors) == 1 return self._notes( @@ -612,10 +615,8 @@ def __call__( if things is None: things = [] if not (pairs + redditors + subreddits + things): - raise TypeError( - "Either the 'pairs', 'redditors', 'subreddits', or 'things' parameters" - " must be provided." - ) + msg = "Either the 'pairs', 'redditors', 'subreddits', or 'things' parameters must be provided." + raise TypeError(msg) if ( len(redditors) * len(subreddits) == 0 and len(redditors) + len(subreddits) > 0 @@ -647,9 +648,8 @@ def __call__( merged_redditors.append(redditor) merged_subreddits.append(subreddit) else: - raise ValueError( - f"Cannot get subreddit and author fields from type {type(item)}" - ) + msg = f"Cannot get subreddit and author fields from type {type(item)}" + raise ValueError(msg) return self._notes( all_notes, merged_redditors, merged_subreddits, **generator_kwargs ) diff --git a/asyncpraw/models/preferences.py b/asyncpraw/models/preferences.py index b29672704..e8018e0ec 100644 --- a/asyncpraw/models/preferences.py +++ b/asyncpraw/models/preferences.py @@ -1,6 +1,8 @@ """Provide the Preferences class.""" +from __future__ import annotations + from json import dumps -from typing import TYPE_CHECKING, Dict, Union +from typing import TYPE_CHECKING from ..const import API_PATH @@ -16,7 +18,7 @@ class Preferences: """ - async def __call__(self) -> Dict[str, Union[bool, int, str]]: + async def __call__(self) -> dict[str, bool | int | str]: """Return the preference settings of the authenticated user as a dict. This method is intended to be accessed as ``reddit.user.preferences()`` like so: @@ -32,7 +34,7 @@ async def __call__(self) -> Dict[str, Union[bool, int, str]]: """ return await self._reddit.get(API_PATH["preferences"]) - def __init__(self, reddit: "asyncpraw.Reddit"): + def __init__(self, reddit: asyncpraw.Reddit): """Initialize a :class:`.Preferences` instance. :param reddit: The :class:`.Reddit` instance. @@ -40,7 +42,9 @@ def __init__(self, reddit: "asyncpraw.Reddit"): """ self._reddit = reddit - async def update(self, **preferences: Union[bool, int, str]): + async def update( + self, **preferences: bool | int | str + ) -> dict[str, bool | int | str]: """Modify the specified settings. :param accept_pms: Who can send you personal messages (one of ``"everyone"`` or diff --git a/asyncpraw/models/reddit/base.py b/asyncpraw/models/reddit/base.py index 1e6bb0d38..ee0c4eed8 100644 --- a/asyncpraw/models/reddit/base.py +++ b/asyncpraw/models/reddit/base.py @@ -1,5 +1,7 @@ """Provide the RedditBase class.""" -from typing import TYPE_CHECKING, Any, Dict, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from ...endpoints import API_PATH @@ -14,13 +16,13 @@ class RedditBase(AsyncPRAWBase): """Base class that represents actual Reddit objects.""" @staticmethod - def _url_parts(url): + def _url_parts(url: str) -> list[str]: parsed = urlparse(url) if not parsed.netloc: raise InvalidURL(url) return parsed.path.rstrip("/").split("/") - def __eq__(self, other: Union[Any, str]) -> bool: + def __eq__(self, other: Any | str) -> bool: """Return whether the other instance equals the current.""" if isinstance(other, str): return other.lower() == str(self).lower() @@ -32,15 +34,12 @@ def __eq__(self, other: Union[Any, str]) -> bool: def __getattr__(self, attribute: str) -> Any: """Return the value of ``attribute``.""" if not attribute.startswith("_") and not self._fetched: - raise AttributeError( - "{0!r} object has no attribute {1!r}. {0!r} object has not been" - " fetched, did you forget to execute '.load()'?".format( - self.__class__.__name__, attribute - ) + msg = "{0!r} object has no attribute {1!r}. {0!r} object has not been fetched, did you forget to execute '.load()'?".format( + self.__class__.__name__, attribute ) - raise AttributeError( - f"{self.__class__.__name__!r} object has no attribute {attribute!r}" - ) + raise AttributeError(msg) + msg = f"{self.__class__.__name__!r} object has no attribute {attribute!r}" + raise AttributeError(msg) def __hash__(self) -> int: """Return the hash of the current instance.""" @@ -48,9 +47,9 @@ def __hash__(self) -> int: def __init__( self, - reddit: "asyncpraw.Reddit", - _data: Optional[Dict[str, Any]], - _extra_attribute_to_check: Optional[str] = None, + reddit: asyncpraw.Reddit, + _data: dict[str, Any] | None, + _extra_attribute_to_check: str | None = None, _fetched: bool = False, _str_field: bool = True, ): @@ -67,12 +66,10 @@ def __init__( and _extra_attribute_to_check in self.__dict__ ): return - raise ValueError( - f"An invalid value was specified for {self.STR_FIELD}. Check that the " - f"argument for the {self.STR_FIELD} parameter is not empty." - ) + msg = f"An invalid value was specified for {self.STR_FIELD}. Check that the argument for the {self.STR_FIELD} parameter is not empty." + raise ValueError(msg) - def __ne__(self, other: Any) -> bool: + def __ne__(self, other: object) -> bool: """Return whether the other instance differs from the current.""" return not self == other @@ -92,7 +89,7 @@ async def _fetch_data(self): path = API_PATH[name].format(**fields) return await self._reddit.request(method="GET", params=params, path=path) - def _reset_attributes(self, *attributes): + def _reset_attributes(self, *attributes: str): for attribute in attributes: if attribute in self.__dict__: del self.__dict__[attribute] diff --git a/asyncpraw/models/reddit/collections.py b/asyncpraw/models/reddit/collections.py index db03330ed..8a404bdd9 100644 --- a/asyncpraw/models/reddit/collections.py +++ b/asyncpraw/models/reddit/collections.py @@ -1,5 +1,7 @@ """Provide Collections functionality.""" -from typing import TYPE_CHECKING, Any, Dict, Generator, List, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Generator from ...const import API_PATH from ...exceptions import ClientException @@ -29,7 +31,7 @@ class CollectionModeration(AsyncPRAWBase): """ - def __init__(self, reddit: "asyncpraw.Reddit", collection_id: str): + def __init__(self, reddit: asyncpraw.Reddit, collection_id: str): """Initialize a :class:`.CollectionModeration` instance. :param collection_id: The ID of a :class:`.Collection`. @@ -38,7 +40,7 @@ def __init__(self, reddit: "asyncpraw.Reddit", collection_id: str): super().__init__(reddit, _data=None) self.collection_id = collection_id - def _post_fullname(self, post): + def _post_fullname(self, post: str | asyncpraw.models.Submission) -> str: """Get a post's fullname. :param post: A fullname, a :class:`.Submission`, a permalink, or an ID. @@ -48,8 +50,9 @@ def _post_fullname(self, post): """ if isinstance(post, Submission): return post.fullname - elif not isinstance(post, str): - raise TypeError(f"Cannot get fullname from object of type {type(post)}.") + if not isinstance(post, str): + msg = f"Cannot get fullname from object of type {type(post)}." + raise TypeError(msg) if post.startswith(f"{self._reddit.config.kinds['submission']}_"): return post try: @@ -57,7 +60,7 @@ def _post_fullname(self, post): except ClientException: return Submission(self._reddit, id=post).fullname - async def add_post(self, submission: "asyncpraw.models.Submission"): + async def add_post(self, submission: asyncpraw.models.Submission): """Add a post to the collection. :param submission: The post to add, a :class:`.Submission`, its permalink as a @@ -103,7 +106,7 @@ async def delete(self): API_PATH["collection_delete"], data={"collection_id": self.collection_id} ) - async def remove_post(self, submission: "asyncpraw.models.Submission"): + async def remove_post(self, submission: asyncpraw.models.Submission): """Remove a post from the collection. :param submission: The post to remove, a :class:`.Submission`, its permalink as @@ -129,7 +132,7 @@ async def remove_post(self, submission: "asyncpraw.models.Submission"): data={"collection_id": self.collection_id, "link_fullname": link_fullname}, ) - async def reorder(self, links: List[Union[str, "asyncpraw.models.Submission"]]): + async def reorder(self, links: list[str | asyncpraw.models.Submission]): r"""Reorder posts in the collection. :param links: A list of :class:`.Submission`\ s or a ``str`` that is either a @@ -239,9 +242,9 @@ class SubredditCollectionsModeration(AsyncPRAWBase): def __init__( self, - reddit: "asyncpraw.Reddit", - subreddit: "asyncpraw.models.Subreddit", - _data: Optional[Dict[str, Any]] = None, + reddit: asyncpraw.Reddit, + subreddit: asyncpraw.models.Subreddit, + _data: dict[str, Any] | None = None, ): """Initialize a :class:`.SubredditCollectionsModeration` instance.""" super().__init__(reddit, _data) @@ -249,8 +252,8 @@ def __init__( @_deprecate_args("title", "description", "display_layout") async def create( - self, *, description: str, display_layout: Optional[str] = None, title: str - ): + self, *, description: str, display_layout: str | None = None, title: str + ) -> Collection: """Create a new :class:`.Collection`. The authenticated account must have appropriate moderator permissions in the @@ -353,11 +356,11 @@ async def __aiter__(self): @deprecate_lazy async def __call__( self, - collection_id: Optional[str] = None, - permalink: Optional[str] = None, + collection_id: str | None = None, + permalink: str | None = None, fetch: bool = True, - **kwargs, - ): + **_, + ) -> Collection: """Return the :class:`.Collection` with the specified ID. :param collection_id: The ID of a :class:`.Collection` (default: ``None``). @@ -396,9 +399,8 @@ async def __call__( """ if (collection_id is None) == (permalink is None): - raise TypeError( - "Exactly one of 'collection_id' or 'permalink' must be provided." - ) + msg = "Exactly one of 'collection_id' or 'permalink' must be provided." + raise TypeError(msg) collection = Collection( self._reddit, collection_id=collection_id, permalink=permalink ) @@ -408,9 +410,9 @@ async def __call__( def __init__( self, - reddit: "asyncpraw.Reddit", - subreddit: "asyncpraw.models.Subreddit", - _data: Optional[Dict[str, Any]] = None, + reddit: asyncpraw.Reddit, + subreddit: asyncpraw.models.Subreddit, + _data: dict[str, Any] | None = None, ): """Initialize a :class:`.SubredditCollections` instance.""" super().__init__(reddit, _data) @@ -483,10 +485,10 @@ def mod(self) -> CollectionModeration: def __init__( self, - reddit: "asyncpraw.Reddit", - _data: Dict[str, Any] = None, - collection_id: Optional[str] = None, - permalink: Optional[str] = None, + reddit: asyncpraw.Reddit, + _data: dict[str, Any] = None, + collection_id: str | None = None, + permalink: str | None = None, ): """Initialize a :class:`.Collection` instance. @@ -497,10 +499,8 @@ def __init__( """ if (_data, collection_id, permalink).count(None) != 2: - raise TypeError( - "Exactly one of '_data', 'collection_id', or 'permalink' must be" - " provided." - ) + msg = "Exactly one of '_data', 'collection_id', or 'permalink' must be provided." + raise TypeError(msg) if permalink: collection_id = self._url_parts(permalink)[4] @@ -528,8 +528,7 @@ def __iter__(self) -> Generator[Any, None, None]: print(submission.title, submission.permalink) """ - for item in self.sorted_links: - yield item + yield from self.sorted_links def __len__(self) -> int: """Get the number of posts in this :class:`.Collection`. @@ -561,14 +560,12 @@ async def _fetch(self): # A well-formed but invalid Collections ID during fetch time # causes Reddit to return something that looks like an error # but with no content. - raise ClientException( - f"Error during fetch. Check collection ID {self.collection_id!r} is" - " correct." - ) + msg = f"Error during fetch. Check collection ID {self.collection_id!r} is correct." + raise ClientException(msg) from None other = type(self)(self._reddit, _data=data) self.__dict__.update(other.__dict__) - self._fetched = True + await super()._fetch() def _fetch_info(self): return "collection", {}, self._info_params @@ -594,7 +591,7 @@ async def follow(self): data={"collection_id": self.collection_id, "follow": True}, ) - async def subreddit(self) -> "asyncpraw.models.Subreddit": + async def subreddit(self) -> asyncpraw.models.Subreddit: """Get the subreddit that this collection belongs to. For example: @@ -606,7 +603,9 @@ async def subreddit(self) -> "asyncpraw.models.Subreddit": print(await collection.subreddit()) """ - async for subreddit in self._reddit.info(fullnames=[self.subreddit_id]): + async for subreddit in self._reddit.info( # noqa: RET503 + fullnames=[self.subreddit_id] + ): return subreddit async def unfollow(self): diff --git a/asyncpraw/models/reddit/comment.py b/asyncpraw/models/reddit/comment.py index d88d94b97..5cb50c015 100644 --- a/asyncpraw/models/reddit/comment.py +++ b/asyncpraw/models/reddit/comment.py @@ -1,5 +1,7 @@ """Provide the Comment class.""" -from typing import TYPE_CHECKING, Any, Dict, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from ...const import API_PATH from ...exceptions import ClientException, InvalidURL @@ -68,14 +70,14 @@ def id_from_url(url: str) -> str: try: comment_index = parts.index("comments") except ValueError: - raise InvalidURL(url) + raise InvalidURL(url) from None if len(parts) - 4 != comment_index: raise InvalidURL(url) return parts[-1] @cachedproperty - def mod(self) -> "asyncpraw.models.reddit.comment.CommentModeration": + def mod(self) -> asyncpraw.models.reddit.comment.CommentModeration: """Provide an instance of :class:`.CommentModeration`. Example usage: @@ -89,7 +91,7 @@ def mod(self) -> "asyncpraw.models.reddit.comment.CommentModeration": return CommentModeration(self) @property - def _kind(self) -> str: + def _kind(self): """Return the class's kind.""" return self._reddit.config.kinds["comment"] @@ -131,7 +133,7 @@ def replies(self) -> CommentForest: return self._replies @property - def submission(self) -> "asyncpraw.models.Submission": + def submission(self) -> asyncpraw.models.Submission: """Return the :class:`.Submission` object this comment belongs to. :raises: :py:class:`AttributeError` if the comment is not fetched. @@ -144,24 +146,24 @@ def submission(self) -> "asyncpraw.models.Submission": return self._submission @submission.setter - def submission(self, submission: "asyncpraw.models.Submission"): + def submission(self, submission: asyncpraw.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 self.replies: reply.submission = submission def __init__( self, - reddit: "asyncpraw.Reddit", - id: Optional[str] = None, # pylint: disable=redefined-builtin - url: Optional[str] = None, - _data: Optional[Dict[str, Any]] = None, + reddit: asyncpraw.Reddit, + id: str | None = None, + url: str | None = None, + _data: dict[str, Any] | None = None, ): """Initialize a :class:`.Comment` instance.""" if (id, url, _data).count(None) != 2: - raise TypeError("Exactly one of 'id', 'url', or '_data' must be provided.") + msg = "Exactly one of 'id', 'url', or '_data' must be provided." + raise TypeError(msg) fetched = False self._replies = [] self._submission = None @@ -176,7 +178,7 @@ def __init__( def __setattr__( self, attribute: str, - value: Union[str, Redditor, CommentForest, "asyncpraw.models.Subreddit"], + value: str | Redditor | CommentForest | asyncpraw.models.Subreddit, ): """Objectify author, replies, and subreddit.""" if attribute == "author": @@ -187,9 +189,8 @@ def __setattr__( else: value = self._reddit._objector.objectify(value).children attribute = "_replies" - elif attribute == "subreddit": - if isinstance(value, str): - value = Subreddit(self._reddit, display_name=value) + elif attribute == "subreddit" and isinstance(value, str): + value = Subreddit(self._reddit, display_name=value) super().__setattr__(attribute, value) def _extract_submission_id(self): @@ -202,19 +203,20 @@ async def _fetch(self): data = data["data"] if not data["children"]: - raise ClientException(f"No data returned for comment {self.fullname}") + msg = f"No data returned for comment {self.fullname}" + raise ClientException(msg) comment_data = data["children"][0]["data"] other = type(self)(self._reddit, _data=comment_data) self.__dict__.update(other.__dict__) - self._fetched = True + await super()._fetch() def _fetch_info(self): return "info", {}, {"id": self.fullname} async def parent( self, - ) -> Union["Comment", "asyncpraw.models.Submission"]: + ) -> Comment | asyncpraw.models.Submission: """Return the parent of the comment. The returned parent will be an instance of either :class:`.Comment`, or @@ -263,8 +265,6 @@ async def parent( to :meth:`.refresh` it would make at least 31 network requests. """ - # pylint: disable=no-member - if not self._fetched: await self._fetch() @@ -274,13 +274,12 @@ async def parent( 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 return parent - async def refresh(self): + async def refresh(self) -> Comment: """Refresh the comment's attributes. If using :meth:`.Reddit.comment` with ``fetch=False``, this method must be @@ -346,7 +345,7 @@ class CommentModeration(ThingModerationMixin): REMOVAL_MESSAGE_API = "removal_comment_message" - def __init__(self, comment: "asyncpraw.models.Comment"): + def __init__(self, comment: asyncpraw.models.Comment): """Initialize a :class:`.CommentModeration` instance. :param comment: The comment to moderate. diff --git a/asyncpraw/models/reddit/draft.py b/asyncpraw/models/reddit/draft.py index afba7911a..8f2aa5fc7 100644 --- a/asyncpraw/models/reddit/draft.py +++ b/asyncpraw/models/reddit/draft.py @@ -1,5 +1,7 @@ """Provide the draft class.""" -from typing import TYPE_CHECKING, Any, Dict, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from ...const import API_PATH from ...exceptions import ClientException @@ -45,21 +47,21 @@ class Draft(RedditBase): async def _prepare_data( cls, *, - flair_id: Optional[str] = None, - flair_text: Optional[str] = None, - is_public_link: Optional[bool] = None, - nsfw: Optional[bool] = None, - original_content: Optional[bool] = None, - selftext: Optional[str] = None, - send_replies: Optional[bool] = None, - spoiler: Optional[bool] = None, - subreddit: Optional[ - Union["asyncpraw.models.Subreddit", "asyncpraw.models.UserSubreddit"] - ] = None, - title: Optional[str] = None, - url: Optional[str] = None, - **draft_kwargs, - ): + flair_id: str | None = None, + flair_text: str | None = None, + is_public_link: bool | None = None, + nsfw: bool | None = None, + original_content: bool | None = None, + selftext: str | None = None, + send_replies: bool | None = None, + spoiler: bool | None = None, + subreddit: asyncpraw.models.Subreddit + | asyncpraw.models.UserSubreddit + | None = None, + title: str | None = None, + url: str | None = None, + **draft_kwargs: Any, + ) -> dict[str, Any]: data = { "body": selftext or url, "flair_id": flair_id, @@ -88,23 +90,23 @@ async def _prepare_data( def __init__( self, - reddit: "asyncpraw.Reddit", - id: Optional[str] = None, # pylint: disable=redefined-builtin - _data: Dict[str, Any] = None, + reddit: asyncpraw.Reddit, + id: str | None = None, + _data: dict[str, Any] = None, ): """Initialize a :class:`.Draft` instance.""" if (id, _data).count(None) != 1: - raise TypeError("Exactly one of 'id' or '_data' must be provided.") + msg = "Exactly one of 'id' or '_data' must be provided." + raise TypeError(msg) fetched = False if id: self.id = id - else: - if len(_data) > 1: - if _data["kind"] in ["markdown", "richtext"]: - _data["selftext"] = _data.pop("body") - elif _data["kind"] == "link": - _data["url"] = _data.pop("body") - fetched = True + elif len(_data) > 1: + if _data["kind"] in ["markdown", "richtext"]: + _data["selftext"] = _data.pop("body") + elif _data["kind"] == "link": + _data["url"] = _data.pop("body") + fetched = True super().__init__(reddit, _data=_data, _fetched=fetched) def __repr__(self) -> str: @@ -115,18 +117,18 @@ def __repr__(self) -> str: ) title = f" title={self.title!r}" if self.title else "" return f"{self.__class__.__name__}(id={self.id!r}{subreddit}{title})" - else: - return f"{self.__class__.__name__}(id={self.id!r})" + return f"{self.__class__.__name__}(id={self.id!r})" async def _fetch(self): async for draft in self._reddit.drafts: if draft.id == self.id: self.__dict__.update(draft.__dict__) - self._fetched = True + await super()._fetch() return - raise ClientException( + msg = ( f"The currently authenticated user not have a draft with an ID of {self.id}" ) + raise ClientException(msg) async def delete(self): """Delete the :class:`.Draft`. @@ -144,18 +146,19 @@ async def delete(self): async def submit( self, *, - flair_id: Optional[str] = None, - flair_text: Optional[str] = None, - nsfw: Optional[bool] = None, - selftext: Optional[str] = None, - spoiler: Optional[bool] = None, - subreddit: Optional[ - Union[str, "asyncpraw.models.Subreddit", "asyncpraw.models.UserSubreddit"] - ] = None, - title: Optional[str] = None, - url: Optional[str] = None, - **submit_kwargs, - ) -> "asyncpraw.models.Submission": + flair_id: str | None = None, + flair_text: str | None = None, + nsfw: bool | None = None, + selftext: str | None = None, + spoiler: bool | None = None, + subreddit: str + | asyncpraw.models.Subreddit + | asyncpraw.models.UserSubreddit + | None = None, + title: str | None = None, + url: str | None = None, + **submit_kwargs: Any, + ) -> asyncpraw.models.Submission: """Submit a draft. :param flair_id: The flair template to select (default: ``None``). @@ -209,10 +212,8 @@ async def submit( """ submit_kwargs["draft_id"] = self.id if not (self.subreddit or subreddit): - raise ValueError( - "'subreddit' must be set on the Draft instance or passed as a keyword" - " argument." - ) + msg = "'subreddit' must be set on the Draft instance or passed as a keyword argument." + raise ValueError(msg) for key, attribute in [ ("flair_id", flair_id), ("flair_text", flair_text), @@ -236,20 +237,21 @@ async def submit( async def update( self, *, - flair_id: Optional[str] = None, - flair_text: Optional[str] = None, - is_public_link: Optional[bool] = None, - nsfw: Optional[bool] = None, - original_content: Optional[bool] = None, - selftext: Optional[str] = None, - send_replies: Optional[bool] = None, - spoiler: Optional[bool] = None, - subreddit: Optional[ - Union[str, "asyncpraw.models.Subreddit", "asyncpraw.models.UserSubreddit"] - ] = None, - title: Optional[str] = None, - url: Optional[str] = None, - **draft_kwargs, + flair_id: str | None = None, + flair_text: str | None = None, + is_public_link: bool | None = None, + nsfw: bool | None = None, + original_content: bool | None = None, + selftext: str | None = None, + send_replies: bool | None = None, + spoiler: bool | None = None, + subreddit: str + | asyncpraw.models.Subreddit + | asyncpraw.models.UserSubreddit + | None = None, + title: str | None = None, + url: str | None = None, + **draft_kwargs: Any, ): """Update the :class:`.Draft`. diff --git a/asyncpraw/models/reddit/emoji.py b/asyncpraw/models/reddit/emoji.py index afeb53bf6..d7bf4af73 100644 --- a/asyncpraw/models/reddit/emoji.py +++ b/asyncpraw/models/reddit/emoji.py @@ -1,6 +1,8 @@ """Provide the Emoji class.""" -import os -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any from ...const import API_PATH from ...exceptions import ClientException @@ -31,7 +33,7 @@ class Emoji(RedditBase): STR_FIELD = "name" - def __eq__(self, other: Union[str, "Emoji"]) -> bool: + def __eq__(self, other: str | Emoji) -> bool: """Return whether the other instance equals the current.""" if isinstance(other, str): return other == str(self) @@ -45,10 +47,10 @@ def __hash__(self) -> int: def __init__( self, - reddit: "asyncpraw.Reddit", - subreddit: "asyncpraw.models.Subreddit", + reddit: asyncpraw.Reddit, + subreddit: asyncpraw.models.Subreddit, name: str, - _data: Optional[Dict[str, Any]] = None, + _data: dict[str, Any] | None = None, ): """Initialize an :class:`.Emoji` instance.""" self.name = name @@ -59,9 +61,10 @@ async def _fetch(self): async for emoji in self.subreddit.emoji: if emoji.name == self.name: self.__dict__.update(emoji.__dict__) - self._fetched = True + await super()._fetch() return - raise ClientException(f"r/{self.subreddit} does not have the emoji {self.name}") + msg = f"r/{self.subreddit} does not have the emoji {self.name}" + raise ClientException(msg) async def delete(self): """Delete an emoji from this subreddit by :class:`.Emoji`. @@ -84,9 +87,9 @@ async def delete(self): async def update( self, *, - mod_flair_only: Optional[bool] = None, - post_flair_allowed: Optional[bool] = None, - user_flair_allowed: Optional[bool] = None, + mod_flair_only: bool | None = None, + post_flair_allowed: bool | None = None, + user_flair_allowed: bool | None = None, ): """Update the permissions of an emoji in this subreddit. @@ -122,12 +125,13 @@ async def update( ) } if all(value is None for value in mapping.values()): - raise TypeError("At least one attribute must be provided") + msg = "At least one attribute must be provided" + raise TypeError(msg) data = {"name": self.name} for attribute, value in mapping.items(): if value is None: - value = getattr(self, attribute) + value = getattr(self, attribute) # noqa: PLW2901 data[attribute] = value url = API_PATH["emoji_update"].format(subreddit=self.subreddit) await self._reddit.post(url, data=data) @@ -138,7 +142,7 @@ async def update( class SubredditEmoji: """Provides a set of functions to a :class:`.Subreddit` for emoji.""" - async def __aiter__(self) -> List[Emoji]: + async def __aiter__(self) -> list[Emoji]: """Return a list of :class:`.Emoji` for the subreddit. This method is to be used to discover all emoji for a subreddit: @@ -155,14 +159,14 @@ async def __aiter__(self) -> List[Emoji]: ) subreddit_keys = [ key - for key in response.keys() + for key in response if key.startswith(self._reddit.config.kinds["subreddit"]) ] assert len(subreddit_keys) == 1 for emoji_name, emoji_data in response[subreddit_keys[0]].items(): yield Emoji(self._reddit, self.subreddit, emoji_name, _data=emoji_data) - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): + def __init__(self, subreddit: asyncpraw.models.Subreddit): """Initialize a :class:`.SubredditEmoji` instance. :param subreddit: The subreddit whose emoji are affected. @@ -175,10 +179,10 @@ async def add( self, *, image_path: str, - mod_flair_only: Optional[bool] = None, + mod_flair_only: bool | None = None, name: str, - post_flair_allowed: Optional[bool] = None, - user_flair_allowed: Optional[bool] = None, + post_flair_allowed: bool | None = None, + user_flair_allowed: bool | None = None, ) -> Emoji: """Add an emoji to this subreddit. @@ -201,8 +205,9 @@ async def add( await subreddit.emoji.add(name="emoji", image_path="emoji.png") """ + file = Path(image_path) data = { - "filepath": os.path.basename(image_path), + "filepath": file.name, "mimetype": "image/jpeg", } if image_path.lower().endswith(".png"): @@ -215,7 +220,7 @@ async def add( upload_data = {item["name"]: item["value"] for item in upload_lease["fields"]} upload_url = f"https:{upload_lease['action']}" - with open(image_path, "rb") as image: + with file.open("rb") as image: upload_data["file"] = image response = await self._reddit._core._requestor._http.post( upload_url, data=upload_data @@ -234,7 +239,7 @@ async def add( return Emoji(self._reddit, self.subreddit, name) @deprecate_lazy - async def get_emoji(self, name: str, fetch: bool = True, **kwargs) -> Emoji: + async def get_emoji(self, name: str, fetch: bool = True, **_: Any) -> Emoji: """Return the :class:`.Emoji` for the subreddit named ``name``. :param name: The name of the emoji. diff --git a/asyncpraw/models/reddit/inline_media.py b/asyncpraw/models/reddit/inline_media.py index 6751b03f3..0b3f90b75 100644 --- a/asyncpraw/models/reddit/inline_media.py +++ b/asyncpraw/models/reddit/inline_media.py @@ -1,4 +1,6 @@ """Provide classes related to inline media.""" +from __future__ import annotations + from ..util import _deprecate_args @@ -7,13 +9,11 @@ class InlineMedia: TYPE = None - def __eq__(self, other: "InlineMedia"): + def __eq__(self, other: InlineMedia) -> bool: """Return whether the other instance equals the current.""" return all( - [ - getattr(self, attr) == getattr(other, attr) - for attr in ["TYPE", "path", "caption", "media_id"] - ] + getattr(self, attr) == getattr(other, attr) + for attr in ["TYPE", "path", "caption", "media_id"] ) @_deprecate_args("path", "caption") @@ -32,7 +32,7 @@ def __repr__(self) -> str: """Return an object initialization representation of the instance.""" return f"<{self.__class__.__name__} caption={self.caption!r}>" - def __str__(self): + def __str__(self) -> str: """Return a string representation of the media in Markdown format.""" return f'\n\n![{self.TYPE}]({self.media_id} "{self.caption if self.caption else ""}")\n\n' diff --git a/asyncpraw/models/reddit/live.py b/asyncpraw/models/reddit/live.py index b3ec127ca..66902f5c4 100644 --- a/asyncpraw/models/reddit/live.py +++ b/asyncpraw/models/reddit/live.py @@ -1,5 +1,7 @@ """Provide the LiveThread class.""" -from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, AsyncIterator, Iterable from ...const import API_PATH from ...util import _deprecate_args @@ -19,14 +21,11 @@ class LiveContributorRelationship: """Provide methods to interact with live threads' contributors.""" @staticmethod - def _handle_permissions(permissions): - if permissions is None: - permissions = {"all"} - else: - permissions = set(permissions) + def _handle_permissions(permissions: Iterable[str]) -> str: + permissions = {"all"} if permissions is None else set(permissions) return ",".join(f"+{x}" for x in permissions) - def __call__(self) -> AsyncIterator["asyncpraw.models.Redditor"]: + def __call__(self) -> AsyncIterator[asyncpraw.models.Redditor]: """Return a :class:`.RedditorList` for live threads' contributors. Usage: @@ -48,7 +47,7 @@ async def generator(): return generator() - def __init__(self, thread: "asyncpraw.models.LiveThread"): + def __init__(self, thread: asyncpraw.models.LiveThread): """Initialize a :class:`.LiveContributorRelationship` instance. :param thread: An instance of :class:`.LiveThread`. @@ -78,9 +77,9 @@ async def accept_invite(self): @_deprecate_args("redditor", "permissions") async def invite( self, - redditor: Union[str, "asyncpraw.models.Redditor"], + redditor: str | asyncpraw.models.Redditor, *, - permissions: Optional[List[str]] = None, + permissions: list[str] | None = None, ): """Invite a redditor to be a contributor of the live thread. @@ -130,7 +129,7 @@ async def leave(self): url = API_PATH["live_leave"].format(id=self.thread.id) await self.thread._reddit.post(url) - async def remove(self, redditor: Union[str, "asyncpraw.models.Redditor"]): + async def remove(self, redditor: str | asyncpraw.models.Redditor): """Remove the redditor from the live thread contributors. :param redditor: A redditor fullname (e.g., ``"t2_1w72"``) or :class:`.Redditor` @@ -146,15 +145,12 @@ async def remove(self, redditor: Union[str, "asyncpraw.models.Redditor"]): await thread.contributor.remove("t2_1w72") # with fullname """ - if isinstance(redditor, Redditor): - fullname = redditor.fullname - else: - fullname = redditor + fullname = redditor.fullname if isinstance(redditor, Redditor) else redditor data = {"id": fullname} url = API_PATH["live_remove_contrib"].format(id=self.thread.id) await self.thread._reddit.post(url, data=data) - async def remove_invite(self, redditor: Union[str, "asyncpraw.models.Redditor"]): + async def remove_invite(self, redditor: str | asyncpraw.models.Redditor): """Remove the invite for redditor. :param redditor: A redditor fullname (e.g., ``"t2_1w72"``) or :class:`.Redditor` @@ -175,10 +171,7 @@ async def remove_invite(self, redditor: Union[str, "asyncpraw.models.Redditor"]) contributor of the live thread. """ - if isinstance(redditor, Redditor): - fullname = redditor.fullname - else: - fullname = redditor + fullname = redditor.fullname if isinstance(redditor, Redditor) else redditor data = {"id": fullname} url = API_PATH["live_remove_invite"].format(id=self.thread.id) await self.thread._reddit.post(url, data=data) @@ -186,9 +179,9 @@ async def remove_invite(self, redditor: Union[str, "asyncpraw.models.Redditor"]) @_deprecate_args("redditor", "permissions") async def update( self, - redditor: Union[str, "asyncpraw.models.Redditor"], + redditor: str | asyncpraw.models.Redditor, *, - permissions: Optional[List[str]] = None, + permissions: list[str] | None = None, ): """Update the contributor permissions for ``redditor``. @@ -230,9 +223,9 @@ async def update( @_deprecate_args("redditor", "permissions") async def update_invite( self, - redditor: Union[str, "asyncpraw.models.Redditor"], + redditor: str | asyncpraw.models.Redditor, *, - permissions: Optional[List[str]] = None, + permissions: list[str] | None = None, ): """Update the contributor invite permissions for ``redditor``. @@ -295,7 +288,7 @@ class LiveThread(RedditBase): STR_FIELD = "id" @cachedproperty - def contrib(self) -> "asyncpraw.models.reddit.live.LiveThreadContribution": + def contrib(self) -> asyncpraw.models.reddit.live.LiveThreadContribution: """Provide an instance of :class:`.LiveThreadContribution`. Usage: @@ -309,7 +302,7 @@ def contrib(self) -> "asyncpraw.models.reddit.live.LiveThreadContribution": return LiveThreadContribution(self) @cachedproperty - def contributor(self) -> "asyncpraw.models.reddit.live.LiveContributorRelationship": + def contributor(self) -> asyncpraw.models.reddit.live.LiveContributorRelationship: """Provide an instance of :class:`.LiveContributorRelationship`. You can call the instance to get a list of contributors which is represented as @@ -327,7 +320,7 @@ def contributor(self) -> "asyncpraw.models.reddit.live.LiveContributorRelationsh return LiveContributorRelationship(self) @cachedproperty - def stream(self) -> "asyncpraw.models.reddit.live.LiveThreadStream": + def stream(self) -> asyncpraw.models.reddit.live.LiveThreadStream: """Provide an instance of :class:`.LiveThreadStream`. Streams are used to indefinitely retrieve new updates made to a live thread, @@ -351,7 +344,7 @@ def stream(self) -> "asyncpraw.models.reddit.live.LiveThreadStream": """ return LiveThreadStream(self) - def __eq__(self, other: Union[str, "asyncpraw.models.LiveThread"]) -> bool: + def __eq__(self, other: str | asyncpraw.models.LiveThread) -> bool: """Return whether the other instance equals the current. .. note:: @@ -369,9 +362,9 @@ def __hash__(self) -> int: def __init__( self, - reddit: "asyncpraw.Reddit", - id: Optional[str] = None, - _data: Optional[Dict[str, Any]] = None, # pylint: disable=redefined-builtin + reddit: asyncpraw.Reddit, + id: str | None = None, + _data: dict[str, Any] | None = None, ): """Initialize a :class:`.LiveThread` instance. @@ -380,7 +373,8 @@ def __init__( """ if (id, _data).count(None) != 1: - raise TypeError("Either 'id' or '_data' must be provided.") + msg = "Either 'id' or '_data' must be provided." + raise TypeError(msg) if id: self.id = id super().__init__(reddit, _data=_data) @@ -390,14 +384,14 @@ async def _fetch(self): data = data["data"] other = type(self)(self._reddit, _data=data) self.__dict__.update(other.__dict__) - self._fetched = True + await super()._fetch() def _fetch_info(self): return "liveabout", {"id": self.id}, None def discussions( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Submission"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Submission]: """Get submissions linking to the thread. :param generator_kwargs: keyword arguments passed to :class:`.ListingGenerator` @@ -423,8 +417,8 @@ def discussions( @deprecate_lazy async def get_update( - self, update_id: str, fetch: bool = True, **kwargs - ) -> "asyncpraw.models.LiveUpdate": + self, update_id: str, fetch: bool = True, **_: Any + ) -> asyncpraw.models.LiveUpdate: """Return a :class:`.LiveUpdate` instance. :param update_id: A live update ID, e.g., @@ -457,7 +451,7 @@ async def get_update( await update._fetch() return update - async def report(self, type: str): # pylint: disable=redefined-builtin + async def report(self, type: str): """Report the thread violating the Reddit rules. :param type: One of ``"spam"``, ``"vote-manipulation"``, @@ -476,8 +470,8 @@ async def report(self, type: str): # pylint: disable=redefined-builtin await self._reddit.post(url, data={"type": type}) async def updates( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.LiveUpdate"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.LiveUpdate]: """Return a :class:`.ListingGenerator` yields :class:`.LiveUpdate` s. :param generator_kwargs: keyword arguments passed to :class:`.ListingGenerator` @@ -508,7 +502,7 @@ async def updates( class LiveThreadContribution: """Provides a set of contribution functions to a :class:`.LiveThread`.""" - def __init__(self, thread: "asyncpraw.models.LiveThread"): + def __init__(self, thread: asyncpraw.models.LiveThread): """Initialize a :class:`.LiveThreadContribution` instance. :param thread: An instance of :class:`.LiveThread`. @@ -558,11 +552,11 @@ async def close(self): async def update( self, *, - description: Optional[str] = None, - nsfw: Optional[bool] = None, - resources: Optional[str] = None, - title: Optional[str] = None, - **other_settings: Optional[str], + description: str | None = None, + nsfw: bool | None = None, + resources: str | None = None, + title: str | None = None, + **other_settings: str | None, ): """Update settings of the live thread. @@ -632,7 +626,7 @@ class LiveThreadStream: """ - def __init__(self, live_thread: "asyncpraw.models.LiveThread"): + def __init__(self, live_thread: asyncpraw.models.LiveThread): """Initialize a :class:`.LiveThreadStream` instance. :param live_thread: The live thread associated with the stream. @@ -641,8 +635,8 @@ def __init__(self, live_thread: "asyncpraw.models.LiveThread"): self.live_thread = live_thread def updates( - self, **stream_options: Dict[str, Any] - ) -> AsyncIterator["asyncpraw.models.LiveUpdate"]: + self, **stream_options: dict[str, Any] + ) -> AsyncIterator[asyncpraw.models.LiveUpdate]: """Yield new updates to the live thread as they become available. :param skip_existing: Set to ``True`` to only fetch items created after the @@ -681,7 +675,7 @@ def updates( class LiveUpdateContribution: """Provides a set of contribution functions to :class:`.LiveUpdate`.""" - def __init__(self, update: "asyncpraw.models.LiveUpdate"): + def __init__(self, update: asyncpraw.models.LiveUpdate): """Initialize a :class:`.LiveUpdateContribution` instance. :param update: An instance of :class:`.LiveUpdate`. @@ -762,7 +756,7 @@ class LiveUpdate(FullnameMixin, RedditBase): _kind = "LiveUpdate" @cachedproperty - def contrib(self) -> "asyncpraw.models.reddit.live.LiveUpdateContribution": + def contrib(self) -> asyncpraw.models.reddit.live.LiveUpdateContribution: """Provide an instance of :class:`.LiveUpdateContribution`. Usage: @@ -783,10 +777,10 @@ def thread(self) -> LiveThread: def __init__( self, - reddit: "asyncpraw.Reddit", - thread_id: Optional[str] = None, - update_id: Optional[str] = None, - _data: Optional[Dict[str, Any]] = None, + reddit: asyncpraw.Reddit, + thread_id: str | None = None, + update_id: str | None = None, + _data: dict[str, Any] | None = None, ): """Initialize a :class:`.LiveUpdate` instance. @@ -818,9 +812,8 @@ def __init__( super().__init__(reddit, _data=None) self._thread = LiveThread(self._reddit, thread_id) else: - raise TypeError( - "Either 'thread_id' and 'update_id', or '_data' must be provided." - ) + msg = "Either 'thread_id' and 'update_id', or '_data' must be provided." + raise TypeError(msg) def __setattr__(self, attribute: str, value: Any): """Objectify author.""" @@ -833,4 +826,4 @@ async def _fetch(self): response = await self._reddit.get(url) other = response[0] self.__dict__.update(other.__dict__) - self._fetched = True + await super()._fetch() diff --git a/asyncpraw/models/reddit/message.py b/asyncpraw/models/reddit/message.py index 1d71da331..104ed4ec9 100644 --- a/asyncpraw/models/reddit/message.py +++ b/asyncpraw/models/reddit/message.py @@ -1,5 +1,7 @@ """Provide the Message class.""" -from typing import TYPE_CHECKING, Any, Dict, Optional +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from ...const import API_PATH from .base import RedditBase @@ -38,7 +40,9 @@ class Message(InboxableMixin, ReplyableMixin, FullnameMixin, RedditBase): STR_FIELD = "id" @classmethod - def parse(cls, data: Dict[str, Any], reddit: "asyncpraw.Reddit"): + def parse( + cls, data: dict[str, Any], reddit: asyncpraw.Reddit + ) -> Message | SubredditMessage: """Return an instance of :class:`.Message` or :class:`.SubredditMessage` from ``data``. :param data: The structured data. @@ -71,7 +75,7 @@ def _kind(self) -> str: return self._reddit.config.kinds["message"] @property - def parent(self) -> Optional["asyncpraw.models.Message"]: + def parent(self) -> asyncpraw.models.Message | None: """Return the parent of the message if it exists. .. note:: @@ -89,9 +93,8 @@ def parent(self) -> Optional["asyncpraw.models.Message"]: """ if not self._parent: if not self._fetched: - raise AttributeError( - "Message must be fetched with `.load()` before accessing the parent." - ) + msg = "Message must be fetched with `.load()` before accessing the parent." + raise AttributeError(msg) if self.parent_id: self._parent = Message( self._reddit, {"id": self.parent_id.split("_")[1]} @@ -100,10 +103,10 @@ def parent(self) -> Optional["asyncpraw.models.Message"]: return self._parent @parent.setter - def parent(self, value): + def parent(self, value: asyncpraw.models.Message | None): self._parent = value - def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): + def __init__(self, reddit: asyncpraw.Reddit, _data: dict[str, Any]): """Initialize a :class:`.Message` instance.""" super().__init__(reddit, _data=_data, _fetched=True) self._parent = None @@ -114,7 +117,7 @@ def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): async def _fetch(self): message = await self._reddit.inbox.message(self.id) self.__dict__.update(message.__dict__) - self._fetched = True + await super()._fetch() async def delete(self): """Delete the message. diff --git a/asyncpraw/models/reddit/mixins/__init__.py b/asyncpraw/models/reddit/mixins/__init__.py index 04f6c556a..932dbf367 100644 --- a/asyncpraw/models/reddit/mixins/__init__.py +++ b/asyncpraw/models/reddit/mixins/__init__.py @@ -1,4 +1,6 @@ """Package providing reddit class mixins.""" +from __future__ import annotations + from json import dumps from typing import TYPE_CHECKING, Optional @@ -26,7 +28,7 @@ class ThingModerationMixin(ModNoteMixin): REMOVAL_MESSAGE_API = None async def _add_removal_reason( - self, *, mod_note: str = "", reason_id: Optional[str] = None + self, *, mod_note: str = "", reason_id: str | None = None ): """Add a removal reason for a :class:`.Comment` or :class:`.Submission`. @@ -40,7 +42,8 @@ async def _add_removal_reason( """ if not reason_id and not mod_note: - raise ValueError("mod_note cannot be blank if reason_id is not specified") + msg = "mod_note cannot be blank if reason_id is not specified" + raise ValueError(msg) # Only the first element of the item_id list is used. data = { "item_ids": [self.thing.fullname], @@ -161,7 +164,7 @@ async def lock(self): @_deprecate_args("spam", "mod_note", "reason_id") async def remove( - self, *, mod_note: str = "", spam: bool = False, reason_id: Optional[str] = None + self, *, mod_note: str = "", spam: bool = False, reason_id: str | None = None ): """Remove a :class:`.Comment` or :class:`.Submission`. @@ -201,8 +204,8 @@ async def send_removal_message( *, message: str, title: str = "ignored", - type: str = "public", # pylint: disable=redefined-builtin - ) -> Optional["asyncpraw.models.Comment"]: + type: str = "public", + ) -> asyncpraw.models.Comment | None: """Send a removal message for a :class:`.Comment` or :class:`.Submission`. .. warning:: @@ -228,7 +231,8 @@ async def send_removal_message( # The API endpoint used to send removal messages is different for posts and # comments, so the derived classes specify which one. if self.REMOVAL_MESSAGE_API is None: - raise NotImplementedError("ThingModerationMixin must be extended.") + msg = "ThingModerationMixin must be extended." + raise NotImplementedError(msg) url = API_PATH[self.REMOVAL_MESSAGE_API] # Only the first element of the item_id list is used. diff --git a/asyncpraw/models/reddit/mixins/editable.py b/asyncpraw/models/reddit/mixins/editable.py index c9048a654..8dc75b10e 100644 --- a/asyncpraw/models/reddit/mixins/editable.py +++ b/asyncpraw/models/reddit/mixins/editable.py @@ -1,5 +1,7 @@ """Provide the EditableMixin class.""" -from typing import TYPE_CHECKING, Union +from __future__ import annotations + +from typing import TYPE_CHECKING from ....const import API_PATH @@ -28,7 +30,7 @@ async def delete(self): async def edit( self, body: str - ) -> Union["asyncpraw.models.Comment", "asyncpraw.models.Submission"]: + ) -> asyncpraw.models.Comment | asyncpraw.models.Submission: """Replace the body of the object with ``body``. :param body: The Markdown formatted content for the updated object. diff --git a/asyncpraw/models/reddit/mixins/gildable.py b/asyncpraw/models/reddit/mixins/gildable.py index 8f02d22ee..d2318241e 100644 --- a/asyncpraw/models/reddit/mixins/gildable.py +++ b/asyncpraw/models/reddit/mixins/gildable.py @@ -14,7 +14,7 @@ async def award( *, gild_type: str = "gid_2", is_anonymous: bool = True, - message: str = None + message: str = None, ) -> dict: """Award the author of the item. diff --git a/asyncpraw/models/reddit/mixins/inboxable.py b/asyncpraw/models/reddit/mixins/inboxable.py index 5952ad372..2e08f8f1b 100644 --- a/asyncpraw/models/reddit/mixins/inboxable.py +++ b/asyncpraw/models/reddit/mixins/inboxable.py @@ -1,5 +1,4 @@ """Provide the InboxableMixin class.""" - from ....const import API_PATH diff --git a/asyncpraw/models/reddit/mixins/messageable.py b/asyncpraw/models/reddit/mixins/messageable.py index 9cb52372e..abca1ba4a 100644 --- a/asyncpraw/models/reddit/mixins/messageable.py +++ b/asyncpraw/models/reddit/mixins/messageable.py @@ -1,5 +1,7 @@ """Provide the MessageableMixin class.""" -from typing import TYPE_CHECKING, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING from ....const import API_PATH from ....util import _deprecate_args @@ -15,7 +17,7 @@ class MessageableMixin: async def message( self, *, - from_subreddit: Optional[Union["asyncpraw.models.Subreddit", str]] = None, + from_subreddit: asyncpraw.models.Subreddit | str | None = None, message: str, subject: str, ): diff --git a/asyncpraw/models/reddit/mixins/modnote.py b/asyncpraw/models/reddit/mixins/modnote.py index 6aa527beb..1c8ab17d1 100644 --- a/asyncpraw/models/reddit/mixins/modnote.py +++ b/asyncpraw/models/reddit/mixins/modnote.py @@ -1,5 +1,7 @@ """Provide the ModNoteMixin class.""" -from typing import TYPE_CHECKING, AsyncGenerator, Optional +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, AsyncGenerator if TYPE_CHECKING: # pragma: no cover import asyncpraw @@ -9,8 +11,8 @@ class ModNoteMixin: """Interface for classes that can have a moderator note set on them.""" def author_notes( - self, **generator_kwargs - ) -> AsyncGenerator["asyncpraw.models.ModNote", None]: + self, **generator_kwargs: Any + ) -> AsyncGenerator[asyncpraw.models.ModNote, None]: """Get the moderator notes for the author of this object in the subreddit it's posted in. :param generator_kwargs: Additional keyword arguments are passed in the @@ -31,8 +33,8 @@ def author_notes( ) async def create_note( - self, *, label: Optional[str] = None, note: str, **other_settings - ) -> "asyncpraw.models.ModNote": + self, *, label: str | None = None, note: str, **other_settings: Any + ) -> asyncpraw.models.ModNote: """Create a moderator note on the author of this object in the subreddit it's posted in. :param label: The label for the note. As of this writing, this can be one of the diff --git a/asyncpraw/models/reddit/mixins/replyable.py b/asyncpraw/models/reddit/mixins/replyable.py index 9b2ba2b33..33842a301 100644 --- a/asyncpraw/models/reddit/mixins/replyable.py +++ b/asyncpraw/models/reddit/mixins/replyable.py @@ -1,5 +1,7 @@ """Provide the ReplyableMixin class.""" -from typing import TYPE_CHECKING, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING from ....const import API_PATH @@ -12,7 +14,7 @@ class ReplyableMixin: async def reply( self, body: str - ) -> Optional[Union["asyncpraw.models.Comment", "asyncpraw.models.Message"]]: + ) -> asyncpraw.models.Comment | asyncpraw.models.Message | None: """Reply to the object. :param body: The Markdown formatted content for a comment. @@ -24,10 +26,9 @@ async def reply( items, such as locked submissions/comments or non-replyable messages. A ``None`` value can be returned if the target is a comment or submission in a - quarantined subreddit and the authenticated user has not opt-ed in to viewing - the content. When this happens the comment will be successfully created on - Reddit and can be retried by drawing the comment from the user's comment - history. + quarantined subreddit and the authenticated user has not opt-ed into viewing the + content. When this happens the comment will be successfully created on Reddit + and can be retried by drawing the comment from the user's comment history. Example usage: diff --git a/asyncpraw/models/reddit/mixins/savable.py b/asyncpraw/models/reddit/mixins/savable.py index bfebdcec6..59194e140 100644 --- a/asyncpraw/models/reddit/mixins/savable.py +++ b/asyncpraw/models/reddit/mixins/savable.py @@ -1,5 +1,5 @@ """Provide the SavableMixin class.""" -from typing import Optional +from __future__ import annotations from ....const import API_PATH from ....util import _deprecate_args @@ -9,7 +9,7 @@ class SavableMixin: """Interface for :class:`.RedditBase` classes that can be saved.""" @_deprecate_args("category") - async def save(self, *, category: Optional[str] = None): + async def save(self, *, category: str | None = None): """Save the object. :param category: The category to save to. If the authenticated user does not diff --git a/asyncpraw/models/reddit/mixins/votable.py b/asyncpraw/models/reddit/mixins/votable.py index c70366b56..c49a57550 100644 --- a/asyncpraw/models/reddit/mixins/votable.py +++ b/asyncpraw/models/reddit/mixins/votable.py @@ -1,11 +1,13 @@ """Provide the VotableMixin class.""" +from __future__ import annotations + from ....const import API_PATH class VotableMixin: """Interface for :class:`.RedditBase` classes that can be voted on.""" - async def _vote(self, direction): + async def _vote(self, direction: int): await self._reddit.post( API_PATH["vote"], data={"dir": str(direction), "id": self.fullname} ) diff --git a/asyncpraw/models/reddit/modmail.py b/asyncpraw/models/reddit/modmail.py index 32fa12b51..4f4f5071e 100644 --- a/asyncpraw/models/reddit/modmail.py +++ b/asyncpraw/models/reddit/modmail.py @@ -1,5 +1,7 @@ """Provide models for new modmail.""" -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from ...const import API_PATH from ...util import _deprecate_args, snake_case_keys @@ -9,6 +11,19 @@ import asyncpraw +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. @@ -50,7 +65,7 @@ class ModmailConversation(RedditBase): STR_FIELD = "id" @staticmethod - def _convert_conversation_objects(data, reddit): + def _convert_conversation_objects(data: dict[str, Any], reddit: asyncpraw.Reddit): """Convert messages and mod actions to Async PRAW objects.""" result = {"messages": [], "modActions": []} for thing in data["objIds"]: @@ -60,7 +75,7 @@ def _convert_conversation_objects(data, reddit): data.update(result) @staticmethod - def _convert_user_summary(data, reddit): + def _convert_user_summary(data: dict[str, Any], reddit: asyncpraw.Reddit): """Convert dictionaries of recent user history to Async PRAW objects.""" parsers = { "recentComments": reddit._objector.parsers[reddit.config.kinds["comment"]], @@ -81,18 +96,15 @@ def _convert_user_summary(data, reddit): data[kind] = sorted(objects, key=lambda x: int(x.id, base=36), reverse=True) @classmethod - def parse( # pylint: disable=arguments-differ + def parse( cls, - data: Dict[str, Any], - reddit: "asyncpraw.Reddit", - convert_objects: bool = True, - ): + data: dict[str, Any], + reddit: asyncpraw.Reddit, + ) -> ModmailConversation: """Return an instance of :class:`.ModmailConversation` from ``data``. :param data: The structured data. :param reddit: An instance of :class:`.Reddit`. - :param convert_objects: If ``True``, convert message and mod action data into - objects (default: ``True``). """ data["authors"] = [ @@ -111,10 +123,10 @@ def parse( # pylint: disable=arguments-differ def __init__( self, - reddit: "asyncpraw.Reddit", - id: Optional[str] = None, # pylint: disable=redefined-builtin + reddit: asyncpraw.Reddit, + id: str | None = None, mark_read: bool = False, - _data: Optional[Dict[str, Any]] = None, + _data: dict[str, Any] | None = None, ): """Initialize a :class:`.ModmailConversation` instance. @@ -123,7 +135,8 @@ def __init__( """ if bool(id) == bool(_data): - raise TypeError("Either 'id' or '_data' must be provided.") + msg = "Either 'id' or '_data' must be provided." + raise TypeError(msg) if id: self.id = id @@ -132,7 +145,9 @@ def __init__( self._info_params = {"markRead": True} if mark_read else None - def _build_conversation_list(self, other_conversations): + 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) @@ -141,7 +156,7 @@ async def _fetch(self): data = await self._fetch_data() other = self._reddit._objector.objectify(data) self.__dict__.update(other.__dict__) - self._fetched = True + await super()._fetch() def _fetch_info(self): return "modmail_conversation", {"id": self.id}, self._info_params @@ -175,7 +190,7 @@ async def highlight(self): await self._reddit.post(API_PATH["modmail_highlight"].format(id=self.id)) @_deprecate_args("num_days") - async def mute(self, *, num_days=3): + async def mute(self, *, num_days: int = 3): """Mute the non-mod user associated with the conversation. :param num_days: Duration of mute in days. Valid options are ``3``, ``7``, or @@ -198,10 +213,7 @@ async def mute(self, *, num_days=3): await conversation.mute(num_days=7) """ - if num_days != 3: # no need to pass params if it's the default - params = {"num_hours": num_days * 24} - else: - params = {} + params = {"num_hours": num_days * 24} if num_days != 3 else {} await self._reddit.request( method="POST", params=params, @@ -210,8 +222,8 @@ async def mute(self, *, num_days=3): @_deprecate_args("other_conversations") async def read( - self, *, other_conversations: Optional[List["ModmailConversation"]] = None - ): # noqa: D207, D301 + self, *, other_conversations: list[ModmailConversation] | None = None + ): """Mark the conversation(s) as read. :param other_conversations: A list of other conversations to mark (default: @@ -233,7 +245,7 @@ async def read( @_deprecate_args("body", "author_hidden", "internal") async def reply( self, *, author_hidden: bool = False, body: str, internal: bool = False - ) -> "ModmailMessage": + ) -> ModmailMessage: """Reply to the conversation. :param author_hidden: When ``True``, author is hidden from non-moderators @@ -272,10 +284,9 @@ async def reply( message_id = response["conversation"]["objIds"][-1]["id"] message_data = response["messages"][message_id] return self._reddit._objector.objectify(message_data) - else: - for message in response.messages: - if message.id == response.obj_ids[-1]["id"]: - return message + for message in response.messages: # noqa: RET503 + if message.id == response.obj_ids[-1]["id"]: + return message async def unarchive(self): """Unarchive the conversation. @@ -323,8 +334,8 @@ async def unmute(self): @_deprecate_args("other_conversations") async def unread( - self, *, other_conversations: Optional[List["ModmailConversation"]] = None - ): # noqa: D207, D301 + self, *, other_conversations: list[ModmailConversation] | None = None + ): """Mark the conversation(s) as unread. :param other_conversations: A list of other conversations to mark (default: @@ -344,19 +355,6 @@ async def unread( await 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): - """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/asyncpraw/models/reddit/more.py b/asyncpraw/models/reddit/more.py index 76187e5e7..40d22fd25 100644 --- a/asyncpraw/models/reddit/more.py +++ b/asyncpraw/models/reddit/more.py @@ -1,5 +1,7 @@ """Provide the MoreComments class.""" -from typing import TYPE_CHECKING, Any, Dict, List, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from ...const import API_PATH from ...util import _deprecate_args @@ -12,13 +14,13 @@ class MoreComments(AsyncPRAWBase): """A class indicating there are more comments.""" - def __eq__(self, other: Union[str, "MoreComments"]) -> bool: + def __eq__(self, other: str | MoreComments) -> bool: """Return ``True`` if these :class:`.MoreComments` instances are the same.""" if isinstance(other, self.__class__): return self.count == other.count and self.children == other.children return super().__eq__(other) - def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): + def __init__(self, reddit: asyncpraw.Reddit, _data: dict[str, Any]): """Initialize a :class:`.MoreComments` instance.""" self.count = self.parent_id = None self.children = [] @@ -26,7 +28,7 @@ def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): self._comments = None self.submission = None - def __lt__(self, other: "MoreComments") -> bool: + def __lt__(self, other: MoreComments) -> bool: """Provide a sort order on the :class:`.MoreComments` object.""" # To work with heapq a "smaller" item is the one with the most comments. We are # intentionally making the biggest element the smallest element to turn the @@ -40,7 +42,7 @@ def __repr__(self) -> str: children[-1] = "..." return f"<{self.__class__.__name__} count={self.count}, children={children!r}>" - async def _continue_comments(self, update): + async def _continue_comments(self, update: bool): assert not self.children, "Please file a bug report with Async PRAW." parent = await self._load_comment(self.parent_id.split("_", 1)[1]) self._comments = parent.replies @@ -49,7 +51,7 @@ async def _continue_comments(self, update): comment.submission = self.submission return self._comments - async def _load_comment(self, comment_id): + async def _load_comment(self, comment_id: str): path = f"{API_PATH['submission'].format(id=self.submission.id)}_/{comment_id}" _, comments = await self._reddit.get( path, @@ -62,9 +64,7 @@ async def _load_comment(self, comment_id): return comments.children[0] @_deprecate_args("update") - async def comments( - self, *, update: bool = True - ) -> List["asyncpraw.models.Comment"]: + async def comments(self, *, update: bool = True) -> list[asyncpraw.models.Comment]: """Fetch and return the comments for a single :class:`.MoreComments` object.""" if self._comments is None: if self.count == 0: # Handle "continue this thread" diff --git a/asyncpraw/models/reddit/multi.py b/asyncpraw/models/reddit/multi.py index d28ded9c6..416fb6e05 100644 --- a/asyncpraw/models/reddit/multi.py +++ b/asyncpraw/models/reddit/multi.py @@ -1,7 +1,9 @@ """Provide the Multireddit class.""" +from __future__ import annotations + import re from json import dumps -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any from ...const import API_PATH from ...util import _deprecate_args @@ -49,7 +51,7 @@ class Multireddit(SubredditListingMixin, RedditBase): RE_INVALID = re.compile(r"[\W_]+", re.UNICODE) @staticmethod - def sluggify(title: str): + def sluggify(title: str) -> str: """Return a slug version of the title. :param title: The title to make a slug of. @@ -90,7 +92,7 @@ def stream(self) -> SubredditStream: """ return SubredditStream(self) - def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): + def __init__(self, reddit: asyncpraw.Reddit, _data: dict[str, Any]): """Initialize a :class:`.Multireddit` instance.""" self.path = None super().__init__(reddit, _data=_data) @@ -110,7 +112,7 @@ async def _fetch(self): data = data["data"] other = type(self)(self._reddit, _data=data) self.__dict__.update(other.__dict__) - self._fetched = True + await super()._fetch() def _fetch_info(self): return ( @@ -119,7 +121,7 @@ def _fetch_info(self): None, ) - async def add(self, subreddit: "asyncpraw.models.Subreddit"): + async def add(self, subreddit: asyncpraw.models.Subreddit): """Add a subreddit to this multireddit. :param subreddit: The subreddit to add to this multi. @@ -142,8 +144,8 @@ async def add(self, subreddit: "asyncpraw.models.Subreddit"): @_deprecate_args("display_name") async def copy( - self, *, display_name: Optional[str] = None - ) -> "asyncpraw.models.Multireddit": + self, *, display_name: str | None = None + ) -> asyncpraw.models.Multireddit: """Copy this multireddit and return the new multireddit. :param display_name: The display name for the copied multireddit. Reddit will @@ -190,7 +192,7 @@ async def delete(self): ) await self._reddit.delete(path) - async def remove(self, subreddit: "asyncpraw.models.Subreddit"): + async def remove(self, subreddit: asyncpraw.models.Subreddit): """Remove a subreddit from this multireddit. :param subreddit: The subreddit to remove from this multi. @@ -213,9 +215,8 @@ async def remove(self, subreddit: "asyncpraw.models.Subreddit"): async def update( self, - **updated_settings: Union[ - str, List[Union[str, "asyncpraw.models.Subreddit", Dict[str, str]]] - ], + **updated_settings: str + | list[str | asyncpraw.models.Subreddit | dict[str, str]], ): """Update this multireddit. diff --git a/asyncpraw/models/reddit/poll.py b/asyncpraw/models/reddit/poll.py index f33015049..a13d488ef 100644 --- a/asyncpraw/models/reddit/poll.py +++ b/asyncpraw/models/reddit/poll.py @@ -1,5 +1,7 @@ """Provide poll-related classes.""" -from typing import Any, Optional +from __future__ import annotations + +from typing import Any from ...util import cachedproperty from ..base import AsyncPRAWBase @@ -72,7 +74,7 @@ class PollData(AsyncPRAWBase): """ @cachedproperty - def user_selection(self) -> Optional[PollOption]: + def user_selection(self) -> PollOption | None: """Get the user's selection in this poll, if any. :returns: The user's selection as a :class:`.PollOption`, or ``None`` if there @@ -83,7 +85,7 @@ def user_selection(self) -> Optional[PollOption]: return None return self.option(self._user_selection) - def __setattr__(self, attribute: str, value: Any): + def __setattr__(self, attribute: str, value: Any) -> None: """Objectify the options attribute, and save user_selection.""" if attribute == "options" and isinstance(value, list): value = [PollOption(self._reddit, option) for option in value] @@ -105,4 +107,5 @@ def option(self, option_id: str) -> PollOption: if option.id == option_id: return option - raise KeyError(f"No poll option with ID {option_id!r}.") + msg = f"No poll option with ID {option_id!r}." + raise KeyError(msg) diff --git a/asyncpraw/models/reddit/redditor.py b/asyncpraw/models/reddit/redditor.py index 52f5143a3..1dfb3f557 100644 --- a/asyncpraw/models/reddit/redditor.py +++ b/asyncpraw/models/reddit/redditor.py @@ -1,6 +1,8 @@ """Provide the Redditor class.""" +from __future__ import annotations + from json import dumps -from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, AsyncGenerator from ...const import API_PATH from ...util import _deprecate_args @@ -74,14 +76,16 @@ class Redditor(MessageableMixin, RedditorListingMixin, FullnameMixin, RedditBase STR_FIELD = "name" @classmethod - def from_data(cls, reddit, data): + def from_data( + cls, reddit: asyncpraw.Reddit, data: dict[str, Any] + ) -> Redditor | None: """Return an instance of :class:`.Redditor`, or ``None`` from ``data``.""" if data == "[deleted]": return None return cls(reddit, data) @cachedproperty - def notes(self) -> "asyncpraw.models.RedditorModNotes": + def notes(self) -> asyncpraw.models.RedditorModNotes: """Provide an instance of :class:`.RedditorModNotes`. This provides an interface for managing moderator notes for a redditor. @@ -105,7 +109,7 @@ def notes(self) -> "asyncpraw.models.RedditorModNotes": return RedditorModNotes(self._reddit, self) @cachedproperty - def stream(self) -> "asyncpraw.models.reddit.redditor.RedditorStream": + def stream(self) -> asyncpraw.models.reddit.redditor.RedditorStream: """Provide an instance of :class:`.RedditorStream`. Streams can be used to indefinitely retrieve new comments made by a redditor, @@ -140,10 +144,10 @@ def _path(self) -> str: def __init__( self, - reddit: "asyncpraw.Reddit", - name: Optional[str] = None, - fullname: Optional[str] = None, - _data: Optional[Dict[str, Any]] = None, + reddit: asyncpraw.Reddit, + name: str | None = None, + fullname: str | None = None, + _data: dict[str, Any] | None = None, ): """Initialize a :class:`.Redditor` instance. @@ -155,11 +159,10 @@ def __init__( """ if (name, fullname, _data).count(None) != 2: - raise TypeError( - "Exactly one of 'name', 'fullname', or '_data' must be provided." - ) + msg = "Exactly one of 'name', 'fullname', or '_data' must be provided." + raise TypeError(msg) if _data: - assert ( + assert ( # noqa: PT018 isinstance(_data, dict) and "name" in _data ), "Please file a bug with Async PRAW." self._listing_use_sort = True @@ -184,18 +187,18 @@ async def _fetch(self): data = data["data"] other = type(self)(self._reddit, _data=data) self.__dict__.update(other.__dict__) - self._fetched = True + await super()._fetch() def _fetch_info(self): return "user_about", {"user": self.name}, None - async def _fetch_username(self, fullname): + async def _fetch_username(self, fullname: str): response = await self._reddit.get( API_PATH["user_by_fullname"], params={"ids": fullname} ) return response[fullname]["name"] - async def _friend(self, *, data, method): + async def _friend(self, *, data: dict[str:Any], method: str): url = API_PATH["friend_v1"].format(user=self) await self._reddit.request(data=dumps(data), method=method, path=url) @@ -265,7 +268,7 @@ async def friend(self, *, note: str = None): """ await self._friend(data={"note": note} if note else {}, method="PUT") - async def friend_info(self) -> "asyncpraw.models.Redditor": + async def friend_info(self) -> asyncpraw.models.Redditor: """Return a :class:`.Redditor` instance with specific friend-related attributes. :returns: A :class:`.Redditor` instance with fields ``date``, ``id``, and @@ -297,12 +300,13 @@ async def gild(self, *, months: int = 1): """ if months < 1 or months > 36: - raise TypeError("months must be between 1 and 36") + msg = "months must be between 1 and 36" + raise TypeError(msg) await self._reddit.post( API_PATH["gild_user"].format(username=self), data={"months": months} ) - async def moderated(self) -> List["asyncpraw.models.Subreddit"]: + async def moderated(self) -> list[asyncpraw.models.Subreddit]: """Return a list of the redditor's moderated subreddits. :returns: A list of :class:`.Subreddit` objects. Return ``[]`` if the redditor @@ -352,7 +356,7 @@ async def moderated(self) -> List["asyncpraw.models.Subreddit"]: """ return await self._reddit.get(API_PATH["moderated"].format(user=self)) or [] - async def multireddits(self) -> List["asyncpraw.models.Multireddit"]: + async def multireddits(self) -> list[asyncpraw.models.Multireddit]: """Return a list of the redditor's public multireddits. For example, to to get :class:`.Redditor` u/spez's multireddits: @@ -365,7 +369,7 @@ async def multireddits(self) -> List["asyncpraw.models.Multireddit"]: """ return await self._reddit.get(API_PATH["multireddit_user"].format(user=self)) - async def trophies(self) -> List["asyncpraw.models.Trophy"]: + async def trophies(self) -> list[asyncpraw.models.Trophy]: """Return a list of the redditor's trophies. :returns: A list of :class:`.Trophy` objects. Return ``[]`` if the redditor has @@ -462,7 +466,7 @@ async def unfriend(self): class RedditorStream: """Provides submission and comment streams.""" - def __init__(self, redditor: "asyncpraw.models.Redditor"): + def __init__(self, redditor: asyncpraw.models.Redditor): """Initialize a :class:`.RedditorStream` instance. :param redditor: The redditor associated with the streams. @@ -471,8 +475,8 @@ def __init__(self, redditor: "asyncpraw.models.Redditor"): self.redditor = redditor def comments( - self, **stream_options: Union[str, int, Dict[str, str]] - ) -> AsyncGenerator["asyncpraw.models.Comment", None]: + self, **stream_options: str | int | dict[str, str] + ) -> AsyncGenerator[asyncpraw.models.Comment, None]: """Yield new comments as they become available. Comments are yielded oldest first. Up to 100 historical comments will initially @@ -492,8 +496,8 @@ def comments( return stream_generator(self.redditor.comments.new, **stream_options) def submissions( - self, **stream_options: Union[str, int, Dict[str, str]] - ) -> AsyncGenerator["asyncpraw.models.Submission", None]: + self, **stream_options: str | int | dict[str, str] + ) -> AsyncGenerator[asyncpraw.models.Submission, None]: """Yield new submissions as they become available. Submissions are yielded oldest first. Up to 100 historical submissions will diff --git a/asyncpraw/models/reddit/removal_reasons.py b/asyncpraw/models/reddit/removal_reasons.py index 447f15b9b..b12a4f1f6 100644 --- a/asyncpraw/models/reddit/removal_reasons.py +++ b/asyncpraw/models/reddit/removal_reasons.py @@ -1,5 +1,7 @@ """Provide the Removal Reason class.""" -from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, AsyncIterator from warnings import warn from ...const import API_PATH @@ -30,7 +32,9 @@ class RemovalReason(RedditBase): STR_FIELD = "id" @staticmethod - def _warn_reason_id(*, id_value: Optional[str], reason_id_value: Optional[str]): + 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 @@ -50,7 +54,7 @@ def _warn_reason_id(*, id_value: Optional[str], reason_id_value: Optional[str]): return reason_id_value return id_value - def __eq__(self, other: Union[str, "RemovalReason"]) -> bool: + def __eq__(self, other: str | RemovalReason) -> bool: """Return whether the other instance equals the current.""" if isinstance(other, str): return other == str(self) @@ -62,11 +66,11 @@ def __hash__(self) -> int: def __init__( self, - reddit: "asyncpraw.Reddit", - subreddit: "asyncpraw.models.Subreddit", - id: Optional[str] = None, # pylint: disable=redefined-builtin - reason_id: Optional[str] = None, - _data: Optional[Dict[str, Any]] = None, + reddit: asyncpraw.Reddit, + subreddit: asyncpraw.models.Subreddit, + id: str | None = None, + reason_id: str | None = None, + _data: dict[str, Any] | None = None, ): """Initialize a :class:`.RemovalReason` instance. @@ -77,12 +81,13 @@ def __init__( compatibility. This parameter should not be used. """ - id = self._warn_reason_id(id_value=id, reason_id_value=reason_id) - if (id, _data).count(None) != 1: - raise ValueError("Either id or _data needs to be given.") + reason_id = self._warn_reason_id(id_value=id, reason_id_value=reason_id) + if (reason_id, _data).count(None) != 1: + msg = "Either id or _data needs to be given." + raise ValueError(msg) - if id: - self.id = id + if reason_id: + self.id = reason_id self.subreddit = subreddit super().__init__(reddit, _data=_data) @@ -90,11 +95,10 @@ async def _fetch(self): async for removal_reason in self.subreddit.mod.removal_reasons: if removal_reason.id == self.id: self.__dict__.update(removal_reason.__dict__) - self._fetched = True + await super()._fetch() return - raise ClientException( - f"Subreddit {self.subreddit} does not have the removal reason {self.id}" - ) + msg = f"Subreddit {self.subreddit} does not have the removal reason {self.id}" + raise ClientException(msg) async def delete(self): """Delete a removal reason from this subreddit. @@ -112,9 +116,7 @@ async def delete(self): await self._reddit.delete(url) @_deprecate_args("message", "title") - async def update( - self, *, message: Optional[str] = None, title: Optional[str] = None - ): + async def update(self, *, message: str | None = None, title: str | None = None): """Update the removal reason from this subreddit. .. note:: @@ -159,7 +161,7 @@ async def __aiter__(self) -> AsyncIterator[RemovalReason]: for reason in await self._removal_reason_list(): yield reason - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): + def __init__(self, subreddit: asyncpraw.models.Subreddit): """Initialize a :class:`.SubredditRemovalReasons` instance. :param subreddit: The subreddit whose removal reasons to work with. @@ -168,7 +170,7 @@ def __init__(self, subreddit: "asyncpraw.models.Subreddit"): self.subreddit = subreddit self._reddit = subreddit._reddit - async def _removal_reason_list(self) -> List[RemovalReason]: + async def _removal_reason_list(self) -> list[RemovalReason]: """Get a list of Removal Reason objects. :returns: A list of instances of :class:`.RemovalReason`. @@ -205,12 +207,15 @@ async def add(self, *, message: str, title: str) -> RemovalReason: """ data = {"message": message, "title": title} url = API_PATH["removal_reasons_list"].format(subreddit=self.subreddit) - id = await self._reddit.post(url, data=data) - return RemovalReason(self._reddit, self.subreddit, id["id"]) + reason_id = await self._reddit.post(url, data=data) + return RemovalReason(self._reddit, self.subreddit, reason_id["id"]) @deprecate_lazy async def get_reason( - self, reason_id: Union[str, int, slice], fetch: bool = True, **kwargs + self, + reason_id: str | (int | slice), + fetch: bool = True, + **_, ) -> RemovalReason: """Return the Removal Reason with the ID/number/slice ``reason_id``. @@ -268,8 +273,7 @@ async def get_reason( if not isinstance(reason_id, str): reasons = await self._removal_reason_list() return reasons[reason_id] - else: - reason = RemovalReason(self._reddit, self.subreddit, reason_id) + reason = RemovalReason(self._reddit, self.subreddit, reason_id) if fetch: await reason._fetch() return reason diff --git a/asyncpraw/models/reddit/rules.py b/asyncpraw/models/reddit/rules.py index 356a62d80..860e2d01d 100644 --- a/asyncpraw/models/reddit/rules.py +++ b/asyncpraw/models/reddit/rules.py @@ -1,5 +1,7 @@ """Provide the Rule class.""" -from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, AsyncIterator from urllib.parse import quote from warnings import warn @@ -39,7 +41,7 @@ class Rule(RedditBase): STR_FIELD = "short_name" @cachedproperty - def mod(self) -> "asyncpraw.models.reddit.rules.RuleModeration": + def mod(self) -> asyncpraw.models.reddit.rules.RuleModeration: """Contain methods used to moderate rules. To delete ``"No spam"`` from r/test try: @@ -65,26 +67,26 @@ def __getattribute__(self, attribute: str) -> Any: """Get the value of an attribute.""" value = super().__getattribute__(attribute) if attribute == "subreddit" and value is None: - raise ValueError( - "The Rule is missing a subreddit. File a bug report at Async PRAW." - ) + msg = "The Rule is missing a subreddit. File a bug report at Async PRAW." + raise ValueError(msg) return value def __init__( self, - reddit: "asyncpraw.Reddit", - subreddit: Optional["asyncpraw.models.Subreddit"] = None, - short_name: Optional[str] = None, - _data: Optional[Dict[str, str]] = None, + reddit: asyncpraw.Reddit, + subreddit: asyncpraw.models.Subreddit | None = None, + short_name: str | None = None, + _data: dict[str, str] | None = None, ): """Initialize a :class:`.Rule` instance.""" if (short_name, _data).count(None) != 1: - raise ValueError("Either short_name or _data needs to be given.") + msg = "Either short_name or _data needs to be given." + raise ValueError(msg) if short_name: self.short_name = short_name # Note: The subreddit parameter can be None, because the objector does not know # this info. In that case, it is the responsibility of the caller to set the - # `subreddit` property on the returned value + # `subreddit` property on the returned value. self.subreddit = subreddit super().__init__(reddit, _data=_data) @@ -92,11 +94,10 @@ async def _fetch(self): async for rule in self.subreddit.rules: if rule.short_name == self.short_name: self.__dict__.update(rule.__dict__) - self._fetched = True + await super()._fetch() return - raise ClientException( - f"Subreddit {self.subreddit} does not have the rule {self.short_name}" - ) + msg = f"Subreddit {self.subreddit} does not have the rule {self.short_name}" + raise ClientException(msg) class RuleModeration: @@ -120,7 +121,7 @@ class RuleModeration: """ - def __init__(self, rule: "asyncpraw.models.Rule"): + def __init__(self, rule: asyncpraw.models.Rule): """Initialize a :class:`.RuleModeration` instance.""" self.rule = rule @@ -146,11 +147,11 @@ async def delete(self): async def update( self, *, - description: Optional[str] = None, - kind: Optional[str] = None, - short_name: Optional[str] = None, - violation_reason: Optional[str] = None, - ) -> "asyncpraw.models.Rule": + description: str | None = None, + kind: str | None = None, + short_name: str | None = None, + violation_reason: str | None = None, + ) -> asyncpraw.models.Rule: """Update the rule from this subreddit. .. note:: @@ -217,7 +218,7 @@ class SubredditRules: """ @cachedproperty - def mod(self) -> "SubredditRulesModeration": + def mod(self) -> SubredditRulesModeration: """Contain methods to moderate subreddit rules as a whole. To add rule ``"No spam"`` to r/test try: @@ -243,7 +244,7 @@ def mod(self) -> "SubredditRulesModeration": """ return SubredditRulesModeration(self) - async def __aiter__(self) -> AsyncIterator["asyncpraw.models.Rule"]: + async def __aiter__(self) -> AsyncIterator[asyncpraw.models.Rule]: """Iterate through the rules of the subreddit. :returns: An asynchronous iterator containing all the rules of a subreddit. @@ -263,7 +264,7 @@ async def __aiter__(self) -> AsyncIterator["asyncpraw.models.Rule"]: for rule in rules: yield rule - async def __call__(self) -> List["asyncpraw.models.Rule"]: + async def __call__(self) -> list[asyncpraw.models.Rule]: r"""Return a list of :class:`.Rule`\ s (Deprecated). :returns: A list of instances of :class:`.Rule`. @@ -292,7 +293,7 @@ async def __call__(self) -> List["asyncpraw.models.Rule"]: method="GET", path=API_PATH["rules"].format(subreddit=self.subreddit) ) - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): + def __init__(self, subreddit: asyncpraw.models.Subreddit): """Initialize a :class:`.SubredditRules` instance. :param subreddit: The subreddit whose rules to work with. @@ -301,7 +302,7 @@ def __init__(self, subreddit: "asyncpraw.models.Subreddit"): self.subreddit = subreddit self._reddit = subreddit._reddit - async def _rule_list(self) -> List[Rule]: + async def _rule_list(self) -> list[Rule]: """Get a list of :class:`.Rule` objects. :returns: A list of instances of :class:`.Rule`. @@ -314,9 +315,7 @@ async def _rule_list(self) -> List[Rule]: rule.subreddit = self.subreddit return rule_list - async def get_rule( - self, short_name: Union[str, int, slice] - ) -> "asyncpraw.models.Rule": + async def get_rule(self, short_name: str | (int | slice)) -> asyncpraw.models.Rule: """Return the :class:`.Rule` for the subreddit with short_name ``short_name``. :param short_name: The short_name of the rule, or the rule number. @@ -395,8 +394,8 @@ async def add( description: str = "", kind: str, short_name: str, - violation_reason: Optional[str] = None, - ) -> "asyncpraw.models.Rule": + violation_reason: str | None = None, + ) -> asyncpraw.models.Rule: """Add a removal reason to this subreddit. :param description: The description for the rule. @@ -436,8 +435,8 @@ async def add( return new_rule async def reorder( - self, rule_list: List["asyncpraw.models.Rule"] - ) -> List["asyncpraw.models.Rule"]: + self, rule_list: list[asyncpraw.models.Rule] + ) -> list[asyncpraw.models.Rule]: """Reorder the rules of a subreddit. :param rule_list: The list of rules, in the wanted order. Each index of the list diff --git a/asyncpraw/models/reddit/submission.py b/asyncpraw/models/reddit/submission.py index 2f9cda46c..b8f716cc2 100644 --- a/asyncpraw/models/reddit/submission.py +++ b/asyncpraw/models/reddit/submission.py @@ -1,7 +1,9 @@ """Provide the Submission class.""" +from __future__ import annotations + import re from json import dumps -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Generator from urllib.parse import urljoin from warnings import warn @@ -35,7 +37,7 @@ class SubmissionFlair: """Provide a set of functions pertaining to :class:`.Submission` flair.""" - def __init__(self, submission: "asyncpraw.models.Submission"): + def __init__(self, submission: asyncpraw.models.Submission): """Initialize a :class:`.SubmissionFlair` instance. :param submission: The :class:`.Submission` associated with the flair functions. @@ -43,7 +45,7 @@ def __init__(self, submission: "asyncpraw.models.Submission"): """ self.submission = submission - async def choices(self) -> List[Dict[str, Union[bool, list, str]]]: + async def choices(self) -> list[dict[str, bool | list | str]]: """Return list of available flair choices. Choices are required in order to use :meth:`.select`. @@ -64,7 +66,7 @@ async def choices(self) -> List[Dict[str, Union[bool, list, str]]]: return data["choices"] @_deprecate_args("flair_template_id", "text") - async def select(self, flair_template_id: str, *, text: Optional[str] = None): + async def select(self, flair_template_id: str, *, text: str | None = None): """Select flair for submission. :param flair_template_id: The flair template to select. The possible values can @@ -107,7 +109,7 @@ class SubmissionModeration(ThingModerationMixin, ModNoteMixin): REMOVAL_MESSAGE_API = "removal_link_message" - def __init__(self, submission: "asyncpraw.models.Submission"): + def __init__(self, submission: asyncpraw.models.Submission): """Initialize a :class:`.SubmissionModeration` instance. :param submission: The submission to moderate. @@ -147,7 +149,7 @@ async def flair( self, *, css_class: str = "", - flair_template_id: Optional[str] = None, + flair_template_id: str | None = None, text: str = "", ): """Set flair for the submission. @@ -279,7 +281,9 @@ async def spoiler(self): ) @_deprecate_args("state", "bottom") - async def sticky(self, *, bottom: bool = True, state: bool = True): + async def sticky( + self, *, bottom: bool = True, state: bool = True + ) -> asyncpraw.models.Submission: """Set the submission's sticky state in its subreddit. :param bottom: When ``True``, set the submission as the bottom sticky. If no top @@ -288,6 +292,8 @@ async def sticky(self, *, bottom: bool = True, state: bool = True): :param state: ``True`` sets the sticky for the submission and ``False`` unsets (default: ``True``). + :returns: The stickied submission object. + .. note:: When a submission is stickied two or more times, the Reddit API responds @@ -556,10 +562,10 @@ def shortlink(self) -> str: def __init__( self, - reddit: "asyncpraw.Reddit", - id: Optional[str] = None, # pylint: disable=redefined-builtin - url: Optional[str] = None, - _data: Optional[Dict[str, Any]] = None, + reddit: asyncpraw.Reddit, + id: str | None = None, + url: str | None = None, + _data: dict[str, Any] | None = None, ): """Initialize a :class:`.Submission` instance. @@ -571,7 +577,8 @@ def __init__( """ if (id, url, _data).count(None) != 2: - raise TypeError("Exactly one of 'id', 'url', or '_data' must be provided.") + msg = "Exactly one of 'id', 'url', or '_data' must be provided." + raise TypeError(msg) self.comment_limit = 2048 # Specify the sort order for ``comments`` @@ -638,11 +645,17 @@ def __setattr__(self, attribute: str, value: Any): ): warn( "The comments for this submission have already been fetched, so the" - " updated comment_sort will not have any effect." + " updated comment_sort will not have any effect.", + stacklevel=2, ) super().__setattr__(attribute, value) - def _chunk(self, *, chunk_size, other_submissions): + def _chunk( + self, + *, + chunk_size: int, + other_submissions: list[asyncpraw.models.Submission] | None, + ) -> Generator[str, None, None]: all_submissions = [self.fullname] if other_submissions: all_submissions += [x.fullname for x in other_submissions] @@ -654,9 +667,9 @@ async def _edit_experimental( self, body: str, *, - preserve_inline_media=False, - inline_media: Optional[Dict[str, "asyncpraw.models.InlineMedia"]] = None, - ) -> Union["asyncpraw.models.Submission"]: + preserve_inline_media: bool = False, + inline_media: dict[str, asyncpraw.models.InlineMedia] | None = None, + ) -> asyncpraw.models.Submission: """Replace the body of the object with ``body``. :param body: The Markdown formatted content for the updated object. @@ -726,7 +739,7 @@ async def _edit_experimental( self.__dict__.update(updated.__dict__) else: self.__dict__.update(updated) - return self # type: ignore + return self async def _fetch(self): data = await self._fetch_data() @@ -740,8 +753,8 @@ async def _fetch(self): submission.comments = CommentForest(self) self.__dict__.update(submission.__dict__) - self._fetched = True self.comments._update(comment_listing.children) + await super()._fetch() async def _fetch_data(self): name, fields, params = self._fetch_info() @@ -813,7 +826,8 @@ def add_fetch_param(self, key: str, value: str): f"This {self.__class__.__name__.lower()} has already been fetched, so" " adding additional fetch parameters will not have any effect." f" Initialize the {self.__class__.__name__} instance with the parameter" - " `fetch=False` to use additional fetch parameters." + " `fetch=False` to use additional fetch parameters.", + stacklevel=2, ) self._additional_fetch_params[key] = value @@ -828,15 +842,15 @@ def add_fetch_param(self, key: str, value: str): ) async def crosspost( self, - subreddit: "asyncpraw.models.Subreddit", + subreddit: asyncpraw.models.Subreddit, *, - flair_id: Optional[str] = None, - flair_text: Optional[str] = None, + flair_id: str | None = None, + flair_text: str | None = None, nsfw: bool = False, send_replies: bool = True, spoiler: bool = False, - title: Optional[str] = None, - ) -> "asyncpraw.models.Submission": + title: str | None = None, + ) -> asyncpraw.models.Submission: """Crosspost the submission to a subreddit. .. note:: @@ -891,7 +905,7 @@ async def crosspost( @_deprecate_args("other_submissions") async def hide( - self, *, other_submissions: Optional[List["asyncpraw.models.Submission"]] = None + self, *, other_submissions: list[asyncpraw.models.Submission] | None = None ): """Hide :class:`.Submission`. @@ -934,7 +948,7 @@ async def mark_visited(self): @_deprecate_args("other_submissions") async def unhide( - self, *, other_submissions: Optional[List["asyncpraw.models.Submission"]] = None + self, *, other_submissions: list[asyncpraw.models.Submission] | None = None ): """Unhide :class:`.Submission`. diff --git a/asyncpraw/models/reddit/subreddit.py b/asyncpraw/models/reddit/subreddit.py index d580efec2..861a1b854 100644 --- a/asyncpraw/models/reddit/subreddit.py +++ b/asyncpraw/models/reddit/subreddit.py @@ -1,29 +1,25 @@ """Provide the Subreddit class.""" +from __future__ import annotations -# pylint: disable=too-many-lines -import socket +import contextlib from asyncio import TimeoutError from copy import deepcopy from csv import writer from io import StringIO from json import dumps -from os.path import basename, dirname, isfile, join +from pathlib import Path from typing import ( TYPE_CHECKING, Any, AsyncGenerator, AsyncIterator, - Dict, Iterator, - List, - Optional, - Union, ) from urllib.parse import urljoin from warnings import warn from xml.etree.ElementTree import XML -from aiohttp import ClientResponse +import aiofiles from aiohttp.http_exceptions import HttpProcessingError from aiohttp.web_ws import WebSocketError from asyncprawcore import Redirect @@ -52,6 +48,8 @@ from .wikipage import WikiPage if TYPE_CHECKING: # pragma: no cover + from aiohttp import ClientResponse + import asyncpraw @@ -69,8 +67,8 @@ class Modmail: """ async def __call__( - self, id: Optional[str] = None, mark_read: bool = False, fetch=True - ): # noqa: D207, D301 + self, id: str | None = None, mark_read: bool = False, fetch: bool = True + ) -> ModmailConversation: """Return an individual conversation. :param id: A reddit base36 conversation ID, e.g., ``"2gmz"``. @@ -126,7 +124,6 @@ async def __call__( print(conversation.user.recent_posts) """ - # pylint: disable=invalid-name,redefined-builtin modmail_conversation = ModmailConversation( self.subreddit._reddit, id=id, mark_read=mark_read ) @@ -134,12 +131,12 @@ async def __call__( await modmail_conversation._fetch() return modmail_conversation - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): + def __init__(self, subreddit: asyncpraw.models.Subreddit): """Initialize a :class:`.Modmail` instance.""" self.subreddit = subreddit def _build_subreddit_list( - self, other_subreddits: Optional[List["asyncpraw.models.Subreddit"]] + self, other_subreddits: list[asyncpraw.models.Subreddit] | None ): """Return a comma-separated list of subreddit display names.""" subreddits = [self.subreddit] + (other_subreddits or []) @@ -149,11 +146,9 @@ def _build_subreddit_list( async def bulk_read( self, *, - other_subreddits: Optional[ - List[Union["asyncpraw.models.Subreddit", str]] - ] = None, - state: Optional[str] = None, - ) -> List[ModmailConversation]: + other_subreddits: list[asyncpraw.models.Subreddit | str] | None = None, + state: str | None = None, + ) -> list[ModmailConversation]: """Mark conversations for subreddit(s) as read. .. note:: @@ -195,12 +190,12 @@ async def bulk_read( def conversations( self, *, - after: Optional[str] = None, - other_subreddits: Optional[List["asyncpraw.models.Subreddit"]] = None, - sort: Optional[str] = None, - state: Optional[str] = None, - **generator_kwargs, - ) -> AsyncIterator[ModmailConversation]: # noqa: D207, D301 + after: str | None = None, + other_subreddits: list[asyncpraw.models.Subreddit] | None = None, + sort: str | None = None, + state: str | None = None, + **generator_kwargs: Any, + ) -> AsyncIterator[ModmailConversation]: """Generate :class:`.ModmailConversation` objects for subreddit(s). :param after: A base36 modmail conversation id. When provided, the listing @@ -266,7 +261,7 @@ async def create( *, author_hidden: bool = False, body: str, - recipient: Union[str, "asyncpraw.models.Redditor"], + recipient: str | asyncpraw.models.Redditor, subject: str, ) -> ModmailConversation: """Create a new :class:`.ModmailConversation`. @@ -301,7 +296,7 @@ async def create( async def subreddits( self, - ) -> AsyncGenerator["asyncpraw.models.Subreddit", None]: + ) -> AsyncGenerator[asyncpraw.models.Subreddit, None]: """Yield subreddits using the new modmail that the user moderates. For example: @@ -322,7 +317,7 @@ async def subreddits( subreddit.last_updated = value["lastUpdated"] yield subreddit - async def unread_count(self) -> Dict[str, int]: + async def unread_count(self) -> dict[str, int]: """Return unread conversation count by conversation state. At time of writing, possible states are: ``"archived"``, ``"highlighted"``, @@ -343,3968 +338,3932 @@ async def unread_count(self) -> Dict[str, int]: return await self.subreddit._reddit.get(API_PATH["modmail_unread_count"]) -class Subreddit(MessageableMixin, SubredditListingMixin, FullnameMixin, RedditBase): - """A class for Subreddits. - - To obtain an instance of this class for r/test execute: - - .. code-block:: python - - subreddit = await reddit.subreddit("test") - - To obtain a lazy instance of this class for subreddit ``r/test`` execute: - - .. code-block:: python - - subreddit = await reddit.subreddit("test") +class SubredditFilters: + """Provide functions to interact with the special :class:`.Subreddit`'s filters. - 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: + Members of this class should be utilized via :meth:`.Subreddit.filters`. For + example, to add a filter, run: .. code-block:: python subreddit = await reddit.subreddit("all") - async for submission in subreddit.hot(limit=25): - print(submission.title) - - Multiple subreddits can be combined with a ``+`` like so: + await subreddit.filters.add("test") - .. code-block:: python + """ - subreddit = await reddit.subreddit("redditdev+learnpython") - async for submission in subreddit.top(time_filter="all"): - print(submission) + async def __aiter__( + self, + ) -> AsyncGenerator[asyncpraw.models.Subreddit, None]: + """Iterate through the special :class:`.Subreddit`'s filters. - Subreddits can be filtered from combined listings as follows. + This method should be invoked as: - .. note:: + .. code-block:: python - These filters are ignored by certain methods, including :attr:`.comments`, - :meth:`.gilded`, and :meth:`.SubredditStream.comments`. + subreddit = await reddit.subreddit("test") + async for subreddit in subreddit.filters: + ... - .. code-block:: python + """ + user = await self.subreddit._reddit.user.me() + url = API_PATH["subreddit_filter_list"].format( + special=self.subreddit, user=user + ) + params = {"unique": self.subreddit._reddit._next_unique} + response_data = await self.subreddit._reddit.get(url, params=params) + for subreddit in response_data.subreddits: + yield subreddit - subreddit = await reddit.subreddit("all-redditdev") - async for submission in subreddit.new(): - print(submission) + def __init__(self, subreddit: asyncpraw.models.Subreddit): + """Initialize a :class:`.SubredditFilters` instance. - .. include:: ../../typical_attributes.rst + :param subreddit: The special subreddit whose filters to work with. - ========================= ========================================================== - 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. - ========================= ========================================================== + As of this writing filters can only be used with the special subreddits ``all`` + and ``mod``. - .. note:: + """ + self.subreddit = subreddit - 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. + async def add(self, subreddit: asyncpraw.models.Subreddit | str): + """Add ``subreddit`` to the list of filtered subreddits. - .. _unix time: https://en.wikipedia.org/wiki/Unix_time + :param subreddit: The subreddit to add to the filter list. - """ + Items from subreddits added to the filtered list will no longer be included when + obtaining listings for r/all. - # pylint: disable=too-many-public-methods + Alternatively, you can filter a subreddit temporarily from a special listing in + a manner like so: - STR_FIELD = "display_name" - MESSAGE_PREFIX = "#" + .. code-block:: python - @staticmethod - async def _create_or_update( - *, - _reddit, - allow_images=None, - allow_post_crossposts=None, - allow_top=None, - collapse_deleted_comments=None, - comment_score_hide_mins=None, - description=None, - domain=None, - exclude_banned_modqueue=None, - header_hover_text=None, - hide_ads=None, - lang=None, - key_color=None, - link_type=None, - name=None, - over_18=None, - public_description=None, - public_traffic=None, - show_media=None, - show_media_preview=None, - spam_comments=None, - spam_links=None, - spam_selfposts=None, - spoilers_enabled=None, - sr=None, - submit_link_label=None, - submit_text=None, - submit_text_label=None, - subreddit_type=None, - suggested_comment_sort=None, - title=None, - wiki_edit_age=None, - wiki_edit_karma=None, - wikimode=None, - **other_settings, - ): - # 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, - } + await reddit.subreddit("all-redditdev-learnpython") - model.update(other_settings) + :raises: ``asyncprawcore.NotFound`` when calling on a non-special subreddit. - await _reddit.post(API_PATH["site_admin"], data=model) + """ + user = await self.subreddit._reddit.user.me() + url = API_PATH["subreddit_filter"].format( + special=self.subreddit, + user=user, + subreddit=subreddit, + ) + await self.subreddit._reddit.put( + url, data={"model": dumps({"name": str(subreddit)})} + ) - @staticmethod - def _subreddit_list(*, other_subreddits, subreddit): - if other_subreddits: - return ",".join([str(subreddit)] + [str(x) for x in other_subreddits]) - return str(subreddit) + async def remove(self, subreddit: asyncpraw.models.Subreddit | str): + """Remove ``subreddit`` from the list of filtered subreddits. - @staticmethod - def _validate_gallery(images): - for image in images: - image_path = image.get("image_path", "") - if image_path: - if not isfile(image_path): - raise TypeError(f"{image_path!r} is not a valid image path.") - else: - raise TypeError("'image_path' is required.") - if not len(image.get("caption", "")) <= 180: - raise TypeError("Caption must be 180 characters or less.") + :param subreddit: The subreddit to remove from the filter list. - @staticmethod - def _validate_inline_media(inline_media: "asyncpraw.models.InlineMedia"): - if not isfile(inline_media.path): - raise ValueError(f"{inline_media.path!r} is not a valid file path.") + :raises: ``asyncprawcore.NotFound`` when calling on a non-special subreddit. - @cachedproperty - def banned(self) -> "asyncpraw.models.reddit.subreddit.SubredditRelationship": - """Provide an instance of :class:`.SubredditRelationship`. + """ + user = await self.subreddit._reddit.user.me() + url = API_PATH["subreddit_filter"].format( + special=self.subreddit, + user=user, + subreddit=subreddit, + ) + await self.subreddit._reddit.delete(url) - For example, to ban a user try: - .. code-block:: python +class SubredditFlair: + """Provide a set of functions to interact with a :class:`.Subreddit`'s flair.""" - subreddit = await reddit.subreddit("test") - await subreddit.banned.add("spez", ban_reason="...") + @cachedproperty + def link_templates( + self, + ) -> asyncpraw.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 subreddit = await reddit.subreddit("test") - async for ban in subreddit.banned(): - print(f"{ban}: {ban.note}") + async for template in subreddit.flair.link_templates: + print(template) """ - return SubredditRelationship(self, "banned") + return SubredditLinkFlairTemplates(self.subreddit) @cachedproperty - def collections(self) -> "asyncpraw.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 - - subreddit = await reddit.subreddit("test") - async for collection in subreddit.collections: - print(collection.permalink) + def templates( + self, + ) -> asyncpraw.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 subreddit = await reddit.subreddit("test") - - collection = subreddit.collections("some_uuid") - collection = subreddit.collections( - permalink="https://reddit.com/r/test/collection/some_uuid" - ) + async for template in subreddit.flair.templates: + print(template) """ - return self._subreddit_collections_class(self._reddit, self) + return SubredditRedditorFlairTemplates(self.subreddit) - @cachedproperty - def contributor( + def __call__( self, - ) -> "asyncpraw.models.reddit.subreddit.ContributorRelationship": - """Provide an instance of :class:`.ContributorRelationship`. + redditor: asyncpraw.models.Redditor | str | None = None, + **generator_kwargs: Any, + ) -> AsyncIterator[asyncpraw.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 subreddit = await reddit.subreddit("test") - await subreddit.contributor.add("spez") + async for flair in subreddit.flair(limit=None): + print(flair) """ - return ContributorRelationship(self, "contributor") + 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) - @cachedproperty - def emoji(self) -> SubredditEmoji: - """Provide an instance of :class:`.SubredditEmoji`. + def __init__(self, subreddit: asyncpraw.models.Subreddit): + """Initialize a :class:`.SubredditFlair` instance. - This attribute can be used to discover all emoji for a subreddit: + :param subreddit: The subreddit whose flair to work with. - .. code-block:: python + """ + self.subreddit = subreddit - subreddit = await reddit.subreddit("test") - async for emoji in subreddit.emoji: - print(emoji) + @_deprecate_args("position", "self_assign", "link_position", "link_self_assign") + async 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. - A single emoji can be lazily retrieved via: + :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 = await reddit.subreddit("test") - emoji = await subreddit.emoji.get_emoji("emoji_name") - - .. 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) + await self.subreddit._reddit.post(url, data=data) - @cachedproperty - def filters(self) -> "asyncpraw.models.reddit.subreddit.SubredditFilters": - """Provide an instance of :class:`.SubredditFilters`. + async def delete(self, redditor: asyncpraw.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:: - subreddit = await reddit.subreddit("all") - await subreddit.filters.add("test") + :meth:`~.SubredditFlair.update` to delete the flair of many redditors at + once. """ - return SubredditFilters(self) - - @cachedproperty - def flair(self) -> "asyncpraw.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 - - subreddit = await reddit.subreddit("test") - async for flair in subreddit.flair(): - print(flair) - - Flair templates can be interacted with through this attribute via: + url = API_PATH["deleteflair"].format(subreddit=self.subreddit) + await self.subreddit._reddit.post(url, data={"name": str(redditor)}) - .. code-block:: python + async def delete_all(self) -> list[dict[str, str | bool | dict[str, str]]]: + """Delete all :class:`.Redditor` flair in the :class:`.Subreddit`. - subreddit = await reddit.subreddit("test") - async for template in subreddit.flair.templates: - print(template) + :returns: List of dictionaries indicating the success or failure of each delete. """ - return SubredditFlair(self) - - @cachedproperty - def mod(self) -> "SubredditModeration": - """Provide an instance of :class:`.SubredditModeration`. - - For example, to accept a moderation invite from r/test: - - .. code-block:: python + all_flairs = [x["user"] async for x in self()] + return await self.update(all_flairs) - subreddit = await reddit.subreddit("test") - await subreddit.mod.accept_invite() + @_deprecate_args("redditor", "text", "css_class", "flair_template_id") + async def set( # noqa: A003 + self, + redditor: asyncpraw.models.Redditor | str, + *, + css_class: str = "", + flair_template_id: str | None = None, + text: str = "", + ): + """Set flair for a :class:`.Redditor`. - """ - return SubredditModeration(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 moderator(self) -> "asyncpraw.models.reddit.subreddit.ModeratorRelationship": - """Provide an instance of :class:`.ModeratorRelationship`. + This method can only be used by an authenticated user who is a moderator of the + associated :class:`.Subreddit`. - For example, to add a moderator try: + For example: .. code-block:: python subreddit = await reddit.subreddit("test") - await subreddit.moderator.add("spez") - - To list the moderators along with their permissions try: - - .. code-block:: python - + await subreddit.flair.set("bboe", text="PRAW author", css_class="mods") + template = "6bd28436-1aa7-11e9-9902-0e05ab0fad46" subreddit = await reddit.subreddit("test") - async for moderator in subreddit.moderator: - print(f"{moderator}: {moderator.mod_permissions}") + await subreddit.flair.set("spez", text="Reddit CEO", flair_template_id=template) """ - return ModeratorRelationship(self, "moderator") - - @cachedproperty - def modmail(self) -> "asyncpraw.models.reddit.subreddit.Modmail": - """Provide an instance of :class:`.Modmail`. - - For example, to send a new modmail from r/test to u/spez with the subject - ``"test"`` along with a message body of ``"hello"``: + 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) + await self.subreddit._reddit.post(url, data=data) - .. code-block:: python + @_deprecate_args("flair_list", "text", "css_class") + async def update( + self, + flair_list: Iterator[ + str | asyncpraw.models.Redditor | dict[str, str | asyncpraw.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. - subreddit = await reddit.subreddit("test") - await subreddit.modmail.create(subject="test", body="hello", recipient="spez") + :param flair_list: Each item in this list should be either: - """ - return Modmail(self) + - 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: ``""``). - @cachedproperty - def muted(self) -> "asyncpraw.models.reddit.subreddit.SubredditRelationship": - """Provide an instance of :class:`.SubredditRelationship`. + :returns: List of dictionaries indicating the success or failure of each update. - For example, muted users can be iterated through like so: + For example, to clear the flair text, and set the ``"praw"`` flair css class on + a few users try: .. code-block:: python - subreddit = await reddit.subreddit("test") - async for mute in subreddit.muted(): - print("{mute}: {mute.date}") + await subreddit.flair.update(["bboe", "spez", "spladug"], css_class="praw") """ - return SubredditRelationship(self, "muted") + 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]) - @cachedproperty - def quaran(self) -> "asyncpraw.models.reddit.subreddit.SubredditQuarantine": - """Provide an instance of :class:`.SubredditQuarantine`. + 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(await self.subreddit._reddit.post(url, data=data)) + lines = lines[100:] + return response - This property is named ``quaran`` because ``quarantine`` is a subreddit - attribute returned by Reddit to indicate whether or not a subreddit is - quarantined. - To opt-in into a quarantined subreddit: +class SubredditFlairTemplates: + """Provide functions to interact with a :class:`.Subreddit`'s flair templates.""" - .. code-block:: python + @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" - subreddit = await reddit.subreddit("test") - await subreddit.quaran.opt_in() + async def __aiter__(self): + """Abstract method to return flair templates.""" + raise NotImplementedError - """ - return SubredditQuarantine(self) + def __init__(self, subreddit: asyncpraw.models.Subreddit): + """Initialize a :class:`.SubredditFlairTemplates` instance. - @cachedproperty - def rules(self) -> SubredditRules: - """Provide an instance of :class:`.SubredditRules`. + :param subreddit: The subreddit whose flair templates to work with. - Use this attribute for interacting with a :class:`.Subreddit`'s rules. + .. note:: - For example, to list all the rules for a subreddit: + This class should not be initialized directly. Instead, obtain an instance + via: - .. code-block:: python + .. code-block:: python - subreddit = await reddit.subreddit("test") - async for rule in subreddit.rules: - print(rule) + subreddit = await reddit.subreddit("test") + subreddit.flair.templates - Moderators can also add rules to the subreddit. For example, to make a rule - called ``"No spam"`` in r/test: + or via - .. code-block:: python + .. code-block:: python - subreddit = await reddit.subreddit("test") - await subreddit.rules.mod.add( - short_name="No spam", kind="all", description="Do not spam. Spam bad" - ) + subreddit = await reddit.subreddit("test") + subreddit.flair.link_templates """ - return SubredditRules(self) - - @cachedproperty - def stream(self) -> "asyncpraw.models.reddit.subreddit.SubredditStream": - """Provide an instance of :class:`.SubredditStream`. - - Streams can be used to indefinitely retrieve new comments made to a subreddit, - like: + self.subreddit = subreddit - .. code-block:: python + async 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), + } + await self.subreddit._reddit.post(url, data=data) - subreddit = await reddit.subreddit("test") - async for comment in subreddit.stream.comments(): - print(comment) + async def _clear(self, *, is_link: bool | None = None): + url = API_PATH["flairtemplateclear"].format(subreddit=self.subreddit) + await self.subreddit._reddit.post( + url, data={"flair_type": self.flair_type(is_link)} + ) - Additionally, new submissions can be retrieved via the stream. In the following - example all submissions are fetched via the special r/all: + async def _reorder(self, flair_list: list, *, is_link: bool | None = None): + url = API_PATH["flairtemplatereorder"].format(subreddit=self.subreddit) + await self.subreddit._reddit.patch( + url, + params={ + "flair_type": self.flair_type(is_link), + "subreddit": self.subreddit.display_name, + }, + json=flair_list, + ) + + async def delete(self, template_id: str): + """Remove a flair template provided by ``template_id``. + + For example, to delete the first :class:`.Redditor` flair template listed, try: .. code-block:: python - subreddit = await reddit.subreddit("all") - async for submission in subreddit.stream.submissions(): - print(submission) + async for template_info in subreddit.flair.templates: + await subreddit.flair.templates.delete(template_info["id"]) + break """ - return SubredditStream(self) + url = API_PATH["flairtemplatedelete"].format(subreddit=self.subreddit) + await self.subreddit._reddit.post(url, data={"flair_template_id": template_id}) - @cachedproperty - def stylesheet(self) -> "asyncpraw.models.reddit.subreddit.SubredditStylesheet": - """Provide an instance of :class:`.SubredditStylesheet`. + @_deprecate_args( + "template_id", + "text", + "css_class", + "text_editable", + "background_color", + "text_color", + "mod_only", + "allowable_content", + "max_emojis", + "fetch", + ) + async 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``. - For example, to add the css data ``.test{color:blue}`` to the existing - stylesheet: + :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 Async 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 - subreddit = await reddit.subreddit("test") - stylesheet = await subreddit.stylesheet() - stylesheet.stylesheet += ".test{color:blue}" - await subreddit.stylesheet.update(stylesheet.stylesheet) + async for template_info in subreddit.flair.templates: + await subreddit.flair.templates.update( + template_info["id"], + text=template_info["flair_text"], + text_editable=True, + ) + break """ - return SubredditStylesheet(self) + 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 async for template in 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 + await self.subreddit._reddit.post(url, data=data) - @cachedproperty - def widgets(self) -> "asyncpraw.models.SubredditWidgets": - """Provide an instance of :class:`.SubredditWidgets`. - **Example usage** +class SubredditModeration: + """Provides a set of moderation functions to a :class:`.Subreddit`. - Get all sidebar widgets: + For example, to accept a moderation invite from r/test: - .. code-block:: python + .. code-block:: python - subreddit = await reddit.subreddit("test") - async for widget in subreddit.widgets.sidebar: - print(widget) + subreddit = await reddit.subreddit("test") + await subreddit.mod.accept_invite() - Get ID card widget: + """ + + @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 notes(self) -> asyncpraw.models.SubredditModNotes: + """Provide an instance of :class:`.SubredditModNotes`. + + 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 = await reddit.subreddit("test") - widget = await subreddit.widgets.id_card() - print(widget) + + async for note in subreddit.mod.notes.redditors("spez"): + print(f"{note.label}: {note.note}") """ - return SubredditWidgets(self) + from ..mod_notes import SubredditModNotes + + return SubredditModNotes(self.subreddit._reddit, subreddit=self.subreddit) @cachedproperty - def wiki(self) -> "asyncpraw.models.reddit.subreddit.SubredditWiki": - """Provide an instance of :class:`.SubredditWiki`. + def removal_reasons(self) -> SubredditRemovalReasons: + """Provide an instance of :class:`.SubredditRemovalReasons`. - This attribute can be used to discover all wikipages for a subreddit: + 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 subreddit = await reddit.subreddit("test") - async for wikipage in subreddit.wiki: - print(wikipage) + async for removal_reason in subreddit.mod.removal_reasons: + print(removal_reason) - To fetch the content for a given wikipage try: + A single removal reason can be retrieved via: .. code-block:: python subreddit = await reddit.subreddit("test") - wikipage = await subreddit.wiki.get_page("proof") - print(wikipage.content_md) - - """ - return SubredditWiki(self) - - @property - def _kind(self) -> str: - """Return the class's kind.""" - return self._reddit.config.kinds["subreddit"] + await subreddit.mod.removal_reasons.get_reason("reason_id") - def __init__( - self, - reddit: "asyncpraw.Reddit", - display_name: Optional[str] = None, - _data: Optional[Dict[str, Any]] = None, - ): - """Initialize a :class:`.Subreddit` instance. + .. note:: - :param reddit: An instance of :class:`.Reddit`. - :param display_name: The name of the subreddit. + Attempting to access attributes of a nonexistent removal reason will result + in a :class:`.ClientException`. - .. note:: + """ + return SubredditRemovalReasons(self.subreddit) - This class should not be initialized directly. Instead, obtain an instance - via: + @cachedproperty + def stream(self) -> asyncpraw.models.reddit.subreddit.SubredditModerationStream: + """Provide an instance of :class:`.SubredditModerationStream`. - .. code-block:: python + Streams can be used to indefinitely retrieve Moderator only items from + :class:`.SubredditModeration` made to moderated subreddits, like: - # to lazily load a subreddit instance - await reddit.subreddit("test") + .. code-block:: python - # to fully load a subreddit instance - await reddit.subreddit("test", fetch=True) + subreddit = await reddit.subreddit("mod") + async for log in subreddit.mod.stream.log(): + print("Mod: {}, Subreddit: {}".format(log.mod, log.subreddit)) """ - if (display_name, _data).count(None) != 1: - raise TypeError("Either 'display_name' or '_data' must be provided.") - if display_name: - self.display_name = display_name - super().__init__(reddit, _data=_data) - self._path = API_PATH["subreddit"].format(subreddit=self) - - async def _convert_to_fancypants(self, markdown_text: str) -> dict: - """Convert a Markdown string to a dict for use with the ``richtext_json`` param. + return SubredditModerationStream(self.subreddit) - :param markdown_text: A Markdown string to convert. + def __init__(self, subreddit: asyncpraw.models.Subreddit): + """Initialize a :class:`.SubredditModeration` instance. - :returns: A dict in ``richtext_json`` format. + :param subreddit: The subreddit to moderate. """ - text_data = {"output_mode": "rtjson", "markdown_text": markdown_text} - rte_body = await self._reddit.post(API_PATH["convert_rte_body"], data=text_data) - return rte_body["output"] + self.subreddit = subreddit + self._stream = None - async def _fetch(self): - data = await self._fetch_data() - data = data["data"] - other = type(self)(self._reddit, _data=data) - self.__dict__.update(other.__dict__) - self._fetched = True + async def accept_invite(self): + """Accept an invitation as a moderator of the community.""" + url = API_PATH["accept_mod_invite"].format(subreddit=self.subreddit) + await self.subreddit._reddit.post(url) - def _fetch_info(self): - return "subreddit_about", {"subreddit": self}, None + @_deprecate_args("only") + def edited( + self, *, only: str | None = None, **generator_kwargs: Any + ) -> AsyncIterator[asyncpraw.models.Comment | asyncpraw.models.Submission]: + """Return a :class:`.ListingGenerator` for edited comments and submissions. - async def _parse_xml_response(self, response: ClientResponse): - """Parse the XML from a response and raise any errors found.""" - xml = await 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) - ) - - async def _read_and_post_media(self, media_path, upload_url, upload_data): - with open(media_path, "rb") as media: - upload_data["file"] = media - response = await self._reddit._core._requestor._http.post( - upload_url, data=upload_data - ) - return response - - async def _submit_media( - self, *, data: Dict[Any, Any], timeout: int, websocket_url: Optional[str] = None - ): - """Submit and return an ``image``, ``video``, or ``videogif``. + :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield + only results of that type. - 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`. - """ - if websocket_url is None: - await self._reddit.post(API_PATH["submit"], data=data) - else: - try: - async with self._reddit._core._requestor._http.ws_connect( - websocket_url, timeout=timeout - ) as websocket: - await self._reddit.post(API_PATH["submit"], data=data) - try: - ws_update = await websocket.receive_json() - except ( - BlockingIOError, - socket.error, - TimeoutError, - WebSocketError, - ) as ws_exception: - raise WebSocketException( - "Websocket error. Check your media file. Your post may" - " still have been created.", - ws_exception, - ) - except ( - BlockingIOError, - socket.error, - TimeoutError, - WebSocketError, - ) as ws_exception: - raise WebSocketException( - "Error establishing websocket connection.", - ws_exception, - ) - if ws_update.get("type") == "failed": - raise MediaPostFailed - url = ws_update["payload"]["redirect"] - return await self._reddit.submission(url=url) + To print all items in the edited queue try: - async def _upload_inline_media(self, inline_media: "asyncpraw.models.InlineMedia"): - """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. + subreddit = await reddit.subreddit("mod") + async for item in subreddit.mod.edited(limit=None): + print(item) """ - self._validate_inline_media(inline_media) - inline_media.media_id, _ = await self._upload_media( - media_path=inline_media.path, upload_type="selfpost" + self._handle_only(generator_kwargs=generator_kwargs, only=only) + return ListingGenerator( + self.subreddit._reddit, + API_PATH["about_edited"].format(subreddit=self.subreddit), + **generator_kwargs, ) - return inline_media - async def _upload_media( - self, - *, - expected_mime_prefix: Optional[str] = None, - media_path: str, - upload_type: str = "link", - ): - """Upload media and return its URL and a websocket (Undocumented endpoint). + def inbox( + self, **generator_kwargs: Any + ) -> AsyncIterator[asyncpraw.models.SubredditMessage]: + """Return a :class:`.ListingGenerator` for moderator messages. - :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"``). + .. warning:: - :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. + 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. - """ - if media_path is None: - media_path = join( - dirname(dirname(dirname(__file__))), "images", "PRAW logo.png" - ) + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. - file_name = basename(media_path).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 - ): - raise ClientException( - f"Expected a mimetype starting with {expected_mime_prefix!r} but got" - f" mimetype {mime_type!r} (from file extension {file_extension!r})." - ) - img_data = {"filepath": file_name, "mimetype": mime_type} + .. seealso:: - url = API_PATH["media_asset"] - # until we learn otherwise, assume this request always succeeds - upload_response = await 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"]} + :meth:`.unread` for unread moderator messages. - response = await self._read_and_post_media(media_path, upload_url, upload_data) - if not response.status == 201: - await self._parse_xml_response(response) - try: - response.raise_for_status() - except HttpProcessingError: - raise ServerError(response=response) + To print the last 5 moderator mail messages and their replies, try: - websocket_url = upload_response["asset"]["websocket_url"] + .. code-block:: python - if upload_type == "link": - return f"{upload_url}/{upload_data['key']}", websocket_url - else: - return upload_response["asset"]["asset_id"], websocket_url + subreddit = await reddit.subreddit("mod") + async for message in subreddit.mod.inbox(limit=5): + print("From: {}, Body: {}".format(message.author, message.body)) + for reply in message.replies: + print("From: {}, Body: {}".format(reply.author, reply.body)) - async def post_requirements(self) -> Dict[str, Union[str, int, bool]]: - """Get the post requirements for a subreddit. + """ + 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, + ) - :returns: A dict with the various requirements. + @_deprecate_args("action", "mod") + def log( + self, + *, + action: str | None = None, + mod: asyncpraw.models.Redditor | str | None = None, + **generator_kwargs: Any, + ) -> AsyncIterator[asyncpraw.models.ModAction]: + """Return a :class:`.ListingGenerator` for moderator log entries. - The returned dict contains the following keys: + :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. - - ``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 the moderator and subreddit of the last 5 modlog entries try: .. code-block:: python - subreddit = await reddit.subreddit("test") - post_requirements = await subreddit.post_requirements - print(post_requirements) + subreddit = await reddit.subreddit("mod") + async for log in subreddit.mod.log(limit=5): + print("Mod: {}, Subreddit: {}".format(log.mod, log.subreddit)) """ - return await self._reddit.get( - API_PATH["post_requirements"].format(subreddit=str(self)) + 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, ) - async def random(self) -> Union["asyncpraw.models.Submission", None]: - """Return a random :class:`.Submission`. + @_deprecate_args("only") + def modqueue( + self, *, only: str | None = None, **generator_kwargs: Any + ) -> AsyncIterator[asyncpraw.models.Submission | asyncpraw.models.Comment]: + """Return a :class:`.ListingGenerator` for modqueue items. - 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 all modqueue items try: .. code-block:: python - subreddit = await reddit.subreddit("AskReddit") - submission = await subreddit.random() - print(submission.title) + subreddit = await reddit.subreddit("mod") + async for item in subreddit.mod.modqueue(limit=None): + print(item) """ - url = API_PATH["subreddit_random"].format(subreddit=self) - try: - await self._reddit.get(url, params={"unique": self._reddit._next_unique}) - except Redirect as redirect: - path = redirect.path - try: - submission = self._submission_class( - self._reddit, url=urljoin(self._reddit.config.reddit_url, path) - ) - await submission._fetch() - return submission - except ClientException: - return None + self._handle_only(generator_kwargs=generator_kwargs, only=only) + return ListingGenerator( + self.subreddit._reddit, + API_PATH["about_modqueue"].format(subreddit=self.subreddit), + **generator_kwargs, + ) - @_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, - ) -> AsyncIterator["asyncpraw.models.Submission"]: - """Return a :class:`.ListingGenerator` for items that match ``query``. + @_deprecate_args("only") + def reports( + self, *, only: str | None = None, **generator_kwargs: Any + ) -> AsyncIterator[asyncpraw.models.Submission | asyncpraw.models.Comment]: + """Return a :class:`.ListingGenerator` for reported comments and submissions. - :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"``). + :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield + only results of that type. - For more information on building a search query see: - https://www.reddit.com/wiki/search + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. - For example, to search all subreddits for ``"praw"`` try: + To print the user and mod report reasons in the report queue try: .. code-block:: python - subreddit = await reddit.subreddit("all") - async for submission in subreddit.search("praw"): - print(submission.title) + subreddit = await reddit.subreddit("mod") + async for reported_item in subreddit.mod.reports(): + print("User Reports: {}".format(reported_item.user_reports)) + print("Mod Reports: {}".format(reported_item.mod_reports)) """ - 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_reports"].format(subreddit=self.subreddit), + **generator_kwargs, ) - url = API_PATH["search"].format(subreddit=self) - return ListingGenerator(self._reddit, url, **generator_kwargs) - @_deprecate_args("number") - async def sticky(self, *, number: int = 1) -> "asyncpraw.models.Submission": - """Return a :class:`.Submission` object for a sticky of the subreddit. + async 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) + response = await self.subreddit._reddit.get(url) + return response["data"] - :param number: Specify which sticky to return. 1 appears at the top (default: - ``1``). + @_deprecate_args("only") + def spam( + self, *, only: str | None = None, **generator_kwargs: Any + ) -> AsyncIterator[asyncpraw.models.Submission | asyncpraw.models.Comment]: + """Return a :class:`.ListingGenerator` for spam comments and submissions. - :raises: ``asyncprawcore.NotFound`` if the sticky does not exist. + :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield + only results of that type. - For example, to get the stickied post on r/test: + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. + + To print the items in the spam queue try: .. code-block:: python - subreddit = await reddit.subreddit("test") - await subreddit.sticky() + subreddit = await reddit.subreddit("mod") + async for item in subreddit.mod.spam(): + print(item) """ - url = API_PATH["about_sticky"].format(subreddit=self) - try: - await self._reddit.get(url, params={"num": number}) - except Redirect as redirect: - path = redirect.path - submission = self._submission_class( - self._reddit, url=urljoin(self._reddit.config.reddit_url, path) + self._handle_only(generator_kwargs=generator_kwargs, only=only) + return ListingGenerator( + self.subreddit._reddit, + API_PATH["about_spam"].format(subreddit=self.subreddit), + **generator_kwargs, ) - await submission._fetch() - return submission - - @_deprecate_args( - "title", - "selftext", - "url", - "flair_id", - "flair_text", - "resubmit", - "send_replies", - "nsfw", - "spoiler", - "collection_id", - "discussion_type", - "inline_media", - "draft_id", - ) - async def submit( - self, - title: str, - *, - collection_id: Optional[str] = None, - discussion_type: Optional[str] = None, - draft_id: Optional[str] = None, - flair_id: Optional[str] = None, - flair_text: Optional[str] = None, - inline_media: Optional[Dict[str, "asyncpraw.models.InlineMedia"]] = None, - nsfw: bool = False, - resubmit: bool = True, - selftext: Optional[str] = None, - send_replies: bool = True, - spoiler: bool = False, - url: Optional[str] = None, - ) -> "asyncpraw.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. - :returns: A :class:`.Submission` object for the newly created submission. + def unmoderated( + self, **generator_kwargs: Any + ) -> AsyncIterator[asyncpraw.models.Submission]: + """Return a :class:`.ListingGenerator` for unmoderated submissions. - Either ``selftext`` or ``url`` can be provided, but not both. + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. - For example, to submit a URL to r/test do: + To print the items in the unmoderated queue try: .. code-block:: python - title = "Async PRAW documentation" - url = "https://asyncpraw.readthedocs.io" - subreddit = await reddit.subreddit("test") - await subreddit.submit(title, url=url) + subreddit = await reddit.subreddit("mod") + async for item in subreddit.mod.unmoderated(): + print(item) - For example, to submit a self post with inline media do: + """ + return ListingGenerator( + self.subreddit._reddit, + API_PATH["about_unmoderated"].format(subreddit=self.subreddit), + **generator_kwargs, + ) - .. code-block:: python + def unread( + self, **generator_kwargs: Any + ) -> AsyncIterator[asyncpraw.models.SubredditMessage]: + """Return a :class:`.ListingGenerator` for unread moderator messages. - from asyncpraw.models import InlineGif, InlineImage, InlineVideo + .. warning:: - 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} - subreddit = await reddit.subreddit("test") - await subreddit.submit("title", inline_media=media, selftext=selftext) + 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. - .. note:: + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. - 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: + .. seealso:: - .. code-block:: + :meth:`.inbox` for all messages. - Text with a gif + To print the mail in the unread modmail queue try: - ![gif](u1rchuphryq51 "optional caption") + .. code-block:: python - an image + subreddit = await reddit.subreddit("mod") + async for message in subreddit.mod.unread(): + print("From: {}, To: {}".format(message.author, message.dest)) - ![img](srnr8tshryq51 "optional caption") + """ + 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, + ) - and video + async def update(self, **settings: str | int | bool) -> dict[str, str | int | bool]: + """Update the :class:`.Subreddit`'s settings. - ![video](gmc7rvthryq51 "optional caption") + See https://www.reddit.com/dev/api#POST_api_site_admin for the full list. - inline + :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:: - To submit a post to a subreddit with the ``"news"`` flair, you can get the - flair id like this: + 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:: + .. code-block:: python - choices = [template async for template in subreddit.flair.link_templates.user_selectable()] - template_id = next(x for x in choices if x["flair_text"] == "news")["flair_template_id"] - await subreddit.submit("title", flair_id=template_id, url="https://www.news.com/") + subreddit = await reddit.subreddit("test") + sidebar = await subreddit.wiki.get_page("config/sidebar") + await sidebar.edit(content="new sidebar content") - .. seealso:: + Additional keyword arguments can be provided to handle new settings as Reddit + introduces them. - - :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 + 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. """ - if (bool(selftext) or selftext == "") == bool(url): - raise TypeError("Either 'selftext' or 'url' must be provided.") - - 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, + if not self.subreddit._fetched: + await self.subreddit._fetch() + # 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), - ("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: await self._upload_inline_media(media) - for placeholder, media in inline_media.items() - } - ) - converted = await self._convert_to_fancypants(body) - data.update(richtext_json=dumps(converted)) - else: - data.update(text=selftext) - else: - data.update(kind="link", url=url) + settings = {remap.get(key, key): value for key, value in settings.items()} + settings["sr"] = self.subreddit.fullname + return await self.subreddit._reddit.patch( + API_PATH["update_settings"], json=settings + ) - return await self._reddit.post(API_PATH["submit"], data=data) - @_deprecate_args( - "title", - "images", - "collection_id", - "discussion_type", - "flair_id", - "flair_text", - "nsfw", - "send_replies", - "spoiler", - ) - async def submit_gallery( - self, - title: str, - images: List[Dict[str, str]], - *, - collection_id: Optional[str] = None, - discussion_type: Optional[str] = None, - flair_id: Optional[str] = None, - flair_text: Optional[str] = None, - nsfw: bool = False, - send_replies: bool = True, - spoiler: bool = False, - ): - """Add an image gallery submission to the subreddit. +class SubredditModerationStream: + """Provides moderator streams.""" - :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``). + def __init__(self, subreddit: asyncpraw.models.Subreddit): + """Initialize a :class:`.SubredditModerationStream` instance. - :returns: A :class:`.Submission` object for the newly created submission. + :param subreddit: The moderated subreddit associated with the streams. - :raises: :class:`.ClientException` if ``image_path`` in ``images`` refers to a - file that is not an image. + """ + self.subreddit = subreddit - For example, to submit an image gallery to r/test do: + @_deprecate_args("only") + def edited( + self, *, only: str | None = None, **stream_options: Any + ) -> AsyncGenerator[asyncpraw.models.Comment | asyncpraw.models.Submission, 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. + + Keyword arguments are passed to :func:`.stream_generator`. + + For example, to retrieve all new edited submissions/comments made to all + moderated subreddits, try: .. 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", - }, - ] - subreddit = await reddit.subreddit("test") - await subreddit.submit_gallery(title, images) + subreddit = await reddit.subreddit("mod") + async for item in subreddit.mod.stream.edited(): + print(item) - .. seealso:: + """ + return stream_generator(self.subreddit.mod.edited, only=only, **stream_options) - - :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 + @_deprecate_args("action", "mod") + def log( + self, + *, + action: str | None = None, + mod: str | asyncpraw.models.Redditor | None = None, + **stream_options: Any, + ) -> AsyncGenerator[asyncpraw.models.ModAction, None]: + """Yield moderator log entries as they become available. + + :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. + + For example, to retrieve all new mod actions made to all moderated subreddits, + try: + + .. code-block:: python + + subreddit = await reddit.subreddit("mod") + async for log in subreddit.mod.stream.log(): + print("Mod: {}, Subreddit: {}".format(log.mod, log.subreddit)) """ - 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": ( - await self._upload_media( - expected_mime_prefix="image", - media_path=image["image_path"], - upload_type="gallery", - ) - )[0], - } - ) - response = await self._reddit.request( - json=data, method="POST", path=API_PATH["submit_gallery_post"] + return stream_generator( + self.subreddit.mod.log, + attribute_name="id", + action=action, + mod=mod, + **stream_options, ) - response = response["json"] - if response["errors"]: - raise RedditAPIException(response["errors"]) - else: - return await 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", - ) - async def submit_image( + @_deprecate_args("other_subreddits", "sort", "state") + def modmail_conversations( self, - title: str, - image_path: str, *, - collection_id: Optional[str] = None, - discussion_type: Optional[str] = None, - flair_id: Optional[str] = None, - flair_text: Optional[str] = None, - nsfw: bool = False, - resubmit: bool = True, - send_replies: bool = True, - spoiler: bool = False, - timeout: int = 10, - without_websockets: bool = False, - ): - """Add an image submission to the subreddit. + other_subreddits: list[asyncpraw.models.Subreddit] | None = None, + sort: str | None = None, + state: str | None = None, + **stream_options: Any, + ) -> AsyncGenerator[ModmailConversation, None]: + """Yield new-modmail conversations as they become available. - :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``). + :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. + + Keyword arguments are passed to :func:`.stream_generator`. + + To print new mail in the unread modmail queue try: + + .. code-block:: python + + subreddit = await reddit.subreddit("all") + async for message in subreddit.mod.stream.modmail_conversations(): + print(f"From: {message.owner}, To: {message.participant}") + + """ + if self.subreddit == "mod": + self.subreddit = Subreddit(self.subreddit._reddit, "all") + return stream_generator( + self.subreddit.modmail.conversations, + attribute_name="id", + exclude_before=True, + other_subreddits=other_subreddits, + sort=sort, + state=state, + **stream_options, + ) + + @_deprecate_args("only") + def modqueue( + self, *, only: str | None = None, **stream_options: Any + ) -> AsyncGenerator[asyncpraw.models.Comment | asyncpraw.models.Submission, None]: + r"""Yield :class:`.Comment`\ s and :class:`.Submission`\ s in the modqueue as they become available. + + :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield + only results of that type. + + Keyword arguments are passed to :func:`.stream_generator`. + + To print all new modqueue items try: + + .. code-block:: python + + subreddit = await reddit.subreddit("mod") + async for item in subreddit.mod.stream.modqueue(): + print(item) + + """ + return stream_generator( + self.subreddit.mod.modqueue, only=only, **stream_options + ) + + @_deprecate_args("only") + def reports( + self, *, only: str | None = None, **stream_options: Any + ) -> AsyncGenerator[asyncpraw.models.Comment | asyncpraw.models.Submission, None]: + r"""Yield reported :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. + + Keyword arguments are passed to :func:`.stream_generator`. + + To print new user and mod report reasons in the report queue try: + + .. code-block:: python + + subreddit = await reddit.subreddit("mod") + async for item in subreddit.mod.stream.reports(): + print(item) + + """ + return stream_generator(self.subreddit.mod.reports, only=only, **stream_options) + + @_deprecate_args("only") + def spam( + self, *, only: str | None = None, **stream_options: Any + ) -> AsyncGenerator[asyncpraw.models.Comment | asyncpraw.models.Submission, 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. - :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 ``image_path`` refers to a file that is - not an image. + To print new items in the spam queue 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. + subreddit = await reddit.subreddit("mod") + async for item in subreddit.mod.stream.spam(): + 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.spam, only=only, **stream_options) - For example, to submit an image to r/test do: + def unmoderated( + self, **stream_options: Any + ) -> AsyncGenerator[asyncpraw.models.Submission, None]: + r"""Yield unmoderated :class:`.Submission`\ s as they become available. + + Keyword arguments are passed to :func:`.stream_generator`. + + To print new items in the unmoderated queue try: .. code-block:: python - title = "My favorite picture" - image = "/path/to/image.png" - subreddit = await reddit.subreddit("test") - await subreddit.submit_image(title, image) + subreddit = await reddit.subreddit("mod") + async for item in subreddit.mod.stream.unmoderated(): + print(item) + + """ + return stream_generator(self.subreddit.mod.unmoderated, **stream_options) + + def unread( + self, **stream_options: Any + ) -> AsyncGenerator[asyncpraw.models.SubredditMessage, None]: + """Yield unread old modmail messages as they become available. + + Keyword arguments are passed to :func:`.stream_generator`. .. seealso:: - - :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 + :meth:`.SubredditModeration.inbox` for all messages. + + To print new mail in the unread modmail queue try: + + .. code-block:: python + + subreddit = await reddit.subreddit("mod") + async for message in subreddit.mod.stream.unread(): + print("From: {}, To: {}".format(message.author, message.dest)) """ - 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 + return stream_generator(self.subreddit.mod.unread, **stream_options) - image_url, websocket_url = await self._upload_media( - expected_mime_prefix="image", media_path=image_path - ) - data.update(kind="image", url=image_url) - if without_websockets: - websocket_url = None - return await self._submit_media( - data=data, timeout=timeout, websocket_url=websocket_url - ) - @_deprecate_args( - "title", - "selftext", - "options", - "duration", - "flair_id", - "flair_text", - "resubmit", - "send_replies", - "nsfw", - "spoiler", - "collection_id", - "discussion_type", - ) - async def submit_poll( - self, - title: str, - *, - collection_id: Optional[str] = None, - discussion_type: Optional[str] = None, - duration: int, - flair_id: Optional[str] = None, - flair_text: Optional[str] = None, - nsfw: bool = False, - options: List[str], - resubmit: bool = True, - selftext: str, - send_replies: bool = True, - spoiler: bool = False, - ): - """Add a poll submission to the subreddit. +class SubredditQuarantine: + """Provides subreddit quarantine related methods. - :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``). + To opt-in into a quarantined subreddit: - :returns: A :class:`.Submission` object for the newly created submission. + .. code-block:: python - For example, to submit a poll to r/test do: + subreddit = await reddit.subreddit("test") + await subreddit.quaran.opt_in() - .. code-block:: python + """ - title = "Do you like Async PRAW?" - subreddit = await reddit.subreddit("test") - await subreddit.submit_poll(title, selftext="", options=["Yes", "No"], duration=3) + def __init__(self, subreddit: asyncpraw.models.Subreddit): + """Initialize a :class:`.SubredditQuarantine` instance. - .. seealso:: + :param subreddit: The :class:`.Subreddit` associated with the quarantine. - - :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 + """ + self.subreddit = subreddit + + async def opt_in(self): + """Permit your user access to the quarantined subreddit. + + Usage: + + .. code-block:: python + + subreddit = await reddit.subreddit("QUESTIONABLE") + async for submission in subreddit.hot(): # Raises asyncprawcore.Forbidden + print(submission) + + await subreddit.quaran.opt_in() + async for submission in subreddit.hot(): + print(submission) # Returns Submission """ - 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 = {"sr_name": self.subreddit} + with contextlib.suppress(Redirect): + await self.subreddit._reddit.post(API_PATH["quarantine_opt_in"], data=data) - return await self._reddit.post(API_PATH["submit_poll_post"], json=data) + async def opt_out(self): + """Remove access to the quarantined 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", - ) - async def submit_video( - self, - title: str, - video_path: str, - *, - collection_id: Optional[str] = None, - discussion_type: Optional[str] = None, - flair_id: Optional[str] = None, - flair_text: Optional[str] = None, - nsfw: bool = False, - resubmit: bool = True, - send_replies: bool = True, - spoiler: bool = False, - thumbnail_path: Optional[str] = None, - timeout: int = 10, - videogif: bool = False, - without_websockets: bool = False, - ): - """Add a video or videogif submission to the subreddit. + Usage: - :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``). + .. code-block:: python - :returns: A :class:`.Submission` object for the newly created submission, unless - ``without_websockets`` is ``True``. + subreddit = await reddit.subreddit("QUESTIONABLE") + async for submission in subreddit.hot(): + print(submission) # Returns Submission - :raises: :class:`.ClientException` if ``video_path`` refers to a file that is - not a video. + await subreddit.quaran.opt_out() + async for submission in subreddit.hot(): # Raises asyncprawcore.Forbidden + print(submission) - .. note:: + """ + data = {"sr_name": self.subreddit} + with contextlib.suppress(Redirect): + await self.subreddit._reddit.post(API_PATH["quarantine_opt_out"], data=data) - 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. +class SubredditRelationship: + """Represents a relationship between a :class:`.Redditor` and :class:`.Subreddit`. - For example, to submit a video to r/test do: + Instances of this class can be iterated through in order to discover the Redditors + that make up the relationship. - .. code-block:: python + For example, banned users of a subreddit can be iterated through like so: - title = "My favorite movie" - video = "/path/to/video.mp4" - subreddit = await reddit.subreddit("test") - await subreddit.submit_video(title, video) + .. code-block:: python - .. seealso:: + subreddit = await reddit.subreddit("test") + async for ban in subreddit.banned(): + print("{ban}: {ban.note}") - - :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 + """ - """ - 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 + def __call__( + self, + redditor: str | asyncpraw.models.Redditor | None = None, + **generator_kwargs: Any, + ) -> AsyncIterator[asyncpraw.models.Redditor]: + r"""Return a :class:`.ListingGenerator` for :class:`.Redditor`\ s in the relationship. - video_url, websocket_url = await self._upload_media( - expected_mime_prefix="video", media_path=video_path - ) - video_poster_url, _ = await self._upload_media(media_path=thumbnail_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=video_poster_url, - ) - if without_websockets: - websocket_url = None - return await self._submit_media( - data=data, timeout=timeout, websocket_url=websocket_url + :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``). + + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. + + """ + 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) - @_deprecate_args("other_subreddits") - async def subscribe( - self, *, other_subreddits: Optional[List["asyncpraw.models.Subreddit"]] = None + def __init__(self, subreddit: asyncpraw.models.Subreddit, relationship: str): + """Initialize a :class:`.SubredditRelationship` instance. + + :param subreddit: The :class:`.Subreddit` for the relationship. + :param relationship: The name of the relationship. + + """ + self.relationship = relationship + self.subreddit = subreddit + + async def add( + self, redditor: str | asyncpraw.models.Redditor, **other_settings: Any ): - """Subscribe to the subreddit. + """Add ``redditor`` to this relationship. - :param other_subreddits: When provided, also subscribe to the provided list of - subreddits. + :param redditor: A redditor name or :class:`.Redditor` instance. - For example, to subscribe to r/test: + """ + data = {"name": str(redditor), "type": self.relationship} + data.update(other_settings) + url = API_PATH["friend"].format(subreddit=self.subreddit) + await self.subreddit._reddit.post(url, data=data) - .. code-block:: python + async def remove(self, redditor: str | asyncpraw.models.Redditor): + """Remove ``redditor`` from this relationship. - subreddit = await reddit.subreddit("test") - await subreddit.subscribe() + :param redditor: A redditor name or :class:`.Redditor` instance. """ - data = { - "action": "sub", - "skip_inital_defaults": True, - "sr_name": self._subreddit_list( - other_subreddits=other_subreddits, subreddit=self - ), - } - await self._reddit.post(API_PATH["subscribe"], data=data) + data = {"name": str(redditor), "type": self.relationship} + url = API_PATH["unfriend"].format(subreddit=self.subreddit) + await self.subreddit._reddit.post(url, data=data) - async def traffic(self) -> Dict[str, List[List[int]]]: - """Return a dictionary of the :class:`.Subreddit`'s traffic statistics. - :raises: ``asyncprawcore.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. +class SubredditStream: + """Provides submission and comment streams.""" - 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. + def __init__(self, subreddit: asyncpraw.models.Subreddit): + """Initialize a :class:`.SubredditStream` instance. + + :param subreddit: The subreddit associated with the streams. + + """ + self.subreddit = subreddit + + def comments( + self, **stream_options: Any + ) -> AsyncGenerator[asyncpraw.models.Comment, None]: + """Yield new comments as they become available. + + Comments are yielded oldest first. Up to 100 historical comments will initially + be returned. + + Keyword arguments are passed to :func:`.stream_generator`. .. note:: - The ``hour`` key does not contain subscribers, and therefore each sub-list - contains three values. + While Async PRAW tries to catch all new comments, some high-volume streams, + especially the r/all stream, may drop some comments. - For example, to get the traffic stats for r/test: + For example, to retrieve all new comments made to r/test, try: .. code-block:: python subreddit = await reddit.subreddit("test") - stats = await subreddit.traffic() + async for comment in subreddit.stream.comments(): + print(comment) + + To only retrieve new submissions starting when the stream is created, pass + ``skip_existing=True``: + + .. code-block:: python + + subreddit = await reddit.subreddit("test") + async for comment in subreddit.stream.comments(skip_existing=True): + print(comment) """ - return await self._reddit.get(API_PATH["about_traffic"].format(subreddit=self)) + return stream_generator(self.subreddit.comments, **stream_options) - @_deprecate_args("other_subreddits") - async def unsubscribe( - self, *, other_subreddits: Optional[List["asyncpraw.models.Subreddit"]] = None - ): - """Unsubscribe from the subreddit. + def submissions( + self, **stream_options: Any + ) -> AsyncGenerator[asyncpraw.models.Submission, None]: + r"""Yield new :class:`.Submission`\ s as they become available. - :param other_subreddits: When provided, also unsubscribe from the provided list - of subreddits. + Submissions are yielded oldest first. Up to 100 historical submissions will + initially be returned. - To unsubscribe from r/test: + Keyword arguments are passed to :func:`.stream_generator`. - .. code-block:: python + .. note:: - subreddit = await reddit.subreddit("test") - await subreddit.unsubscribe() + While Async PRAW tries to catch all new submissions, some high-volume + streams, especially the r/all stream, may drop some submissions. - """ - data = { - "action": "unsub", - "sr_name": self._subreddit_list( - other_subreddits=other_subreddits, subreddit=self - ), - } - await self._reddit.post(API_PATH["subscribe"], data=data) + For example, to retrieve all new submissions made to all of Reddit, try: + + .. code-block:: python + subreddit = await reddit.subreddit("all") + async for submission in subreddit.stream.submissions(): + print(submission) -WidgetEncoder._subreddit_class = Subreddit + """ + return stream_generator(self.subreddit.new, **stream_options) -class SubredditFilters: - """Provide functions to interact with the special :class:`.Subreddit`'s filters. +class SubredditStylesheet: + """Provides a set of stylesheet functions to a :class:`.Subreddit`. - Members of this class should be utilized via :meth:`.Subreddit.filters`. For - example, to add a filter, run: + For example, to add the css data ``.test{color:blue}`` to the existing stylesheet: .. code-block:: python - subreddit = await reddit.subreddit("all") - await subreddit.filters.add("test") + subreddit = await reddit.subreddit("test") + stylesheet = await subreddit.stylesheet() + stylesheet.stylesheet.stylesheet += ".test{color:blue}" + await subreddit.stylesheet.update(stylesheet.stylesheet) """ - async def __aiter__( - self, - ) -> AsyncGenerator["asyncpraw.models.Subreddit", None]: - """Iterate through the special :class:`.Subreddit`'s filters. + async def __call__(self) -> asyncpraw.models.Stylesheet: + """Return the :class:`.Subreddit`'s stylesheet. - This method should be invoked as: + To be used as: .. code-block:: python subreddit = await reddit.subreddit("test") - async for subreddit in subreddit.filters: - ... + stylesheet = await subreddit.stylesheet() """ - user = await self.subreddit._reddit.user.me() - url = API_PATH["subreddit_filter_list"].format( - special=self.subreddit, user=user - ) - params = {"unique": self.subreddit._reddit._next_unique} - response_data = await self.subreddit._reddit.get(url, params=params) - for subreddit in response_data.subreddits: - yield subreddit + url = API_PATH["about_stylesheet"].format(subreddit=self.subreddit) + return await self.subreddit._reddit.get(url) - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): - """Initialize a :class:`.SubredditFilters` instance. + def __init__(self, subreddit: asyncpraw.models.Subreddit): + """Initialize a :class:`.SubredditStylesheet` instance. - :param subreddit: The special subreddit whose filters to work with. + :param subreddit: The :class:`.Subreddit` associated with the stylesheet. - As of this writing filters can only be used with the special subreddits ``all`` - and ``mod``. + An instance of this class is provided as: + + .. code-block:: python + + subreddit = await reddit.subreddit("test") + subreddit.stylesheet """ self.subreddit = subreddit - async def add(self, subreddit: Union["asyncpraw.models.Subreddit", str]): - """Add ``subreddit`` to the list of filtered subreddits. + async def _update_structured_styles(self, style_data: dict[str, str | Any]): + url = API_PATH["structured_styles"].format(subreddit=self.subreddit) + await self.subreddit._reddit.patch(url, data=style_data) - :param subreddit: The subreddit to add to the filter list. + async def _upload_image( + self, *, data: dict[str, str | Any], image_path: str + ) -> dict[str, Any]: + async with aiofiles.open(image_path, "rb") as image: + header = await image.read(len(JPEG_HEADER)) + await image.seek(0) + data["img_type"] = "jpg" if header == JPEG_HEADER else "png" + url = API_PATH["upload_image"].format(subreddit=self.subreddit) + response = await 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 Async PRAW." + raise RedditAPIException([[error_type, error_value, None]]) + return response - Items from subreddits added to the filtered list will no longer be included when - obtaining listings for r/all. + async 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) - Alternatively, you can filter a subreddit temporarily from a special listing in - a manner like so: + response = await self.subreddit._reddit.post(url, data=data) + upload_lease = response["s3UploadLease"] + upload_data = {item["name"]: item["value"] for item in upload_lease["fields"]} + upload_url = f"https:{upload_lease['action']}" - .. code-block:: python + async with aiofiles.open(image_path, "rb") as image: + upload_data["file"] = image + response = await self.subreddit._reddit._core._requestor._http.post( + upload_url, data=upload_data + ) + response.raise_for_status() - await reddit.subreddit("all-redditdev-learnpython") + return f"{upload_url}/{upload_data['key']}" - :raises: ``asyncprawcore.NotFound`` when calling on a non-special subreddit. + async def delete_banner(self): + """Remove the current :class:`.Subreddit` (redesign) banner image. - """ - user = await self.subreddit._reddit.user.me() - url = API_PATH["subreddit_filter"].format( - special=self.subreddit, - user=user, - subreddit=subreddit, - ) - await self.subreddit._reddit.put( - url, data={"model": dumps({"name": str(subreddit)})} - ) + Succeeds even if there is no banner image. - async def remove(self, subreddit: Union["asyncpraw.models.Subreddit", str]): - """Remove ``subreddit`` from the list of filtered subreddits. + For example: - :param subreddit: The subreddit to remove from the filter list. + .. code-block:: python - :raises: ``asyncprawcore.NotFound`` when calling on a non-special subreddit. + subreddit = await reddit.subreddit("test") + await subreddit.stylesheet.delete_banner() """ - user = await self.subreddit._reddit.user.me() - url = API_PATH["subreddit_filter"].format( - special=self.subreddit, - user=user, - subreddit=subreddit, - ) - await self.subreddit._reddit.delete(url) - + data = {"bannerBackgroundImage": ""} + await self._update_structured_styles(data) -class SubredditFlair: - """Provide a set of functions to interact with a :class:`.Subreddit`'s flair.""" + async def delete_banner_additional_image(self): + """Remove the current :class:`.Subreddit` (redesign) banner additional image. - @cachedproperty - def link_templates( - self, - ) -> "asyncpraw.models.reddit.subreddit.SubredditLinkFlairTemplates": - """Provide an instance of :class:`.SubredditLinkFlairTemplates`. + 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 link flair - templates. For example, to list all the link flair templates for a subreddit - which you have the ``flair`` moderator permission on try: + For example: .. code-block:: python subreddit = await reddit.subreddit("test") - async for template in subreddit.flair.link_templates: - print(template) + await subreddit.stylesheet.delete_banner_additional_image() """ - return SubredditLinkFlairTemplates(self.subreddit) + data = {"bannerPositionedImage": "", "secondaryBannerPositionedImage": ""} + await self._update_structured_styles(data) - @cachedproperty - def templates( - self, - ) -> "asyncpraw.models.reddit.subreddit.SubredditRedditorFlairTemplates": - """Provide an instance of :class:`.SubredditRedditorFlairTemplates`. + async def delete_banner_hover_image(self): + """Remove the current :class:`.Subreddit` (redesign) banner hover image. - 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: + Succeeds even if there is no hover image. + + For example: .. code-block:: python subreddit = await reddit.subreddit("test") - async for template in subreddit.flair.templates: - print(template) + await subreddit.stylesheet.delete_banner_hover_image() """ - return SubredditRedditorFlairTemplates(self.subreddit) - - def __call__( - self, - redditor: Optional[Union["asyncpraw.models.Redditor", str]] = None, - **generator_kwargs: Any, - ) -> AsyncIterator["asyncpraw.models.Redditor"]: - """Return a :class:`.ListingGenerator` for Redditors and their flairs. + data = {"secondaryBannerPositionedImage": ""} + await self._update_structured_styles(data) - :param redditor: When provided, yield at most a single :class:`.Redditor` - instance (default: ``None``). + async def delete_header(self): + """Remove the current :class:`.Subreddit` header image. - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + Succeeds even if there is no header image. - Usage: + For example: .. code-block:: python subreddit = await reddit.subreddit("test") - async for flair in subreddit.flair(limit=None): - print(flair) + await subreddit.stylesheet.delete_header() """ - 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) - - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): - """Initialize a :class:`.SubredditFlair` instance. - - :param subreddit: The subreddit whose flair to work with. + url = API_PATH["delete_sr_header"].format(subreddit=self.subreddit) + await self.subreddit._reddit.post(url) - """ - self.subreddit = subreddit + async def delete_image(self, name: str): + """Remove the named image from the :class:`.Subreddit`. - @_deprecate_args("position", "self_assign", "link_position", "link_self_assign") - async 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. + Succeeds even if the named image does not exist. - :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``). + For example: .. 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. + subreddit = await reddit.subreddit("test") + await subreddit.stylesheet.delete_image("smile") """ - 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) - await self.subreddit._reddit.post(url, data=data) - - async def delete(self, redditor: Union["asyncpraw.models.Redditor", str]): - """Delete flair for a :class:`.Redditor`. - - :param redditor: A redditor name or :class:`.Redditor` instance. + url = API_PATH["delete_sr_image"].format(subreddit=self.subreddit) + await self.subreddit._reddit.post(url, data={"img_name": name}) - .. seealso:: + async def delete_mobile_banner(self): + """Remove the current :class:`.Subreddit` (redesign) mobile banner. - :meth:`~.SubredditFlair.update` to delete the flair of many redditors at - once. + Succeeds even if there is no mobile banner. - """ - url = API_PATH["deleteflair"].format(subreddit=self.subreddit) - await self.subreddit._reddit.post(url, data={"name": str(redditor)}) + For example: - async def delete_all(self) -> List[Dict[str, Union[str, bool, Dict[str, str]]]]: - """Delete all :class:`.Redditor` flair in the :class:`.Subreddit`. + .. code-block:: python - :returns: List of dictionaries indicating the success or failure of each delete. + subreddit = await reddit.subreddit("test") + await subreddit.stylesheet.delete_banner_hover_image() """ - all_flairs = [x["user"] async for x in self()] - return await self.update(all_flairs) - - @_deprecate_args("redditor", "text", "css_class", "flair_template_id") - async def set( - self, - redditor: Union["asyncpraw.models.Redditor", str], - *, - css_class: str = "", - flair_template_id: Optional[str] = None, - text: str = "", - ): - """Set flair for a :class:`.Redditor`. + data = {"mobileBannerImage": ""} + await self._update_structured_styles(data) - :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``. + async def delete_mobile_header(self): + """Remove the current :class:`.Subreddit` mobile header. - This method can only be used by an authenticated user who is a moderator of the - associated :class:`.Subreddit`. + Succeeds even if there is no mobile header. For example: .. code-block:: python subreddit = await reddit.subreddit("test") - await subreddit.flair.set("bboe", text="PRAW author", css_class="mods") - template = "6bd28436-1aa7-11e9-9902-0e05ab0fad46" - subreddit = await reddit.subreddit("test") - await subreddit.flair.set("spez", text="Reddit CEO", flair_template_id=template) + await subreddit.stylesheet.delete_mobile_header() """ - if css_class and flair_template_id is not None: - raise TypeError( - "Parameter 'css_class' cannot be used in conjunction with" - " 'flair_template_id'." - ) - 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) - await self.subreddit._reddit.post(url, data=data) - - @_deprecate_args("flair_list", "text", "css_class") - async def update( - self, - flair_list: Iterator[ - Union[ - str, - "asyncpraw.models.Redditor", - Dict[str, Union[str, "asyncpraw.models.Redditor"]], - ] - ], - *, - text: str = "", - css_class: str = "", - ) -> List[Dict[str, Union[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: + url = API_PATH["delete_sr_header"].format(subreddit=self.subreddit) + await self.subreddit._reddit.post(url) - - 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: ``""``). + async def delete_mobile_icon(self): + """Remove the current :class:`.Subreddit` mobile icon. - :returns: List of dictionaries indicating the success or failure of each update. + Succeeds even if there is no mobile icon. - For example, to clear the flair text, and set the ``"praw"`` flair css class on - a few users try: + For example: .. code-block:: python - await subreddit.flair.update(["bboe", "spez", "spladug"], css_class="praw") + subreddit = await reddit.subreddit("test") + await subreddit.stylesheet.delete_mobile_icon() """ - 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]) + url = API_PATH["delete_sr_icon"].format(subreddit=self.subreddit) + await self.subreddit._reddit.post(url) - 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(await self.subreddit._reddit.post(url, data=data)) - lines = lines[100:] - return response + @_deprecate_args("stylesheet", "reason") + async def update(self, stylesheet: str, *, reason: str | None = None): + """Update the :class:`.Subreddit`'s stylesheet. + :param stylesheet: The CSS for the new stylesheet. + :param reason: The reason for updating the stylesheet. -class SubredditFlairTemplates: - """Provide functions to interact with a :class:`.Subreddit`'s flair templates.""" + For example: - @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 - async def __aiter__(self): - """Abstract method to return flair templates.""" - raise NotImplementedError() + subreddit = await reddit.subreddit("test") + await subreddit.stylesheet.update("p { color: green; }", reason="color text green") - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): - """Initialize a SubredditFlairTemplates instance. + """ + data = {"op": "save", "reason": reason, "stylesheet_contents": stylesheet} + url = API_PATH["subreddit_stylesheet"].format(subreddit=self.subreddit) + await self.subreddit._reddit.post(url, data=data) - :param subreddit: The subreddit whose flair templates to work with. + @_deprecate_args("name", "image_path") + async def upload(self, *, image_path: str, name: str) -> dict[str, str]: + """Upload an image to the :class:`.Subreddit`. - .. note:: + :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. - This class should not be initialized directly. Instead, obtain an instance - via: + :returns: A dictionary containing a link to the uploaded image under the key + ``img_src``. - .. code-block:: python + :raises: ``asyncprawcore.TooLarge`` if the overall request body is too large. - subreddit = await reddit.subreddit("test") - subreddit.flair.templates + :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. - or via + For example: - .. code-block:: python + .. code-block:: python - subreddit = await reddit.subreddit("test") - subreddit.flair.link_templates + subreddit = await reddit.subreddit("test") + await subreddit.stylesheet.upload(name="smile", image_path="img.png") """ - self.subreddit = subreddit + return await self._upload_image( + data={"name": name, "upload_type": "img"}, image_path=image_path + ) - async def _add( - self, - *, - allowable_content: Optional[str] = None, - background_color: Optional[str] = None, - css_class: str = "", - is_link: Optional[bool] = None, - max_emojis: Optional[int] = None, - mod_only: Optional[bool] = None, - text: str, - text_color: Optional[str] = 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), - } - await self.subreddit._reddit.post(url, data=data) + async def upload_banner(self, image_path: str): + """Upload an image for the :class:`.Subreddit`'s (redesign) banner image. - async def _clear(self, *, is_link: Optional[bool] = None): - url = API_PATH["flairtemplateclear"].format(subreddit=self.subreddit) - await self.subreddit._reddit.post( - url, data={"flair_type": self.flair_type(is_link)} - ) + :param image_path: A path to a jpeg or png image. - async def delete(self, template_id: str): - """Remove a flair template provided by ``template_id``. + :raises: ``asyncprawcore.TooLarge`` if the overall request body is too large. - For example, to delete the first :class:`.Redditor` flair template listed, 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 - async for template_info in subreddit.flair.templates: - await subreddit.flair.templates.delete(template_info["id"]) - break + subreddit = await reddit.subreddit("test") + await subreddit.stylesheet.upload_banner("banner.png") """ - url = API_PATH["flairtemplatedelete"].format(subreddit=self.subreddit) - await self.subreddit._reddit.post(url, data={"flair_template_id": template_id}) - - async def _reorder(self, flair_list: list, *, is_link: Optional[bool] = None): - url = API_PATH["flairtemplatereorder"].format(subreddit=self.subreddit) - await self.subreddit._reddit.patch( - url, - params={ - "flair_type": self.flair_type(is_link), - "subreddit": self.subreddit.display_name, - }, - json=flair_list, + image_type = "bannerBackgroundImage" + image_url = await self._upload_style_asset( + image_path=image_path, image_type=image_type ) + await self._update_structured_styles({image_type: image_url}) - @_deprecate_args( - "template_id", - "text", - "css_class", - "text_editable", - "background_color", - "text_color", - "mod_only", - "allowable_content", - "max_emojis", - "fetch", - ) - async def update( + @_deprecate_args("image_path", "align") + async def upload_banner_additional_image( self, - template_id: str, + image_path: str, *, - allowable_content: Optional[str] = None, - background_color: Optional[str] = None, - css_class: Optional[str] = None, - fetch: bool = True, - max_emojis: Optional[int] = None, - mod_only: Optional[bool] = None, - text: Optional[str] = None, - text_color: Optional[str] = None, - text_editable: Optional[bool] = None, + align: str | None = None, ): - """Update the flair template provided by ``template_id``. + """Upload an image for the :class:`.Subreddit`'s (redesign) additional image. - :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 Async 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``). + :param image_path: A path to a jpeg or png image. + :param align: Either ``"left"``, ``"centered"``, or ``"right"``. (default: + ``"left"``). - .. warning:: + :raises: ``asyncprawcore.TooLarge`` if the overall request body is too large. - If parameter ``fetch`` is set to ``False``, all parameters not provided will - be reset to their default (``None`` or ``False``) values. + :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, to make a user flair template text editable, try: + For example: .. code-block:: python - async for template_info in subreddit.flair.templates: - await subreddit.flair.templates.update( - template_info["id"], - text=template_info["flair_text"], - text_editable=True, - ) - break + subreddit = await reddit.subreddit("test") + await subreddit.stylesheet.upload_banner_additional_image("banner.png") """ - 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 async for template in self if template["id"] == template_id - ] - if len(_existing_data) != 1: - raise InvalidFlairTemplateID(template_id) - else: - existing_data = _existing_data[0] - for key, value in existing_data.items(): - if data.get(key) is None: - data[key] = value - await self.subreddit._reddit.post(url, data=data) - - -class SubredditModeration: - """Provides a set of moderation functions to a :class:`.Subreddit`. - - For example, to accept a moderation invite from r/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 - .. code-block:: python + image_type = "bannerPositionedImage" + image_url = await self._upload_style_asset( + image_path=image_path, image_type=image_type + ) + style_data = {image_type: image_url} + if alignment: + style_data.update(alignment) + await self._update_structured_styles(style_data) - subreddit = await reddit.subreddit("test") - await subreddit.mod.accept_invite() + async 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. - @staticmethod - def _handle_only(*, generator_kwargs: Dict[str, Any], only: Optional[str]): - if only is not None: - if only == "submissions": - only = "links" - RedditBase._safely_add_arguments( - arguments=generator_kwargs, key="params", only=only - ) + Fails if the :class:`.Subreddit` does not have an additional image defined. - @cachedproperty - def notes(self) -> "asyncpraw.models.SubredditModNotes": - """Provide an instance of :class:`.SubredditModNotes`. + :raises: ``asyncprawcore.TooLarge`` if the overall request body is too large. - This provides an interface for managing moderator notes for this 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. - For example, all the notes for u/spez in r/test can be iterated through like so: + For example: .. code-block:: python subreddit = await reddit.subreddit("test") - - async for note in subreddit.mod.notes.redditors("spez"): - print(f"{note.label}: {note.note}") + await subreddit.stylesheet.upload_banner_hover_image("banner.png") """ - from ..mod_notes import SubredditModNotes + image_type = "secondaryBannerPositionedImage" + image_url = await self._upload_style_asset( + image_path=image_path, image_type=image_type + ) + await self._update_structured_styles({image_type: image_url}) - return SubredditModNotes(self.subreddit._reddit, subreddit=self.subreddit) + async def upload_header(self, image_path: str) -> dict[str, str]: + """Upload an image to be used as the :class:`.Subreddit`'s header image. - @cachedproperty - def removal_reasons(self) -> SubredditRemovalReasons: - """Provide an instance of :class:`.SubredditRemovalReasons`. + :param image_path: A path to a jpeg or png 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: + :returns: A dictionary containing a link to the uploaded image under the key + ``img_src``. - .. code-block:: python + :raises: ``asyncprawcore.TooLarge`` if the overall request body is too large. - subreddit = await reddit.subreddit("test") - async for removal_reason in subreddit.mod.removal_reasons: - print(removal_reason) + :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. - A single removal reason can be retrieved via: + For example: .. code-block:: python subreddit = await reddit.subreddit("test") - await subreddit.mod.removal_reasons.get_reason("reason_id") - - .. note:: - - Attempting to access attributes of a nonexistent removal reason will result - in a :class:`.ClientException`. + await subreddit.stylesheet.upload_header("header.png") """ - return SubredditRemovalReasons(self.subreddit) + return await self._upload_image( + data={"upload_type": "header"}, image_path=image_path + ) - @cachedproperty - def stream(self) -> "asyncpraw.models.reddit.subreddit.SubredditModerationStream": - """Provide an instance of :class:`.SubredditModerationStream`. + async def upload_mobile_banner(self, image_path: str): + """Upload an image for the :class:`.Subreddit`'s (redesign) mobile banner. - Streams can be used to indefinitely retrieve Moderator only items from - :class:`.SubredditModeration` made to moderated subreddits, like: + :param image_path: A path to a JPEG or PNG image. + + For example: .. code-block:: python - subreddit = await reddit.subreddit("mod") - async for log in subreddit.mod.stream.log(): - print("Mod: {}, Subreddit: {}".format(log.mod, log.subreddit)) + subreddit = await reddit.subreddit("test") + await subreddit.stylesheet.upload_mobile_banner("banner.png") - """ - return SubredditModerationStream(self.subreddit) + Fails if the :class:`.Subreddit` does not have an additional image defined. - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): - """Initialize a :class:`.SubredditModeration` instance. + :raises: ``prawcore.TooLarge`` if the overall request body is too large. - :param subreddit: The subreddit to moderate. + :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. """ - self.subreddit = subreddit - self._stream = None + image_type = "mobileBannerImage" + image_url = await self._upload_style_asset( + image_path=image_path, image_type=image_type + ) + await self._update_structured_styles({image_type: image_url}) - async def accept_invite(self): - """Accept an invitation as a moderator of the community.""" - url = API_PATH["accept_mod_invite"].format(subreddit=self.subreddit) - await self.subreddit._reddit.post(url) + async def upload_mobile_header(self, image_path: str) -> dict[str, str]: + """Upload an image to be used as the :class:`.Subreddit`'s mobile header. - @_deprecate_args("only") - def edited( - self, *, only: Optional[str] = None, **generator_kwargs: Any - ) -> AsyncIterator[ - Union["asyncpraw.models.Comment", "asyncpraw.models.Submission"] - ]: - """Return a :class:`.ListingGenerator` for edited comments and submissions. + :param image_path: A path to a jpeg or png image. - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + :returns: A dictionary containing a link to the uploaded image under the key + ``img_src``. - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + :raises: ``asyncprawcore.TooLarge`` if the overall request body is too large. - To print all items in the edited 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 - subreddit = await reddit.subreddit("mod") - async for item in subreddit.mod.edited(limit=None): - print(item) + subreddit = await reddit.subreddit("test") + await subreddit.stylesheet.upload_mobile_header("header.png") """ - self._handle_only(generator_kwargs=generator_kwargs, only=only) - return ListingGenerator( - self.subreddit._reddit, - API_PATH["about_edited"].format(subreddit=self.subreddit), - **generator_kwargs, + return await self._upload_image( + data={"upload_type": "banner"}, image_path=image_path ) - def inbox( - self, **generator_kwargs: Any - ) -> AsyncIterator["asyncpraw.models.SubredditMessage"]: - """Return a :class:`.ListingGenerator` for moderator messages. - - .. warning:: + async def upload_mobile_icon(self, image_path: str) -> dict[str, str]: + """Upload an image to be used as the :class:`.Subreddit`'s mobile icon. - 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: ``asyncprawcore.TooLarge`` if the overall request body is too large. - :meth:`.unread` for unread moderator 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 last 5 moderator mail messages and their replies, try: + For example: .. code-block:: python - subreddit = await reddit.subreddit("mod") - async for message in subreddit.mod.inbox(limit=5): - print("From: {}, Body: {}".format(message.author, message.body)) - for reply in message.replies: - print("From: {}, Body: {}".format(reply.author, reply.body)) + subreddit = await reddit.subreddit("test") + await subreddit.stylesheet.upload_mobile_icon("icon.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_messages"].format(subreddit=self.subreddit), - **generator_kwargs, + return await self._upload_image( + data={"upload_type": "icon"}, image_path=image_path ) - @_deprecate_args("action", "mod") - def log( - self, - *, - action: Optional[str] = None, - mod: Optional[Union["asyncpraw.models.Redditor", str]] = None, - **generator_kwargs: Any, - ) -> AsyncIterator["asyncpraw.models.ModAction"]: - """Return a :class:`.ListingGenerator` for moderator log entries. - :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. +class SubredditWiki: + """Provides a set of wiki functions to a :class:`.Subreddit`.""" - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + async def __aiter__(self) -> AsyncGenerator[WikiPage, None]: + """Iterate through the pages of the wiki. - To print the moderator and subreddit of the last 5 modlog entries try: + This method is to be used to discover all wikipages for a subreddit: .. code-block:: python - subreddit = await reddit.subreddit("mod") - async for log in subreddit.mod.log(limit=5): - print("Mod: {}, Subreddit: {}".format(log.mod, log.subreddit)) + subreddit = await reddit.subreddit("test") + async for wikipage in subreddit.wiki: + print(wikipage) """ - 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, + response = await 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("only") - def modqueue( - self, *, only: Optional[str] = None, **generator_kwargs: Any - ) -> AsyncIterator[ - Union["asyncpraw.models.Submission", "asyncpraw.models.Comment"] - ]: - """Return a :class:`.ListingGenerator` for modqueue items. - - :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 all modqueue items try: - - .. code-block:: python + def __init__(self, subreddit: asyncpraw.models.Subreddit): + """Initialize a :class:`.SubredditWiki` instance. - subreddit = await reddit.subreddit("mod") - async for item in subreddit.mod.modqueue(limit=None): - print(item) + :param subreddit: The subreddit whose wiki to work with. """ - self._handle_only(generator_kwargs=generator_kwargs, only=only) - return ListingGenerator( - self.subreddit._reddit, - API_PATH["about_modqueue"].format(subreddit=self.subreddit), - **generator_kwargs, - ) - - @_deprecate_args("only") - def reports( - self, *, only: Optional[str] = None, **generator_kwargs: Any - ) -> AsyncIterator[ - Union["asyncpraw.models.Submission", "asyncpraw.models.Comment"] - ]: - """Return a :class:`.ListingGenerator` for reported comments and submissions. + self.banned = SubredditRelationship(subreddit, "wikibanned") + self.contributor = SubredditRelationship(subreddit, "wikicontributor") + self.subreddit = subreddit - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + @_deprecate_args("name", "content", "reason") + async def create( + self, + *, + content: str, + name: str, + reason: str | None = None, + **other_settings: Any, + ) -> WikiPage: + """Create a new :class:`.WikiPage`. - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + :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. - To print the user and mod report reasons in the report queue try: + To create the wiki page ``"praw_test"`` in r/test try: .. code-block:: python - subreddit = await reddit.subreddit("mod") - async for reported_item in subreddit.mod.reports(): - print("User Reports: {}".format(reported_item.user_reports)) - print("Mod Reports: {}".format(reported_item.mod_reports)) + subreddit = await reddit.subreddit("test") + await subreddit.wiki.create( + name="praw_test", content="wiki body text", reason="Async PRAW Test Creation" + ) """ - self._handle_only(generator_kwargs=generator_kwargs, only=only) - return ListingGenerator( - self.subreddit._reddit, - API_PATH["about_reports"].format(subreddit=self.subreddit), - **generator_kwargs, - ) - - async def settings(self) -> Dict[str, Union[str, int, bool]]: - """Return a dictionary of the :class:`.Subreddit`'s current settings.""" - url = API_PATH["subreddit_settings"].format(subreddit=self.subreddit) - response = await self.subreddit._reddit.get(url) - return response["data"] - - @_deprecate_args("only") - def spam( - self, *, only: Optional[str] = None, **generator_kwargs: Any - ) -> AsyncIterator[ - Union["asyncpraw.models.Submission", "asyncpraw.models.Comment"] - ]: - """Return a :class:`.ListingGenerator` for spam comments and submissions. + name = name.replace(" ", "_").lower() + new = WikiPage(self.subreddit._reddit, self.subreddit, name) + await new.edit(content=content, reason=reason, **other_settings) + return new - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + @deprecate_lazy + async def get_page(self, page_name: str, fetch: bool = True, **_: Any) -> WikiPage: + """Return the :class:`.WikiPage` for the :class:`.Subreddit` named ``page_name``. - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + :param page_name: Name of the wikipage. + :param fetch: Determines if Async PRAW will fetch the object (default: + ``True``). - To print the items in the spam queue try: + This method is to be used to fetch a specific wikipage, like so: .. code-block:: python - subreddit = await reddit.subreddit("mod") - async for item in subreddit.mod.spam(): - print(item) + subreddit = await reddit.subreddit("test") + wikipage = await subreddit.wiki.get_page("proof") + print(wikipage.content_md) """ - self._handle_only(generator_kwargs=generator_kwargs, only=only) - return ListingGenerator( - self.subreddit._reddit, - API_PATH["about_spam"].format(subreddit=self.subreddit), - **generator_kwargs, - ) + wikipage = WikiPage(self.subreddit._reddit, self.subreddit, page_name.lower()) + if fetch: + await wikipage._fetch() + return wikipage - def unmoderated( + def revisions( self, **generator_kwargs: Any - ) -> AsyncIterator["asyncpraw.models.Submission"]: - """Return a :class:`.ListingGenerator` for unmoderated submissions. + ) -> AsyncGenerator[ + dict[str, asyncpraw.models.Redditor | WikiPage | str | int | bool | None], + None, + ]: + """Return a :class:`.ListingGenerator` for recent wiki revisions. Additional keyword arguments are passed in the initialization of :class:`.ListingGenerator`. - To print the items in the unmoderated queue try: + To view the wiki revisions for ``"praw_test"`` in r/test try: .. code-block:: python - subreddit = await reddit.subreddit("mod") - async for item in subreddit.mod.unmoderated(): + subreddit = await reddit.subreddit("test") + page = await subreddit.wiki.get_page("praw_test") + async for item in page.revisions(): print(item) """ - return ListingGenerator( - self.subreddit._reddit, - API_PATH["about_unmoderated"].format(subreddit=self.subreddit), - **generator_kwargs, + url = API_PATH["wiki_revisions"].format(subreddit=self.subreddit) + return WikiPage._revision_generator( + generator_kwargs=generator_kwargs, subreddit=self.subreddit, url=url ) - def unread( - self, **generator_kwargs: Any - ) -> AsyncIterator["asyncpraw.models.SubredditMessage"]: - """Return a :class:`.ListingGenerator` for unread moderator messages. - - .. warning:: - - 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. - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. +class ContributorRelationship(SubredditRelationship): + r"""Provides methods to interact with a :class:`.Subreddit`'s contributors. - .. seealso:: + Contributors are also known as approved submitters. - :meth:`.inbox` for all messages. + Contributors of a subreddit can be iterated through like so: - To print the mail in the unread modmail queue try: + .. code-block:: python - .. code-block:: python + subreddit = await reddit.subreddit("test") + async for contributor in subreddit.contributor(): + print(contributor) - subreddit = await reddit.subreddit("mod") - async for message in subreddit.mod.unread(): - print("From: {}, To: {}".format(message.author, message.dest)) + """ - """ - 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, + async def leave(self): + """Abdicate the contributor position.""" + if not self.subreddit._fetched: + await self.subreddit._fetch() + await self.subreddit._reddit.post( + API_PATH["leavecontributor"], data={"id": self.subreddit.fullname} ) - async def update( - self, **settings: Union[str, int, bool] - ) -> Dict[str, Union[str, int, bool]]: - """Update the :class:`.Subreddit`'s settings. - - See https://www.reddit.com/dev/api#POST_api_site_admin for the full list. - - :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:: +class ModeratorRelationship(SubredditRelationship): + r"""Provides methods to interact with a :class:`.Subreddit`'s moderators. - 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: + Moderators of a subreddit can be iterated through like so: - .. code-block:: python + .. code-block:: python - subreddit = await reddit.subreddit("test") - sidebar = await subreddit.wiki.get_page("config/sidebar") - await sidebar.edit(content="new sidebar content") + subreddit = await reddit.subreddit("test") + async for moderator in subreddit.moderator: + print(moderator) - Additional keyword arguments can be provided to handle new settings as Reddit - introduces them. + """ - 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. + PERMISSIONS = { + "access", + "chat_config", + "chat_operator", + "config", + "flair", + "mail", + "posts", + "wiki", + } - """ - if not self.subreddit._fetched: - await self.subreddit._fetch() - # 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 await self.subreddit._reddit.patch( - API_PATH["update_settings"], json=settings + @staticmethod + def _handle_permissions( + *, + 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 + async def __aiter__(self): + """Asynchronously iterate through Redditors who are moderators. -class SubredditModerationStream: - """Provides moderator streams.""" + For example, to list the moderators along with their permissions try: - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): - """Initialize a :class:`.SubredditModerationStream` instance. + .. code-block:: python - :param subreddit: The moderated subreddit associated with the streams. + subreddit = await reddit.subreddit("test") + async for moderator in subreddit.moderator: + print(f"{moderator}: {moderator.mod_permissions}") """ - self.subreddit = subreddit + url = API_PATH[f"list_{self.relationship}"].format(subreddit=self.subreddit) + results = await self.subreddit._reddit.get(url) + for result in results: + yield result - @_deprecate_args("only") - def edited( - self, *, only: Optional[str] = None, **stream_options: Any - ) -> AsyncGenerator[ - Union["asyncpraw.models.Comment", "asyncpraw.models.Submission"], None - ]: - """Yield edited comments and submissions as they become available. + async def __call__( + self, redditor: str | asyncpraw.models.Redditor | None = None + ) -> list[asyncpraw.models.Redditor]: + r"""Return a list of :class:`.Redditor`\ s who are moderators. - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + :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``). - Keyword arguments are passed to :func:`.stream_generator`. + .. note:: - For example, to retrieve all new edited submissions/comments made to all - moderated subreddits, try: + 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- + + .. note:: + + Unlike other relationship callables, this relationship is not paginated. + Thus, it simply returns the full list, rather than an iterator for the + results. + + To be used like: .. code-block:: python - subreddit = await reddit.subreddit("mod") - async for item in subreddit.mod.stream.edited(): - print(item) + subreddit = await reddit.subreddit("test") + moderators = await subreddit.moderator() + + For example, to list the moderators along with their permissions try: + + .. code-block:: python + + subreddit = await reddit.subreddit("test") + moderators = await subreddit.moderator() + for moderator in moderators: + print(f"{moderator}: {moderator.mod_permissions}") """ - return stream_generator(self.subreddit.mod.edited, only=only, **stream_options) + params = {} if redditor is None else {"user": redditor} + url = API_PATH[f"list_{self.relationship}"].format(subreddit=self.subreddit) + return await self.subreddit._reddit.get(url, params=params) - @_deprecate_args("action", "mod") - def log( + @_deprecate_args("redditor", "permissions") + async def add( self, + redditor: str | asyncpraw.models.Redditor, *, - action: Optional[str] = None, - mod: Optional[Union[str, "asyncpraw.models.Redditor"]] = None, - **stream_options: Any, - ) -> AsyncGenerator["asyncpraw.models.ModAction", None]: - """Yield moderator log entries as they become available. + permissions: list[str] | None = None, + **other_settings: Any, + ): + """Add or invite ``redditor`` to be a moderator of the :class:`.Subreddit`. - :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 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 retrieve all new mod actions made to all moderated subreddits, - try: + An invite will be sent unless the user making this call is an admin user. + + For example, to invite u/spez with ``"posts"`` and ``"mail"`` permissions to + r/test, try: .. code-block:: python - subreddit = await reddit.subreddit("mod") - async for log in subreddit.mod.stream.log(): - print("Mod: {}, Subreddit: {}".format(log.mod, log.subreddit)) + subreddit = await reddit.subreddit("test") + await subreddit.moderator.add("spez", permissions=["posts", "mail"]) """ - return stream_generator( - self.subreddit.mod.log, - attribute_name="id", - action=action, - mod=mod, - **stream_options, + other_settings = self._handle_permissions( + other_settings=other_settings, permissions=permissions ) + await super().add(redditor, **other_settings) - @_deprecate_args("other_subreddits", "sort", "state") - def modmail_conversations( + @_deprecate_args("redditor", "permissions") + async def invite( self, + redditor: str | asyncpraw.models.Redditor, *, - other_subreddits: Optional[List["asyncpraw.models.Subreddit"]] = None, - sort: Optional[str] = None, - state: Optional[str] = None, - **stream_options: Any, - ) -> AsyncGenerator[ModmailConversation, None]: - """Yield new-modmail conversations as they become available. - - :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. + 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 mail in the unread modmail queue try: + For example, to invite u/spez with ``"posts"`` and ``"mail"`` + permissions to r/test, try: .. code-block:: python - subreddit = await reddit.subreddit("all") - async for message in subreddit.mod.stream.modmail_conversations(): - print("From: {}, To: {}".format(message.owner, message.participant)) + subreddit = await reddit.subreddit("test") + await subreddit.moderator.invite("spez", permissions=["posts", "mail"]) - """ # noqa: E501 - if self.subreddit == "mod": - self.subreddit = Subreddit(self.subreddit._reddit, "all") - return stream_generator( - self.subreddit.modmail.conversations, - attribute_name="id", - exclude_before=True, - other_subreddits=other_subreddits, - sort=sort, - state=state, - **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) + await self.subreddit._reddit.post(url, data=data) - @_deprecate_args("only") - def modqueue( - self, *, only: Optional[str] = None, **stream_options: Any - ) -> AsyncGenerator[ - Union["asyncpraw.models.Comment", "asyncpraw.models.Submission"], None - ]: - r"""Yield :class:`.Comment`\ s and :class:`.Submission`\ s in the modqueue as they become available. + @_deprecate_args("redditor") + def invited( + self, + *, + redditor: str | asyncpraw.models.Redditor | None = None, + **generator_kwargs: Any, + ) -> AsyncIterator[asyncpraw.models.Redditor]: + r"""Return a :class:`.ListingGenerator` for :class:`.Redditor`\ s invited to be moderators. - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + :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``). - Keyword arguments are passed to :func:`.stream_generator`. + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. - To print all new modqueue items try: + .. note:: - .. code-block:: python + 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. - subreddit = await reddit.subreddit("mod") - async for item in subreddit.mod.stream.modqueue(): - print(item) + Usage: - """ - return stream_generator( - self.subreddit.mod.modqueue, only=only, **stream_options - ) + .. code-block:: python - @_deprecate_args("only") - def reports( - self, *, only: Optional[str] = None, **stream_options: Any - ) -> AsyncGenerator[ - Union["asyncpraw.models.Comment", "asyncpraw.models.Submission"], None - ]: - r"""Yield reported :class:`.Comment`\ s and :class:`.Submission`\ s as they become available. + subreddit = await reddit.subreddit("test") + async for invited_mod in subreddit.moderator.invited(): + print(invited_mod) - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + """ + 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) - Keyword arguments are passed to :func:`.stream_generator`. + async def leave(self): + """Abdicate the moderator position (use with care). - To print new user and mod report reasons in the report queue try: + For example: .. code-block:: python - subreddit = await reddit.subreddit("mod") - async for item in subreddit.mod.stream.reports(): - print(item) + subreddit = await reddit.subreddit("test") + await subreddit.moderator.leave() """ - return stream_generator(self.subreddit.mod.reports, only=only, **stream_options) - - @_deprecate_args("only") - def spam( - self, *, only: Optional[str] = None, **stream_options: Any - ) -> AsyncGenerator[ - Union["asyncpraw.models.Comment", "asyncpraw.models.Submission"], None - ]: - r"""Yield spam :class:`.Comment`\ s and :class:`.Submission`\ s as they become available. + await self.remove( + self.subreddit._reddit.config.username or self.subreddit._reddit.user.me() + ) - :param only: If specified, one of ``"comments"`` or ``"submissions"`` to yield - only results of that type. + async def remove_invite(self, redditor: str | asyncpraw.models.Redditor): + """Remove the moderator invite for ``redditor``. - Keyword arguments are passed to :func:`.stream_generator`. + :param redditor: A redditor name or :class:`.Redditor` instance. - To print new items in the spam queue try: + For example: .. code-block:: python - subreddit = await reddit.subreddit("mod") - async for item in subreddit.mod.stream.spam(): - print(item) + subreddit = await reddit.subreddit("test") + await subreddit.moderator.remove_invite("spez") """ - return stream_generator(self.subreddit.mod.spam, only=only, **stream_options) + data = {"name": str(redditor), "type": "moderator_invite"} + url = API_PATH["unfriend"].format(subreddit=self.subreddit) + await self.subreddit._reddit.post(url, data=data) - def unmoderated( - self, **stream_options: Any - ) -> AsyncGenerator["asyncpraw.models.Submission", None]: - r"""Yield unmoderated :class:`.Submission`\ s as they become available. + @_deprecate_args("redditor", "permissions") + async def update( + self, + redditor: str | asyncpraw.models.Redditor, + *, + permissions: list[str] | None = None, + ): + """Update the moderator permissions for ``redditor``. - 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 unmoderated queue try: + For example, to add all permissions to the moderator, try: .. code-block:: python - subreddit = await reddit.subreddit("mod") - async for item in subreddit.mod.stream.unmoderated(): - print(item) + await subreddit.moderator.update("spez") + + To remove all permissions from the moderator, try: - """ - return stream_generator(self.subreddit.mod.unmoderated, **stream_options) + .. code-block:: python - def unread( - self, **stream_options: Any - ) -> AsyncGenerator["asyncpraw.models.SubredditMessage", None]: - """Yield unread old modmail messages as they become available. + await subreddit.moderator.update("spez", permissions=[]) - Keyword arguments are passed to :func:`.stream_generator`. + """ + url = API_PATH["setpermissions"].format(subreddit=self.subreddit) + data = self._handle_permissions( + other_settings={"name": str(redditor), "type": "moderator"}, + permissions=permissions, + ) + await self.subreddit._reddit.post(url, data=data) - .. seealso:: + @_deprecate_args("redditor", "permissions") + async def update_invite( + self, + redditor: str | asyncpraw.models.Redditor, + *, + permissions: list[str] | None = None, + ): + """Update the moderator invite permissions for ``redditor``. - :meth:`.SubredditModeration.inbox` for all messages. + :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 mail in the unread modmail queue try: + For example, to grant the ``"flair"`` and ``"mail"`` permissions to the + moderator invite, try: .. code-block:: python - subreddit = await reddit.subreddit("mod") - async for message in subreddit.mod.stream.unread(): - print("From: {}, To: {}".format(message.author, message.dest)) + await subreddit.moderator.update_invite("spez", permissions=["flair", "mail"]) """ - return stream_generator(self.subreddit.mod.unread, **stream_options) + url = API_PATH["setpermissions"].format(subreddit=self.subreddit) + data = self._handle_permissions( + other_settings={"name": str(redditor), "type": "moderator_invite"}, + permissions=permissions, + ) + await self.subreddit._reddit.post(url, data=data) -class SubredditQuarantine: - """Provides subreddit quarantine related methods. +class Subreddit(MessageableMixin, SubredditListingMixin, FullnameMixin, RedditBase): + """A class for Subreddits. - To opt-in into a quarantined subreddit: + To obtain an instance of this class for r/test execute: .. code-block:: python subreddit = await reddit.subreddit("test") - await subreddit.quaran.opt_in() - """ + To obtain a lazy instance of this class for subreddit ``r/test`` execute: - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): - """Initialize a :class:`.SubredditQuarantine` instance. + .. code-block:: python - :param subreddit: The :class:`.Subreddit` associated with the quarantine. + subreddit = await reddit.subreddit("test") - """ - self.subreddit = subreddit + 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: - async def opt_in(self): - """Permit your user access to the quarantined subreddit. + .. code-block:: python - Usage: + subreddit = await reddit.subreddit("all") + async for submission in subreddit.hot(limit=25): + print(submission.title) - .. code-block:: python + Multiple subreddits can be combined with a ``+`` like so: - subreddit = await reddit.subreddit("QUESTIONABLE") - async for submission in subreddit.hot(): # Raises asyncprawcore.Forbidden - print(submission) + .. code-block:: python - await subreddit.quaran.opt_in() - async for submission in subreddit.hot(): - print(submission) # Returns Submission + subreddit = await reddit.subreddit("redditdev+learnpython") + async for submission in subreddit.top(time_filter="all"): + print(submission) - """ - data = {"sr_name": self.subreddit} - try: - await self.subreddit._reddit.post(API_PATH["quarantine_opt_in"], data=data) - except Redirect: - pass + Subreddits can be filtered from combined listings as follows. - async def opt_out(self): - """Remove access to the quarantined subreddit. + .. note:: - Usage: + These filters are ignored by certain methods, including :attr:`.comments`, + :meth:`.gilded`, and :meth:`.SubredditStream.comments`. - .. code-block:: python + .. code-block:: python - subreddit = await reddit.subreddit("QUESTIONABLE") - async for submission in subreddit.hot(): - print(submission) # Returns Submission + subreddit = await reddit.subreddit("all-redditdev") + async for submission in subreddit.new(): + print(submission) - await subreddit.quaran.opt_out() - async for submission in subreddit.hot(): # Raises asyncprawcore.Forbidden - print(submission) + .. include:: ../../typical_attributes.rst - """ - data = {"sr_name": self.subreddit} - try: - await self.subreddit._reddit.post(API_PATH["quarantine_opt_out"], data=data) - except Redirect: - pass + ========================= ========================================================== + 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. + ========================= ========================================================== + .. note:: -class SubredditRelationship: - """Represents a relationship between a :class:`.Redditor` and :class:`.Subreddit`. + 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. - Instances of this class can be iterated through in order to discover the Redditors - that make up the relationship. + .. _unix time: https://en.wikipedia.org/wiki/Unix_time - For example, banned users of a subreddit can be iterated through like so: + """ - .. code-block:: python + STR_FIELD = "display_name" + MESSAGE_PREFIX = "#" - subreddit = await reddit.subreddit("test") - async for ban in subreddit.banned(): - print("{ban}: {ban.note}") + @staticmethod + async def _create_or_update( + *, + _reddit: asyncpraw.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, + } - """ + model.update(other_settings) - def __call__( - self, - redditor: Optional[Union[str, "asyncpraw.models.Redditor"]] = None, - **generator_kwargs, - ) -> AsyncIterator["asyncpraw.models.Redditor"]: - r"""Return a :class:`.ListingGenerator` for :class:`.Redditor`\ s in the relationship. + await _reddit.post(API_PATH["site_admin"], data=model) - :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``). + @staticmethod + def _subreddit_list( + *, + other_subreddits: list[str | asyncpraw.models.Subreddit], + subreddit: asyncpraw.models.Subreddit, + ) -> str: + if other_subreddits: + return ",".join([str(subreddit)] + [str(x) for x in other_subreddits]) + return str(subreddit) - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + @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) - """ - 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) + @staticmethod + def _validate_inline_media(inline_media: asyncpraw.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) - def __init__(self, subreddit: "asyncpraw.models.Subreddit", relationship: str): - """Initialize a :class:`.SubredditRelationship` instance. + @cachedproperty + def banned(self) -> asyncpraw.models.reddit.subreddit.SubredditRelationship: + """Provide an instance of :class:`.SubredditRelationship`. - :param subreddit: The :class:`.Subreddit` for the relationship. - :param relationship: The name of the relationship. + For example, to ban a user try: - """ - self.relationship = relationship - self.subreddit = subreddit + .. code-block:: python - async def add( - self, redditor: Union[str, "asyncpraw.models.Redditor"], **other_settings: Any - ): - """Add ``redditor`` to this relationship. + subreddit = await reddit.subreddit("test") + await subreddit.banned.add("spez", ban_reason="...") - :param redditor: A redditor name or :class:`.Redditor` instance. + To list the banned users along with any notes, try: + + .. code-block:: python + + subreddit = await reddit.subreddit("test") + async for ban in subreddit.banned(): + print(f"{ban}: {ban.note}") """ - data = {"name": str(redditor), "type": self.relationship} - data.update(other_settings) - url = API_PATH["friend"].format(subreddit=self.subreddit) - await self.subreddit._reddit.post(url, data=data) + return SubredditRelationship(self, "banned") - async def remove(self, redditor: Union[str, "asyncpraw.models.Redditor"]): - """Remove ``redditor`` from this relationship. + @cachedproperty + def collections(self) -> asyncpraw.models.reddit.collections.SubredditCollections: + r"""Provide an instance of :class:`.SubredditCollections`. - :param redditor: A redditor name or :class:`.Redditor` instance. + To see the permalinks of all :class:`.Collection`\ s that belong to a subreddit, + try: - """ - data = {"name": str(redditor), "type": self.relationship} - url = API_PATH["unfriend"].format(subreddit=self.subreddit) - await self.subreddit._reddit.post(url, data=data) + .. code-block:: python + + subreddit = await reddit.subreddit("test") + async for collection in subreddit.collections: + print(collection.permalink) + To get a specific :class:`.Collection` by its UUID or permalink, use one of the + following: -class SubredditStream: - """Provides submission and comment streams.""" + .. code-block:: python - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): - """Initialize a :class:`.SubredditStream` instance. + subreddit = await reddit.subreddit("test") - :param subreddit: The subreddit associated with the streams. + collection = subreddit.collections("some_uuid") + collection = subreddit.collections( + permalink="https://reddit.com/r/test/collection/some_uuid" + ) """ - self.subreddit = subreddit + return self._subreddit_collections_class(self._reddit, self) - def comments( - self, **stream_options: Any - ) -> AsyncGenerator["asyncpraw.models.Comment", None]: - """Yield new comments as they become available. + @cachedproperty + def contributor( + self, + ) -> asyncpraw.models.reddit.subreddit.ContributorRelationship: + """Provide an instance of :class:`.ContributorRelationship`. - Comments are yielded oldest first. Up to 100 historical comments will initially - be returned. + Contributors are also known as approved submitters. - Keyword arguments are passed to :func:`.stream_generator`. + To add a contributor try: - .. note:: + .. code-block:: python - While Async PRAW tries to catch all new comments, some high-volume streams, - especially the r/all stream, may drop some comments. + subreddit = await reddit.subreddit("test") + await subreddit.contributor.add("spez") - For example, to retrieve all new comments made to r/test, try: + """ + return ContributorRelationship(self, "contributor") + + @cachedproperty + def emoji(self) -> SubredditEmoji: + """Provide an instance of :class:`.SubredditEmoji`. + + This attribute can be used to discover all emoji for a subreddit: .. code-block:: python subreddit = await reddit.subreddit("test") - async for comment in subreddit.stream.comments(): - print(comment) + async for emoji in subreddit.emoji: + print(emoji) - To only retrieve new submissions starting when the stream is created, pass - ``skip_existing=True``: + A single emoji can be lazily retrieved via: .. code-block:: python subreddit = await reddit.subreddit("test") - async for comment in subreddit.stream.comments(skip_existing=True): - print(comment) - - """ - return stream_generator(self.subreddit.comments, **stream_options) - - def submissions( - self, **stream_options: Any - ) -> AsyncGenerator["asyncpraw.models.Submission", None]: - r"""Yield new :class:`.Submission`\ s as they become available. + emoji = await subreddit.emoji.get_emoji("emoji_name") - Submissions are yielded oldest first. Up to 100 historical submissions will - initially be returned. + .. note:: - Keyword arguments are passed to :func:`.stream_generator`. + Attempting to access attributes of a nonexistent emoji will result in a + :class:`.ClientException`. - .. note:: + """ + return SubredditEmoji(self) - While Async PRAW tries to catch all new submissions, some high-volume - streams, especially the r/all stream, may drop some submissions. + @cachedproperty + def filters(self) -> asyncpraw.models.reddit.subreddit.SubredditFilters: + """Provide an instance of :class:`.SubredditFilters`. - For example, to retrieve all new submissions made to all of Reddit, try: + For example, to add a filter, run: .. code-block:: python subreddit = await reddit.subreddit("all") - async for submission in subreddit.stream.submissions(): - print(submission) + await subreddit.filters.add("test") """ - return stream_generator(self.subreddit.new, **stream_options) - - -class SubredditStylesheet: - """Provides a set of stylesheet functions to a :class:`.Subreddit`. - - For example, to add the css data ``.test{color:blue}`` to the existing stylesheet: + return SubredditFilters(self) - .. code-block:: python + @cachedproperty + def flair(self) -> asyncpraw.models.reddit.subreddit.SubredditFlair: + """Provide an instance of :class:`.SubredditFlair`. - subreddit = await reddit.subreddit("test") - stylesheet = await subreddit.stylesheet() - stylesheet.stylesheet.stylesheet += ".test{color:blue}" - await subreddit.stylesheet.update(stylesheet.stylesheet) + 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 - async def __call__(self) -> "asyncpraw.models.Stylesheet": - """Return the :class:`.Subreddit`'s stylesheet. + subreddit = await reddit.subreddit("test") + async for flair in subreddit.flair(): + print(flair) - To be used as: + Flair templates can be interacted with through this attribute via: .. code-block:: python subreddit = await reddit.subreddit("test") - stylesheet = await subreddit.stylesheet() + async for template in subreddit.flair.templates: + print(template) """ - url = API_PATH["about_stylesheet"].format(subreddit=self.subreddit) - return await self.subreddit._reddit.get(url) - - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): - """Initialize a :class:`.SubredditStylesheet` instance. + return SubredditFlair(self) - :param subreddit: The :class:`.Subreddit` associated with the stylesheet. + @cachedproperty + def mod(self) -> SubredditModeration: + """Provide an instance of :class:`.SubredditModeration`. - An instance of this class is provided as: + For example, to accept a moderation invite from r/test: .. code-block:: python subreddit = await reddit.subreddit("test") - subreddit.stylesheet + await subreddit.mod.accept_invite() """ - self.subreddit = subreddit - - async def _update_structured_styles(self, style_data: Dict[str, Union[str, Any]]): - url = API_PATH["structured_styles"].format(subreddit=self.subreddit) - await self.subreddit._reddit.patch(url, data=style_data) - - async def _upload_image( - self, *, data: Dict[str, Union[str, Any]], image_path: str - ) -> Dict[str, Any]: - with open(image_path, "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 = await 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 Async PRAW." - raise RedditAPIException([[error_type, error_value, None]]) - return response - - async def _upload_style_asset(self, *, image_path: str, image_type: str) -> str: - data = {"imagetype": image_type, "filepath": basename(image_path)} - data["mimetype"] = "image/jpeg" - if image_path.lower().endswith(".png"): - data["mimetype"] = "image/png" - url = API_PATH["style_asset_lease"].format(subreddit=self.subreddit) - - response = await self.subreddit._reddit.post(url, data=data) - upload_lease = response["s3UploadLease"] - upload_data = {item["name"]: item["value"] for item in upload_lease["fields"]} - upload_url = f"https:{upload_lease['action']}" + return SubredditModeration(self) - with open(image_path, "rb") as image: - upload_data["file"] = image - response = await self.subreddit._reddit._core._requestor._http.post( - upload_url, data=upload_data - ) - response.raise_for_status() + @cachedproperty + def moderator(self) -> asyncpraw.models.reddit.subreddit.ModeratorRelationship: + """Provide an instance of :class:`.ModeratorRelationship`. - return f"{upload_url}/{upload_data['key']}" + For example, to add a moderator try: - async def delete_banner(self): - """Remove the current :class:`.Subreddit` (redesign) banner image. + .. code-block:: python - Succeeds even if there is no banner image. + subreddit = await reddit.subreddit("test") + await subreddit.moderator.add("spez") - For example: + To list the moderators along with their permissions try: .. code-block:: python subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.delete_banner() + async for moderator in subreddit.moderator: + print(f"{moderator}: {moderator.mod_permissions}") """ - data = {"bannerBackgroundImage": ""} - await self._update_structured_styles(data) - - async def delete_banner_additional_image(self): - """Remove the current :class:`.Subreddit` (redesign) banner additional image. + return ModeratorRelationship(self, "moderator") - Succeeds even if there is no additional image. Will also delete any configured - hover image. + @cachedproperty + def modmail(self) -> asyncpraw.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 subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.delete_banner_additional_image() + await subreddit.modmail.create(subject="test", body="hello", recipient="spez") """ - data = {"bannerPositionedImage": "", "secondaryBannerPositionedImage": ""} - await self._update_structured_styles(data) - - async def delete_banner_hover_image(self): - """Remove the current :class:`.Subreddit` (redesign) banner hover image. + return Modmail(self) - Succeeds even if there is no hover image. + @cachedproperty + def muted(self) -> asyncpraw.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 subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.delete_banner_hover_image() + async for mute in subreddit.muted(): + print("{mute}: {mute.date}") """ - data = {"secondaryBannerPositionedImage": ""} - await self._update_structured_styles(data) + return SubredditRelationship(self, "muted") - async def delete_header(self): - """Remove the current :class:`.Subreddit` header image. + @cachedproperty + def quaran(self) -> asyncpraw.models.reddit.subreddit.SubredditQuarantine: + """Provide an instance of :class:`.SubredditQuarantine`. - Succeeds even if there is no header image. + 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 subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.delete_header() + await subreddit.quaran.opt_in() """ - url = API_PATH["delete_sr_header"].format(subreddit=self.subreddit) - await self.subreddit._reddit.post(url) + return SubredditQuarantine(self) - async def delete_image(self, name: str): - """Remove the named image from the :class:`.Subreddit`. + @cachedproperty + def rules(self) -> SubredditRules: + """Provide an instance of :class:`.SubredditRules`. - Succeeds even if the named image does not exist. + 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 = await reddit.subreddit("test") - await subreddit.stylesheet.delete_image("smile") - - """ - url = API_PATH["delete_sr_image"].format(subreddit=self.subreddit) - await self.subreddit._reddit.post(url, data={"img_name": name}) - - async def delete_mobile_banner(self): - """Remove the current :class:`.Subreddit` (redesign) mobile banner. - - Succeeds even if there is no mobile banner. + async for rule in subreddit.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 subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.delete_banner_hover_image() + await subreddit.rules.mod.add( + short_name="No spam", kind="all", description="Do not spam. Spam bad" + ) """ - data = {"mobileBannerImage": ""} - await self._update_structured_styles(data) - - async def delete_mobile_header(self): - """Remove the current :class:`.Subreddit` mobile header. + return SubredditRules(self) - Succeeds even if there is no mobile header. + @cachedproperty + def stream(self) -> asyncpraw.models.reddit.subreddit.SubredditStream: + """Provide an instance of :class:`.SubredditStream`. - For example: + Streams can be used to indefinitely retrieve new comments made to a subreddit, + like: .. code-block:: python subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.delete_mobile_header() - - """ - url = API_PATH["delete_sr_header"].format(subreddit=self.subreddit) - await self.subreddit._reddit.post(url) - - async def delete_mobile_icon(self): - """Remove the current :class:`.Subreddit` mobile icon. - - Succeeds even if there is no mobile icon. + async for comment in subreddit.stream.comments(): + print(comment) - For example: + 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 - subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.delete_mobile_icon() + subreddit = await reddit.subreddit("all") + async for submission in subreddit.stream.submissions(): + print(submission) """ - url = API_PATH["delete_sr_icon"].format(subreddit=self.subreddit) - await self.subreddit._reddit.post(url) - - @_deprecate_args("stylesheet", "reason") - async def update(self, stylesheet: str, *, reason: Optional[str] = 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) -> asyncpraw.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 subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.update("p { color: green; }", reason="color text green") + stylesheet = await subreddit.stylesheet() + stylesheet.stylesheet += ".test{color:blue}" + await subreddit.stylesheet.update(stylesheet.stylesheet) """ - data = { - "op": "save", - "reason": reason, - "stylesheet_contents": stylesheet, - } - url = API_PATH["subreddit_stylesheet"].format(subreddit=self.subreddit) - await self.subreddit._reddit.post(url, data=data) + return SubredditStylesheet(self) - @_deprecate_args("name", "image_path") - async def upload(self, *, image_path: str, name: str) -> Dict[str, str]: - """Upload an image to the :class:`.Subreddit`. + @cachedproperty + def widgets(self) -> asyncpraw.models.SubredditWidgets: + """Provide an instance of :class:`.SubredditWidgets`. - :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. + **Example usage** - :returns: A dictionary containing a link to the uploaded image under the key - ``img_src``. + Get all sidebar widgets: - :raises: ``asyncprawcore.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. + subreddit = await reddit.subreddit("test") + async for widget in subreddit.widgets.sidebar: + print(widget) - For example: + Get ID card widget: .. code-block:: python subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.upload(name="smile", image_path="img.png") + widget = await subreddit.widgets.id_card() + print(widget) """ - return await self._upload_image( - data={"name": name, "upload_type": "img"}, image_path=image_path - ) + return SubredditWidgets(self) - async def upload_banner(self, image_path: str): - """Upload an image for the :class:`.Subreddit`'s (redesign) banner image. + @cachedproperty + def wiki(self) -> asyncpraw.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: ``asyncprawcore.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. + subreddit = await reddit.subreddit("test") + async for wikipage in subreddit.wiki: + print(wikipage) - For example: + To fetch the content for a given wikipage try: .. code-block:: python subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.upload_banner("banner.png") + wikipage = await subreddit.wiki.get_page("proof") + print(wikipage.content_md) """ - image_type = "bannerBackgroundImage" - image_url = await self._upload_style_asset( - image_path=image_path, image_type=image_type - ) - await self._update_structured_styles({image_type: image_url}) + return SubredditWiki(self) - @_deprecate_args("image_path", "align") - async 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: Optional[str] = None, + reddit: asyncpraw.Reddit, + display_name: str | None = None, + _data: dict[str, Any] | None = None, ): - """Upload an image for the :class:`.Subreddit`'s (redesign) additional image. + """Initialize a :class:`.Subreddit` instance. - :param image_path: A path to a jpeg or png image. - :param align: Either ``"left"``, ``"centered"``, or ``"right"``. (default: - ``"left"``). + :param reddit: An instance of :class:`.Reddit`. + :param display_name: The name of the subreddit. - :raises: ``asyncprawcore.TooLarge`` if the overall request body is too large. + .. note:: - :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. + This class should not be initialized directly. Instead, obtain an instance + via: - For example: + .. code-block:: python - .. code-block:: python + # to lazily load a subreddit instance + await reddit.subreddit("test") - subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.upload_banner_additional_image("banner.png") + # to fully load a subreddit instance + await reddit.subreddit("test", fetch=True) """ - alignment = {} - if align is not None: - if align not in {"left", "centered", "right"}: - raise ValueError( - "'align' argument must be either 'left', 'centered', or 'right'" - ) - alignment["bannerPositionedImagePosition"] = align + 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) - image_type = "bannerPositionedImage" - image_url = await self._upload_style_asset( - image_path=image_path, image_type=image_type - ) - style_data = {image_type: image_url} - if alignment: - style_data.update(alignment) - await self._update_structured_styles(style_data) + async def _convert_to_fancypants(self, markdown_text: str) -> dict: + """Convert a Markdown string to a dict for use with the ``richtext_json`` param. - async def upload_banner_hover_image(self, image_path: str): - """Upload an image for the :class:`.Subreddit`'s (redesign) additional image. + :param markdown_text: A Markdown string to convert. - :param image_path: A path to a jpeg or png image. + :returns: A dict in ``richtext_json`` format. - Fails if the :class:`.Subreddit` does not have an additional image defined. + """ + text_data = {"output_mode": "rtjson", "markdown_text": markdown_text} + rte_body = await self._reddit.post(API_PATH["convert_rte_body"], data=text_data) + return rte_body["output"] - :raises: ``asyncprawcore.TooLarge`` if the overall request body is too large. + async def _fetch(self): + data = await self._fetch_data() + data = data["data"] + other = type(self)(self._reddit, _data=data) + self.__dict__.update(other.__dict__) + await super()._fetch() - :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. + def _fetch_info(self): + return "subreddit_about", {"subreddit": self}, None - For example: + async def _parse_xml_response(self, response: ClientResponse): + """Parse the XML from a response and raise any errors found.""" + xml = await 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) + ) - .. code-block:: python + async def _read_and_post_media( + self, media_path: str, upload_url: str, upload_data: dict[str, Any] + ) -> ClientResponse: + async with aiofiles.open(media_path, "rb") as media: + upload_data["file"] = media + return await self._reddit._core._requestor._http.post( + upload_url, data=upload_data + ) - subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.upload_banner_hover_image("banner.png") + async def _submit_media( + self, *, data: dict[Any, Any], timeout: int, websocket_url: str | None = None + ): + """Submit and return an ``image``, ``video``, or ``videogif``. + + This is a helper method for submitting posts that are not link posts or self + posts. """ - image_type = "secondaryBannerPositionedImage" - image_url = await self._upload_style_asset( - image_path=image_path, image_type=image_type + if websocket_url is None: + await self._reddit.post(API_PATH["submit"], data=data) + return None + try: + async with self._reddit._core._requestor._http.ws_connect( + websocket_url, timeout=timeout + ) as websocket: + await self._reddit.post(API_PATH["submit"], data=data) + try: + ws_update = await websocket.receive_json() + except ( + OSError, + BlockingIOError, + TimeoutError, + WebSocketError, + ) as ws_exception: + msg = "Websocket error. Check your media file. Your post may still have been created." + raise WebSocketException( + msg, + ws_exception, + ) from None + except (OSError, BlockingIOError, TimeoutError, WebSocketError) as ws_exception: + msg = "Error establishing websocket connection." + raise WebSocketException( + msg, + ws_exception, + ) from None + if ws_update.get("type") == "failed": + raise MediaPostFailed + url = ws_update["payload"]["redirect"] + return await self._reddit.submission(url=url) + + async def _upload_inline_media(self, inline_media: asyncpraw.models.InlineMedia): + """Upload media for use in self posts and return ``inline_media``. + + :param inline_media: An :class:`.InlineMedia` object to validate and upload. + + """ + self._validate_inline_media(inline_media) + inline_media.media_id, _ = await self._upload_media( + media_path=inline_media.path, upload_type="selfpost" ) - await self._update_structured_styles({image_type: image_url}) + return inline_media - async def upload_header(self, image_path: str) -> Dict[str, str]: - """Upload an image to be used as the :class:`.Subreddit`'s header image. + async 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). - :param image_path: A path to a jpeg or png image. + :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 dictionary containing a link to the uploaded image under the key - ``img_src``. + :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. - :raises: ``asyncprawcore.TooLarge`` if the overall request body is too large. + """ + if media_path is None: + file = Path(__file__).absolute() + media_path = file.parent.parent.parent / "images" / "PRAW logo.png" + else: + file = Path(media_path) + + 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} + + url = API_PATH["media_asset"] + # until we learn otherwise, assume this request always succeeds + upload_response = await 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 = await self._read_and_post_media(media_path, upload_url, upload_data) + if response.status != 201: + await self._parse_xml_response(response) + try: + response.raise_for_status() + except HttpProcessingError: + raise ServerError(response=response) from None - For example: + websocket_url = upload_response["asset"]["websocket_url"] - .. code-block:: python + if upload_type == "link": + return f"{upload_url}/{upload_data['key']}", websocket_url + return upload_response["asset"]["asset_id"], websocket_url - subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.upload_header("header.png") + async def post_requirements(self) -> dict[str, str | int | bool]: + """Get the post requirements for a subreddit. - """ - return await self._upload_image( - data={"upload_type": "header"}, image_path=image_path - ) + :returns: A dict with the various requirements. - async def upload_mobile_banner(self, image_path: str): - """Upload an image for the :class:`.Subreddit`'s (redesign) mobile banner. + The returned dict contains the following keys: - :param image_path: A path to a JPEG or PNG image. + - ``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 subreddit = await reddit.subreddit("test") - await 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. + post_requirements = await subreddit.post_requirements + print(post_requirements) """ - image_type = "mobileBannerImage" - image_url = await self._upload_style_asset( - image_path=image_path, image_type=image_type + return await self._reddit.get( + API_PATH["post_requirements"].format(subreddit=str(self)) ) - await self._update_structured_styles({image_type: image_url}) - - async def upload_mobile_header(self, image_path: str) -> Dict[str, str]: - """Upload an image to be used as the :class:`.Subreddit`'s mobile header. - - :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``. - - :raises: ``asyncprawcore.TooLarge`` if the overall request body is too large. + async def random(self) -> asyncpraw.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 - subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.upload_mobile_header("header.png") + subreddit = await reddit.subreddit("AskReddit") + submission = await subreddit.random() + print(submission.title) """ - return await self._upload_image( - data={"upload_type": "banner"}, image_path=image_path - ) - - async 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. + url = API_PATH["subreddit_random"].format(subreddit=self) + try: + await self._reddit.get(url, params={"unique": self._reddit._next_unique}) + except Redirect as redirect: + path = redirect.path + try: + submission = self._submission_class( + self._reddit, url=urljoin(self._reddit.config.reddit_url, path) + ) + await submission._fetch() + return submission + except ClientException: + return None - :returns: A dictionary containing a link to the uploaded image under the key - ``img_src``. + @_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, + ) -> AsyncIterator[asyncpraw.models.Submission]: + """Return a :class:`.ListingGenerator` for items that match ``query``. - :raises: ``asyncprawcore.TooLarge`` if the overall request body is too large. + :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"``). - :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 more information on building a search query see: + https://www.reddit.com/wiki/search - For example: + For example, to search all subreddits for ``"praw"`` try: .. code-block:: python - subreddit = await reddit.subreddit("test") - await subreddit.stylesheet.upload_mobile_icon("icon.png") + subreddit = await reddit.subreddit("all") + async for submission in subreddit.search("praw"): + print(submission.title) """ - return await self._upload_image( - data={"upload_type": "icon"}, image_path=image_path + 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) + @_deprecate_args("number") + async def sticky(self, *, number: int = 1) -> asyncpraw.models.Submission: + """Return a :class:`.Submission` object for a sticky of the subreddit. -class SubredditWiki: - """Provides a set of wiki functions to a :class:`.Subreddit`.""" + :param number: Specify which sticky to return. 1 appears at the top (default: + ``1``). - async def __aiter__(self) -> AsyncGenerator[WikiPage, None]: - """Iterate through the pages of the wiki. + :raises: ``asyncprawcore.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 subreddit = await reddit.subreddit("test") - async for wikipage in subreddit.wiki: - print(wikipage) + await subreddit.sticky() """ - response = await 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: + await self._reddit.get(url, params={"num": number}) + except Redirect as redirect: + path = redirect.path + submission = 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) + await submission._fetch() + return submission - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): - """Initialize a :class:`.SubredditWiki` instance. + @_deprecate_args( + "title", + "selftext", + "url", + "flair_id", + "flair_text", + "resubmit", + "send_replies", + "nsfw", + "spoiler", + "collection_id", + "discussion_type", + "inline_media", + "draft_id", + ) + async 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, asyncpraw.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, + ) -> asyncpraw.models.Submission: + r"""Add a submission to the :class:`.Subreddit`. - :param subreddit: The subreddit whose wiki to work with. + :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. - """ - self.banned = SubredditRelationship(subreddit, "wikibanned") - self.contributor = SubredditRelationship(subreddit, "wikicontributor") - self.subreddit = subreddit + :returns: A :class:`.Submission` object for the newly created submission. - @_deprecate_args("name", "content", "reason") - async def create( - self, - *, - content: str, - name: str, - reason: Optional[str] = None, - **other_settings: Any, - ): - """Create a new :class:`.WikiPage`. + Either ``selftext`` or ``url`` can be provided, but not both. - :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 submit a URL to r/test do: + + .. code-block:: python - To create the wiki page ``"praw_test"`` in r/test try: + title = "Async PRAW documentation" + url = "https://asyncpraw.readthedocs.io" + subreddit = await reddit.subreddit("test") + await subreddit.submit(title, url=url) + + For example, to submit a self post with inline media do: .. code-block:: python + from asyncpraw.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} subreddit = await reddit.subreddit("test") - await subreddit.wiki.create( - name="praw_test", content="wiki body text", reason="Async PRAW Test Creation" - ) + await subreddit.submit("title", inline_media=media, selftext=selftext) - """ - name = name.replace(" ", "_").lower() - new = WikiPage(self.subreddit._reddit, self.subreddit, name) - await new.edit(content=content, reason=reason, **other_settings) - return new + .. note:: - @deprecate_lazy - async def get_page(self, page_name, fetch: bool = True, **kwargs) -> WikiPage: - """Return the :class:`.WikiPage` for the :class:`.Subreddit` named ``page_name``. + 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: - :param page_name: Name of the wikipage. - :param fetch: Determines if Async PRAW will fetch the object (default: - ``True``). + .. code-block:: - This method is to be used to fetch a specific wikipage, like so: + Text with a gif - .. code-block:: python + ![gif](u1rchuphryq51 "optional caption") - subreddit = await reddit.subreddit("test") - wikipage = await subreddit.wiki.get_page("proof") - print(wikipage.content_md) + an image - """ - wikipage = WikiPage(self.subreddit._reddit, self.subreddit, page_name.lower()) - if fetch: - await wikipage._fetch() - return wikipage + ![img](srnr8tshryq51 "optional caption") - def revisions( - self, **generator_kwargs: Any - ) -> AsyncGenerator[ - Dict[ - str, Optional[Union["asyncpraw.models.Redditor", WikiPage, str, int, bool]] - ], - None, - ]: - """Return a :class:`.ListingGenerator` for recent wiki revisions. + and video - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + ![video](gmc7rvthryq51 "optional caption") - To view the wiki revisions for ``"praw_test"`` in r/test try: + inline - .. code-block:: python + .. note:: - subreddit = await reddit.subreddit("test") - page = await subreddit.wiki.get_page("praw_test") - async for item in page.revisions(): - print(item) + To submit a post to a subreddit with the ``"news"`` flair, you can get the + flair id like this: - """ - url = API_PATH["wiki_revisions"].format(subreddit=self.subreddit) - return WikiPage._revision_generator( - generator_kwargs=generator_kwargs, subreddit=self.subreddit, url=url - ) + .. code-block:: + choices = [template async for template in subreddit.flair.link_templates.user_selectable()] + template_id = next(x for x in choices if x["flair_text"] == "news")["flair_template_id"] + await subreddit.submit("title", flair_id=template_id, url="https://www.news.com/") -class ContributorRelationship(SubredditRelationship): - r"""Provides methods to interact with a :class:`.Subreddit`'s contributors. + .. seealso:: - Contributors are also known as approved submitters. + - :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 - Contributors of a subreddit can be iterated through like so: + """ + if (bool(selftext) or selftext == "") == bool(url): + msg = "Either 'selftext' or 'url' must be provided." + raise TypeError(msg) - .. code-block:: python + 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: await self._upload_inline_media(media) + for placeholder, media in inline_media.items() + } + ) + converted = await self._convert_to_fancypants(body) + data.update(richtext_json=dumps(converted)) + else: + data.update(text=selftext) + else: + data.update(kind="link", url=url) - subreddit = await reddit.subreddit("test") - async for contributor in subreddit.contributor(): - print(contributor) + return await self._reddit.post(API_PATH["submit"], data=data) - """ + @_deprecate_args( + "title", + "images", + "collection_id", + "discussion_type", + "flair_id", + "flair_text", + "nsfw", + "send_replies", + "spoiler", + ) + async 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, + ) -> asyncpraw.models.Submission: + """Add an image gallery submission to the subreddit. - async def leave(self): - """Abdicate the contributor position.""" - if not self.subreddit._fetched: - await self.subreddit._fetch() - await self.subreddit._reddit.post( - API_PATH["leavecontributor"], data={"id": self.subreddit.fullname} - ) + :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. -class ModeratorRelationship(SubredditRelationship): - r"""Provides methods to interact with a :class:`.Subreddit`'s moderators. + :raises: :class:`.ClientException` if ``image_path`` in ``images`` refers to a + file that is not an image. - Moderators of a subreddit can be iterated through like so: + For example, to submit an image gallery to r/test do: - .. code-block:: python + .. code-block:: python - subreddit = await reddit.subreddit("test") - async for moderator in subreddit.moderator: - print(moderator) + 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", + }, + ] + subreddit = await reddit.subreddit("test") + await subreddit.submit_gallery(title, images) - """ + .. seealso:: - PERMISSIONS = { - "access", - "chat_config", - "chat_operator", - "config", - "flair", - "mail", - "posts", - "wiki", - } + - :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 - @staticmethod - def _handle_permissions( - *, - other_settings: Optional[dict] = None, - permissions: Optional[List[str]] = None, - ): - other_settings = deepcopy(other_settings) if other_settings else {} - other_settings["permissions"] = permissions_string( - known_permissions=ModeratorRelationship.PERMISSIONS, permissions=permissions + """ + 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": ( + await self._upload_media( + expected_mime_prefix="image", + media_path=image["image_path"], + upload_type="gallery", + ) + )[0], + } + ) + response = await self._reddit.request( + json=data, method="POST", path=API_PATH["submit_gallery_post"] ) - return other_settings - - async def __aiter__(self): - """Asynchronously iterate through Redditors who are moderators. - - For example, to list the moderators along with their permissions try: - - .. code-block:: python + response = response["json"] + if response["errors"]: + raise RedditAPIException(response["errors"]) + return await self._reddit.submission(url=response["data"]["url"]) - subreddit = await reddit.subreddit("test") - async for moderator in subreddit.moderator: - print(f"{moderator}: {moderator.mod_permissions}") + @_deprecate_args( + "title", + "image_path", + "flair_id", + "flair_text", + "resubmit", + "send_replies", + "nsfw", + "spoiler", + "timeout", + "collection_id", + "without_websockets", + "discussion_type", + ) + async 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, + ) -> asyncpraw.models.Submission | None: + """Add an image submission to the subreddit. - """ - url = API_PATH[f"list_{self.relationship}"].format(subreddit=self.subreddit) - results = await self.subreddit._reddit.get(url) - for result in results: - yield result + :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``). - async def __call__( - self, redditor: Optional[Union[str, "asyncpraw.models.Redditor"]] = None - ) -> List["asyncpraw.models.Redditor"]: # pylint: disable=arguments-differ - r"""Return a list of :class:`.Redditor`\ s who are moderators. + :returns: A :class:`.Submission` object for the newly created submission, unless + ``without_websockets`` is ``True``. - :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``). + :raises: :class:`.ClientException` if ``image_path`` refers to a file that is + not an image. .. note:: - 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- - - .. 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. - Unlike other relationship callables, this relationship is not paginated. - Thus it simply returns the full list, rather than an iterator for the - results. + 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. - To be used like: + For example, to submit an image to r/test do: .. code-block:: python + title = "My favorite picture" + image = "/path/to/image.png" subreddit = await reddit.subreddit("test") - moderators = await subreddit.moderator() - - For example, to list the moderators along with their permissions try: + await subreddit.submit_image(title, image) - .. code-block:: python + .. seealso:: - subreddit = await reddit.subreddit("test") - moderators = await subreddit.moderator() - for moderator in moderators: - 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 await 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") - async def add( + image_url, websocket_url = await self._upload_media( + expected_mime_prefix="image", media_path=image_path + ) + data.update(kind="image", url=image_url) + if without_websockets: + websocket_url = None + return await self._submit_media( + data=data, timeout=timeout, websocket_url=websocket_url + ) + + @_deprecate_args( + "title", + "selftext", + "options", + "duration", + "flair_id", + "flair_text", + "resubmit", + "send_replies", + "nsfw", + "spoiler", + "collection_id", + "discussion_type", + ) + async def submit_poll( self, - redditor: Union[str, "asyncpraw.models.Redditor"], + title: str, *, - permissions: Optional[List[str]] = 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, + ) -> asyncpraw.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 + title = "Do you like Async PRAW?" subreddit = await reddit.subreddit("test") - await subreddit.moderator.add("spez", permissions=["posts", "mail"]) - - """ - other_settings = self._handle_permissions( - other_settings=other_settings, permissions=permissions - ) - await super().add(redditor, **other_settings) - - # pylint: enable=arguments-differ - @_deprecate_args("redditor", "permissions") - async def invite( - self, - redditor: Union[str, "asyncpraw.models.Redditor"], - *, - permissions: Optional[List[str]] = 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``). + await subreddit.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 - subreddit = await reddit.subreddit("test") - await subreddit.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) - await self.subreddit._reddit.post(url, data=data) + return await 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", + ) + async def submit_video( self, + title: str, + video_path: str, *, - redditor: Optional[Union[str, "asyncpraw.models.Redditor"]] = None, - **generator_kwargs: Any, - ) -> AsyncIterator["asyncpraw.models.Redditor"]: - r"""Return a :class:`.ListingGenerator` for :class:`.Redditor`\ s invited to be moderators. + 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, + ) -> asyncpraw.models.Submission | None: + """Add a video or videogif 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 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``). - Additional keyword arguments are passed in the initialization of - :class:`.ListingGenerator`. + :returns: A :class:`.Submission` object for the newly created submission, unless + ``without_websockets`` is ``True``. + + :raises: :class:`.ClientException` if ``video_path`` refers to a file that is + not a video. .. note:: - 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. + 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. - Usage: + 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 a video to r/test do: .. code-block:: python + title = "My favorite movie" + video = "/path/to/video.mp4" subreddit = await reddit.subreddit("test") - async for invited_mod in subreddit.moderator.invited(): - print(invited_mod) - - """ - 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) - - async def leave(self): - """Abdicate the moderator position (use with care). - - For example: + await subreddit.submit_video(title, video) - .. code-block:: python + .. seealso:: - subreddit = await reddit.subreddit("test") - await subreddit.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 """ - await 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, websocket_url = await self._upload_media( + expected_mime_prefix="video", media_path=video_path + ) + video_poster_url, _ = await self._upload_media(media_path=thumbnail_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=video_poster_url, + ) + if without_websockets: + websocket_url = None + return await self._submit_media( + data=data, timeout=timeout, websocket_url=websocket_url ) - async def remove_invite(self, redditor: Union[str, "asyncpraw.models.Redditor"]): - """Remove the moderator invite for ``redditor``. + @_deprecate_args("other_subreddits") + async def subscribe( + self, *, other_subreddits: list[asyncpraw.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 subreddit = await reddit.subreddit("test") - await subreddit.moderator.remove_invite("spez") + await subreddit.subscribe() """ - data = {"name": str(redditor), "type": "moderator_invite"} - url = API_PATH["unfriend"].format(subreddit=self.subreddit) - await 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 + ), + } + await self._reddit.post(API_PATH["subscribe"], data=data) - @_deprecate_args("redditor", "permissions") - async def update( - self, - redditor: Union[str, "asyncpraw.models.Redditor"], - *, - permissions: Optional[List[str]] = None, - ): - """Update the moderator permissions for ``redditor``. + async 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: ``asyncprawcore.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:: - await 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 - await subreddit.moderator.update("spez", permissions=[]) + subreddit = await reddit.subreddit("test") + stats = await subreddit.traffic() """ - url = API_PATH["setpermissions"].format(subreddit=self.subreddit) - data = self._handle_permissions( - other_settings={"name": str(redditor), "type": "moderator"}, - permissions=permissions, - ) - await self.subreddit._reddit.post(url, data=data) + return await self._reddit.get(API_PATH["about_traffic"].format(subreddit=self)) - @_deprecate_args("redditor", "permissions") - async def update_invite( - self, - redditor: Union[str, "asyncpraw.models.Redditor"], - *, - permissions: Optional[List[str]] = None, + @_deprecate_args("other_subreddits") + async def unsubscribe( + self, *, other_subreddits: list[asyncpraw.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 - await subreddit.moderator.update_invite("spez", permissions=["flair", "mail"]) + subreddit = await reddit.subreddit("test") + await subreddit.unsubscribe() """ - url = API_PATH["setpermissions"].format(subreddit=self.subreddit) - data = self._handle_permissions( - other_settings={"name": str(redditor), "type": "moderator_invite"}, - permissions=permissions, - ) - await self.subreddit._reddit.post(url, data=data) + data = { + "action": "unsub", + "sr_name": self._subreddit_list( + other_subreddits=other_subreddits, subreddit=self + ), + } + await self._reddit.post(API_PATH["subscribe"], data=data) + + +WidgetEncoder._subreddit_class = Subreddit class SubredditLinkFlairTemplates(SubredditFlairTemplates): @@ -4312,7 +4271,7 @@ class SubredditLinkFlairTemplates(SubredditFlairTemplates): async def __aiter__( self, - ) -> AsyncGenerator[Dict[str, Union[str, int, bool, List[Dict[str, str]]]], None]: + ) -> AsyncGenerator[dict[str, str | int | bool | list[dict[str, str]]], None]: """Iterate through the link flair templates as a moderator. For example: @@ -4343,12 +4302,12 @@ async def add( self, text: str, *, - allowable_content: Optional[str] = None, - background_color: Optional[str] = None, + allowable_content: str | None = None, + background_color: str | None = None, css_class: str = "", - max_emojis: Optional[int] = None, - mod_only: Optional[bool] = None, - text_color: Optional[str] = None, + max_emojis: int | None = None, + mod_only: bool | None = None, + text_color: str | None = None, text_editable: bool = False, ): """Add a link flair template to the associated subreddit. @@ -4406,7 +4365,7 @@ async def clear(self): """ await self._clear(is_link=True) - async def reorder(self, flair_list: List[str]): + async def reorder(self, flair_list: list[str]): """Reorder a list of flairs. :param flair_list: A list of flair IDs. @@ -4424,7 +4383,7 @@ async def reorder(self, flair_list: List[str]): async def user_selectable( self, - ) -> AsyncGenerator[Dict[str, Union[str, bool]], None]: + ) -> AsyncGenerator[dict[str, str | bool], None]: """Iterate through the link flair templates as a regular user. For example: @@ -4448,7 +4407,7 @@ class SubredditRedditorFlairTemplates(SubredditFlairTemplates): async def __aiter__( self, - ) -> AsyncGenerator[Dict[str, Union[str, int, bool, List[Dict[str, str]]]], None]: + ) -> AsyncGenerator[dict[str, str | int | bool | list[dict[str, str]]], None]: """Iterate through the user flair templates. For example: @@ -4480,12 +4439,12 @@ async def add( self, text: str, *, - allowable_content: Optional[str] = None, - background_color: Optional[str] = None, + allowable_content: str | None = None, + background_color: str | None = None, css_class: str = "", - max_emojis: Optional[int] = None, - mod_only: Optional[bool] = None, - text_color: Optional[str] = None, + max_emojis: int | None = None, + mod_only: bool | None = None, + text_color: str | None = None, text_editable: bool = False, ): """Add a redditor flair template to the associated subreddit. @@ -4543,7 +4502,7 @@ async def clear(self): """ await self._clear(is_link=False) - async def reorder(self, flair_list: List[str]): + async def reorder(self, flair_list: list[str]): """Reorder a list of flairs. :param flair_list: A list of flair IDs. diff --git a/asyncpraw/models/reddit/user_subreddit.py b/asyncpraw/models/reddit/user_subreddit.py index 5b665e265..0f548225b 100644 --- a/asyncpraw/models/reddit/user_subreddit.py +++ b/asyncpraw/models/reddit/user_subreddit.py @@ -1,6 +1,8 @@ """Provide the :class:`.UserSubreddit` class.""" +from __future__ import annotations + import inspect -from typing import TYPE_CHECKING, Dict, Union +from typing import TYPE_CHECKING, Any, Callable from warnings import warn from ...util.cache import cachedproperty @@ -51,10 +53,10 @@ class UserSubreddit(Subreddit): """ @staticmethod - def _dict_depreciated_wrapper(func): + def _dict_deprecated_wrapper(func: Callable) -> Callable: """Show deprecation notice for dict only methods.""" - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any): warn( "'Redditor.subreddit' is no longer a dict and is now an UserSubreddit" f" object. Using '{func.__name__}' is deprecated and will be removed in" @@ -67,7 +69,7 @@ def wrapper(*args, **kwargs): return wrapper @cachedproperty - def mod(self) -> "asyncpraw.models.reddit.user_subreddit.UserSubredditModeration": + def mod(self) -> asyncpraw.models.reddit.user_subreddit.UserSubredditModeration: """Provide an instance of :class:`.UserSubredditModeration`. For example, to update the authenticated user's display name: @@ -80,7 +82,7 @@ def mod(self) -> "asyncpraw.models.reddit.user_subreddit.UserSubredditModeration """ return UserSubredditModeration(self) - def __getitem__(self, item): + def __getitem__(self, item: str) -> Any: """Show deprecation notice for dict method ``__getitem__``.""" warn( "'Redditor.subreddit' is no longer a dict and is now an UserSubreddit" @@ -90,7 +92,7 @@ def __getitem__(self, item): ) return getattr(self, item) - def __init__(self, reddit: "asyncpraw.Reddit", *args, **kwargs): + def __init__(self, reddit: asyncpraw.Reddit, *args: Any, **kwargs: Any): """Initialize an :class:`.UserSubreddit` instance. :param reddit: An instance of :class:`.Reddit`. @@ -112,21 +114,22 @@ def __init__(self, reddit: "asyncpraw.Reddit", *args, **kwargs): """ - def predicate(item): + def predicate(item: str): name = getattr(item, "__name__", None) return name not in dir(object) + dir(Subreddit) and name in dir(dict) - for name, member in inspect.getmembers(dict, predicate=predicate): + for name, _member in inspect.getmembers(dict, predicate=predicate): if name != "__getitem__": setattr( self, name, - self._dict_depreciated_wrapper(getattr(self.__dict__, name)), + self._dict_deprecated_wrapper(getattr(self.__dict__, name)), ) super().__init__(reddit, *args, **kwargs) +# noinspection PyIncorrectDocstring class UserSubredditModeration(SubredditModeration): """Provides a set of moderation functions to a :class:`.UserSubreddit`. @@ -140,8 +143,8 @@ class UserSubredditModeration(SubredditModeration): """ async def update( - self, **settings: Union[str, int, bool] - ) -> Dict[str, Union[str, int, bool]]: + self, **settings: str | (int | bool) + ) -> dict[str, str | (int | bool)]: """Update the :class:`.Subreddit`'s settings. :param all_original_content: Mandate all submissions to be original content diff --git a/asyncpraw/models/reddit/widgets.py b/asyncpraw/models/reddit/widgets.py old mode 100755 new mode 100644 index 7d1786741..83156d284 --- a/asyncpraw/models/reddit/widgets.py +++ b/asyncpraw/models/reddit/widgets.py @@ -1,8 +1,11 @@ """Provide classes related to widgets.""" +from __future__ import annotations -import os.path from json import JSONEncoder, dumps -from typing import TYPE_CHECKING, Any, Dict, List, Union +from pathlib import Path +from typing import TYPE_CHECKING, Any, TypeVar + +import aiofiles from ...const import API_PATH from ...util import _deprecate_args @@ -13,20 +16,7 @@ if TYPE_CHECKING: # pragma: no cover import asyncpraw -WidgetType = Union[ - "asyncpraw.models.ButtonWidget", - "asyncpraw.models.Calendar", - "asyncpraw.models.CommunityList", - "asyncpraw.models.CustomWidget", - "asyncpraw.models.IDCard", - "asyncpraw.models.ImageWidget", - "asyncpraw.models.Menu", - "asyncpraw.models.ModeratorsWidget", - "asyncpraw.models.PostFlairWidget", - "asyncpraw.models.RulesWidget", - "asyncpraw.models.TextArea", - "asyncpraw.models.Widget", -] +WidgetType: TypeVar = TypeVar("WidgetType", bound="Widget") class Button(AsyncPRAWBase): @@ -271,7 +261,7 @@ class SubredditWidgets(AsyncPRAWBase): """ @cachedproperty - def mod(self) -> "asyncpraw.models.SubredditWidgetsModeration": + def mod(self) -> asyncpraw.models.SubredditWidgetsModeration: """Get an instance of :class:`.SubredditWidgetsModeration`. .. note:: @@ -286,16 +276,12 @@ def mod(self) -> "asyncpraw.models.SubredditWidgetsModeration": def __getattr__(self, attr: str) -> Any: """Return the value of ``attr``.""" if not attr.startswith("_") and not self._fetched: - raise AttributeError( - f"{self.__class__.__name__!r} object has no attribute {attr!r}, did you" - " forget to run '.refresh()'?" - ) - raise AttributeError( # pragma: no cover; I have no idea how to cover this - f"{self.__class__.__name__!r} object has no attribute {attr!r}, did you" - " forget to run '.refresh()'?" - ) + msg = f"{self.__class__.__name__!r} object has no attribute {attr!r}, did you forget to run '.refresh()'?" + raise AttributeError(msg) + msg = f"{self.__class__.__name__!r} object has no attribute {attr!r}, did you forget to run '.refresh()'?" # pragma: no cover + raise AttributeError(msg) # pragma: no cover - def __init__(self, subreddit: "asyncpraw.models.Subreddit"): + def __init__(self, subreddit: asyncpraw.models.Subreddit): """Initialize a :class:`.SubredditWidgets` instance. :param subreddit: The :class:`.Subreddit` the widgets belong to. @@ -325,12 +311,12 @@ async def _fetch(self): self._fetched = True - async def id_card(self) -> "asyncpraw.models.IDCard": + async def id_card(self) -> asyncpraw.models.IDCard: """Get this :class:`.Subreddit`'s :class:`.IDCard` widget.""" items = await self.items() return items[self.layout["idCardWidget"]] - async def items(self) -> Dict[str, "asyncpraw.models.Widget"]: + async def items(self) -> dict[str, asyncpraw.models.Widget]: """Get this :class:`.Subreddit`'s widgets as a dict from ID to widget.""" if self._items is None: if not self._raw_items: @@ -341,7 +327,7 @@ async def items(self) -> Dict[str, "asyncpraw.models.Widget"]: self._items[item_name] = self._reddit._objector.objectify(data) return self._items - async def moderators_widget(self) -> "asyncpraw.models.ModeratorsWidget": + async def moderators_widget(self) -> asyncpraw.models.ModeratorsWidget: """Get this :class:`.Subreddit`'s :class:`.ModeratorsWidget`.""" items = await self.items() return items[self.layout["moderatorWidget"]] @@ -363,13 +349,13 @@ async def refresh(self): """ await self._fetch() - async def sidebar(self) -> List["asyncpraw.models.Widget"]: + async def sidebar(self) -> list[asyncpraw.models.Widget]: r"""Get a list of :class:`.Widget`\ s that make up the sidebar.""" items = await self.items() for widget in self.layout["sidebar"]["order"]: yield items[widget] - async def topbar(self) -> List["asyncpraw.models.Menu"]: + async def topbar(self) -> list[asyncpraw.models.Menu]: r"""Get a list of :class:`.Widget`\ s that make up the top bar.""" items = await self.items() for widget in self.layout["topbar"]["order"]: @@ -380,26 +366,25 @@ class Widget(AsyncPRAWBase): """Base class to represent a :class:`.Widget`.""" @cachedproperty - def mod(self) -> "asyncpraw.models.WidgetModeration": + def mod(self) -> asyncpraw.models.WidgetModeration: """Get an instance of :class:`.WidgetModeration` for this widget. .. note:: - Using any of the methods of :class:`.WidgetModeration` will likely make - outdated the data in the :class:`.SubredditWidgets` that this widget belongs - to. To remedy this, call :meth:`~.SubredditWidgets.refresh`. + Using any of the methods of :class:`.WidgetModeration` will likely make the + data in the :class:`.SubredditWidgets` that this widget belongs to outdated. + To remedy this, call :meth:`~.SubredditWidgets.refresh`. """ return WidgetModeration(self, self.subreddit, self._reddit) - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: """Check equality against another object.""" if isinstance(other, Widget): return self.id.lower() == other.id.lower() return str(other).lower() == self.id.lower() - # pylint: disable=invalid-name - def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): + def __init__(self, reddit: asyncpraw.Reddit, _data: dict[str, Any]): """Initialize a :class:`.Widget` instance.""" self.subreddit = "" # in case it isn't in _data self.id = "" # in case it isn't in _data @@ -410,11 +395,11 @@ def __init__(self, reddit: "asyncpraw.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 ``AsyncPRAWBase`` objects.""" if isinstance(o, self._subreddit_class): return str(o) - elif isinstance(o, AsyncPRAWBase): + if isinstance(o, AsyncPRAWBase): return {key: val for key, val in vars(o).items() if not key.startswith("_")} return JSONEncoder.default(self, o) @@ -729,7 +714,7 @@ class CustomWidget(Widget): """ - def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): + def __init__(self, reddit: asyncpraw.Reddit, _data: dict[str, Any]): """Initialize a :class:`.CustomWidget` instance.""" _data["imageData"] = [ ImageData(reddit, data) for data in _data.pop("imageData") @@ -962,7 +947,7 @@ class ModeratorsWidget(Widget, BaseList): CHILD_ATTRIBUTE = "mods" - def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): + def __init__(self, reddit: asyncpraw.Reddit, _data: dict[str, Any]): """Initialize a :class:`.ModeratorsWidget` instance.""" if self.CHILD_ATTRIBUTE not in _data: # .mod.update() sometimes returns payload without "mods" field @@ -1082,7 +1067,7 @@ class RulesWidget(Widget, BaseList): CHILD_ATTRIBUTE = "data" - def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): + def __init__(self, reddit: asyncpraw.Reddit, _data: dict[str, Any]): """Initialize a :class:`.RulesWidget` instance.""" if self.CHILD_ATTRIBUTE not in _data: # .mod.update() sometimes returns payload without "data" field @@ -1149,6 +1134,87 @@ class TextArea(Widget): """ +class WidgetModeration: + """Class for moderating a particular widget. + + Example usage: + + .. code-block:: python + + subreddit = await reddit.subreddit("test") + sidebar = [widget async for widget in subreddit.widgets.sidebar()] + widget = sidebar[0] + await widget.mod.update(shortName="My new title") + await widget.mod.delete() + + """ + + def __init__( + self, + widget: asyncpraw.models.Widget, + subreddit: asyncpraw.models.Subreddit | str, + reddit: asyncpraw.Reddit, + ): + """Initialize a :class:`.WidgetModeration` instance.""" + self.widget = widget + self._reddit = reddit + self._subreddit = subreddit + + async def delete(self): + """Delete the widget. + + Example usage: + + .. code-block:: python + + await widget.mod.delete() + + """ + path = API_PATH["widget_modify"].format( + widget_id=self.widget.id, subreddit=self._subreddit + ) + await self._reddit.delete(path) + + async 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 + + await 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 = await 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. @@ -1171,14 +1237,12 @@ class SubredditWidgetsModeration: """ - def __init__( - self, subreddit: "asyncpraw.models.Subreddit", reddit: "asyncpraw.Reddit" - ): + def __init__(self, subreddit: asyncpraw.models.Subreddit, reddit: asyncpraw.Reddit): """Initialize a :class:`.SubredditWidgetsModeration` instance.""" self._subreddit = subreddit self._reddit = reddit - async def _create_widget(self, payload: Dict[str, Any]) -> WidgetType: + async def _create_widget(self, payload: dict[str, Any]) -> WidgetType: path = API_PATH["widget_create"].format(subreddit=self._subreddit) widget = await self._reddit.post( path, data={"json": dumps(payload, cls=WidgetEncoder)} @@ -1190,14 +1254,12 @@ async def _create_widget(self, payload: Dict[str, Any]) -> WidgetType: async def add_button_widget( self, *, - buttons: List[ - Dict[str, Union[Dict[str, str], str, int, Dict[str, Union[str, int]]]] - ], + buttons: list[dict[str, dict[str, str | int] | str | int]], description: str, short_name: str, - styles: Dict[str, str], - **other_settings, - ) -> "asyncpraw.models.ButtonWidget": + styles: dict[str, str], + **other_settings: Any, + ) -> asyncpraw.models.ButtonWidget: """Add and return a :class:`.ButtonWidget`. :param buttons: A list of dictionaries describing buttons, as specified in @@ -1335,13 +1397,13 @@ async def add_button_widget( async def add_calendar( self, *, - configuration: Dict[str, Union[bool, int]], + configuration: dict[str, bool | int], google_calendar_id: str, requires_sync: bool, short_name: str, - styles: Dict[str, str], - **other_settings, - ) -> "asyncpraw.models.Calendar": + styles: dict[str, str], + **other_settings: Any, + ) -> asyncpraw.models.Calendar: """Add and return a :class:`.Calendar` widget. :param configuration: A dictionary as specified in `Reddit docs`_. For example: @@ -1409,12 +1471,12 @@ async def add_calendar( async def add_community_list( self, *, - data: List[Union[str, "asyncpraw.models.Subreddit"]], + data: list[str | asyncpraw.models.Subreddit], description: str = "", short_name: str, - styles: Dict[str, str], - **other_settings, - ) -> "asyncpraw.models.CommunityList": + styles: dict[str, str], + **other_settings: Any, + ) -> asyncpraw.models.CommunityList: """Add and return a :class:`.CommunityList` widget. :param data: A list of subreddits. Subreddits can be represented as ``str`` or @@ -1457,12 +1519,12 @@ async def add_custom_widget( *, css: str, height: int, - image_data: List[Dict[str, Union[str, int]]], + image_data: list[dict[str, str | int]], short_name: str, - styles: Dict[str, str], + styles: dict[str, str], text: str, - **other_settings, - ) -> "asyncpraw.models.CustomWidget": + **other_settings: Any, + ) -> asyncpraw.models.CustomWidget: """Add and return a :class:`.CustomWidget`. :param css: The CSS for the widget, no longer than 100000 characters. @@ -1547,11 +1609,11 @@ async def add_custom_widget( async def add_image_widget( self, *, - data: List[Dict[str, Union[str, int]]], + data: list[dict[str, str | int]], short_name: str, - styles: Dict[str, str], - **other_settings, - ) -> "asyncpraw.models.ImageWidget": + styles: dict[str, str], + **other_settings: Any, + ) -> asyncpraw.models.ImageWidget: """Add and return an :class:`.ImageWidget`. :param data: A list of dictionaries as specified in `Reddit docs`_. Each @@ -1621,9 +1683,9 @@ async def add_image_widget( async def add_menu( self, *, - data: List[Dict[str, Union[List[Dict[str, str]], str]]], - **other_settings, - ) -> "asyncpraw.models.Menu": + data: list[dict[str, list[dict[str, str]] | str]], + **other_settings: Any, + ) -> asyncpraw.models.Menu: """Add and return a :class:`.Menu` widget. :param data: A list of dictionaries describing menu contents, as specified in @@ -1686,11 +1748,11 @@ async def add_post_flair_widget( self, *, display: str, - order: List[str], + order: list[str], short_name: str, - styles: Dict[str, str], - **other_settings, - ) -> "asyncpraw.models.PostFlairWidget": + styles: dict[str, str], + **other_settings: dict[str, Any], + ) -> asyncpraw.models.PostFlairWidget: """Add and return a :class:`.PostFlairWidget`. :param display: Display style. Either ``"cloud"`` or ``"list"``. @@ -1733,8 +1795,13 @@ async def add_post_flair_widget( @_deprecate_args("short_name", "text", "styles") async def add_text_area( - self, *, short_name: str, styles: Dict[str, str], text: str, **other_settings - ) -> "asyncpraw.models.TextArea": + self, + *, + short_name: str, + styles: dict[str, str], + text: str, + **other_settings: Any, + ) -> asyncpraw.models.TextArea: """Add and return a :class:`.TextArea` widget. :param short_name: A name for the widget, no longer than 30 characters. @@ -1767,9 +1834,7 @@ async def add_text_area( return await self._create_widget(text_area) @_deprecate_args("new_order", "section") - async def reorder( - self, new_order: List[Union[WidgetType, str]], *, section: str = "sidebar" - ): + async def reorder(self, new_order: list[Widget | str], *, section: str = "sidebar"): """Reorder the widgets. :param new_order: A list of widgets. Represented as a list that contains @@ -1820,8 +1885,9 @@ async def upload_image(self, file_path: str) -> str: ) """ + file = Path(file_path) img_data = { - "filepath": os.path.basename(file_path), + "filepath": file.name, "mimetype": "image/jpeg", } if file_path.lower().endswith(".png"): @@ -1834,7 +1900,7 @@ async def upload_image(self, file_path: str) -> str: upload_data = {item["name"]: item["value"] for item in upload_lease["fields"]} upload_url = f"https:{upload_lease['action']}" - with open(file_path, "rb") as image: + async with aiofiles.open(file_path, "rb") as image: upload_data["file"] = image response = await self._reddit._core._requestor._http.post( upload_url, data=upload_data @@ -1842,84 +1908,3 @@ async 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 - - subreddit = await reddit.subreddit("test") - sidebar = [widget async for widget in subreddit.widgets.sidebar()] - widget = sidebar[0] - await widget.mod.update(shortName="My new title") - await widget.mod.delete() - - """ - - def __init__( - self, - widget: "asyncpraw.models.Widget", - subreddit: Union["asyncpraw.models.Subreddit", str], - reddit: "asyncpraw.Reddit", - ): - """Initialize a :class:`.WidgetModeration` instance.""" - self.widget = widget - self._reddit = reddit - self._subreddit = subreddit - - async def delete(self): - """Delete the widget. - - Example usage: - - .. code-block:: python - - await widget.mod.delete() - - """ - path = API_PATH["widget_modify"].format( - widget_id=self.widget.id, subreddit=self._subreddit - ) - await self._reddit.delete(path) - - async def update(self, **kwargs) -> WidgetType: - """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 - - await 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 = await self._reddit.put( - path, data={"json": dumps(payload, cls=WidgetEncoder)} - ) - widget.subreddit = self._subreddit - return widget diff --git a/asyncpraw/models/reddit/wikipage.py b/asyncpraw/models/reddit/wikipage.py index d1381cc98..774395ab5 100644 --- a/asyncpraw/models/reddit/wikipage.py +++ b/asyncpraw/models/reddit/wikipage.py @@ -1,12 +1,11 @@ """Provide the WikiPage class.""" +from __future__ import annotations + from typing import ( TYPE_CHECKING, Any, AsyncGenerator, AsyncIterator, - Dict, - Optional, - Union, ) from ...const import API_PATH @@ -23,7 +22,7 @@ class WikiPageModeration: """Provides a set of moderation functions for a :class:`.WikiPage`. - For example, to add ``spez`` as an editor on the wikipage ``praw_test`` try: + For example, to add u/spez as an editor on the wikipage ``"praw_test"`` try: .. code-block:: python @@ -33,7 +32,7 @@ class WikiPageModeration: """ - def __init__(self, wikipage: "WikiPage"): + def __init__(self, wikipage: WikiPage): """Initialize a :class:`.WikiPageModeration` instance. :param wikipage: The wikipage to moderate. @@ -41,7 +40,7 @@ def __init__(self, wikipage: "WikiPage"): """ self.wikipage = wikipage - async def add(self, redditor: "asyncpraw.models.Redditor"): + async def add(self, redditor: asyncpraw.models.Redditor): """Add an editor to this :class:`.WikiPage`. :param redditor: A redditor name or :class:`.Redditor` instance. @@ -61,7 +60,7 @@ async def add(self, redditor: "asyncpraw.models.Redditor"): ) await self.wikipage._reddit.post(url, data=data) - async def remove(self, redditor: "asyncpraw.models.Redditor"): + async def remove(self, redditor: asyncpraw.models.Redditor): """Remove an editor from this :class:`.WikiPage`. :param redditor: A redditor name or :class:`.Redditor` instance. @@ -134,7 +133,7 @@ async def revert(self): }, ) - async def settings(self) -> Dict[str, Any]: + async def settings(self) -> dict[str, Any]: """Return the settings for this :class:`.WikiPage`.""" url = API_PATH["wiki_page_settings"].format( subreddit=self.wikipage.subreddit, page=self.wikipage.name @@ -145,7 +144,7 @@ async def settings(self) -> Dict[str, Any]: @_deprecate_args("listed", "permlevel") async def update( self, *, listed: bool, permlevel: int, **other_settings: Any - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Update the settings for this :class:`.WikiPage`. :param listed: Show this page on page list. @@ -156,7 +155,7 @@ async def update( :returns: The updated WikiPage settings. - To set the wikipage ``praw_test`` in r/test to mod only and disable it from + To set the wikipage ``"praw_test"`` in r/test to mod only and disable it from showing in the page list, try: .. code-block:: python @@ -202,12 +201,10 @@ class WikiPage(RedditBase): @staticmethod async def _revision_generator( *, - generator_kwargs: Dict[str, Any], - subreddit: "asyncpraw.models.Subreddit", + generator_kwargs: dict[str, Any], + subreddit: asyncpraw.models.Subreddit, url: str, - ) -> AsyncGenerator[ - Dict[str, Optional[Union[Redditor, "WikiPage", str, int, bool]]], None - ]: + ) -> AsyncGenerator[dict[str, Redditor | WikiPage | str | int | bool | None], None]: async for revision in ListingGenerator( subreddit._reddit, url, **generator_kwargs ): @@ -237,11 +234,11 @@ def mod(self) -> WikiPageModeration: def __init__( self, - reddit: "asyncpraw.Reddit", - subreddit: "asyncpraw.models.Subreddit", + reddit: asyncpraw.Reddit, + subreddit: asyncpraw.models.Subreddit, name: str, - revision: Optional[str] = None, - _data: Optional[Dict[str, Any]] = None, + revision: str | None = None, + _data: dict[str, Any] | None = None, ): """Initialize a :class:`.WikiPage` instance. @@ -273,7 +270,7 @@ async def _fetch(self): self._reddit, _data=data["revision_by"]["data"] ) self.__dict__.update(data) - self._fetched = True + await super()._fetch() def _fetch_info(self): return ( @@ -284,7 +281,7 @@ def _fetch_info(self): def discussions( self, **generator_kwargs: Any - ) -> AsyncIterator["asyncpraw.models.Submission"]: + ) -> AsyncIterator[asyncpraw.models.Submission]: """Return a :class:`.ListingGenerator` for discussions of a wiki page. Discussions are site-wide links to a wiki page. @@ -312,7 +309,7 @@ def discussions( @_deprecate_args("content", "reason") async def edit( - self, *, content: str, reason: Optional[str] = None, **other_settings: Any + self, *, content: str, reason: str | None = None, **other_settings: Any ): """Edit this wiki page's contents. @@ -335,7 +332,7 @@ async def edit( API_PATH["wiki_edit"].format(subreddit=self.subreddit), data=other_settings ) - async def revision(self, revision: str): + async def revision(self, revision: str) -> WikiPage: """Return a specific version of this page by revision ID. To view revision ``"1234abc"`` of ``"praw_test"`` in r/test: @@ -352,8 +349,8 @@ async def revision(self, revision: str): return page def revisions( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncGenerator["WikiPage", None]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncGenerator[WikiPage, None]: """Return a :class:`.ListingGenerator` for page revisions. Additional keyword arguments are passed in the initialization of diff --git a/asyncpraw/models/redditors.py b/asyncpraw/models/redditors.py index 6a4baf474..8db5473b0 100644 --- a/asyncpraw/models/redditors.py +++ b/asyncpraw/models/redditors.py @@ -1,7 +1,9 @@ """Provide the Redditors class.""" +from __future__ import annotations + from itertools import islice from types import SimpleNamespace -from typing import TYPE_CHECKING, AsyncIterator, Dict, Iterable, Union +from typing import TYPE_CHECKING, AsyncIterator, Iterable import asyncprawcore @@ -22,8 +24,8 @@ class Redditors(AsyncPRAWBase): """Redditors is a Listing class that provides various :class:`.Redditor` lists.""" def new( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: """Return a :class:`.ListingGenerator` for new :class:`.Redditors`. :returns: :class:`.Redditor` profiles, which are a type of :class:`.Subreddit`. @@ -67,8 +69,8 @@ async def partial_redditors( yield PartialRedditor(fullname=fullname, **user_data) def popular( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: """Return a :class:`.ListingGenerator` for popular :class:`.Redditors`. :returns: :class:`.Redditor` profiles, which are a type of :class:`.Subreddit`. @@ -82,8 +84,8 @@ def popular( ) def search( - self, query: str, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, query: str, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: r"""Return a :class:`.ListingGenerator` of Redditors for ``query``. :param query: The query string to filter Redditors by. @@ -100,8 +102,8 @@ def search( ) def stream( - self, **stream_options: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, **stream_options: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: """Yield new Redditors as they are created. Redditors are yielded oldest first. Up to 100 historical Redditors will diff --git a/asyncpraw/models/subreddits.py b/asyncpraw/models/subreddits.py index ad18b5053..c09b708c0 100644 --- a/asyncpraw/models/subreddits.py +++ b/asyncpraw/models/subreddits.py @@ -1,5 +1,7 @@ """Provide the Subreddits class.""" -from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, AsyncIterator from warnings import warn from ..const import API_PATH @@ -17,12 +19,12 @@ class Subreddits(AsyncPRAWBase): """Subreddits is a Listing class that provides various subreddit lists.""" @staticmethod - def _to_list(subreddit_list): + def _to_list(subreddit_list: list[str | asyncpraw.models.Subreddit]) -> str: return ",".join([str(x) for x in subreddit_list]) def default( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: """Return a :class:`.ListingGenerator` for default subreddits. Additional keyword arguments are passed in the initialization of @@ -33,7 +35,9 @@ def default( self._reddit, API_PATH["subreddits_default"], **generator_kwargs ) - def gold(self, **generator_kwargs) -> AsyncIterator["asyncpraw.models.Subreddit"]: + def gold( + self, **generator_kwargs: Any + ) -> AsyncIterator[asyncpraw.models.Subreddit]: """Alias for :meth:`.premium` to maintain backwards compatibility.""" warn( "'subreddits.gold' has be renamed to 'subreddits.premium'.", @@ -43,8 +47,8 @@ def gold(self, **generator_kwargs) -> AsyncIterator["asyncpraw.models.Subreddit" return self.premium(**generator_kwargs) def new( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: """Return a :class:`.ListingGenerator` for new subreddits. Additional keyword arguments are passed in the initialization of @@ -56,8 +60,8 @@ def new( ) def popular( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: """Return a :class:`.ListingGenerator` for popular subreddits. Additional keyword arguments are passed in the initialization of @@ -69,8 +73,8 @@ def popular( ) def premium( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: """Return a :class:`.ListingGenerator` for premium subreddits. Additional keyword arguments are passed in the initialization of @@ -83,11 +87,9 @@ def premium( async def recommended( self, - subreddits: List[Union[str, "asyncpraw.models.Subreddit"]], - omit_subreddits: Optional[ - List[Union[str, "asyncpraw.models.Subreddit"]] - ] = None, - ) -> List["asyncpraw.models.Subreddit"]: + subreddits: list[str | asyncpraw.models.Subreddit], + omit_subreddits: list[str | asyncpraw.models.Subreddit] | None = None, + ) -> list[asyncpraw.models.Subreddit]: """Return subreddits recommended for the given list of subreddits. :param subreddits: A list of :class:`.Subreddit` instances and/or subreddit @@ -97,9 +99,11 @@ async def recommended( """ if not isinstance(subreddits, list): - raise TypeError("subreddits must be a list") + msg = "subreddits must be a list" + raise TypeError(msg) if omit_subreddits is not None and not isinstance(omit_subreddits, list): - raise TypeError("omit_subreddits must be a list or None") + msg = "omit_subreddits must be a list or None" + raise TypeError(msg) params = {"omit": self._to_list(omit_subreddits or [])} url = API_PATH["sub_recommended"].format(subreddits=self._to_list(subreddits)) @@ -109,8 +113,8 @@ async def recommended( ] def search( - self, query: str, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, query: str, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: """Return a :class:`.ListingGenerator` of subreddits matching ``query``. Subreddits are searched by both their title and description. @@ -137,7 +141,7 @@ async def search_by_name( *, include_nsfw: bool = True, exact: bool = False, - ) -> List["asyncpraw.models.Subreddit"]: + ) -> list[asyncpraw.models.Subreddit]: r"""Return list of :class:`.Subreddit`\ s whose names begin with ``query``. :param query: Search for subreddits beginning with this string. @@ -155,7 +159,7 @@ async def search_by_name( async def search_by_topic( self, query: str ) -> AsyncIterator[ - "asyncpraw.models.Subreddit" + asyncpraw.models.Subreddit ]: # pragma: no cover; TODO: not currently working """Return list of Subreddits whose topics match ``query``. @@ -174,8 +178,8 @@ async def search_by_topic( yield subreddit def stream( - self, **stream_options: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, **stream_options: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: """Yield new subreddits as they are created. Subreddits are yielded oldest first. Up to 100 historical subreddits will diff --git a/asyncpraw/models/trophy.py b/asyncpraw/models/trophy.py index 00723e289..dbc9926cd 100644 --- a/asyncpraw/models/trophy.py +++ b/asyncpraw/models/trophy.py @@ -1,5 +1,7 @@ """Represent the :class:`.Trophy` class.""" -from typing import TYPE_CHECKING, Any, Dict, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from .base import AsyncPRAWBase @@ -28,13 +30,13 @@ class Trophy(AsyncPRAWBase): """ - def __eq__(self, other: Union["Trophy", Any]) -> bool: + def __eq__(self, other: Trophy | Any) -> bool: """Check if two Trophies are equal.""" if isinstance(other, self.__class__): return self.name == other.name return super().__eq__(other) - def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): + def __init__(self, reddit: asyncpraw.Reddit, _data: dict[str, Any]): """Initialize a :class:`.Trophy` instance. :param reddit: An instance of :class:`.Reddit`. @@ -42,7 +44,8 @@ def __init__(self, reddit: "asyncpraw.Reddit", _data: Dict[str, Any]): be provided. """ - assert isinstance(_data, dict) and "name" in _data + assert isinstance(_data, dict) + assert "name" in _data super().__init__(reddit, _data=_data) def __repr__(self) -> str: @@ -51,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/asyncpraw/models/user.py b/asyncpraw/models/user.py index c4fb1e792..b3bbca7b1 100644 --- a/asyncpraw/models/user.py +++ b/asyncpraw/models/user.py @@ -1,5 +1,7 @@ """Provides the User class.""" -from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, AsyncIterator from warnings import warn from asyncprawcore import Conflict @@ -22,7 +24,7 @@ class User(AsyncPRAWBase): """The :class:`.User` class provides methods for the currently authenticated user.""" @cachedproperty - def preferences(self) -> "asyncpraw.models.Preferences": + def preferences(self) -> asyncpraw.models.Preferences: """Get an instance of :class:`.Preferences`. The preferences can be accessed as a ``dict`` like so: @@ -51,7 +53,7 @@ def preferences(self) -> "asyncpraw.models.Preferences": """ return Preferences(self._reddit) - def __init__(self, reddit: "asyncpraw.Reddit"): + def __init__(self, reddit: asyncpraw.Reddit): """Initialize an :class:`.User` instance. This class is intended to be interfaced with through ``reddit.user``. @@ -59,13 +61,13 @@ def __init__(self, reddit: "asyncpraw.Reddit"): """ super().__init__(reddit, _data=None) - async def blocked(self) -> List["asyncpraw.models.Redditor"]: + async def blocked(self) -> list[asyncpraw.models.Redditor]: r"""Return a :class:`.RedditorList` of blocked :class:`.Redditor`\ s.""" return await self._reddit.get(API_PATH["blocked"]) def contributor_subreddits( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: r"""Return a :class:`.ListingGenerator` of contributor :class:`.Subreddit`\ s. These are subreddits in which the user is an approved user. @@ -87,8 +89,8 @@ def contributor_subreddits( @_deprecate_args("user") async def friends( - self, *, user: Optional[Union[str, "asyncpraw.models.Redditor"]] = None - ) -> Union[List["asyncpraw.models.Redditor"], "asyncpraw.models.Redditor"]: + self, *, user: str | asyncpraw.models.Redditor | None = None + ) -> list[asyncpraw.models.Redditor] | asyncpraw.models.Redditor: r"""Return a :class:`.RedditorList` of friends or a :class:`.Redditor` in the friends list. :param user: Checks to see if you are friends with the redditor. Either an @@ -98,8 +100,8 @@ async def friends( ``user`` is specified. The :class:`.Redditor` instance(s) returned also has friend attributes. - :raises: An instance of ``asyncprawcore.exceptions.BadRequest`` if you are not - friends with the specified :class:`.Redditor`. + :raises: An instance of :class:`.RedditAPIException` if you are not friends with + the specified :class:`.Redditor`. """ endpoint = ( @@ -109,7 +111,7 @@ async def friends( ) return await self._reddit.get(endpoint) - async def karma(self) -> Dict["asyncpraw.models.Subreddit", Dict[str, int]]: + async def karma(self) -> dict[asyncpraw.models.Subreddit, dict[str, int]]: r"""Return a dictionary mapping :class:`.Subreddit`\ s to their karma. The returned dict contains subreddits as keys. Each subreddit key contains a @@ -132,9 +134,7 @@ async def karma(self) -> Dict["asyncpraw.models.Subreddit", Dict[str, int]]: return karma_map @_deprecate_args("use_cache") - async def me( - self, *, use_cache: bool = True - ) -> Optional["asyncpraw.models.Redditor"]: # pylint: disable=invalid-name + async def me(self, *, use_cache: bool = True) -> asyncpraw.models.Redditor | None: """Return a :class:`.Redditor` instance for the authenticated user. :param use_cache: When ``True``, and if this function has been previously @@ -164,15 +164,16 @@ async def me( stacklevel=2, ) return None - raise ReadOnlyException("`user.me()` does not work in read_only mode") + msg = "`user.me()` does not work in read_only mode" + raise ReadOnlyException(msg) if "_me" not in self.__dict__ or not use_cache: user_data = await self._reddit.get(API_PATH["me"]) self._me = Redditor(self._reddit, _data=user_data) return self._me def moderator_subreddits( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: """Return a :class:`.ListingGenerator` subreddits that the user moderates. Additional keyword arguments are passed in the initialization of @@ -194,17 +195,17 @@ def moderator_subreddits( self._reddit, API_PATH["my_moderator"], **generator_kwargs ) - async def multireddits(self) -> List["asyncpraw.models.Multireddit"]: + async def multireddits(self) -> list[asyncpraw.models.Multireddit]: r"""Return a list of :class:`.Multireddit`\ s belonging to the user.""" return await self._reddit.get(API_PATH["my_multireddits"]) async def pin( self, - submission: "asyncpraw.models.Submission", + submission: asyncpraw.models.Submission, *, num: int = None, state: bool = True, - ): + ) -> asyncpraw.models.Submission: """Set the pin state of a submission on the authenticated user's profile. :param submission: An instance of :class:`.Submission` that will be @@ -237,6 +238,8 @@ async def pin( :param state: ``True`` pins the submission, ``False`` unpins (default: ``True``). + :returns: The pinned submission. + :raises: ``asyncprawcore.BadRequest`` when pinning a removed or deleted submission. @@ -263,8 +266,8 @@ async def pin( pass def subreddits( - self, **generator_kwargs: Union[str, int, Dict[str, str]] - ) -> AsyncIterator["asyncpraw.models.Subreddit"]: + self, **generator_kwargs: str | int | dict[str, str] + ) -> AsyncIterator[asyncpraw.models.Subreddit]: r"""Return a :class:`.ListingGenerator` of :class:`.Subreddit`\ s the user is subscribed to. Additional keyword arguments are passed in the initialization of @@ -282,7 +285,7 @@ def subreddits( self._reddit, API_PATH["my_subreddits"], **generator_kwargs ) - async def trusted(self) -> List["asyncpraw.models.Redditor"]: + async def trusted(self) -> list[asyncpraw.models.Redditor]: r"""Return a :class:`.RedditorList` of trusted :class:`.Redditor`\ s. To display the usernames of your trusted users and the times at which you diff --git a/asyncpraw/models/util.py b/asyncpraw/models/util.py index f0ea19ae4..265ee4ba7 100644 --- a/asyncpraw/models/util.py +++ b/asyncpraw/models/util.py @@ -1,19 +1,21 @@ """Provide helper classes used by other models.""" +from __future__ import annotations + import asyncio import random from collections import OrderedDict from functools import wraps -from typing import Any, AsyncGenerator, Callable, List, Optional, Set, Union +from typing import Any, AsyncGenerator, Callable from warnings import warn from ..util import _deprecate_args -def deprecate_lazy(func): # noqa: D401 - """A decorator used for deprecating the ``lazy`` keyword argument.""" +def deprecate_lazy(func: Callable) -> Callable[..., Any]: + """A decorator used for deprecating the ``lazy`` keyword argument.""" # noqa: D401 @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any): if "lazy" in kwargs: kwargs.setdefault("fetch", not kwargs.pop("lazy")) warn( @@ -30,7 +32,7 @@ def wrapper(*args, **kwargs): @_deprecate_args("permissions", "known_permissions") def permissions_string( - *, known_permissions: Set[str], permissions: Optional[List[str]] + *, known_permissions: set[str], permissions: list[str] | None ) -> str: """Return a comma separated string of permission changes. @@ -61,7 +63,7 @@ async def stream_generator( *, attribute_name: str = "fullname", exclude_before: bool = False, - pause_after: Optional[int] = None, + pause_after: int | None = None, skip_existing: bool = False, **function_kwargs: Any, ) -> AsyncGenerator[Any, None]: @@ -229,10 +231,10 @@ def __init__(self, max_counter: int): self._base = 1 self._max = max_counter - def counter(self) -> Union[int, float]: + def counter(self) -> int | float: """Increment the counter and return the current value with jitter.""" max_jitter = self._base / 16.0 - value = self._base + random.random() * max_jitter - max_jitter / 2 + value = self._base + random.random() * max_jitter - max_jitter / 2 # noqa: S311 self._base = min(self._base * 2, self._max) return value diff --git a/asyncpraw/objector.py b/asyncpraw/objector.py index 925da4fa6..acc94b74e 100644 --- a/asyncpraw/objector.py +++ b/asyncpraw/objector.py @@ -1,21 +1,24 @@ """Provides the Objector class.""" +from __future__ import annotations + from datetime import datetime from json import loads -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any from .exceptions import ClientException, RedditAPIException -from .models.reddit.base import RedditBase from .util import snake_case_keys if TYPE_CHECKING: # pragma: no cover import asyncpraw + from .models.reddit.base import RedditBase + class Objector: """The objector builds :class:`.RedditBase` objects.""" @classmethod - def check_error(cls, data: Union[List[Any], Dict[str, Dict[str, str]]]): + def check_error(cls, data: list[Any] | dict[str, dict[str, str]]): """Raise an error if the argument resolves to an error object.""" error = cls.parse_error(data) if error: @@ -23,8 +26,8 @@ def check_error(cls, data: Union[List[Any], Dict[str, Dict[str, str]]]): @classmethod def parse_error( - cls, data: Union[List[Any], Dict[str, Dict[str, str]]] - ) -> Optional[RedditAPIException]: + cls, data: list[Any] | dict[str, dict[str, str]] + ) -> RedditAPIException | None: """Convert JSON response into an error object. :param data: The dict to be converted. @@ -44,12 +47,11 @@ def parse_error( return None if len(errors) < 1: # See `Collection._fetch()`. - raise ClientException("successful error response", data) + msg = "successful error response" + raise ClientException(msg, data) return RedditAPIException(errors) - def __init__( - self, reddit: "asyncpraw.Reddit", parsers: Optional[Dict[str, Any]] = None - ): + def __init__(self, reddit: asyncpraw.Reddit, parsers: dict[str, Any] | None = None): """Initialize an :class:`.Objector` instance. :param reddit: An instance of :class:`.Reddit`. @@ -58,7 +60,9 @@ def __init__( self.parsers = {} if parsers is None else parsers self._reddit = reddit - def _objectify_dict(self, data): + 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. @@ -154,7 +158,7 @@ def _objectify_dict(self, data): parser = self.parsers[self._reddit.config.kinds["redditor"]] elif {"parent_id"}.issubset(data): parser = self.parsers[self._reddit.config.kinds["comment"]] - elif "collection_id" in data.keys(): + elif "collection_id" in data: parser = self.parsers["Collection"] elif {"moderators", "moderatorIds", "allUsersLoaded", "subredditId"}.issubset( data @@ -167,7 +171,7 @@ def _objectify_dict(self, data): moderators.append(mod) data["moderators"] = moderators parser = self.parsers["moderator-list"] - elif "username" in data.keys(): + elif "username" in data: data["name"] = data.pop("username") parser = self.parsers[self._reddit.config.kinds["redditor"]] elif {"mod_permissions", "name", "sr", "subscribers"}.issubset(data): @@ -215,9 +219,9 @@ def _objectify_dict(self, data): return data return parser.parse(data, self._reddit) - def objectify( - self, data: Optional[Union[Dict[str, Any], List[Any], bool]] - ) -> Optional[Union[RedditBase, Dict[str, Any], List[Any], bool]]: + def objectify( # noqa: PLR0911,PLR0912,PLR0915 + self, data: dict[str, Any] | list[Any] | bool | None + ) -> RedditBase | dict[str, Any] | list[Any] | bool | None: """Create :class:`.RedditBase` objects from data. :param data: The structured data. @@ -226,7 +230,6 @@ def objectify( ``None``. """ - # pylint: disable=too-many-return-statements if data is None: # 204 no content return None if isinstance(data, list): @@ -247,8 +250,7 @@ def objectify( parser = self.parsers[data["kind"]] if data["kind"] == "ModeratedList": return parser.parse(data, self._reddit) - else: - return parser.parse(data["data"], self._reddit) + return parser.parse(data["data"], self._reddit) if "json" in data and "data" in data["json"]: if "websocket_url" in data["json"]["data"]: return data @@ -257,7 +259,7 @@ def objectify( if "rules" in data["json"]["data"]: return self.objectify(loads(data["json"]["data"]["rules"])) if "drafts_count" in data["json"]["data"] and all( - [key not in data["json"]["data"] for key in ["name", "url"]] + key not in data["json"]["data"] for key in ["name", "url"] ): # Draft data["json"]["data"].pop("drafts_count") return self.parsers["Draft"].parse(data["json"]["data"], self._reddit) @@ -281,7 +283,7 @@ def objectify( return parser.parse(data, self._reddit) if "rules" in data: return self.objectify(data["rules"]) - elif isinstance(data, dict): + if isinstance(data, dict): return self._objectify_dict(data) return data diff --git a/asyncpraw/reddit.py b/asyncpraw/reddit.py index 51d70128b..2a60fba31 100644 --- a/asyncpraw/reddit.py +++ b/asyncpraw/reddit.py @@ -1,4 +1,6 @@ """Provide the Reddit class.""" +from __future__ import annotations + import asyncio import configparser import os @@ -11,12 +13,7 @@ TYPE_CHECKING, Any, AsyncGenerator, - Dict, Iterable, - List, - Optional, - Type, - Union, ) from warnings import warn @@ -44,7 +41,6 @@ from .models.util import deprecate_lazy from .objector import Objector from .util import _deprecate_args -from .util.token_manager import BaseTokenManager try: from update_checker import update_check @@ -54,7 +50,12 @@ UPDATE_CHECKER_MISSING = True if TYPE_CHECKING: # pragma: no cover + import asyncprawcore + import asyncpraw + import asyncpraw.models + + from .util.token_manager import BaseTokenManager Comment = models.Comment Redditor = models.Redditor @@ -95,7 +96,7 @@ def _next_unique(self) -> int: @property def read_only(self) -> bool: - """Return ``True`` when using the ReadOnlyAuthorizer.""" + """Return ``True`` when using the ``ReadOnlyAuthorizer``.""" return self._core == self._read_only_core @read_only.setter @@ -109,9 +110,10 @@ def read_only(self, value: bool) -> None: if value: self._core = self._read_only_core elif self._authorized_core is None: - raise ClientException( + msg = ( "read_only cannot be unset as only the ReadOnlyAuthorizer is available." ) + raise ClientException(msg) else: self._core = self._authorized_core @@ -140,24 +142,26 @@ def validate_on_submit(self) -> bool: def validate_on_submit(self, val: bool): self._validate_on_submit = val - async def __aenter__(self): + async def __aenter__(self): # noqa: ANN204 """Handle the context manager open.""" return self - async def __aexit__(self, *_args): + async def __aexit__(self, *_: object): """Handle the context manager close.""" await self.close() - def __deepcopy__(self, memodict={}): + def __deepcopy__(self, memodict: dict[str, Any] | None = None) -> Reddit: """Shallow copy on deepcopy. A shallow copied is performed on deepcopy as :py:class:`asyncio.AbstractEventLoop` cannot be deepcopied. """ + if memodict is None: + memodict = {} # pragma: no cover return copy(self) - def __enter__(self): + def __enter__(self): # noqa: ANN204 """Handle the context manager open. .. deprecated:: 7.1.1 @@ -176,7 +180,7 @@ def __enter__(self): ) return self # pragma: no cover - def __exit__(self, *_args): + def __exit__(self, *_args: object): """Handle the context manager close.""" @_deprecate_args( @@ -188,14 +192,14 @@ def __exit__(self, *_args): ) def __init__( self, - site_name: Optional[str] = None, + site_name: str | None = None, *, - config_interpolation: Optional[str] = None, - requestor_class: Optional[Type[Requestor]] = None, - requestor_kwargs: Optional[Dict[str, Any]] = None, - token_manager: Optional[BaseTokenManager] = None, - **config_settings: Optional[Union[str, bool, int]], - ): # noqa: D207, D301 + config_interpolation: str | None = None, + requestor_class: type[asyncprawcore.requestor.Requestor] | None = None, + requestor_kwargs: dict[str, Any] | None = None, + token_manager: BaseTokenManager | None = None, + **config_settings: str | bool | int | None, + ): """Initialize a :class:`.Reddit` instance. :param site_name: The name of a section in your ``praw.ini`` file from which to @@ -284,7 +288,9 @@ async def request(self, *args, **kwargs): self._validate_on_submit = False try: - config_section = site_name or os.getenv("praw_site") or "DEFAULT" + config_section = ( + site_name or os.getenv("praw_site") or "DEFAULT" # noqa: SIM112 + ) self.config = Config( config_section, config_interpolation, **config_settings ) @@ -311,11 +317,8 @@ async def request(self, *args, **kwargs): required_message.format(attribute) ) if self.config.client_secret is self.config.CONFIG_NOT_SET: - raise MissingRequiredAttributeException( - f"{required_message.format('client_secret')}\nFor installed" - " applications this value must be set to None via a keyword argument" - " to the Reddit class constructor." - ) + msg = f"{required_message.format('client_secret')}\nFor installed applications this value must be set to None via a keyword argument to the Reddit class constructor." + raise MissingRequiredAttributeException(msg) self._check_for_update() self._prepare_objector() self.requestor = self._prepare_asyncprawcore( @@ -521,9 +524,7 @@ def _check_for_update(self): update_check(__package__, __version__) Reddit.update_checked = True - def _handle_rate_limit( - self, exception: RedditAPIException - ) -> Optional[Union[int, float]]: + def _handle_rate_limit(self, exception: RedditAPIException) -> int | float | None: for item in exception.items: if item.error_type == "RATELIMIT": amount_search = self._ratelimit_regex.search(item.message) @@ -535,18 +536,17 @@ def _handle_rate_limit( elif amount_search.group(2).startswith("millisecond"): seconds = 0 if seconds <= int(self.config.ratelimit_seconds): - sleep_seconds = seconds + 1 - return sleep_seconds + return seconds + 1 return None async def _objectify_request( self, *, - data: Optional[Union[Dict[str, Union[str, Any]], bytes, IO, str]] = None, - files: Optional[Dict[str, IO]] = None, - json: Optional[Union[Dict[Any, Any], List[Any]]] = None, + data: dict[str, str | Any] | bytes | IO | str | None = None, + files: dict[str, IO] | None = None, + json: dict[Any, Any] | list[Any] | None = None, method: str = "", - params: Optional[Union[str, Dict[str, str]]] = None, + params: str | dict[str, str] | None = None, path: str = "", ) -> Any: """Run a request through the ``Objector``. @@ -575,7 +575,12 @@ async def _objectify_request( ) ) - def _prepare_asyncprawcore(self, *, requestor_class=None, requestor_kwargs=None): + def _prepare_asyncprawcore( + self, + *, + requestor_class: type[Requestor] = None, + requestor_kwargs: Any | None = None, + ) -> Requestor: requestor_class = requestor_class or Requestor requestor_kwargs = requestor_kwargs or {} @@ -593,7 +598,9 @@ def _prepare_asyncprawcore(self, *, requestor_class=None, requestor_kwargs=None) return requestor - def _prepare_common_authorizer(self, authenticator): + def _prepare_common_authorizer( + self, authenticator: asyncprawcore.auth.BaseAuthenticator + ): if self._token_manager is not None: warn( "Token managers have been deprecated and will be removed in the near" @@ -603,10 +610,8 @@ def _prepare_common_authorizer(self, authenticator): stacklevel=2, ) if self.config.refresh_token: - raise TypeError( - "'refresh_token' setting cannot be provided when providing" - " 'token_manager'" - ) + msg = "'refresh_token' setting cannot be provided when providing 'token_manager'" + raise TypeError(msg) self._token_manager.reddit = self authorizer = Authorizer( @@ -671,7 +676,7 @@ def _prepare_objector(self): } self._objector = Objector(self, mappings) - def _prepare_trusted_asyncprawcore(self, requestor): + def _prepare_trusted_asyncprawcore(self, requestor: Requestor): authenticator = TrustedAuthenticator( requestor, self.config.client_id, @@ -689,7 +694,7 @@ def _prepare_trusted_asyncprawcore(self, requestor): else: self._prepare_common_authorizer(authenticator) - def _prepare_untrusted_asyncprawcore(self, requestor): + def _prepare_untrusted_asyncprawcore(self, requestor: Requestor): authenticator = UntrustedAuthenticator( requestor, self.config.client_id, self.config.redirect_uri ) @@ -704,13 +709,13 @@ async def close(self): @_deprecate_args("id", "url", "fetch") @deprecate_lazy async def comment( - self, # pylint: disable=invalid-name - id: Optional[str] = None, # pylint: disable=redefined-builtin + self, + id: str | None = None, *, fetch: bool = True, - url: Optional[str] = None, - **kwargs, - ): + url: str | None = None, + **_, + ) -> models.Comment: """Return an instance of :class:`.Comment`. :param id: The ID of the comment. @@ -743,9 +748,9 @@ async def delete( self, path: str, *, - data: Optional[Union[Dict[str, Union[str, Any]], bytes, IO, str]] = None, - json: Optional[Union[Dict[Any, Any], List[Any]]] = None, - params: Optional[Union[str, Dict[str, str]]] = None, + data: dict[str, str | Any] | bytes | IO | str | None = None, + json: dict[Any, Any] | list[Any] | None = None, + params: str | dict[str, str] | None = None, ) -> Any: """Return parsed objects returned from a DELETE request to ``path``. @@ -762,7 +767,7 @@ async def delete( data=data, json=json, method="DELETE", params=params, path=path ) - def domain(self, domain: str): + def domain(self, domain: str) -> models.DomainListing: """Return an instance of :class:`.DomainListing`. :param domain: The domain to obtain submission listings for. @@ -775,8 +780,8 @@ async def get( self, path: str, *, - params: Optional[Union[str, Dict[str, Union[str, int]]]] = None, - ): + params: str | dict[str, str | int] | None = None, + ) -> Any: """Return parsed objects returned from a GET request to ``path``. :param path: The path to fetch. @@ -789,15 +794,13 @@ async def get( def info( self, *, - fullnames: Optional[Iterable[str]] = None, - subreddits: Optional[Iterable[Union["asyncpraw.models.Subreddit", str]]] = None, - url: Optional[str] = None, + fullnames: Iterable[str] | None = None, + subreddits: Iterable[asyncpraw.models.Subreddit | str] | None = None, + url: str | None = None, ) -> AsyncGenerator[ - Union[ - "asyncpraw.models.Subreddit", - "asyncpraw.models.Comment", - "asyncpraw.models.Submission", - ], + asyncpraw.models.Subreddit + | asyncpraw.models.Comment + | asyncpraw.models.Submission, None, ]: """Fetch information about each item in ``fullnames``, ``url``, or ``subreddits``. @@ -828,22 +831,20 @@ def info( """ none_count = (fullnames, url, subreddits).count(None) if none_count != 2: - raise TypeError( - "Either 'fullnames', 'url', or 'subreddits' must be provided." - ) + msg = "Either 'fullnames', 'url', or 'subreddits' must be provided." + raise TypeError(msg) is_using_fullnames = fullnames is not None ids_or_names = fullnames if is_using_fullnames else subreddits if ids_or_names is not None: if isinstance(ids_or_names, str): - raise TypeError( - "'fullnames' and 'subreddits' must be a non-str iterable." - ) + msg = "'fullnames' and 'subreddits' must be a non-str iterable." + raise TypeError(msg) api_parameter_name = "id" if is_using_fullnames else "sr_name" - async def generator(names): + async def generator(names: Iterable[str | asyncpraw.models.Subreddit]): if is_using_fullnames: iterable = iter(names) else: @@ -858,8 +859,8 @@ async def generator(names): return generator(ids_or_names) - async def generator(url): - params = {"url": url} + async def generator(_url: str): + params = {"url": _url} for result in await self.get(API_PATH["info"], params=params): yield result @@ -870,9 +871,9 @@ async def patch( self, path: str, *, - data: Optional[Union[Dict[str, Union[str, Any]], bytes, IO, str]] = None, - json: Optional[Union[Dict[Any, Any], List[Any]]] = None, - params: Optional[Union[str, Dict[str, str]]] = None, + data: dict[str, str | Any] | bytes | IO | str | None = None, + json: dict[Any, Any] | list[Any] | None = None, + params: str | dict[str, str] | None = None, ) -> Any: """Return parsed objects returned from a PATCH request to ``path``. @@ -894,10 +895,10 @@ async def post( self, path: str, *, - data: Optional[Union[Dict[str, Union[str, Any]], bytes, IO, str]] = None, - files: Optional[Dict[str, IO]] = None, - json: Optional[Union[Dict[Any, Any], List[Any]]] = None, - params: Optional[Union[str, Dict[str, str]]] = None, + data: dict[str, str | Any] | bytes | IO | str | None = None, + files: dict[str, IO] | None = None, + json: dict[Any, Any] | list[Any] | None = None, + params: str | dict[str, str] | None = None, ) -> Any: """Return parsed objects returned from a POST request to ``path``. @@ -934,7 +935,9 @@ async def post( if seconds is None: break second_string = "second" if seconds == 1 else "seconds" - logger.debug(f"Rate limit hit, sleeping for {seconds} {second_string}") + logger.debug( + "Rate limit hit, sleeping for %d %s", seconds, second_string + ) await asyncio.sleep(seconds) raise last_exception @@ -943,9 +946,9 @@ async def put( self, path: str, *, - data: Optional[Union[Dict[str, Union[str, Any]], bytes, IO, str]] = None, - json: Optional[Union[Dict[Any, Any], List[Any]]] = None, - ): + data: dict[str, str | Any] | bytes | IO | str | None = None, + json: dict[Any, Any] | list[Any] | None = None, + ) -> Any: """Return parsed objects returned from a PUT request to ``path``. :param path: The path to fetch. @@ -963,7 +966,7 @@ async def put( @_deprecate_args("nsfw") async def random_subreddit( self, *, nsfw: bool = False - ) -> "asyncpraw.models.Subreddit": + ) -> asyncpraw.models.Subreddit: """Return a random instance of :class:`.Subreddit`. :param nsfw: Return a random NSFW (not safe for work) subreddit (default: @@ -983,11 +986,11 @@ async def random_subreddit( @_deprecate_args("name", "fullname", "fetch") async def redditor( self, - name: Optional[str] = None, + name: str | None = None, *, fetch: bool = False, - fullname: Optional[str] = None, - ) -> "asyncpraw.models.Redditor": + fullname: str | None = None, + ) -> asyncpraw.models.Redditor: """Return an instance of :class:`.Redditor`. :param name: The name of the redditor. @@ -1007,11 +1010,11 @@ async def redditor( async def request( self, *, - data: Optional[Union[Dict[str, Union[str, Any]], bytes, IO, str]] = None, - files: Optional[Dict[str, IO]] = None, - json: Optional[Union[Dict[Any, Any], List[Any]]] = None, + data: dict[str, str | Any] | bytes | IO | str | None = None, + files: dict[str, IO] | None = None, + json: dict[Any, Any] | list[Any] | None = None, method: str, - params: Optional[Union[str, Dict[str, Union[str, int]]]] = None, + params: str | dict[str, str | int] | None = None, path: str, ) -> Any: """Return the parsed JSON data returned from a request to URL. @@ -1030,7 +1033,8 @@ async def request( """ if data and json: - raise ClientException("At most one of 'data' or 'json' is supported.") + msg = "At most one of 'data' or 'json' is supported." + raise ClientException(msg) try: return await self._core.request( data=data, @@ -1048,7 +1052,7 @@ async def request( if text: data = {"reason": text} else: - raise exception + raise exception from None if set(data) == {"error", "message"}: raise explanation = data.get("explanation") @@ -1063,14 +1067,14 @@ async def request( @_deprecate_args("id", "url", "fetch") @deprecate_lazy - async def submission( # pylint: disable=invalid-name,redefined-builtin + async def submission( self, - id: Optional[str] = None, + id: str | None = None, *, fetch: bool = True, - url: Optional[str] = None, - **kwargs, - ) -> "asyncpraw.models.Submission": + url: str | None = None, + **_, + ) -> asyncpraw.models.Submission: """Return an instance of :class:`.Submission`. :param id: A Reddit base36 submission ID, e.g., ``2gmzqe``. diff --git a/asyncpraw/util/__init__.py b/asyncpraw/util/__init__.py index 5b7891699..064b8ff73 100644 --- a/asyncpraw/util/__init__.py +++ b/asyncpraw/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/asyncpraw/util/cache.py b/asyncpraw/util/cache.py index 8e492f665..acd777617 100644 --- a/asyncpraw/util/cache.py +++ b/asyncpraw/util/cache.py @@ -1,8 +1,10 @@ """Caching utilities.""" -from typing import Any, Callable, Optional +from __future__ import annotations +from typing import Any, Callable -class cachedproperty: + +class cachedproperty: # noqa: N801 """A decorator for caching a property's result. Similar to :py:class:`property`, but the wrapped method's result is cached on the @@ -20,10 +22,10 @@ class cachedproperty: """ # This to make sphinx run properly - def __call__(self, *args, **kwargs): # pragma: no cover noqa: D102 - pass + def __call__(self, *args: Any, **kwargs: Any): # pragma: no cover + """Empty method to make sphinx run properly.""" - def __get__(self, obj: Optional[Any], objtype: Optional[Any] = None) -> Any: + 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 @@ -36,7 +38,7 @@ def __get__(self, obj: Optional[Any], objtype: Optional[Any] = None) -> Any: value = obj.__dict__[self.func.__name__] = self.func(obj) return value - def __init__(self, func: Callable[[Any], Any], doc: Optional[str] = None): + def __init__(self, func: Callable[[Any], Any], doc: str | None = None): """Initialize a :class:`.cachedproperty` instance.""" self.func = self.__wrapped__ = func diff --git a/asyncpraw/util/deprecate_args.py b/asyncpraw/util/deprecate_args.py index 52a7630ea..f11cf4366 100644 --- a/asyncpraw/util/deprecate_args.py +++ b/asyncpraw/util/deprecate_args.py @@ -1,13 +1,15 @@ """Positional argument deprecation decorator.""" +from __future__ import annotations + import inspect from functools import wraps from inspect import iscoroutinefunction -from typing import Any, Callable, Tuple +from typing import Any, Callable from warnings import warn -def _deprecate_args(*old_args: str): - def _generate_arg_string(used_args: Tuple[str, ...]) -> str: +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/asyncpraw/util/snake.py b/asyncpraw/util/snake.py index 4873291ac..778a33e81 100644 --- a/asyncpraw/util/snake.py +++ b/asyncpraw/util/snake.py @@ -1,7 +1,8 @@ """Contains functions dealing with snake case conversions.""" +from __future__ import annotations import re -from typing import Any, Dict +from typing import Any _re_camel_to_snake = re.compile(r"([a-z0-9](?=[A-Z])|[A-Z](?=[A-Z][a-z]))") @@ -11,7 +12,7 @@ def camel_to_snake(name: str) -> str: return _re_camel_to_snake.sub(r"\1_", name).lower() -def snake_case_keys(dictionary: Dict[str, Any]) -> Dict[str, Any]: +def snake_case_keys(dictionary: dict[str, Any]) -> dict[str, Any]: """Return a new dictionary with keys converted to snake_case. :param dictionary: The dict to be corrected. diff --git a/asyncpraw/util/token_manager.py b/asyncpraw/util/token_manager.py index 3df0adaa5..4f61ab6b2 100644 --- a/asyncpraw/util/token_manager.py +++ b/asyncpraw/util/token_manager.py @@ -11,19 +11,27 @@ Tokens managers have been deprecated and will be removed in the near future. """ +from __future__ import annotations + from abc import ABC, abstractmethod from contextlib import asynccontextmanager +from typing import TYPE_CHECKING import aiofiles from . import _deprecate_args +if TYPE_CHECKING: # pragma: no cover + import asyncprawcore + + import asyncpraw + class BaseTokenManager(ABC): """An abstract class for all token managers.""" @abstractmethod - def post_refresh_callback(self, authorizer): + def post_refresh_callback(self, authorizer: asyncprawcore.auth.BaseAuthorizer): """Handle callback that is invoked after a refresh token is used. :param authorizer: The ``asyncprawcore.Authorizer`` instance used containing @@ -35,7 +43,7 @@ def post_refresh_callback(self, authorizer): """ @abstractmethod - def pre_refresh_callback(self, authorizer): + def pre_refresh_callback(self, authorizer: asyncprawcore.auth.BaseAuthorizer): """Handle callback that is invoked before refreshing PRAW's authorization. :param authorizer: The ``asyncprawcore.Authorizer`` instance used containing @@ -47,16 +55,15 @@ def pre_refresh_callback(self, authorizer): """ @property - def reddit(self): + def reddit(self) -> asyncpraw.Reddit: """Return the :class:`.Reddit` instance bound to the token manager.""" return self._reddit @reddit.setter - def reddit(self, value): + def reddit(self, value: asyncpraw.Reddit): if self._reddit is not None: - raise RuntimeError( - "'reddit' can only be set once and is done automatically" - ) + msg = "'reddit' can only be set once and is done automatically" + raise RuntimeError(msg) self._reddit = value def __init__(self): @@ -79,7 +86,7 @@ class FileTokenManager(BaseTokenManager): """ - def __init__(self, filename): + def __init__(self, filename: str): """Initialize a :class:`.FileTokenManager` instance. :param filename: The file the contains the refresh token. @@ -88,12 +95,14 @@ def __init__(self, filename): super().__init__() self._filename = filename - async def post_refresh_callback(self, authorizer): + async def post_refresh_callback( + self, authorizer: asyncprawcore.auth.BaseAuthorizer + ): """Update the saved copy of the refresh token.""" async with aiofiles.open(self._filename, "w") as fp: await fp.write(authorizer.refresh_token) - async def pre_refresh_callback(self, authorizer): + async def pre_refresh_callback(self, authorizer: asyncprawcore.auth.BaseAuthorizer): """Load the refresh token from the file.""" if authorizer.refresh_token is None: async with aiofiles.open(self._filename) as fp: @@ -115,7 +124,7 @@ class SQLiteTokenManager(BaseTokenManager): """ @_deprecate_args("database", "key") - def __init__(self, *, database, key): + def __init__(self, *, database: str, key: str): """Initialize a :class:`.SQLiteTokenManager` instance. :param database: The path to the SQLite database. @@ -141,7 +150,7 @@ async def _get(self): raise KeyError return result[0] - async def _set(self, refresh_token): + async def _set(self, refresh_token: str): """Set the refresh token in the database. This function will overwrite an existing value if the corresponding ``key`` @@ -177,7 +186,7 @@ async def connection(self): self._setup_ran = True yield self._connection - async def is_registered(self): + async def is_registered(self) -> bool: """Return whether ``key`` already has a ``refresh_token``.""" async with self.connection() as conn: cursor = await conn.execute( @@ -186,7 +195,9 @@ async def is_registered(self): result = await cursor.fetchone() return result is not None - async def post_refresh_callback(self, authorizer): + async def post_refresh_callback( + self, authorizer: asyncprawcore.auth.BaseAuthorizer + ): """Update the refresh token in the database.""" await self._set(authorizer.refresh_token) @@ -195,12 +206,12 @@ async def post_refresh_callback(self, authorizer): # to always load the latest refresh_token from the database. authorizer.refresh_token = None - async def pre_refresh_callback(self, authorizer): + async def pre_refresh_callback(self, authorizer: asyncprawcore.auth.BaseAuthorizer): """Load the refresh token from the database.""" assert authorizer.refresh_token is None authorizer.refresh_token = await self._get() - async def register(self, refresh_token): + async def register(self, refresh_token: str) -> bool: """Register the initial refresh token in the database. :returns: ``True`` if ``refresh_token`` is saved to the database, otherwise, diff --git a/docs/code_overview/models/awards.txt b/docs/code_overview/models/awards.txt index 0dfa51b17..5efb1e976 100644 --- a/docs/code_overview/models/awards.txt +++ b/docs/code_overview/models/awards.txt @@ -145,4 +145,4 @@ Ternion All-Powerful .. image:: https://www.redditstatic.com/gold/awards/ something that hits you in the heart, mind and soul. Some might call it unachievanium. Gives the author 6 months of Premium and 5000 Coins. -========================== ====================================================================================================================================================================================== ========================================== ================================================== ====== \ No newline at end of file +========================== ====================================================================================================================================================================================== ========================================== ================================================== ====== diff --git a/docs/code_overview/other.rst b/docs/code_overview/other.rst index dc34428b7..6e5ce7a24 100644 --- a/docs/code_overview/other.rst +++ b/docs/code_overview/other.rst @@ -148,6 +148,7 @@ them bound to an attribute of one of the Async PRAW models. other/rule other/stylesheet other/sublisting + other/subredditlistingmixin other/subredditmessage other/token_manager other/trophy diff --git a/docs/code_overview/other/asyncprawbase.rst b/docs/code_overview/other/asyncprawbase.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/button.rst b/docs/code_overview/other/button.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/buttonwidget.rst b/docs/code_overview/other/buttonwidget.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/calendar.rst b/docs/code_overview/other/calendar.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/communitylist.rst b/docs/code_overview/other/communitylist.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/customwidget.rst b/docs/code_overview/other/customwidget.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/draftlist.rst b/docs/code_overview/other/draftlist.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/idcard.rst b/docs/code_overview/other/idcard.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/image.rst b/docs/code_overview/other/image.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/imagedata.rst b/docs/code_overview/other/imagedata.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/imagewidget.rst b/docs/code_overview/other/imagewidget.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/menu.rst b/docs/code_overview/other/menu.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/menulink.rst b/docs/code_overview/other/menulink.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/mod_action.rst b/docs/code_overview/other/mod_action.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/moderatorswidget.rst b/docs/code_overview/other/moderatorswidget.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/postflairwidget.rst b/docs/code_overview/other/postflairwidget.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/ruleswidget.rst b/docs/code_overview/other/ruleswidget.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/submenu.rst b/docs/code_overview/other/submenu.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/subredditlistingmixin.rst b/docs/code_overview/other/subredditlistingmixin.rst new file mode 100644 index 000000000..b7acd1094 --- /dev/null +++ b/docs/code_overview/other/subredditlistingmixin.rst @@ -0,0 +1,5 @@ +SubredditListingMixin +===================== + +.. autoclass:: asyncpraw.models.listing.mixins.subreddit.SubredditListingMixin + :inherited-members: diff --git a/docs/code_overview/other/subredditwidgets.rst b/docs/code_overview/other/subredditwidgets.rst old mode 100755 new mode 100644 diff --git a/docs/code_overview/other/textarea.rst b/docs/code_overview/other/textarea.rst old mode 100755 new mode 100644 diff --git a/docs/conf.py b/docs/conf.py index 9170abf80..0f25e218d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,6 +21,7 @@ nitpick_ignore = [ ("py:class", "IO"), ("py:class", "asyncprawcore.requestor.Requestor"), + ("py:class", "asyncprawcore.auth.BaseAuthorizer"), ("py:class", "asyncpraw.models.redditors.PartialRedditor"), ] nitpicky = True diff --git a/docs/examples/obtain_refresh_token.py b/docs/examples/obtain_refresh_token.py index 1fca2d2bd..a01c8304e 100755 --- a/docs/examples/obtain_refresh_token.py +++ b/docs/examples/obtain_refresh_token.py @@ -51,9 +51,7 @@ async def main(): client = receive_connection() data = client.recv(1024).decode("utf-8") param_tokens = data.split(" ", 2)[1].split("?", 1)[1].split("&") - params = { - key: value for (key, value) in [token.split("=") for token in param_tokens] - } + params = dict([token.split("=") for token in param_tokens]) if state != params["state"]: send_message( @@ -89,7 +87,7 @@ def receive_connection(): def send_message(client, message): """Send message to client and close the connection.""" print(message) - client.send(f"HTTP/1.1 200 OK\r\n\r\n{message}".encode("utf-8")) + client.send(f"HTTP/1.1 200 OK\r\n\r\n{message}".encode()) client.close() diff --git a/docs/getting_started/authentication.rst b/docs/getting_started/authentication.rst index e9ed7d8fc..b187baf5d 100644 --- a/docs/getting_started/authentication.rst +++ b/docs/getting_started/authentication.rst @@ -82,7 +82,7 @@ The output should contain the same name as you entered for ``username``. that the username and password you are using are for the same user with which the application is associated: - .. code-block:: text + .. code-block:: OAuthException: invalid_grant error processing request diff --git a/docs/getting_started/authentication_flow_table.txt b/docs/getting_started/authentication_flow_table.txt index 31a092d50..a8644a75c 100644 --- a/docs/getting_started/authentication_flow_table.txt +++ b/docs/getting_started/authentication_flow_table.txt @@ -10,4 +10,4 @@ |Alternative Flows|:ref:`application_only_client_credentials_flow`| | | + +-----------------------------------------------+-----------------------------------------------+-------------------------------+ | | :ref:`application_only_installed_client_flow` | -+-----------------+-------------------------------------------------------------------------------------------------------------------------------+ \ No newline at end of file ++-----------------+-------------------------------------------------------------------------------------------------------------------------------+ diff --git a/pyproject.toml b/pyproject.toml index da3215712..aa641016b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,75 @@ +[build-system] +build-backend = "flit_core.buildapi" +requires = ["flit_core >=3.4,<4"] + +[project] +authors = [{name = "Joel Payne", email = "lilspazjoekp@gmail.com"}] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Utilities" +] +dependencies = [ + "aiofiles", + "aiohttp <4; python_version < '3.12'", + "aiohttp==3.9.0b0; python_version == '3.12'", + "aiosqlite <=0.17.0", + "asyncprawcore >=2.1, <3", + "update_checker >=0.18" +] +dynamic = ["version", "description"] +keywords = ["reddit", "api", "wrapper", "asyncpraw", "praw", "async", "asynchronous"] +license = {file = "LICENSE.txt"} +maintainers = [{name = "Joel Payne", email = "lilspazjoekp@gmail.com"}] +name = "asyncpraw" +readme = "README.rst" +requires-python = "~=3.7" + +[project.optional-dependencies] +ci = ["coveralls"] +dev = [ + "packaging", + "asyncpraw[lint]", + "asyncpraw[test]" +] +lint = [ + "asyncpraw[readthedocs]", + "pre-commit", + "ruff >=0.0.292" +] +readthedocs = [ + "furo", + "sphinx", + "sphinxcontrib-trio" +] +test = [ + "asynctest ==0.13.* ; python_version < '3.8'", # TODO: Remove me when support for 3.7 is dropped + "mock ==4.*", + "pytest ==7.*", + "pytest-asyncio ==0.18.*", + "pytest-vcr ==1.*", + "testfixtures ==6.*", + "urllib3 ==1.*", + "vcrpy ==4.2.1" +] + +[project.urls] +"Change Log" = "https://asyncpraw.readthedocs.io/en/latest/package_info/change_log.html" +"Documentation" = "https://asyncpraw.readthedocs.io/" +"Issue Tracker" = "https://github.com/praw-dev/asyncpraw/issues" +"Source Code" = "https://github.com/praw-dev/asyncpraw" + [tool.black] extend_exclude = '/(\.venv.*)/' line-length = 88 @@ -13,3 +85,75 @@ skip_glob = '.venv*' asyncio_mode = "auto" filterwarnings = "ignore::DeprecationWarning" testpaths = "tests" + +[tool.ruff] +target-version = "py37" +include = [ + "asyncpraw/**/*.py" +] +ignore = [ + "A002", # shadowing built-in + "ANN101", # missing type annotation for self in method + "ANN102", # missing type annotation for cls in classmethod + "ANN202", # missing return type for private method + "ANN401", # typing.Any usage + "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 +] +select = [ + "A", # flake8-builtins + "ANN", # flake8-annotations + "ASYNC", # flake8-async + "ARG", # flake8-unused-arguments + "B", # flake8-bugbear + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "D", # pydocstyle + "DTZ", # flake8-datetimez + "E", # pycodestyle errors + "EM", # flake8-errmsg + "ERA", # eradicate + "EXE", # flake8-executable + "F", # pyflakes + "FA", # flake8-future-annotations + "FIX", # flake8-fix me + "FLY", # flynt + "G", # flake8-logging-format + "I", # isort + "INP", # flake8-no-pep420 + "ISC", # flake8-implicit-str-concat + "N", # pep8-naming + "PIE", # flake8-pie + "PGH", # pygrep-hooks + "PL", # Pylint + "PT", # flake8-pytest-style + "PTH", # flake8-use-pathlib + "PYI", # flake8-pyi + "Q", # flake8-quotes + "RET", # flake8-return + "RSE", # flake8-raise + "S", # bandit + "SIM", # flake8-simplify + "T10", # flake8-debugger + "T20", # flake8-print + "TCH", # flake8-type-checking + "TD", # flake8-todos + "W", # pycodestyle warnings + "UP" # pyupgrade +] +ignore-init-module-imports = true + +[tool.ruff.flake8-annotations] +allow-star-arg-any = true +mypy-init-return = true +suppress-dummy-args = true +suppress-none-returning = true + +[tool.ruff.per-file-ignores] +"__init__.py" = ["F401"] +"asyncpraw/models/mod_notes.py" = ["FA100"] diff --git a/setup.py b/setup.py deleted file mode 100644 index a1611662f..000000000 --- a/setup.py +++ /dev/null @@ -1,87 +0,0 @@ -"""asyncpraw setup.py""" -import re -from codecs import open -from os import path - -from setuptools import find_packages, setup - -PACKAGE_NAME = "asyncpraw" -HERE = path.abspath(path.dirname(__file__)) -with open(path.join(HERE, "README.rst"), encoding="utf-8") as fp: - README = fp.read() -with open(path.join(HERE, PACKAGE_NAME, "const.py"), encoding="utf-8") as fp: - VERSION = re.search(r'__version__ = "([^"]+)"', fp.read()).group(1) - -extras = { - "ci": ["coveralls"], - "dev": ["packaging"], - "lint": ["pre-commit"], - "readthedocs": [ - "furo", - "sphinx", - "sphinxcontrib-trio", - ], - "test": [ - "asynctest ==0.13.* ; python_version < '3.8'", # TODO: Remove me when support for 3.7 is dropped - "mock ==4.*", - "pytest ==7.*", - "pytest-asyncio ==0.18.*", - "pytest-vcr ==1.*", - "testfixtures ==6.*", - "urllib3 ==1.*", - "vcrpy ==4.2.1", - ], -} -extras["lint"] += extras["readthedocs"] -extras["dev"] += extras["lint"] + extras["test"] - -setup( - name=PACKAGE_NAME, - author="Joel Payne", - author_email="lilspazjoekp@gmail.com", - python_requires=">=3.7", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Topic :: Utilities", - ], - description=( - 'Async PRAW, an abbreviation for "Asynchronous Python Reddit API Wrapper", is a' - " Python package that allows for simple access to Reddit's API." - ), - extras_require=extras, - install_requires=[ - "aiofiles ==23.*", - "aiohttp <4; python_version < '3.12'", - "aiohttp==3.9.0b0; python_version == '3.12'", - "aiosqlite <=0.17.0", - "asyncprawcore >=2.1, <3", - "update_checker >=0.18", - ], - keywords="reddit api wrapper asyncpraw praw async asynchronous", - license="Simplified BSD License", - long_description=README, - package_data={ - "": ["LICENSE.txt", "praw_license.txt"], - PACKAGE_NAME: ["*.ini", "images/*.png"], - }, - packages=find_packages(exclude=["tests", "tests.*", "tools", "tools.*"]), - project_urls={ - "Change Log": "https://asyncpraw.readthedocs.io/en/latest/package_info/change_log.html", - "Documentation": "https://asyncpraw.readthedocs.io/", - "Issue Tracker": "https://github.com/praw-dev/asyncpraw/issues", - "Source Code": "https://github.com/praw-dev/asyncpraw", - }, - version=VERSION, -) diff --git a/tests/conftest.py b/tests/conftest.py index 33dc4bcc8..6f2ab891f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ def patch_sleep(monkeypatch): async def _sleep(*_, **__): """Dud sleep function.""" - pass + return monkeypatch.setattr(asyncio, "sleep", value=_sleep) @@ -41,11 +41,6 @@ def pytest_configure(config): ) -class Placeholders: - def __init__(self, _dict): - self.__dict__ = _dict - - os.environ["praw_check_for_updates"] = "False" placeholders = { @@ -59,3 +54,8 @@ def __init__(self, _dict): placeholders["basic_auth"] = b64encode( f"{placeholders['client_id']}:{placeholders['client_secret']}".encode("utf-8") ).decode("utf-8") + + +class Placeholders: + def __init__(self, _dict): + self.__dict__ = _dict diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index c3dc06b1b..3128bd013 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -9,7 +9,7 @@ from asyncpraw import Reddit from tests import HelperMethodMixin -from .utils import ( +from ..utils import ( CustomPersister, CustomSerializer, ensure_environment_variables, diff --git a/tests/integration/files/comment_ids.txt b/tests/integration/files/comment_ids.txt index 9509ed7b1..4703d312a 100644 --- a/tests/integration/files/comment_ids.txt +++ b/tests/integration/files/comment_ids.txt @@ -1 +1 @@ -gxf43y8,gxfq8eb,gxfmdc4,gxfc01r,gxfkyrq,gxfh1lj,gxfnxps,gxf8ob6,gxfdlau,gxfobx5,gxfn2mw,gxf7sp0,gxfd2ce,gxesisg,gxfqxpe,gxf6y58,gxf97ai,gxfowzs,gxfhzci,gxfrzn7,gxfha2d,gxfh3qu,gxfrzym,gxfscal,gxfdxz2,gxfbu4f,gxf4gn4,gxfte82,gxfradg,gxfpphi,gxfrznr,gxffcjt,gxfsl9d,gxfom0d,gxfpphy,gxfnyaf,gxfiic8,gxf7sri,gxfe4bj,gxfezxd,gxfif0w,gxfetda,gxfg1uv,gxfrjdh,gxf9207,gxekg31,gxffiwh,gxfhzea,gxf8upg,gxfjkai,gxfcprv,gxfmq2m,gxf7utc,gxfp091,gxf911i,gxfhzez,gxf7g5o,gxf38gd,gxf8pd9,gxftea9,gxfk39v,gxfns0l,gxf6x7a,gxfq8ig,gxem10c,gxfomk1,gxf7mhr,gxfht41,gxfbu79,gxfnjht,gxf8271,gxf8ofl,gxetr5g,gxfql6e,gxfnyd5,gxf3xrx,gxf9135,gxfa9c2,gxfb9zq,gxfp6mj,gxfmwgd,gxfq28e,gxfn2rz,gxfjqoi,gxfj7pv,gxf7g7p,gxfk9ng,gxfsvdq,gxf85i7,gxft80y,gxfn2sn,gxfnyeg,gxfq8kp,gxf2pkb,gxftecy,gxfl59v,gxerh11,gxfted4,gxfbu9o,gxffpbx,gxfqbl7,gxfnls4,gxf7sw5,gxfetqh,gxfp2af,gxfoy2q,gxfdlhv,gxfob32,gxfm78g,gxfeymw,gxfn2ud,gxfnyga,gxfjeyv,gxfgxf6,gxfk3e6,gxfd3a6,gxfeh4h,gxfppob,gxftef0,gxfgenx,gxf681u,gxf6edf,gxf681w,gxfqqch,gxffcqy,gxfr49b,gxfs1r7,gxflbo6,gxfrn8j,gxfs67d,gxf85m2,gxf8bxq,gxfiv73,gxf8bxv,gxfksqc,gxfk3g5,gxfo4ty,gxfm7ax,gxfk3gb,gxfb533,gxfeni8,gxftegx,gxfont1,gxfli12,gxfs688,gxfl0x8,gxfoiag,gxfje6v,gxe7a8p,gxeu3yy,gxfq2e4,gxfpyyt,gxfnfl1,gxfhacm,gxfq2eh,gxfn2y2,gxfq5af,gxf919p,gxfrnam,gxfngda,gxfd8za,gxfnc5l,gxfetvf,gxfsvk0,gxfc729,gxfp6tf,gxfgero,gxfmqbp,gxf8ibv,gxfk9u4,gxfam6l,gxfiimm,gxf6xg2,gxfioyb,gxfm11w,gxftkuv,gxfbuft,gxfp0ii,gxftkv0,gxfnfml,gxfqfty,gxfcq20,gxfgt8c,gxf686k,gxf6xgu,gxfnlyp,gxfg8he,gxf8icz,gxfqlfb,gxf73sq,gxfmqd7,gxfbvia,gxfpji7,gxfmwow,gxf7zep,gxfqf42,gxfohl3,gxf3fsc,gxf8idm,gxf7zf2,gxfmwpd,gxf5p8y,gxfe4nn,gxf93qo,gxfmwpl,gxfpzlw,gxf1hjz,gxfkg80,gxfi61v,gxf73tx,gxfmqef,gxenm7e,gxf3lez,gxfp0ks,gxf9kbx,gxf6r77,gxfqf5k,gxfrtq2,gxfpw70,gxfjxa5,gxfppvj,gxfam9t,gxf56bm,gxfli6b,gxfq8uc,gxfsqqu,gxfiipy,gxfa9mw,gxfqrt5,gxfn6wn,gxfama4,gxffcy9,gxf91ea,gxfsvab,gxf9e1o,gxewqxv,gxfba4x,gxf7t60,gxf9x0s,gxfgl7z,gxfnype,gxfrx3o,gxfqfh4,gxfr4hl,gxftkzg,gxfr4hr,gxfs60b,gxfq8w5,gxfqf7r,gxfdfgq,gxfpogn,gxf7mvh,gxfovtw,gxfhtht,gxf85ua,gxfsj2s,gxf8v4n,gxficgx,gxfd95u,gxffl0b,gxfq2lg,gxfka05,gxfrtt4,gxf1uay,gxey5kb,gxfn35a,gxfn35c,gxfboab,gxfmwtw,gxfteph,gxesjaw,gxfaz0b,gxfrtto,gxf7mwx,gxeuzt1,gxfrnii,gxfqf9p,gxfcjww,gxf44ib,gxewedw,gxf8ij9,gxfhzvd,gxfhtju,gxfeb4v,gxfqccr,gxevbzv,gxftl2h,gxfn9ij,gxfmwvl,gxfnm5z,gxfnm63,gxfs070,gxfmwvz,gxfaz29,gxfehhe,gxfo8uu,gxfq2o6,gxfohs7,gxfn9jd,gxeru30,gxfqgx9,gxfnfv6,gxfrq83,gxevvh0,gxf9e6v,gxfmdy2,gxfentn,gxfq90g,gxfp737,gxfoxq8,gxfpq1x,gxfbodi,gxffw2v,gxfnsj3,gxei6gg,gxfnyup,gxfm1bn,gxfq088,gxflp7j,gxfr4n0,gxfnsjh,gxftetd,gxfmkav,gxea4v6,gxfenv4,gxfo579,gxfo57b,gxf8va5,gxfa9um,gxfg2fz,gxfip9e,gxfqfdy,gxfm7oo,gxfm7op,gxfnywa,gxfmkbx,gxfrzln,gxf8oz5,gxfqetv,gxfbbsl,gxelvau,gxfk3uh,gxfqgxr,gxfbu6t,gxft280,gxf5cw1,gxfmkcm,gxfq93e,gxfq93h,gxf7zpr,gxftevk,gxf68ib,gxfi6ca,gxfq2sb,gxfqffi,gxfg8te,gxf6xt0,gxf6xt2,gxfqcgh,gxfmvd3,gxfnfzi,gxfpjua,gxfrb1x,gxfj1yu,gxfq273,gxf9362,gxfn3cw,gxfn3d1,gxfn3d4,gxf745m,gxfs97m,gxfp782,gxfrnpw,gxfeuag,gxec65k,gxf8cf4,gxfmqqq,gxf7tgk,gxffd9h,gxfq2ue,gxf8vss,gxfpjvu,gxfjran,gxf7n5e,gxeaghh,gxfi03e,gxfp0xq,gxfij27,gxfpdkw,gxflv5v,gxfliis,gxfc7ig,gxfnmdl,gxfopqv,gxfbuvg,gxfehou,gxf4fo8,gxfipeb,gxf7n6d,gxf8655,gxfpmen,gxfsjdp,gxfk3z5,gxfp7aa,gxfqgyj,gxfmkh1,gxfrn3l,gxfme5p,gxf50dq,gxfn3g0,gxfproe,gxf3ltm,gxfow2n,gxflvka,gxfipfi,gxfrq9h,gxf0mdc,gxfs74b,gxfeo1w,gxfm1jb,gxfn3go,gxf7n7u,gxfouob,gxfpdn0,gxficst,gxfc7kg,gxfrnte,gxfsyih,gxfq2xn,gxfq0vw,gxfkmzk,gxfq2xt,gxf9fty,gxftf1l,gxfojfr,gxf5d2l,gxfn3hv,gxfmqut,gxfm7w6,gxfi06w,gxfr2sn,gxfsw3m,gxfi0qm,gxf6rnv,gxftf2l,gxfpzmh,gxfmx75,gxfn9ue,gxfi07u,gxftlek,gxf5jfj,gxfpqcg,gxfjeso,gxfmqwa,gxf91va,gxf91vb,gxfs0jh,gxfhaym,gxfazeo,gxfhtxg,gxfmea6,gxftknh,gxfeo5u,gxfipjn,gxf5jh4,gxevc27,gxeoc0s,gxf7tni,gxfbc2n,gxfp7fk,gxf9yt7,gxfrbac,gxfr4z0,gxfr33x,gxf7zzl,gxftjqt,gxfckcc,gxfn3lv,gxfqsck,gxfox5i,gxftf5z,gxfk45k,gxfeo7d,gxfpqff,gxfmxam,gxfss7s,gxfrbbo,gxftb6i,gxec1lq,gxflcf3,gxflvdu,gxf8pb7,gxfscff,gxfpqg5,gxfivxj,gxfamug,gxfp161,gxfijai,gxf6y41,gxfcqpe,gxfmed0,gxfn3n9,gxffdiu,gxfq33u,gxf8qkq,gxfkzsu,gxf1ohu,gxffq6c,gxfkthq,gxfp2fh,gxfe5h4,gxfpwsz,gxfq9g5,gxfn3o7,gxf8vo9,gxf802k,gxfi0d9,gxfeula,gxfeo9t,gxfspya,gxffwiv,gxf39ex,gxeprwy,gxfm6lv,gxfrtgo,gxfq9gy,gxfmxdf,gxfoi9g,gxf82cp,gxfehz2,gxfeoao,gxf216q,gxftkob,gxflvgm,gxfjxxj,gxfklkg,gxfdz0z,gxfm1sl,gxfo5nc,gxftfa2,gxfbouz,gxffdlo,gxfrpig,gxfk49q,gxfpe11,gxfkgwz,gxfnzc5,gxfobza,gxfbc87,gxfmp3w,gxff0yz,gxfg2wc,gxfh4tp,gxfouy6,gxf7nhs,gxfnzci,gxft8g5,gxfnmph,gxf922h,gxf5pyy,gxfpqkd,gxffjy6,gxfamyq,gxfipqg,gxf4bex,gxf74ju,gxfq9y2,gxfp1at,gxfq9jq,gxf4bfc,gxfr55m,gxfeuou,gxfn4x1,gxesjxo,gxfrugd,gxfjrou,gxfjjc8,gxfp1bn,gxfoiy0,gxfbcae,gxf5ddj,gxfl69o,gxfnnvu,gxfpwxq,gxfmr5x,gxf5de2,gxfmxhn,gxf8phr,gxfl36g,gxf6yah,gxforfm,gxfnt49,gxfp1d9,gxfknce,gxfd9v3,gxfk4dt,gxfmr76,gxfjy2g,gxfiptm,gxfgfng,gxfg30d,gxf8mpw,gxf9xrz,gxfn3up,gxfr2s9,gxf8pj9,gxf339i,gxfm896,gxfijij,gxfn3v6,gxfs0us,gxfnzh2,gxfhu8n,gxfqfyx,gxfkla2,gxfijj2,gxfjrs2,gxfr35n,gxfq9no,gxf7nmt,gxf8pk7,gxfpe24,gxf5dgt,gxf80a6,gxfmxkh,gxfq9o5,gxfpe2g,gxf83cu,gxfan3w,gxfjrsu,gxfn3wf,gxf8pkz,gxfnimu,gxfgm12,gxf8j9m,gxf7rcy,gxf86mm,gxf7tyl,gxf98k3,gxfp1g4,gxfg32u,gxfp1g7,gxfmkyg,gxf4xnz,gxfsrtr,gxf8vxg,gxfefb3,gxfngks,gxevd7x,gxfnt7y,gxfi6ya,gxfnzjp,gxfnmwo,gxfqasz,gxfp1bw,gxftfg5,gxfnmwx,gxfow5n,gxfd9z9,gxfqmdj,gxfgm2y,gxfd9zg,gxfn3ym,gxfpe4w,gxfnmxh,gxfi6zb,gxfjsun,gxfidb3,gxfqg2v,gxf4bmw,gxfatii,gxfn3zf,gxftgny,gxfr0cv,gxf6ygr,gxfq9rw,gxftfk2,gxf6xbf,gxfdgck,gxfqb7t,gxfiyb0,gxfijnw,gxf85kd,gxfan80,gxfgm0z,gxfov84,gxft832,gxfda1n,gxf697g,gxf33fi,gxfrodb,gxfeom8,gxfbp5y,gxfow64,gxfcr3g,gxfp7vy,gxfqg4v,gxfqi43,gxf3mes,gxfntqb,gxfmrek,gxfnmve,gxfml32,gxfmtdh,gxf8w1t,gxfiq0y,gxf6yiu,gxfeuyw,gxf698p,gxf4bpo,gxevdcd,gxfngpa,gxf6yj3,gxfq3ip,gxfi72s,gxfo5zp,gxfnn12,gxffdy1,gxfpnkf,gxfkvui,gxfqcuo,gxfl6jm,gxfra5q,gxfovad,gxfbckm,gxfl087,gxfknl4,gxfmxrd,gxfn42y,gxfp0e5,gxf6s8g,gxf4bqq,gxf6yk7,gxffwxk,gxffwxm,gxfoinw,gxf2x6l,gxfp1mn,gxfp1mp,gxe8waq,gxfqg78,gxf6fly,gxfn43u,gxfqmiz,gxfohfm,gxf45g7,gxfn4xh,gxf8psq,gxf45gc,gxf8h68,gxeq3s2,gxf6s9x,gxfpx9i,gxfpilg,gxfq3l8,gxfktyo,gxfl6lt,gxfn44z,gxfoipg,gxfazzq,gxf69bv,gxfsqch,gxft9ds,gxfmri9,gxfhbk4,gxftfph,gxfml6v,gxfmrij,gxfqg9a,gxf6lzl,gxf8puf,gxf7hll,gxfpxay,gxfp80r,gxfbpb1,gxfl0bi,gxfckwv,gxfngtd,gxfk4pu,gxfmrjb,gxftfqg,gxfngtl,gxfgjy5,gxf48fc,gxez8jf,gxf5ds5,gxffkee,gxfo7yv,gxfpxcc,gxfq9zl,gxfcxlg,gxfq9zp,gxfq1ac,gxftfrv,gxf9li7,gxfq5i3,gxftfs6,gxfskxz,gxfda98,gxfml9q,gxf92jz,gxeo6cw,gxfdt87,gxfswu0,gxfbj22,gxfh74v,gxfnaki,gxfqa10,gxfqa11,gxfp1s9,gxfjyh6,gxfknrk,gxetyzd,gxfm8nv,gxfovh8,gxftftq,gxf69gf,gxfovhc,gxfsqjl,gxfg3fp,gxfqk9d,gxfidm5,gxfpqrh,gxfovhy,gxf8jne,gxf8dbv,gxfpqe0,gxfovi7,gxfb05i,gxfev7v,gxfnn9t,gxfrono,gxfeowl,gxfop7a,gxfmi7d,gxff7vj,gxfdgo1,gxfjfl0,gxfmrol,gxfrpcs,gxfmf1m,gxfjyjv,gxfngz7,gxfn4c4,gxfb1x0,gxf6wbj,gxfth3j,gxfqf05,gxfiwn4,gxf8q0y,gxfop8j,gxfn4ck,gxf3t1i,gxfngzr,gxf80qw,gxfkngs,gxf9fbe,gxfn4cr,gxfngzw,gxfjyku,gxfsqmp,gxfqa50,gxf8jpx,gxfnnbq,gxfhgmx,gxff1lm,gxfjs9o,gxfqggt,gxfoixv,gxfnzza,gxfnnc8,gxfqa5n,gxfnzzg,gxf80rx,gxfqbul,gxei7ld,gxfhi44,gxfqt4n,gxffzys,gxfl0jm,gxf8wed,gxftfyf,gxfk4y2,gxfn4ek,gxf6fx4,gxfk4yh,gxfn4ey,gxfqb0q,gxfp1y5,gxfl0ki,gxfn4fb,gxf5wyw,gxfovmx,gxesqwa,gxfs7qj,gxfsugi,gxfp2k3,gxfh5iu,gxf4c3b,gxeau60,gxfc27v,gxfqgjg,gxfkldh,gxfc282,gxf3zgj,gxfqa85,gxfta5m,gxfk500,gxff80h,gxff80u,gxf92sh,gxfmf6r,gxfnask,gxfqmw5,gxfo02u,gxfmrtz,gxf759l,gxfbvxq,gxf9vst,gxel0ts,gxfn4hh,gxf8whk,gxf877g,gxf3mv1,gxfpxmn,gxfpxmq,gxfrwpx,gxfqaa3,gxfmy6l,gxfnwq5,gxf8jv4,gxfpeoh,gxfptjx,gxfn51f,gxfn4if,gxfprbt,gxfbd0a,gxfj9gb,gxfo6fx,gxfj9gf,gxfnaua,gxfk52f,gxfk52g,gxf57sa,gxfesl6,gxfpepb,gxf7hyp,gxfh5ma,gxfp1k6,gxfab3t,gxft9s0,gxfqabl,gxfn4jp,gxewfre,gxf92vi,gxf51hr,gxfp236,gxflpzn,gxfk53p,gxfqac7,gxf7bo9,gxfqf1h,gxfthqj,gxfr5yc,gxfrili,gxftg4n,gxfeqp3,gxffxf0,gxfo3te,gxfh5ny,gxfoj5d,gxfmrxy,gxfmry4,gxf4v7k,gxfn4li,gxfdgxz,gxfp8gk,gxfbpqn,gxf7uog,gxfq429,gxf6xht,gxfeu2b,gxfskk7,gxfdna2,gxfoj6j,gxfo6jj,gxf69sw,gxfidyc,gxf8jz9,gxf8jzb,gxfpesq,gxfpn16,gxfn4mr,gxfinvi,gxfiqly,gxf4hzd,gxfqaey,gxfl73w,gxf8qbi,gxftjr2,gxfjsjt,gxfms0a,gxfrj36,gxfs45p,gxfsqxf,gxfnnmd,gxfhc2f,gxfg3tk,gxfnhax,gxfo09m,gxfqafw,gxfo09t,gxfie03,gxf4vag,gxf8qcp,gxfn4ob,gxfqagc,gxfpxtg,gxfpmm6,gxfmlpz,gxf8dq3,gxf6z5q,gxflwfz,gxfn4ox,gxfsqyq,gxfkuiw,gxfowa4,gxf8g7r,gxfd4ek,gxfpril,gxfqb1d,gxfdtoz,gxfm93v,gxft9ya,gxfk59h,gxfmyef,gxfeizt,gxfmyej,gxf9ynh,gxfoye5,gxex583,gxfjsmt,gxf462e,gxfp275,gxfg3wb,gxfnnp9,gxfrp33,gxfnhds,gxfjrxc,gxf8k3m,gxfmls3,gxfjyyt,gxfk5ae,gxffr9n,gxfms3x,gxfn4r4,gxfojbl,gxfie36,gxfluq6,gxf8qfq,gxf9yon,gxfn4rc,gxfe02j,gxfskpp,gxfmyfz,gxf8k4g,gxfskpu,gxftgbn,gxfn4ro,gxf8qg5,gxfqn6y,gxfhr8n,gxffxm4,gxfbpwu,gxfjyzz,gxf8z6w,gxfod16,gxfms58,gxfr6gw,gxf8wso,gxfrci1,gxfmyh7,gxfqakv,gxfoxco,gxf9yqb,gxfmyhh,gxfie52,gxfgaai,gxfkbob,gxfms69,gxfiqsl,gxfow12,gxf465j,gxfdh63,gxfk7qq,gxfr8kz,gxfk6ou,gxf7uwo,gxf8d2s,gxfrp6i,gxf7uwr,gxfnb5o,gxfq9th,gxeyq83,gxef8li,gxf8qiy,gxfms7h,gxf6zbi,gxfqbhu,gxfn7ov,gxfq1cp,gxfms7r,gxfqamx,gxfp2e3,gxfkod8,gxfp8pp,gxftgf2,gxfj4rp,gxfki1s,gxel7j9,gxfnhie,gxfn4vb,gxfqnak,gxfq4bz,gxfrap4,gxfikj6,gxf7oms,gxfljzp,gxffxq0,gxeu5x7,gxfauf0,gxfldoe,gxfnb7n,gxfkoed,gxfjmh3,gxfrncc,gxf4cju,gxfngoy,gxf2xzm,gxfp2fr,gxf3gym,gxfo0in,gxf8qlc,gxeqtut,gxflwo6,gxfja1o,gxf8ka2,gxfmsa7,gxeuv8w,gxfmsad,gxf8pqh,gxfn4xm,gxfpy2j,gxfsnnf,gxf6a4f,gxfq4e9,gxey7cz,gxf7v0m,gxfmfns,gxfmsaz,gxfg43z,gxf9yvk,gxfmymp,gxfnhli,gxfk5i1,gxfqaqj,gxfd4nw,gxfj9we,gxfnhlu,gxf0nvo,gxf5r6t,gxfs1ye,gxfp2i0,gxfmsbw,gxff8j4,gxfn4za,gxfqarc,gxf7u50,gxfbdh7,gxfk5j1,gxfp2iq,gxf8x4b,gxfskxx,gxf7v2f,gxfplhk,gxfojk9,gxfqh3e,gxf7v2j,gxfiebw,gxfmyof,gxfo6xc,gxfk5jn,gxeu61g,gxf8qol,gxfnhna,gxf7c4c,gxfprtr,gxf70nb,gxfnhnw,gxfq9ul,gxf7v3n,gxf4vn8,gxfsrav,gxfqmw6,gxeuznu,gxfmse6,gxf6zi9,gxfl16r,gxfh64m,gxfqh53,gxfk5l4,gxfp2l1,gxfqawp,gxfmja5,gxekv36,gxfcq35,gxfl7je,gxfp4rr,gxf75v0,gxf8kfl,gxfrqj7,gxfgn79,gxemsmu,gxfmyrc,gxf4j21,gxfl18a,gxfe6po,gxfpf99,gxf93em,gxftabj,gxf6zk0,gxfnbet,gxf7tak,gxfsl35,gxfswlo,gxet47e,gxfclu3,gxffez0,gxfn53k,gxfqavn,gxfnbfa,gxf7t3t,gxf6zkp,gxf8qsa,gxfbdlo,gxevee6,gxfsxpb,gxfswog,gxflqjn,gxf8r6j,gxfr06d,gxfe6qv,gxflqjv,gxfjlo2,gxfm9im,gxfr7kt,gxf5epe,gxfmshi,gxfnbg7,gxfir3s,gxfkomx,gxf2gbj,gxfsm6m,gxf81j2,gxfnbgf,gxfd4u9,gxftgoz,gxfs24l,gxfnbgo,gxf6wgc,gxfgun6,gxfmsi7,gxfsl3m,gxfhter,gxfn55h,gxfmsie,gxfrvtb,gxfa5ek,gxfq4m9,gxf6aco,gxffrox,gxff8pf,gxf7v90,gxft666,gxfganr,gxf7im3,gxfcyke,gxfqnlr,gxf9n6d,gxfqayq,gxfbqbn,gxekikn,gxfoweg,gxfs26i,gxf5wn6,gxfi1w1,gxf8qvn,gxez9js,gxf6zoj,gxfowf8,gxf93j9,gxfowfh,gxfl1da,gxf4j73,gxfnuia,gxfd4xk,gxfjt4q,gxfn58d,gxfpzcl,gxfo0ue,gxfsmg7,gxfsz03,gxf4cwe,gxfr3dv,gxffrro,gxf7vbt,gxfoyhb,gxfqd7o,gxfk5sv,gxfepuo,gxfo76p,gxfsxuq,gxfsl7p,gxf7p0n,gxf8x9m,gxfp2sr,gxfjgj0,gxfrvxq,gxfn67x,gxfnblc,gxfnhwx,gxf8kmr,gxftgty,gxfepvb,gxf7ce4,gxfl7r1,gxf8ti3,gxfps3q,gxfepvv,gxfscoy,gxfg4gf,gxfnbma,gxfn5aw,gxf8qzd,gxf5evu,gxf57dm,gxfjt7l,gxfn5b7,gxfbuiu,gxelx9i,gxfl7s7,gxfnhyg,gxfcsdn,gxfn5bh,gxf3b1r,gxf6aia,gxf8xbo,gxfirav,gxfmsoo,gxft48p,gxfff7i,gxffrun,gxfb7i9,gxfo0xu,gxfrarv,gxfpoty,gxfjt8m,gxfnbnr,gxfmz0p,gxfslai,gxffrv0,gxfojwt,gxfejm6,gxf4vyo,gxfqc97,gxfkv6d,gxf5rko,gxfhvq4,gxfpluq,gxfp2w4,gxfirc6,gxf4jc8,gxelecs,gxfedbj,gxfbk6r,gxfnuno,gxfircl,gxf5xx5,gxfn5dp,gxfs2de,gxf4w04,gxfqb5x,gxfo0zq,gxfpjwf,gxfni15,gxfni18,gxfps7h,gxfk5xr,gxfsl4z,gxfq1si,gxfqc0q,gxfowm7,gxfojz5,gxfpyjm,gxfgtus,gxfpyjs,gxevl0r,gxfqd2q,gxfmz3k,gxfjzn5,gxfowms,gxfmz3v,gxfp2yk,gxf8egt,gxfoyid,gxfeq0x,gxfhyq3,gxfp7hf,gxfa5os,gxf93ri,gxf6xk3,gxfn5gd,gxf40gx,gxfqb8k,gxfp2zq,gxf34vb,gxfslf1,gxferwd,gxfq6mv,gxfqf6w,gxf1wcz,gxfiet0,gxfosjh,gxfn5h5,gxfrf0f,gxfok1t,gxfg4n9,gxfqg8x,gxfg4nc,gxfs2ha,gxfn8cd,gxfmz6c,gxfni52,gxf8u1l,gxfp314,gxfl6mc,gxf7zhr,gxfo7fi,gxfnusg,gxfrpuq,gxfpouz,gxf8xie,gxfkp0p,gxfgtyi,gxfmmjw,gxfmsvh,gxfp9da,gxf9ghb,gxf93uc,gxfoqey,gxfmg8v,gxfr0le,gxf57ez,gxfni6c,gxfqu9y,gxfpyo7,gxfqnyg,gxfhcy1,gxfsf65,gxeqo5r,gxf8r81,gxf4pua,gxfj45w,gxfr5ji,gxfoqft,gxfni6y,gxfn5jv,gxf64f3,gxfowrm,gxf8h3v,gxf8gcx,gxfslrz,gxfsy5o,gxfnuul,gxfnoj6,gxfrpx4,gxf9gje,gxfjn5n,gxflr0c,gxfnojh,gxfeuxs,gxfixvm,gxfn5l0,gxf8r9l,gxfmz9p,gxf7026,gxfeq6n,gxfso3d,gxfghec,gxfmmmt,gxf8ra1,gxfmmn0,gxf6h3x,gxffyg0,gxfnbxd,gxfnuw3,gxfk65g,gxf7j1j,gxfi8mh,gxfslka,gxfdulr,gxfowtz,gxf64hl,gxfqhq3,gxf703n,gxfpobu,gxfok7a,gxerv1d,gxfqc24,gxfq53h,gxfjn7z,gxfq53k,gxf34mw,gxffs5v,gxflr2s,gxfhqby,gxfkp5p,gxfn5ni,gxf6au7,gxfq545,gxfk9zl,gxeb81a,gxf7cs4,gxfeq9i,gxfqprd,gxfmmpl,gxfowvu,gxfk2m7,gxf822k,gxfqbgg,gxfmvr4,gxf5lky,gxfpfut,gxfnc06,gxflr47,gxfp37t,gxfi2dm,gxffyj4,gxf9ac1,gxfpytu,gxfqbgz,gxfo7mc,gxfbqtx,gxfo1av,gxfth94,gxf9zmi,gxfbe71,gxfh6sg,gxfoxi3,gxfma3p,gxfslnt,gxfiy0i,gxf941h,gxf4ddi,gxfif21,gxftha2,gxfewn1,gxfrdfi,gxfmt33,gxfcz41,gxf7vsz,gxfhps4,gxf2w81,gxf3556,gxf8rf0,gxf29ji,gxf8rf4,gxfgb82,gxerhcz,gxfkp92,gxfr74o,gxfohtd,gxfjy9t,gxfrq3m,gxfc3j7,gxf6nl0,gxfaoyu,gxfqbjb,gxfn5rd,gxfpskr,gxf9431,gxfqf2b,gxfjnvs,gxfn5rn,gxekph3,gxfg4es,gxf4q2i,gxfflyz,gxf473w,gxfowzi,gxf7pj3,gxf3bic,gxf8rgo,gxfnoqy,gxf88ib,gxfap08,gxfnift,gxfqafs,gxfqbks,gxf8rh9,gxfsye7,gxfslr5,gxfplt4,gxfslrf,gxfqble,gxfnos3,gxfd5iw,gxfrdix,gxfjtq2,gxf7pks,gxfbqym,gxf40u7,gxftb28,gxfpxcb,gxfk6dh,gxfmctd,gxfo1ft,gxfnv4a,gxfqo6c,gxfn8f7,gxfqana,gxfek49,gxfoe3h,gxfq5b4,gxfniht,gxf946a,gxfoqqt,gxf7vxi,gxfbkob,gxfk031,gxf8l7z,gxf3blc,gxell6d,gxfpg1j,gxffbzw,gxf594t,gxfjssy,gxfr445,gxfma9u,gxfniir,gxf8xvo,gxfoe4o,gxfpz0p,gxfpg22,gxfp3ez,gxfnij0,gxfi8wi,gxf7vyt,gxfqbod,gxfoe5a,gxfjtsv,gxfo1i8,gxfbwq8,gxf88ma,gxfb1rb,gxf6u20,gxfll0r,gxfthgs,gxfsnbx,gxfpjph,gxf7vzp,gxfqa0n,gxfn5x6,gxftgq9,gxfap4s,gxfpz2c,gxff9hj,gxfjtu1,gxfe0fi,gxfmmz4,gxfaci9,gxfp4wz,gxfoe6t,gxfslw6,gxf82c8,gxfavh6,gxf2z18,gxfmtb2,gxfo1k2,gxfs2xv,gxfp9t0,gxfslwl,gxfrdnq,gxfe6ws,gxf7ppo,gxfhwbx,gxfb84v,gxfjtv6,gxfqbqs,gxfox6d,gxfp3hy,gxfmtp0,gxfk6im,gxfrwn3,gxf47b5,gxfavia,gxfqoeb,gxft8xz,gxfnime,gxfifbd,gxfiryk,gxf76rx,gxfo1la,gxeqolt,gxfnimq,gxf4g3n,gxfmzo6,gxfca3d,gxfspga,gxf9zxd,gxftb8l,gxfjay4,gxeifji,gxekjeb,gxf8f1x,gxf2z3q,gxf70hm,gxfravz,gxfpz5s,gxfpyff,gxfifd0,gxfox8m,gxfoj33,gxfmn2h,gxf4wnf,gxfk6ku,gxfifdf,gxfoqxg,gxfslzq,gxf8rpz,gxfk6l5,gxfflhr,gxfo7z3,gxfn61x,gxf4dpi,gxfmzqi,gxfpzad,gxfqbu8,gxfhq41,gxejb77,gxf8y2c,gxf8rqt,gxf8rr1,gxfoxa7,gxfnvcw,gxfpjor,gxfleva,gxf4wp5,gxfoqyy,gxfqoi4,gxftgbb,gxfmtg0,gxfqbv5,gxfrwr6,gxf8lg7,gxfo1p5,gxfqbvi,gxfsm1s,gxfsm1t,gxf64yv,gxf669z,gxfthnx,gxf94fk,gxfrk4y,gxfmzsj,gxfgblg,gxffyye,gxfsypk,gxfavnd,gxfrj2f,gxfp9z2,gxfo1qb,gxfm3dt,gxfqi89,gxfk0ct,gxfsm33,gxfju1e,gxf6bbn,gxf1xas,gxfmzti,gxfnvfb,gxfl8m0,gxfi95l,gxfbemy,gxf7w7x,gxf7w7y,gxf82jj,gxeqhly,gxf650m,gxfnch3,gxfbrag,gxfmnek,gxfs358,gxftk9m,gxf82jz,gxf47ht,gxfqfye,gxfmtiv,gxfokqg,gxfq2nz,gxfsyrg,gxf9h4w,gxfsyrm,gxfovpz,gxfdc7a,gxfgoav,gxf7w97,gxfqbym,gxf35lb,gxfs0se,gxfnitt,gxfdc7n,gxfoefo,gxf8b72,gxfnity,gxf88wq,gxf47j1,gxfo84e,gxfniu7,gxf534y,gxfitej,gxfniuc,gxfapew,gxfthrd,gxfrqjx,gxfsm5q,gxfhq9c,gxfniut,gxf70os,gxfjb5p,gxepmwu,gxfmam7,gxfjhhc,gxfo85d,gxflxza,gxfncjs,gxf653d,gxfoq7r,gxftizm,gxfnivi,gxfthsi,gxfor4k,gxf2zbt,gxfh10a,gxfppm1,gxfhwm5,gxfapgi,gxf8y8z,gxfrrho,gxfrdye,gxf94pd,gxf4wvj,gxewnsg,gxfkou6,gxf8rxy,gxfsm81,gxf6hs2,gxfb24j,gxf59na,gxf9gap,gxfbers,gxfs39l,gxfrpt3,gxfjhjg,gxfrg76,gxfn6a6,gxfo87j,gxf8ln7,gxftbit,gxfoxhy,gxf8ryw,gxfqie1,gxfk6u3,gxflqrq,gxfs9lz,gxfbeso,gxfowon,gxfo88f,gxfkptf,gxfoxit,gxfkw55,gxf8rzx,gxftbjy,gxfniyr,gxfmaq1,gxf3op7,gxfqc3w,gxfqb4d,gxfrkcx,gxf7wes,gxfncnr,gxf7q3e,gxfrx0c,gxf7q3h,gxf70td,gxfqge3,gxfnco5,gxftcos,gxfczqi,gxfbbqf,gxf5skv,gxfqos1,gxf71xa,gxfkpvb,gxevym2,gxf8s1n,gxfncos,gxfpmv2,gxf70u7,gxft12m,gxfp6vt,gxfeebr,gxfqz70,gxfg5jf,gxeu7f5,gxfsmc2,gxfqc5w,gxfpa8l,gxfncpi,gxfpmvt,gxe9hjv,gxfqiho,gxfd63i,gxfs3dt,gxfn6ec,gxfqgee,gxf9x69,gxfqc6h,gxfifqk,gxfmnfx,gxfmqlv,gxfqe17,gxfpzjy,gxft0bu,gxf5sn6,gxfbrk6,gxf65af,gxfhwso,gxfmatm,gxfqx6r,gxfoxn4,gxfgi8h,gxfqc7n,gxfo8d0,gxfbrkq,gxfbrkr,gxfbrks,gxfoeot,gxf7win,gxfo21y,gxf8s4p,gxeoocd,gxfkpyr,gxfbl9z,gxf59q0,gxfim4a,gxfbrlq,gxe6mc5,gxfmtts,gxfmttw,gxfkwaz,gxfn6h8,gxfnj4e,gxfqc9i,gxf6bo9,gxekir3,gxfswjj,gxfk71a,gxfs3hb,gxf2zkz,gxfkdd1,gxf41if,gxfti22,gxfe84r,gxf70z1,gxflrxr,gxfn06o,gxf8lv7,gxfqcac,gxfqjku,gxfi37d,gxfqgf3,gxfsmgu,gxfkdds,gxf7wle,gxfkjpi,gxfoxqf,gxfqimj,gxfft1o,gxf7jyp,gxfr1le,gxfjbh0,gxfl90a,gxfoxr4,gxfgc0y,gxfowsb,gxfnj6z,gxerxf1,gxfcnam,gxf9hin,gxf5g4w,gxfiyuu,gxf6upj,gxfgv09,gxedcsc,gxfjo5d,gxfnj7q,gxfqa90,gxfqvbe,gxfisjv,gxforgx,gxf4e8k,gxf7120,gxfk74s,gxfrx9a,gxfq1ol,gxf8s9v,gxf94x0,gxf65gl,gxfs56m,gxfncx9,gxewxgd,gxfoxtd,gxfeyes,gxfoxtp,gxfer7m,gxfqcei,gxfbyyg,gxetidy,gxfmtzr,gxfqvdk,gxfism1,gxfsml7,gxfp464,gxfb8t9,gxfqcf3,gxfqcf4,gxfmu04,gxfncyv,gxf3620,gxfpug4,gxfmu0d,gxfncz6,gxfq4zo,gxfiyye,gxfi3co,gxfsanp,gxfn3ai,gxfkdj6,gxfiyyu,gxf8scn,gxf4808,gxfn6o9,gxe9570,gxfteul,gxfl95d,gxfmhea,gxf8sd0,gxfctqt,gxf6fus,gxf8sd7,gxfhx2b,gxfo2av,gxfpn6w,gxfe8bt,gxfn6pb,gxf7qgg,gxfqa4o,gxfbf76,gxfcng5,gxfnjcm,gxforlk,gxfn6pm,gxfisoz,gxferb9,gxefah6,gxfcngt,gxfi9qm,gxfqci7,gxfo2c3,gxf5dxq,gxf2we1,gxfnw0t,gxf8sf4,gxf6vo0,gxf717q,gxfg5wo,gxf835a,gxfq67l,gxfo2d1,gxf835j,gxfnvr3,gxfs8kh,gxfqp6m,gxf5a0v,gxf7q80,gxf8sg3,gxfb8xz,gxfp4ax,gxfolem,gxflpsn,gxf4efg,gxeolji,gxfhsml,gxfk103,gxfo2dz,gxel950,gxf9zux,gxfi122,gxf9o2m,gxf9540,gxf81tc,gxfp05u,gxfqw9n,gxf4961,gxfawbs,gxfadd6,gxftict,gxfnw33,gxf484z,gxf4xf8,gxfimgl,gxfnnji,gxftid6,gxfoqhb,gxfmm2r,gxfqikr,gxfn6tg,gxeys7b,gxeisng,gxfnjgr,gxf7dxp,gxfjuq7,gxfctw1,gxf4ksy,gxfmnv8,gxfqixi,gxfimhj,gxfi3ix,gxfctwe,gxfmb8m,gxfmu7i,gxfsmsw,gxfnw4x,gxf5zea,gxf7b1g,gxfawdw,gxflsai,gxf486z,gxfe8ho,gxfk7el,gxfbfcu,gxffn2a,gxfq33q,gxfjurs,gxf4uzw,gxfowsp,gxf3ix9,gxfc25p,gxf8sk1,gxfsllz,gxf8t7y,gxfnpue,gxelquw,gxfnw66,gxfcnms,gxerrfp,gxf83ae,gxftig8,gxfrvau,gxfr8a6,gxf8skz,gxf89md,gxf6v1z,gxfp1x0,gxfr1z2,gxf958l,gxfqdbr,gxfjxk2,gxfqb7y,gxf8yxi,gxf5i0w,gxftihq,gxfp411,gxf60pr,gxfmnz5,gxf6v36,gxfptr8,gxfnd9n,gxfo3yp,gxfqcq9,gxf8smq,gxfrjgz,gxf6igo,gxfbs3e,gxfptrv,gxfrxml,gxf71fi,gxfgx1x,gxforuu,gxfqmw4,gxfqvpm,gxftij1,gxfoxpp,gxfmuca,gxf6v4r,gxfr21o,gxfdn2d,gxf7x2d,gxforvv,gxfslel,gxf8som,gxf8z0j,gxfb2v9,gxfo8xz,gxfk1sc,gxfptu9,gxfigcy,gxfhr2w,gxfermh,gxfnwbh,gxft7vu,gxfbs67,gxf6c82,gxf95d0,gxft8z9,gxf8sq3,gxfnjov,gxf7e5v,gxfmhro,gxfptvb,gxfq0pg,gxf305a,gxfit1c,gxfgiv5,gxfl37n,gxfqcue,gxf8sqv,gxfqvt7,gxezo26,gxfgp73,gxfsm1d,gxf7kia,gxf8sr7,gxfoyac,gxfqvtp,gxf8g4g,gxf9odf,gxewomg,gxf6v8t,gxfqcvi,gxft6hp,gxftinl,gxfaqb9,gxfrrga,gxfqdcv,gxfc4w2,gxf65z9,gxfk1c7,gxf8sso,gxfnwej,gxf8sss,gxfnwel,gxfn74e,gxekki7,gxf65zo,gxfn74p,gxftcd7,gxf3cv0,gxfpadt,gxf48h4,gxfe2g9,gxfd6uk,gxfqcxc,gxfdd6a,gxfndh0,gxfieef,gxfnwfw,gxfmbk2,gxf5zpf,gxfn761,gxfk1e2,gxfcnwt,gxfn768,gxfnwgh,gxfrnit,gxfra0z,gxf7eae,gxfo93r,gxff4ex,gxfo2sc,gxfo93x,gxfo2se,gxfk404,gxfqplu,gxf96yu,gxfs0ny,gxfa7fr,gxfo0lr,gxfsn56,gxfey3v,gxfphd8,gxfjv3j,gxflsmq,gxfqcz7,gxfsn5h,gxfq0c7,gxfpu0q,gxfqczf,gxe8a4f,gxfbscj,gxfn77o,gxf45e5,gxfq71z,gxfn3du,gxfertd,gxfg6e7,gxfp1yz,gxfdjl2,gxf71pj,gxfrrl7,gxfbsdo,gxfbm25,gxfsh8d,gxfr8mo,gxfey5v,gxev3wa,gxfoltn,gxfmbni,gxfndkv,gxfit8m,gxfpu2v,gxft66g,gxf6vex,gxfg7tj,gxfnjwt,gxfr1m5,gxer60z,gxfmbo9,gxfrkkv,gxf7ee4,gxfoe2t,gxfo97m,gxf9bxf,gxfo2w4,gxfnjxl,gxfek9e,gxfohyq,gxfsn8x,gxfchpu,gxffaut,gxeigtz,gxf4rlm,gxf7r25,gxfr2da,gxf6667,gxfq4yo,gxfr2de,gxfs6n6,gxfoxrq,gxfndmu,gxfndmx,gxf4xxm,gxfmuoc,gxfnjym,gxf4rm9,gxf95n8,gxf4ezb,gxexmuy,gxf6672,gxfitb3,gxfoyjk,gxf8t0k,gxf7krp,gxforgc,gxfn10q,gxfoyjw,gxf6p6d,gxf95o9,gxfcwsf,gxfm0fe,gxfhyz9,gxfbm67,gxf24u9,gxfpl0o,gxfnqbo,gxfkqva,gxfrqwx,gxft7e6,gxf48p8,gxfbnhq,gxfn7do,gxe6pel,gxfol8p,gxfjoyo,gxfpnvm,gxfqd5v,gxfoylr,gxf8a40,gxfkkl0,gxfnk1i,gxfc25o,gxfrrr0,gxf7ku6,gxftcn7,gxfnash,gxfosay,gxft6bv,gxfrpsx,gxfqhfn,gxfsndb,gxfrf4h,gxfndqt,gxf6qih,gxffty0,gxfn7fd,gxfqpui,gxfk1nf,gxfr2hr,gxffhb6,gxftgmx,gxerrz8,gxfc292,gxfbm99,gxfrrsg,gxfn7g1,gxfigs5,gxfqjr1,gxfb9mh,gxfq0l6,gxfeyd7,gxflsvw,gxf9ytu,gxf8ghs,gxf9v2a,gxfiah1,gxfpnyf,gxex1mx,gxf95sf,gxfnk42,gxfqhmr,gxfopda,gxf2nx5,gxevzq4,gxftcpq,gxfnng0,gxfpwkb,gxfndt3,gxfe87w,gxf7r8x,gxf8t6a,gxfjvec,gxevzqt,gxfqpx2,gxf6s7h,gxfnws9,gxf83wd,gxf5h36,gxf8t6v,gxfn16w,gxf6cp7,gxf36x9,gxf3pw0,gxfqw9e,gxfqdas,gxfh8lx,gxfr2l5,gxevahm,gxf6vob,gxfbg0t,gxf7200,gxf6voi,gxfth3l,gxfa1ks,gxfpucs,gxfk1rh,gxfnk6m,gxf5awt,gxft6gg,gxfmi9e,gxfndv7,gxfpud3,gxfp52w,gxf7kzd,gxfbz0c,gxfoyrh,gxfqpz2,gxfmuww,gxelzi9,gxflzb6,gxeo9oj,gxfqdc4,gxfr2mh,gxfjvgr,gxfnwuk,gxfqdcd,gxfq2wd,gxfbspg,gxf7pl5,gxfsniv,gxf8gm2,gxfb9qz,gxfnwv1,gxfmuxr,gxfoexh,gxflayd,gxfnzrx,gxem5uz,gxemc6n,gxfhlbo,gxfialx,gxfdjxz,gxfpii1,gxf8myl,gxewp4y,gxf7xoj,gxfoyti,gxer96v,gxfigy4,gxf6vro,gxfl3rq,gxf3713,gxfq0rd,gxfn7mi,gxf7xp7,gxf66hp,gxfcuou,gxf8780,gxfnwx0,gxf5h7x,gxfsnlc,gxezib5,gxfmv04,gxfobpa,gxfla47,gxfndyx,gxfpphj,gxeo9rw,gxf4lml,gxfrybm,gxfsnm1,gxf7xqj,gxfd7d9,gxf7vn1,gxfqjrk,gxf4901,gxf3wd3,gxfmqtg,gxfo9lm,gxf4fbu,gxf6vtq,gxf4yaq,gxfne03,gxfr2qs,gxfqdgm,gxfqdgr,gxfcbsf,gxf8zp0,gxft6m0,gxf8gqj,gxfr2ri,gxf7ldr,gxfoanq,gxf42py,gxfnqo8,gxfqq4p,gxfhy2y,gxf7yt4,gxfq76c,gxfspuj,gxf4fdk,gxf8aft,gxf8tei,gxerdh6,gxfp59a,gxfs4ps,gxfnkdf,gxfphwk,gxfesbq,gxfne1z,gxfryeh,gxf8n3e,gxf8tf0,gxffnxq,gxfdgvc,gxfg0ky,gxfbz7d,gxfg0l6,gxfth4v,gxfk1z0,gxf8tfh,gxfr94v,gxfbsw1,gxfombm,gxf7xtw,gxffnyf,gxffhmx,gxfsnpn,gxfmih8,gxflzim,gxft85t,gxfbswh,gxfr95e,gxfn7rn,gxetpuc,gxft6ol,gxfefpz,gxfkemw,gxfn7ru,gxfk4fz,gxf7xul,gxffnz4,gxfnx2a,gxft8pn,gxfih48,gxf66ne,gxfhy5p,gxfkkz3,gxft6w3,gxfrs51,gxfnojt,gxfla9k,gxfl3y4,gxflt8f,gxfsfqx,gxfp45z,gxfsnrf,gxfddu6,gxfk21b,gxfn7tc,gxfd175,gxeu2ja,gxfiqih,gxftjdl,gxfdsa2,gxfl1qn,gxf6vz1,gxf8gv6,gxf30sb,gxfqq91,gxf8ajt,gxf7xwq,gxf378u,gxfpoc0,gxfhrw3,gxfsnsk,gxfbszb,gxfr2wp,gxffuda,gxfl404,gxfk8ee,gxf8493,gxfn7uw,gxfjpft,gxfar2i,gxfn1jf,gxftd3g,gxfiavi,gxfn3hp,gxfmv80,gxf7ezc,gxft6sc,gxf8tk1,gxf9cis,gxfgjoq,gxfqcfm,gxevnhr,gxffhrl,gxf33y1,gxe903d,gxf8tkq,gxfi06t,gxfbmps,gxfhry9,gxfq7d6,gxfd7m6,gxf44u0,gxfthmd,gxfqdp3,gxf0kig,gxfpoey,gxf9ivv,gxfpuqk,gxf72e5,gxfa2p1,gxfhfc1,gxf9iw3,gxf7ld0,gxe6pyf,gxf6d48,gxft73p,gxfd5bb,gxf84r5,gxf6w33,gxfl439,gxf6wx5,gxfdwxl,gxfn7y5,gxf5q8w,gxeq7lt,gxfr9c2,gxf386j,gxfmvb8,gxfqdqm,gxfbmjc,gxfl4xa,gxf726x,gxfr9ct,gxfkxsw,gxfg74z,gxfoz6s,gxftd7p,gxfq7fq,gxfn1nr,gxfpoh4,gxfpusq,gxfs3nr,gxf7lf5,gxfcc3b,gxfn7zs,gxfde0p,gxfde0q,gxfovrz,gxfrfp9,gxfagqk,gxfpw83,gxfmipv,gxfbt52,gxfk8jr,gxftjkh,gxf6pub,gxfgdhw,gxfkejz,gxfhydy,gxflzrs,gxf49cr,gxexl5o,gxfnqzk,gxfo4e3,gxeufdw,gxf7f51,gxfmnpt,gxf5hm2,gxf3753,gxfr33h,gxfdkdr,gxfb3w4,gxf7lh1,gxfr9fb,gxfar96,gxfpuju,gxfp5ku,gxf6w73,gxfk8li,gxez62v,gxfm3dv,gxfnkpe,gxftdas,gxf3k45,gxfnxco,gxfbmvv,gxfbgkc,gxfbc0q,gxfqduo,gxfq7j5,gxfnr1g,gxfq17q,gxfln6w,gxf8h49,gxfsbe3,gxfnkq4,gxfqk6l,gxfjvzl,gxe9cxg,gxfqdvb,gxfnxdm,gxfpwnz,gxfsoqy,gxf7y67,gxfr9hd,gxfnef7,gxfnefb,gxfr362,gxfhyhh,gxfqdw6,gxfihg8,gxfjd25,gxfmvhe,gxfd7ty,gxfozc7,gxfp5a4,gxfbzl8,gxfbmy6,gxfmvhs,gxfrb8o,gxfniab,gxf8rbo,gxfg0zh,gxfnksf,gxfa8e8,gxf37k4,gxfqk93,gxfbabu,gxf4m4t,gxfm68g,gxe6jv5,gxfr382,gxfbac9,gxfi9pr,gxfnxge,gxetjxb,gxfsbhf,gxfnxgo,gxf72nd,gxfskjr,gxfqdyn,gxevnsh,gxfiu5u,gxfnom6,gxfneig,gxfneij,gxf6wcd,gxe796m,gxfmvk4,gxfqkat,gxfehtj,gxfmvkc,gxfsorm,gxfa24v,gxfpopc,gxfqkb5,gxfk8r6,gxflnbr,gxfos6y,gxetwm2,gxfnr6m,gxfn887,gxf84mh,gxf8twr,gxf84ml,gxfsbjk,gxf3wxd,gxfsuib,gxfcozb,gxfcozd,gxfqe0t,gxfihku,gxexf2g,gxfomc7,gxeon0t,gxfnxji,gxf8txt,gxfqchv,gxeww44,gxe8oeu,gxfbaft,gxfisp1,gxf4skk,gxf84o5,gxfemjq,gxfi4z0,gxfihm9,gxf83x9,gxfozhy,gxfa27v,gxflars,gxfposp,gxfjrb3,gxfr9ox,gxf8tzo,gxfij7y,gxfslmh,gxf8no9,gxfp9on,gxfbtgd,gxfkf6n,gxfcvwb,gxft78l,gxfk2jr,gxfqx2g,gxfma2k,gxfryzv,gxfrnwy,gxfa29a,gxf9iqw,gxfot82,gxf5hwz,gxfnenp,gxfhm2e,gxfs5br,gxepipk,gxfsum4,gxfeyp6,gxfomwu,gxfgv6d,gxf7s3m,gxfbn5v,gxf8u12,gxf677t,gxf5tmm,gxfnxn2,gxfr9qn,gxfnxn5,gxesoih,gxfjpy3,gxfo3z3,gxf7s4g,gxf5hy9,gxfbtia,gxfltsz,gxf6dk7,gxfp2j3,gxfnl0s,gxf7ygf,gxfn80o,gxff5rl,gxe6rxa,gxfjds9,gxfsoca,gxfbn7g,gxf679b,gxfhyrk,gxf8b42,gxfh9hf,gxf8u2t,gxfm05i,gxf60xz,gxfp8hj,gxemqme,gxfq7v3,gxfqe6r,gxf3e54,gxfn8ex,gxf67a2,gxfneqm,gxfqmip,gxeklsx,gxfnxpf,gxfhysk,gxfra8h,gxfomzw,gxfner2,gxfner4,gxfomau,gxfon06,gxfogop,gxfn8fw,gxfs2ol,gxf8u4g,gxftdoh,gxf8hhe,gxfnxqb,gxfnl39,gxfet1p,gxf7lw3,gxfs0m5,gxfotco,gxf8u55,gxfnl3v,gxfp5zx,gxfh38f,gxfoo0d,gxfay07,gxf7yjy,gxelhh1,gxf6qba,gxftk1k,gxetwvt,gxfd4cq,gxf6ywo,gxftaxs,gxfnete,gxfg7nr,gxfnxs8,gxfi572,gxevyz2,gxfii1f,gxfnl5g,gxfqead,gxfmvvd,gxfl4nv,gxf67do,gxfm6lf,gxf8u7d,gxf8hkc,gxf6wod,gxf6woh,gxf612r,gxffoqf,gxevbi5,gxfp62p,gxfd891,gxfgkcp,gxfqcwc,gxf3wif,gxfbaq4,gxfd1kt,gxfk2ry,gxfjq4x,gxfpp1v,gxfth8l,gxfk93u,gxfa8t9,gxfq80u,gxffv35,gxfefrv,gxfr3mu,gxfn0g7,gxeyhbg,gxfa2i5,gxfnxv2,gxf7egr,gxfnewg,gxfdelv,gxf9jjp,gxflu0n,gxfn5px,gxfam8a,gxfrr4b,gxfrvc1,gxfalhm,gxfg1g0,gxf9e8n,gxf6y32,gxf5bvi,gxfsokj,gxf7sdi,gxfiulo,gxfrsz1,gxesos2,gxfn8mr,gxfk2us,gxfqeet,gxfhsom,gxfl1in,gxffo16,gxfdkzj,gxfo0a3,gxfib82,gxehg92,gxfb4hz,gxfk2vd,gxfp0fn,gxezd01,gxealqq,gxfkyhf,gxf6wt0,gxf836h,gxfg4w4,gxfqxek,gxfsom7,gxfq1sx,gxet7s4,gxfoxhl,gxfk2wa,gxf1tik,gxf6wtq,gxfmntp,gxfrzcg,gxfoh54,gxf6wtu,gxfioc3,gxfqvku,gxfszdq,gxf6dvc,gxf9709,gxftk8r,gxfp39m,gxf90ow,gxfnlc2,gxf5byb,gxfdepw,gxfsnom,gxf5t0h,gxfoqmk,gxfjqa3,gxf6wup,gxfdfz7,gxfnf13,gxfbavu,gxfotln,gxf5om5,gxfn8pq,gxeb4rm,gxfm0h1,gxf5rja,gxfqei4,gxfsouq,gxf7tpk,gxf79iu,gxfmu5d,gxfc06w,gxf5ibe,gxftgtn,gxfjewa,gxfpr6d,gxf3r45,gxfo6m9,gxf7ytn,gxfegpb,gxf972s,gxfo52r,gxfi5g8,gxf6wws,gxffo1y,gxfqypd,gxf90rt,gxfinif,gxeh3qm,gxfgx81,gxf4mr9,gxf6wxj,gxfpqgk,gxfii4a,gxfny2j,gxfn3m1,gxfdrgg,gxfi5hf,gxfiog5,gxfn2h3,gxfra6g,gxfqc7v,gxf7gzy,gxf8o5v,gxfd26t,gxf79lj,gxf7fx5,gxf8uhm,gxfoopb,gxfqelf,gxezw4n,gxf8ui0,gxf8bjl,gxf67oz,gxeh3sk,gxfetfa,gxeuprd,gxf79mh,gxfbb0b,gxfpj0d,gxf5if3,gxfqem9,gxf7ma1,gxfrt6y,gxfr3wv,gxfk9e9,gxfr1vn,gxfmw7q,gxfonff,gxfr3xd,gxfnf6u,gxf6uyv,gxfii7g,gxfk9f1,gxfqxm6,gxfocbs,gxfp6eo,gxfg4xf,gxfsotw,gxflho9,gxfi5kw,gxf8o94,gxf8ukq,gxfayff,gxfny6k,gxf7sni,gxfs5w1,gxfnf82,gxfte51,gxflhp6,gxfcd0d,gxfdrkw,gxf7yzn,gxf73dy,gxfrwfw,gxfl52y,gxfhmnv,gxfnf9b \ No newline at end of file +gxf43y8,gxfq8eb,gxfmdc4,gxfc01r,gxfkyrq,gxfh1lj,gxfnxps,gxf8ob6,gxfdlau,gxfobx5,gxfn2mw,gxf7sp0,gxfd2ce,gxesisg,gxfqxpe,gxf6y58,gxf97ai,gxfowzs,gxfhzci,gxfrzn7,gxfha2d,gxfh3qu,gxfrzym,gxfscal,gxfdxz2,gxfbu4f,gxf4gn4,gxfte82,gxfradg,gxfpphi,gxfrznr,gxffcjt,gxfsl9d,gxfom0d,gxfpphy,gxfnyaf,gxfiic8,gxf7sri,gxfe4bj,gxfezxd,gxfif0w,gxfetda,gxfg1uv,gxfrjdh,gxf9207,gxekg31,gxffiwh,gxfhzea,gxf8upg,gxfjkai,gxfcprv,gxfmq2m,gxf7utc,gxfp091,gxf911i,gxfhzez,gxf7g5o,gxf38gd,gxf8pd9,gxftea9,gxfk39v,gxfns0l,gxf6x7a,gxfq8ig,gxem10c,gxfomk1,gxf7mhr,gxfht41,gxfbu79,gxfnjht,gxf8271,gxf8ofl,gxetr5g,gxfql6e,gxfnyd5,gxf3xrx,gxf9135,gxfa9c2,gxfb9zq,gxfp6mj,gxfmwgd,gxfq28e,gxfn2rz,gxfjqoi,gxfj7pv,gxf7g7p,gxfk9ng,gxfsvdq,gxf85i7,gxft80y,gxfn2sn,gxfnyeg,gxfq8kp,gxf2pkb,gxftecy,gxfl59v,gxerh11,gxfted4,gxfbu9o,gxffpbx,gxfqbl7,gxfnls4,gxf7sw5,gxfetqh,gxfp2af,gxfoy2q,gxfdlhv,gxfob32,gxfm78g,gxfeymw,gxfn2ud,gxfnyga,gxfjeyv,gxfgxf6,gxfk3e6,gxfd3a6,gxfeh4h,gxfppob,gxftef0,gxfgenx,gxf681u,gxf6edf,gxf681w,gxfqqch,gxffcqy,gxfr49b,gxfs1r7,gxflbo6,gxfrn8j,gxfs67d,gxf85m2,gxf8bxq,gxfiv73,gxf8bxv,gxfksqc,gxfk3g5,gxfo4ty,gxfm7ax,gxfk3gb,gxfb533,gxfeni8,gxftegx,gxfont1,gxfli12,gxfs688,gxfl0x8,gxfoiag,gxfje6v,gxe7a8p,gxeu3yy,gxfq2e4,gxfpyyt,gxfnfl1,gxfhacm,gxfq2eh,gxfn2y2,gxfq5af,gxf919p,gxfrnam,gxfngda,gxfd8za,gxfnc5l,gxfetvf,gxfsvk0,gxfc729,gxfp6tf,gxfgero,gxfmqbp,gxf8ibv,gxfk9u4,gxfam6l,gxfiimm,gxf6xg2,gxfioyb,gxfm11w,gxftkuv,gxfbuft,gxfp0ii,gxftkv0,gxfnfml,gxfqfty,gxfcq20,gxfgt8c,gxf686k,gxf6xgu,gxfnlyp,gxfg8he,gxf8icz,gxfqlfb,gxf73sq,gxfmqd7,gxfbvia,gxfpji7,gxfmwow,gxf7zep,gxfqf42,gxfohl3,gxf3fsc,gxf8idm,gxf7zf2,gxfmwpd,gxf5p8y,gxfe4nn,gxf93qo,gxfmwpl,gxfpzlw,gxf1hjz,gxfkg80,gxfi61v,gxf73tx,gxfmqef,gxenm7e,gxf3lez,gxfp0ks,gxf9kbx,gxf6r77,gxfqf5k,gxfrtq2,gxfpw70,gxfjxa5,gxfppvj,gxfam9t,gxf56bm,gxfli6b,gxfq8uc,gxfsqqu,gxfiipy,gxfa9mw,gxfqrt5,gxfn6wn,gxfama4,gxffcy9,gxf91ea,gxfsvab,gxf9e1o,gxewqxv,gxfba4x,gxf7t60,gxf9x0s,gxfgl7z,gxfnype,gxfrx3o,gxfqfh4,gxfr4hl,gxftkzg,gxfr4hr,gxfs60b,gxfq8w5,gxfqf7r,gxfdfgq,gxfpogn,gxf7mvh,gxfovtw,gxfhtht,gxf85ua,gxfsj2s,gxf8v4n,gxficgx,gxfd95u,gxffl0b,gxfq2lg,gxfka05,gxfrtt4,gxf1uay,gxey5kb,gxfn35a,gxfn35c,gxfboab,gxfmwtw,gxfteph,gxesjaw,gxfaz0b,gxfrtto,gxf7mwx,gxeuzt1,gxfrnii,gxfqf9p,gxfcjww,gxf44ib,gxewedw,gxf8ij9,gxfhzvd,gxfhtju,gxfeb4v,gxfqccr,gxevbzv,gxftl2h,gxfn9ij,gxfmwvl,gxfnm5z,gxfnm63,gxfs070,gxfmwvz,gxfaz29,gxfehhe,gxfo8uu,gxfq2o6,gxfohs7,gxfn9jd,gxeru30,gxfqgx9,gxfnfv6,gxfrq83,gxevvh0,gxf9e6v,gxfmdy2,gxfentn,gxfq90g,gxfp737,gxfoxq8,gxfpq1x,gxfbodi,gxffw2v,gxfnsj3,gxei6gg,gxfnyup,gxfm1bn,gxfq088,gxflp7j,gxfr4n0,gxfnsjh,gxftetd,gxfmkav,gxea4v6,gxfenv4,gxfo579,gxfo57b,gxf8va5,gxfa9um,gxfg2fz,gxfip9e,gxfqfdy,gxfm7oo,gxfm7op,gxfnywa,gxfmkbx,gxfrzln,gxf8oz5,gxfqetv,gxfbbsl,gxelvau,gxfk3uh,gxfqgxr,gxfbu6t,gxft280,gxf5cw1,gxfmkcm,gxfq93e,gxfq93h,gxf7zpr,gxftevk,gxf68ib,gxfi6ca,gxfq2sb,gxfqffi,gxfg8te,gxf6xt0,gxf6xt2,gxfqcgh,gxfmvd3,gxfnfzi,gxfpjua,gxfrb1x,gxfj1yu,gxfq273,gxf9362,gxfn3cw,gxfn3d1,gxfn3d4,gxf745m,gxfs97m,gxfp782,gxfrnpw,gxfeuag,gxec65k,gxf8cf4,gxfmqqq,gxf7tgk,gxffd9h,gxfq2ue,gxf8vss,gxfpjvu,gxfjran,gxf7n5e,gxeaghh,gxfi03e,gxfp0xq,gxfij27,gxfpdkw,gxflv5v,gxfliis,gxfc7ig,gxfnmdl,gxfopqv,gxfbuvg,gxfehou,gxf4fo8,gxfipeb,gxf7n6d,gxf8655,gxfpmen,gxfsjdp,gxfk3z5,gxfp7aa,gxfqgyj,gxfmkh1,gxfrn3l,gxfme5p,gxf50dq,gxfn3g0,gxfproe,gxf3ltm,gxfow2n,gxflvka,gxfipfi,gxfrq9h,gxf0mdc,gxfs74b,gxfeo1w,gxfm1jb,gxfn3go,gxf7n7u,gxfouob,gxfpdn0,gxficst,gxfc7kg,gxfrnte,gxfsyih,gxfq2xn,gxfq0vw,gxfkmzk,gxfq2xt,gxf9fty,gxftf1l,gxfojfr,gxf5d2l,gxfn3hv,gxfmqut,gxfm7w6,gxfi06w,gxfr2sn,gxfsw3m,gxfi0qm,gxf6rnv,gxftf2l,gxfpzmh,gxfmx75,gxfn9ue,gxfi07u,gxftlek,gxf5jfj,gxfpqcg,gxfjeso,gxfmqwa,gxf91va,gxf91vb,gxfs0jh,gxfhaym,gxfazeo,gxfhtxg,gxfmea6,gxftknh,gxfeo5u,gxfipjn,gxf5jh4,gxevc27,gxeoc0s,gxf7tni,gxfbc2n,gxfp7fk,gxf9yt7,gxfrbac,gxfr4z0,gxfr33x,gxf7zzl,gxftjqt,gxfckcc,gxfn3lv,gxfqsck,gxfox5i,gxftf5z,gxfk45k,gxfeo7d,gxfpqff,gxfmxam,gxfss7s,gxfrbbo,gxftb6i,gxec1lq,gxflcf3,gxflvdu,gxf8pb7,gxfscff,gxfpqg5,gxfivxj,gxfamug,gxfp161,gxfijai,gxf6y41,gxfcqpe,gxfmed0,gxfn3n9,gxffdiu,gxfq33u,gxf8qkq,gxfkzsu,gxf1ohu,gxffq6c,gxfkthq,gxfp2fh,gxfe5h4,gxfpwsz,gxfq9g5,gxfn3o7,gxf8vo9,gxf802k,gxfi0d9,gxfeula,gxfeo9t,gxfspya,gxffwiv,gxf39ex,gxeprwy,gxfm6lv,gxfrtgo,gxfq9gy,gxfmxdf,gxfoi9g,gxf82cp,gxfehz2,gxfeoao,gxf216q,gxftkob,gxflvgm,gxfjxxj,gxfklkg,gxfdz0z,gxfm1sl,gxfo5nc,gxftfa2,gxfbouz,gxffdlo,gxfrpig,gxfk49q,gxfpe11,gxfkgwz,gxfnzc5,gxfobza,gxfbc87,gxfmp3w,gxff0yz,gxfg2wc,gxfh4tp,gxfouy6,gxf7nhs,gxfnzci,gxft8g5,gxfnmph,gxf922h,gxf5pyy,gxfpqkd,gxffjy6,gxfamyq,gxfipqg,gxf4bex,gxf74ju,gxfq9y2,gxfp1at,gxfq9jq,gxf4bfc,gxfr55m,gxfeuou,gxfn4x1,gxesjxo,gxfrugd,gxfjrou,gxfjjc8,gxfp1bn,gxfoiy0,gxfbcae,gxf5ddj,gxfl69o,gxfnnvu,gxfpwxq,gxfmr5x,gxf5de2,gxfmxhn,gxf8phr,gxfl36g,gxf6yah,gxforfm,gxfnt49,gxfp1d9,gxfknce,gxfd9v3,gxfk4dt,gxfmr76,gxfjy2g,gxfiptm,gxfgfng,gxfg30d,gxf8mpw,gxf9xrz,gxfn3up,gxfr2s9,gxf8pj9,gxf339i,gxfm896,gxfijij,gxfn3v6,gxfs0us,gxfnzh2,gxfhu8n,gxfqfyx,gxfkla2,gxfijj2,gxfjrs2,gxfr35n,gxfq9no,gxf7nmt,gxf8pk7,gxfpe24,gxf5dgt,gxf80a6,gxfmxkh,gxfq9o5,gxfpe2g,gxf83cu,gxfan3w,gxfjrsu,gxfn3wf,gxf8pkz,gxfnimu,gxfgm12,gxf8j9m,gxf7rcy,gxf86mm,gxf7tyl,gxf98k3,gxfp1g4,gxfg32u,gxfp1g7,gxfmkyg,gxf4xnz,gxfsrtr,gxf8vxg,gxfefb3,gxfngks,gxevd7x,gxfnt7y,gxfi6ya,gxfnzjp,gxfnmwo,gxfqasz,gxfp1bw,gxftfg5,gxfnmwx,gxfow5n,gxfd9z9,gxfqmdj,gxfgm2y,gxfd9zg,gxfn3ym,gxfpe4w,gxfnmxh,gxfi6zb,gxfjsun,gxfidb3,gxfqg2v,gxf4bmw,gxfatii,gxfn3zf,gxftgny,gxfr0cv,gxf6ygr,gxfq9rw,gxftfk2,gxf6xbf,gxfdgck,gxfqb7t,gxfiyb0,gxfijnw,gxf85kd,gxfan80,gxfgm0z,gxfov84,gxft832,gxfda1n,gxf697g,gxf33fi,gxfrodb,gxfeom8,gxfbp5y,gxfow64,gxfcr3g,gxfp7vy,gxfqg4v,gxfqi43,gxf3mes,gxfntqb,gxfmrek,gxfnmve,gxfml32,gxfmtdh,gxf8w1t,gxfiq0y,gxf6yiu,gxfeuyw,gxf698p,gxf4bpo,gxevdcd,gxfngpa,gxf6yj3,gxfq3ip,gxfi72s,gxfo5zp,gxfnn12,gxffdy1,gxfpnkf,gxfkvui,gxfqcuo,gxfl6jm,gxfra5q,gxfovad,gxfbckm,gxfl087,gxfknl4,gxfmxrd,gxfn42y,gxfp0e5,gxf6s8g,gxf4bqq,gxf6yk7,gxffwxk,gxffwxm,gxfoinw,gxf2x6l,gxfp1mn,gxfp1mp,gxe8waq,gxfqg78,gxf6fly,gxfn43u,gxfqmiz,gxfohfm,gxf45g7,gxfn4xh,gxf8psq,gxf45gc,gxf8h68,gxeq3s2,gxf6s9x,gxfpx9i,gxfpilg,gxfq3l8,gxfktyo,gxfl6lt,gxfn44z,gxfoipg,gxfazzq,gxf69bv,gxfsqch,gxft9ds,gxfmri9,gxfhbk4,gxftfph,gxfml6v,gxfmrij,gxfqg9a,gxf6lzl,gxf8puf,gxf7hll,gxfpxay,gxfp80r,gxfbpb1,gxfl0bi,gxfckwv,gxfngtd,gxfk4pu,gxfmrjb,gxftfqg,gxfngtl,gxfgjy5,gxf48fc,gxez8jf,gxf5ds5,gxffkee,gxfo7yv,gxfpxcc,gxfq9zl,gxfcxlg,gxfq9zp,gxfq1ac,gxftfrv,gxf9li7,gxfq5i3,gxftfs6,gxfskxz,gxfda98,gxfml9q,gxf92jz,gxeo6cw,gxfdt87,gxfswu0,gxfbj22,gxfh74v,gxfnaki,gxfqa10,gxfqa11,gxfp1s9,gxfjyh6,gxfknrk,gxetyzd,gxfm8nv,gxfovh8,gxftftq,gxf69gf,gxfovhc,gxfsqjl,gxfg3fp,gxfqk9d,gxfidm5,gxfpqrh,gxfovhy,gxf8jne,gxf8dbv,gxfpqe0,gxfovi7,gxfb05i,gxfev7v,gxfnn9t,gxfrono,gxfeowl,gxfop7a,gxfmi7d,gxff7vj,gxfdgo1,gxfjfl0,gxfmrol,gxfrpcs,gxfmf1m,gxfjyjv,gxfngz7,gxfn4c4,gxfb1x0,gxf6wbj,gxfth3j,gxfqf05,gxfiwn4,gxf8q0y,gxfop8j,gxfn4ck,gxf3t1i,gxfngzr,gxf80qw,gxfkngs,gxf9fbe,gxfn4cr,gxfngzw,gxfjyku,gxfsqmp,gxfqa50,gxf8jpx,gxfnnbq,gxfhgmx,gxff1lm,gxfjs9o,gxfqggt,gxfoixv,gxfnzza,gxfnnc8,gxfqa5n,gxfnzzg,gxf80rx,gxfqbul,gxei7ld,gxfhi44,gxfqt4n,gxffzys,gxfl0jm,gxf8wed,gxftfyf,gxfk4y2,gxfn4ek,gxf6fx4,gxfk4yh,gxfn4ey,gxfqb0q,gxfp1y5,gxfl0ki,gxfn4fb,gxf5wyw,gxfovmx,gxesqwa,gxfs7qj,gxfsugi,gxfp2k3,gxfh5iu,gxf4c3b,gxeau60,gxfc27v,gxfqgjg,gxfkldh,gxfc282,gxf3zgj,gxfqa85,gxfta5m,gxfk500,gxff80h,gxff80u,gxf92sh,gxfmf6r,gxfnask,gxfqmw5,gxfo02u,gxfmrtz,gxf759l,gxfbvxq,gxf9vst,gxel0ts,gxfn4hh,gxf8whk,gxf877g,gxf3mv1,gxfpxmn,gxfpxmq,gxfrwpx,gxfqaa3,gxfmy6l,gxfnwq5,gxf8jv4,gxfpeoh,gxfptjx,gxfn51f,gxfn4if,gxfprbt,gxfbd0a,gxfj9gb,gxfo6fx,gxfj9gf,gxfnaua,gxfk52f,gxfk52g,gxf57sa,gxfesl6,gxfpepb,gxf7hyp,gxfh5ma,gxfp1k6,gxfab3t,gxft9s0,gxfqabl,gxfn4jp,gxewfre,gxf92vi,gxf51hr,gxfp236,gxflpzn,gxfk53p,gxfqac7,gxf7bo9,gxfqf1h,gxfthqj,gxfr5yc,gxfrili,gxftg4n,gxfeqp3,gxffxf0,gxfo3te,gxfh5ny,gxfoj5d,gxfmrxy,gxfmry4,gxf4v7k,gxfn4li,gxfdgxz,gxfp8gk,gxfbpqn,gxf7uog,gxfq429,gxf6xht,gxfeu2b,gxfskk7,gxfdna2,gxfoj6j,gxfo6jj,gxf69sw,gxfidyc,gxf8jz9,gxf8jzb,gxfpesq,gxfpn16,gxfn4mr,gxfinvi,gxfiqly,gxf4hzd,gxfqaey,gxfl73w,gxf8qbi,gxftjr2,gxfjsjt,gxfms0a,gxfrj36,gxfs45p,gxfsqxf,gxfnnmd,gxfhc2f,gxfg3tk,gxfnhax,gxfo09m,gxfqafw,gxfo09t,gxfie03,gxf4vag,gxf8qcp,gxfn4ob,gxfqagc,gxfpxtg,gxfpmm6,gxfmlpz,gxf8dq3,gxf6z5q,gxflwfz,gxfn4ox,gxfsqyq,gxfkuiw,gxfowa4,gxf8g7r,gxfd4ek,gxfpril,gxfqb1d,gxfdtoz,gxfm93v,gxft9ya,gxfk59h,gxfmyef,gxfeizt,gxfmyej,gxf9ynh,gxfoye5,gxex583,gxfjsmt,gxf462e,gxfp275,gxfg3wb,gxfnnp9,gxfrp33,gxfnhds,gxfjrxc,gxf8k3m,gxfmls3,gxfjyyt,gxfk5ae,gxffr9n,gxfms3x,gxfn4r4,gxfojbl,gxfie36,gxfluq6,gxf8qfq,gxf9yon,gxfn4rc,gxfe02j,gxfskpp,gxfmyfz,gxf8k4g,gxfskpu,gxftgbn,gxfn4ro,gxf8qg5,gxfqn6y,gxfhr8n,gxffxm4,gxfbpwu,gxfjyzz,gxf8z6w,gxfod16,gxfms58,gxfr6gw,gxf8wso,gxfrci1,gxfmyh7,gxfqakv,gxfoxco,gxf9yqb,gxfmyhh,gxfie52,gxfgaai,gxfkbob,gxfms69,gxfiqsl,gxfow12,gxf465j,gxfdh63,gxfk7qq,gxfr8kz,gxfk6ou,gxf7uwo,gxf8d2s,gxfrp6i,gxf7uwr,gxfnb5o,gxfq9th,gxeyq83,gxef8li,gxf8qiy,gxfms7h,gxf6zbi,gxfqbhu,gxfn7ov,gxfq1cp,gxfms7r,gxfqamx,gxfp2e3,gxfkod8,gxfp8pp,gxftgf2,gxfj4rp,gxfki1s,gxel7j9,gxfnhie,gxfn4vb,gxfqnak,gxfq4bz,gxfrap4,gxfikj6,gxf7oms,gxfljzp,gxffxq0,gxeu5x7,gxfauf0,gxfldoe,gxfnb7n,gxfkoed,gxfjmh3,gxfrncc,gxf4cju,gxfngoy,gxf2xzm,gxfp2fr,gxf3gym,gxfo0in,gxf8qlc,gxeqtut,gxflwo6,gxfja1o,gxf8ka2,gxfmsa7,gxeuv8w,gxfmsad,gxf8pqh,gxfn4xm,gxfpy2j,gxfsnnf,gxf6a4f,gxfq4e9,gxey7cz,gxf7v0m,gxfmfns,gxfmsaz,gxfg43z,gxf9yvk,gxfmymp,gxfnhli,gxfk5i1,gxfqaqj,gxfd4nw,gxfj9we,gxfnhlu,gxf0nvo,gxf5r6t,gxfs1ye,gxfp2i0,gxfmsbw,gxff8j4,gxfn4za,gxfqarc,gxf7u50,gxfbdh7,gxfk5j1,gxfp2iq,gxf8x4b,gxfskxx,gxf7v2f,gxfplhk,gxfojk9,gxfqh3e,gxf7v2j,gxfiebw,gxfmyof,gxfo6xc,gxfk5jn,gxeu61g,gxf8qol,gxfnhna,gxf7c4c,gxfprtr,gxf70nb,gxfnhnw,gxfq9ul,gxf7v3n,gxf4vn8,gxfsrav,gxfqmw6,gxeuznu,gxfmse6,gxf6zi9,gxfl16r,gxfh64m,gxfqh53,gxfk5l4,gxfp2l1,gxfqawp,gxfmja5,gxekv36,gxfcq35,gxfl7je,gxfp4rr,gxf75v0,gxf8kfl,gxfrqj7,gxfgn79,gxemsmu,gxfmyrc,gxf4j21,gxfl18a,gxfe6po,gxfpf99,gxf93em,gxftabj,gxf6zk0,gxfnbet,gxf7tak,gxfsl35,gxfswlo,gxet47e,gxfclu3,gxffez0,gxfn53k,gxfqavn,gxfnbfa,gxf7t3t,gxf6zkp,gxf8qsa,gxfbdlo,gxevee6,gxfsxpb,gxfswog,gxflqjn,gxf8r6j,gxfr06d,gxfe6qv,gxflqjv,gxfjlo2,gxfm9im,gxfr7kt,gxf5epe,gxfmshi,gxfnbg7,gxfir3s,gxfkomx,gxf2gbj,gxfsm6m,gxf81j2,gxfnbgf,gxfd4u9,gxftgoz,gxfs24l,gxfnbgo,gxf6wgc,gxfgun6,gxfmsi7,gxfsl3m,gxfhter,gxfn55h,gxfmsie,gxfrvtb,gxfa5ek,gxfq4m9,gxf6aco,gxffrox,gxff8pf,gxf7v90,gxft666,gxfganr,gxf7im3,gxfcyke,gxfqnlr,gxf9n6d,gxfqayq,gxfbqbn,gxekikn,gxfoweg,gxfs26i,gxf5wn6,gxfi1w1,gxf8qvn,gxez9js,gxf6zoj,gxfowf8,gxf93j9,gxfowfh,gxfl1da,gxf4j73,gxfnuia,gxfd4xk,gxfjt4q,gxfn58d,gxfpzcl,gxfo0ue,gxfsmg7,gxfsz03,gxf4cwe,gxfr3dv,gxffrro,gxf7vbt,gxfoyhb,gxfqd7o,gxfk5sv,gxfepuo,gxfo76p,gxfsxuq,gxfsl7p,gxf7p0n,gxf8x9m,gxfp2sr,gxfjgj0,gxfrvxq,gxfn67x,gxfnblc,gxfnhwx,gxf8kmr,gxftgty,gxfepvb,gxf7ce4,gxfl7r1,gxf8ti3,gxfps3q,gxfepvv,gxfscoy,gxfg4gf,gxfnbma,gxfn5aw,gxf8qzd,gxf5evu,gxf57dm,gxfjt7l,gxfn5b7,gxfbuiu,gxelx9i,gxfl7s7,gxfnhyg,gxfcsdn,gxfn5bh,gxf3b1r,gxf6aia,gxf8xbo,gxfirav,gxfmsoo,gxft48p,gxfff7i,gxffrun,gxfb7i9,gxfo0xu,gxfrarv,gxfpoty,gxfjt8m,gxfnbnr,gxfmz0p,gxfslai,gxffrv0,gxfojwt,gxfejm6,gxf4vyo,gxfqc97,gxfkv6d,gxf5rko,gxfhvq4,gxfpluq,gxfp2w4,gxfirc6,gxf4jc8,gxelecs,gxfedbj,gxfbk6r,gxfnuno,gxfircl,gxf5xx5,gxfn5dp,gxfs2de,gxf4w04,gxfqb5x,gxfo0zq,gxfpjwf,gxfni15,gxfni18,gxfps7h,gxfk5xr,gxfsl4z,gxfq1si,gxfqc0q,gxfowm7,gxfojz5,gxfpyjm,gxfgtus,gxfpyjs,gxevl0r,gxfqd2q,gxfmz3k,gxfjzn5,gxfowms,gxfmz3v,gxfp2yk,gxf8egt,gxfoyid,gxfeq0x,gxfhyq3,gxfp7hf,gxfa5os,gxf93ri,gxf6xk3,gxfn5gd,gxf40gx,gxfqb8k,gxfp2zq,gxf34vb,gxfslf1,gxferwd,gxfq6mv,gxfqf6w,gxf1wcz,gxfiet0,gxfosjh,gxfn5h5,gxfrf0f,gxfok1t,gxfg4n9,gxfqg8x,gxfg4nc,gxfs2ha,gxfn8cd,gxfmz6c,gxfni52,gxf8u1l,gxfp314,gxfl6mc,gxf7zhr,gxfo7fi,gxfnusg,gxfrpuq,gxfpouz,gxf8xie,gxfkp0p,gxfgtyi,gxfmmjw,gxfmsvh,gxfp9da,gxf9ghb,gxf93uc,gxfoqey,gxfmg8v,gxfr0le,gxf57ez,gxfni6c,gxfqu9y,gxfpyo7,gxfqnyg,gxfhcy1,gxfsf65,gxeqo5r,gxf8r81,gxf4pua,gxfj45w,gxfr5ji,gxfoqft,gxfni6y,gxfn5jv,gxf64f3,gxfowrm,gxf8h3v,gxf8gcx,gxfslrz,gxfsy5o,gxfnuul,gxfnoj6,gxfrpx4,gxf9gje,gxfjn5n,gxflr0c,gxfnojh,gxfeuxs,gxfixvm,gxfn5l0,gxf8r9l,gxfmz9p,gxf7026,gxfeq6n,gxfso3d,gxfghec,gxfmmmt,gxf8ra1,gxfmmn0,gxf6h3x,gxffyg0,gxfnbxd,gxfnuw3,gxfk65g,gxf7j1j,gxfi8mh,gxfslka,gxfdulr,gxfowtz,gxf64hl,gxfqhq3,gxf703n,gxfpobu,gxfok7a,gxerv1d,gxfqc24,gxfq53h,gxfjn7z,gxfq53k,gxf34mw,gxffs5v,gxflr2s,gxfhqby,gxfkp5p,gxfn5ni,gxf6au7,gxfq545,gxfk9zl,gxeb81a,gxf7cs4,gxfeq9i,gxfqprd,gxfmmpl,gxfowvu,gxfk2m7,gxf822k,gxfqbgg,gxfmvr4,gxf5lky,gxfpfut,gxfnc06,gxflr47,gxfp37t,gxfi2dm,gxffyj4,gxf9ac1,gxfpytu,gxfqbgz,gxfo7mc,gxfbqtx,gxfo1av,gxfth94,gxf9zmi,gxfbe71,gxfh6sg,gxfoxi3,gxfma3p,gxfslnt,gxfiy0i,gxf941h,gxf4ddi,gxfif21,gxftha2,gxfewn1,gxfrdfi,gxfmt33,gxfcz41,gxf7vsz,gxfhps4,gxf2w81,gxf3556,gxf8rf0,gxf29ji,gxf8rf4,gxfgb82,gxerhcz,gxfkp92,gxfr74o,gxfohtd,gxfjy9t,gxfrq3m,gxfc3j7,gxf6nl0,gxfaoyu,gxfqbjb,gxfn5rd,gxfpskr,gxf9431,gxfqf2b,gxfjnvs,gxfn5rn,gxekph3,gxfg4es,gxf4q2i,gxfflyz,gxf473w,gxfowzi,gxf7pj3,gxf3bic,gxf8rgo,gxfnoqy,gxf88ib,gxfap08,gxfnift,gxfqafs,gxfqbks,gxf8rh9,gxfsye7,gxfslr5,gxfplt4,gxfslrf,gxfqble,gxfnos3,gxfd5iw,gxfrdix,gxfjtq2,gxf7pks,gxfbqym,gxf40u7,gxftb28,gxfpxcb,gxfk6dh,gxfmctd,gxfo1ft,gxfnv4a,gxfqo6c,gxfn8f7,gxfqana,gxfek49,gxfoe3h,gxfq5b4,gxfniht,gxf946a,gxfoqqt,gxf7vxi,gxfbkob,gxfk031,gxf8l7z,gxf3blc,gxell6d,gxfpg1j,gxffbzw,gxf594t,gxfjssy,gxfr445,gxfma9u,gxfniir,gxf8xvo,gxfoe4o,gxfpz0p,gxfpg22,gxfp3ez,gxfnij0,gxfi8wi,gxf7vyt,gxfqbod,gxfoe5a,gxfjtsv,gxfo1i8,gxfbwq8,gxf88ma,gxfb1rb,gxf6u20,gxfll0r,gxfthgs,gxfsnbx,gxfpjph,gxf7vzp,gxfqa0n,gxfn5x6,gxftgq9,gxfap4s,gxfpz2c,gxff9hj,gxfjtu1,gxfe0fi,gxfmmz4,gxfaci9,gxfp4wz,gxfoe6t,gxfslw6,gxf82c8,gxfavh6,gxf2z18,gxfmtb2,gxfo1k2,gxfs2xv,gxfp9t0,gxfslwl,gxfrdnq,gxfe6ws,gxf7ppo,gxfhwbx,gxfb84v,gxfjtv6,gxfqbqs,gxfox6d,gxfp3hy,gxfmtp0,gxfk6im,gxfrwn3,gxf47b5,gxfavia,gxfqoeb,gxft8xz,gxfnime,gxfifbd,gxfiryk,gxf76rx,gxfo1la,gxeqolt,gxfnimq,gxf4g3n,gxfmzo6,gxfca3d,gxfspga,gxf9zxd,gxftb8l,gxfjay4,gxeifji,gxekjeb,gxf8f1x,gxf2z3q,gxf70hm,gxfravz,gxfpz5s,gxfpyff,gxfifd0,gxfox8m,gxfoj33,gxfmn2h,gxf4wnf,gxfk6ku,gxfifdf,gxfoqxg,gxfslzq,gxf8rpz,gxfk6l5,gxfflhr,gxfo7z3,gxfn61x,gxf4dpi,gxfmzqi,gxfpzad,gxfqbu8,gxfhq41,gxejb77,gxf8y2c,gxf8rqt,gxf8rr1,gxfoxa7,gxfnvcw,gxfpjor,gxfleva,gxf4wp5,gxfoqyy,gxfqoi4,gxftgbb,gxfmtg0,gxfqbv5,gxfrwr6,gxf8lg7,gxfo1p5,gxfqbvi,gxfsm1s,gxfsm1t,gxf64yv,gxf669z,gxfthnx,gxf94fk,gxfrk4y,gxfmzsj,gxfgblg,gxffyye,gxfsypk,gxfavnd,gxfrj2f,gxfp9z2,gxfo1qb,gxfm3dt,gxfqi89,gxfk0ct,gxfsm33,gxfju1e,gxf6bbn,gxf1xas,gxfmzti,gxfnvfb,gxfl8m0,gxfi95l,gxfbemy,gxf7w7x,gxf7w7y,gxf82jj,gxeqhly,gxf650m,gxfnch3,gxfbrag,gxfmnek,gxfs358,gxftk9m,gxf82jz,gxf47ht,gxfqfye,gxfmtiv,gxfokqg,gxfq2nz,gxfsyrg,gxf9h4w,gxfsyrm,gxfovpz,gxfdc7a,gxfgoav,gxf7w97,gxfqbym,gxf35lb,gxfs0se,gxfnitt,gxfdc7n,gxfoefo,gxf8b72,gxfnity,gxf88wq,gxf47j1,gxfo84e,gxfniu7,gxf534y,gxfitej,gxfniuc,gxfapew,gxfthrd,gxfrqjx,gxfsm5q,gxfhq9c,gxfniut,gxf70os,gxfjb5p,gxepmwu,gxfmam7,gxfjhhc,gxfo85d,gxflxza,gxfncjs,gxf653d,gxfoq7r,gxftizm,gxfnivi,gxfthsi,gxfor4k,gxf2zbt,gxfh10a,gxfppm1,gxfhwm5,gxfapgi,gxf8y8z,gxfrrho,gxfrdye,gxf94pd,gxf4wvj,gxewnsg,gxfkou6,gxf8rxy,gxfsm81,gxf6hs2,gxfb24j,gxf59na,gxf9gap,gxfbers,gxfs39l,gxfrpt3,gxfjhjg,gxfrg76,gxfn6a6,gxfo87j,gxf8ln7,gxftbit,gxfoxhy,gxf8ryw,gxfqie1,gxfk6u3,gxflqrq,gxfs9lz,gxfbeso,gxfowon,gxfo88f,gxfkptf,gxfoxit,gxfkw55,gxf8rzx,gxftbjy,gxfniyr,gxfmaq1,gxf3op7,gxfqc3w,gxfqb4d,gxfrkcx,gxf7wes,gxfncnr,gxf7q3e,gxfrx0c,gxf7q3h,gxf70td,gxfqge3,gxfnco5,gxftcos,gxfczqi,gxfbbqf,gxf5skv,gxfqos1,gxf71xa,gxfkpvb,gxevym2,gxf8s1n,gxfncos,gxfpmv2,gxf70u7,gxft12m,gxfp6vt,gxfeebr,gxfqz70,gxfg5jf,gxeu7f5,gxfsmc2,gxfqc5w,gxfpa8l,gxfncpi,gxfpmvt,gxe9hjv,gxfqiho,gxfd63i,gxfs3dt,gxfn6ec,gxfqgee,gxf9x69,gxfqc6h,gxfifqk,gxfmnfx,gxfmqlv,gxfqe17,gxfpzjy,gxft0bu,gxf5sn6,gxfbrk6,gxf65af,gxfhwso,gxfmatm,gxfqx6r,gxfoxn4,gxfgi8h,gxfqc7n,gxfo8d0,gxfbrkq,gxfbrkr,gxfbrks,gxfoeot,gxf7win,gxfo21y,gxf8s4p,gxeoocd,gxfkpyr,gxfbl9z,gxf59q0,gxfim4a,gxfbrlq,gxe6mc5,gxfmtts,gxfmttw,gxfkwaz,gxfn6h8,gxfnj4e,gxfqc9i,gxf6bo9,gxekir3,gxfswjj,gxfk71a,gxfs3hb,gxf2zkz,gxfkdd1,gxf41if,gxfti22,gxfe84r,gxf70z1,gxflrxr,gxfn06o,gxf8lv7,gxfqcac,gxfqjku,gxfi37d,gxfqgf3,gxfsmgu,gxfkdds,gxf7wle,gxfkjpi,gxfoxqf,gxfqimj,gxfft1o,gxf7jyp,gxfr1le,gxfjbh0,gxfl90a,gxfoxr4,gxfgc0y,gxfowsb,gxfnj6z,gxerxf1,gxfcnam,gxf9hin,gxf5g4w,gxfiyuu,gxf6upj,gxfgv09,gxedcsc,gxfjo5d,gxfnj7q,gxfqa90,gxfqvbe,gxfisjv,gxforgx,gxf4e8k,gxf7120,gxfk74s,gxfrx9a,gxfq1ol,gxf8s9v,gxf94x0,gxf65gl,gxfs56m,gxfncx9,gxewxgd,gxfoxtd,gxfeyes,gxfoxtp,gxfer7m,gxfqcei,gxfbyyg,gxetidy,gxfmtzr,gxfqvdk,gxfism1,gxfsml7,gxfp464,gxfb8t9,gxfqcf3,gxfqcf4,gxfmu04,gxfncyv,gxf3620,gxfpug4,gxfmu0d,gxfncz6,gxfq4zo,gxfiyye,gxfi3co,gxfsanp,gxfn3ai,gxfkdj6,gxfiyyu,gxf8scn,gxf4808,gxfn6o9,gxe9570,gxfteul,gxfl95d,gxfmhea,gxf8sd0,gxfctqt,gxf6fus,gxf8sd7,gxfhx2b,gxfo2av,gxfpn6w,gxfe8bt,gxfn6pb,gxf7qgg,gxfqa4o,gxfbf76,gxfcng5,gxfnjcm,gxforlk,gxfn6pm,gxfisoz,gxferb9,gxefah6,gxfcngt,gxfi9qm,gxfqci7,gxfo2c3,gxf5dxq,gxf2we1,gxfnw0t,gxf8sf4,gxf6vo0,gxf717q,gxfg5wo,gxf835a,gxfq67l,gxfo2d1,gxf835j,gxfnvr3,gxfs8kh,gxfqp6m,gxf5a0v,gxf7q80,gxf8sg3,gxfb8xz,gxfp4ax,gxfolem,gxflpsn,gxf4efg,gxeolji,gxfhsml,gxfk103,gxfo2dz,gxel950,gxf9zux,gxfi122,gxf9o2m,gxf9540,gxf81tc,gxfp05u,gxfqw9n,gxf4961,gxfawbs,gxfadd6,gxftict,gxfnw33,gxf484z,gxf4xf8,gxfimgl,gxfnnji,gxftid6,gxfoqhb,gxfmm2r,gxfqikr,gxfn6tg,gxeys7b,gxeisng,gxfnjgr,gxf7dxp,gxfjuq7,gxfctw1,gxf4ksy,gxfmnv8,gxfqixi,gxfimhj,gxfi3ix,gxfctwe,gxfmb8m,gxfmu7i,gxfsmsw,gxfnw4x,gxf5zea,gxf7b1g,gxfawdw,gxflsai,gxf486z,gxfe8ho,gxfk7el,gxfbfcu,gxffn2a,gxfq33q,gxfjurs,gxf4uzw,gxfowsp,gxf3ix9,gxfc25p,gxf8sk1,gxfsllz,gxf8t7y,gxfnpue,gxelquw,gxfnw66,gxfcnms,gxerrfp,gxf83ae,gxftig8,gxfrvau,gxfr8a6,gxf8skz,gxf89md,gxf6v1z,gxfp1x0,gxfr1z2,gxf958l,gxfqdbr,gxfjxk2,gxfqb7y,gxf8yxi,gxf5i0w,gxftihq,gxfp411,gxf60pr,gxfmnz5,gxf6v36,gxfptr8,gxfnd9n,gxfo3yp,gxfqcq9,gxf8smq,gxfrjgz,gxf6igo,gxfbs3e,gxfptrv,gxfrxml,gxf71fi,gxfgx1x,gxforuu,gxfqmw4,gxfqvpm,gxftij1,gxfoxpp,gxfmuca,gxf6v4r,gxfr21o,gxfdn2d,gxf7x2d,gxforvv,gxfslel,gxf8som,gxf8z0j,gxfb2v9,gxfo8xz,gxfk1sc,gxfptu9,gxfigcy,gxfhr2w,gxfermh,gxfnwbh,gxft7vu,gxfbs67,gxf6c82,gxf95d0,gxft8z9,gxf8sq3,gxfnjov,gxf7e5v,gxfmhro,gxfptvb,gxfq0pg,gxf305a,gxfit1c,gxfgiv5,gxfl37n,gxfqcue,gxf8sqv,gxfqvt7,gxezo26,gxfgp73,gxfsm1d,gxf7kia,gxf8sr7,gxfoyac,gxfqvtp,gxf8g4g,gxf9odf,gxewomg,gxf6v8t,gxfqcvi,gxft6hp,gxftinl,gxfaqb9,gxfrrga,gxfqdcv,gxfc4w2,gxf65z9,gxfk1c7,gxf8sso,gxfnwej,gxf8sss,gxfnwel,gxfn74e,gxekki7,gxf65zo,gxfn74p,gxftcd7,gxf3cv0,gxfpadt,gxf48h4,gxfe2g9,gxfd6uk,gxfqcxc,gxfdd6a,gxfndh0,gxfieef,gxfnwfw,gxfmbk2,gxf5zpf,gxfn761,gxfk1e2,gxfcnwt,gxfn768,gxfnwgh,gxfrnit,gxfra0z,gxf7eae,gxfo93r,gxff4ex,gxfo2sc,gxfo93x,gxfo2se,gxfk404,gxfqplu,gxf96yu,gxfs0ny,gxfa7fr,gxfo0lr,gxfsn56,gxfey3v,gxfphd8,gxfjv3j,gxflsmq,gxfqcz7,gxfsn5h,gxfq0c7,gxfpu0q,gxfqczf,gxe8a4f,gxfbscj,gxfn77o,gxf45e5,gxfq71z,gxfn3du,gxfertd,gxfg6e7,gxfp1yz,gxfdjl2,gxf71pj,gxfrrl7,gxfbsdo,gxfbm25,gxfsh8d,gxfr8mo,gxfey5v,gxev3wa,gxfoltn,gxfmbni,gxfndkv,gxfit8m,gxfpu2v,gxft66g,gxf6vex,gxfg7tj,gxfnjwt,gxfr1m5,gxer60z,gxfmbo9,gxfrkkv,gxf7ee4,gxfoe2t,gxfo97m,gxf9bxf,gxfo2w4,gxfnjxl,gxfek9e,gxfohyq,gxfsn8x,gxfchpu,gxffaut,gxeigtz,gxf4rlm,gxf7r25,gxfr2da,gxf6667,gxfq4yo,gxfr2de,gxfs6n6,gxfoxrq,gxfndmu,gxfndmx,gxf4xxm,gxfmuoc,gxfnjym,gxf4rm9,gxf95n8,gxf4ezb,gxexmuy,gxf6672,gxfitb3,gxfoyjk,gxf8t0k,gxf7krp,gxforgc,gxfn10q,gxfoyjw,gxf6p6d,gxf95o9,gxfcwsf,gxfm0fe,gxfhyz9,gxfbm67,gxf24u9,gxfpl0o,gxfnqbo,gxfkqva,gxfrqwx,gxft7e6,gxf48p8,gxfbnhq,gxfn7do,gxe6pel,gxfol8p,gxfjoyo,gxfpnvm,gxfqd5v,gxfoylr,gxf8a40,gxfkkl0,gxfnk1i,gxfc25o,gxfrrr0,gxf7ku6,gxftcn7,gxfnash,gxfosay,gxft6bv,gxfrpsx,gxfqhfn,gxfsndb,gxfrf4h,gxfndqt,gxf6qih,gxffty0,gxfn7fd,gxfqpui,gxfk1nf,gxfr2hr,gxffhb6,gxftgmx,gxerrz8,gxfc292,gxfbm99,gxfrrsg,gxfn7g1,gxfigs5,gxfqjr1,gxfb9mh,gxfq0l6,gxfeyd7,gxflsvw,gxf9ytu,gxf8ghs,gxf9v2a,gxfiah1,gxfpnyf,gxex1mx,gxf95sf,gxfnk42,gxfqhmr,gxfopda,gxf2nx5,gxevzq4,gxftcpq,gxfnng0,gxfpwkb,gxfndt3,gxfe87w,gxf7r8x,gxf8t6a,gxfjvec,gxevzqt,gxfqpx2,gxf6s7h,gxfnws9,gxf83wd,gxf5h36,gxf8t6v,gxfn16w,gxf6cp7,gxf36x9,gxf3pw0,gxfqw9e,gxfqdas,gxfh8lx,gxfr2l5,gxevahm,gxf6vob,gxfbg0t,gxf7200,gxf6voi,gxfth3l,gxfa1ks,gxfpucs,gxfk1rh,gxfnk6m,gxf5awt,gxft6gg,gxfmi9e,gxfndv7,gxfpud3,gxfp52w,gxf7kzd,gxfbz0c,gxfoyrh,gxfqpz2,gxfmuww,gxelzi9,gxflzb6,gxeo9oj,gxfqdc4,gxfr2mh,gxfjvgr,gxfnwuk,gxfqdcd,gxfq2wd,gxfbspg,gxf7pl5,gxfsniv,gxf8gm2,gxfb9qz,gxfnwv1,gxfmuxr,gxfoexh,gxflayd,gxfnzrx,gxem5uz,gxemc6n,gxfhlbo,gxfialx,gxfdjxz,gxfpii1,gxf8myl,gxewp4y,gxf7xoj,gxfoyti,gxer96v,gxfigy4,gxf6vro,gxfl3rq,gxf3713,gxfq0rd,gxfn7mi,gxf7xp7,gxf66hp,gxfcuou,gxf8780,gxfnwx0,gxf5h7x,gxfsnlc,gxezib5,gxfmv04,gxfobpa,gxfla47,gxfndyx,gxfpphj,gxeo9rw,gxf4lml,gxfrybm,gxfsnm1,gxf7xqj,gxfd7d9,gxf7vn1,gxfqjrk,gxf4901,gxf3wd3,gxfmqtg,gxfo9lm,gxf4fbu,gxf6vtq,gxf4yaq,gxfne03,gxfr2qs,gxfqdgm,gxfqdgr,gxfcbsf,gxf8zp0,gxft6m0,gxf8gqj,gxfr2ri,gxf7ldr,gxfoanq,gxf42py,gxfnqo8,gxfqq4p,gxfhy2y,gxf7yt4,gxfq76c,gxfspuj,gxf4fdk,gxf8aft,gxf8tei,gxerdh6,gxfp59a,gxfs4ps,gxfnkdf,gxfphwk,gxfesbq,gxfne1z,gxfryeh,gxf8n3e,gxf8tf0,gxffnxq,gxfdgvc,gxfg0ky,gxfbz7d,gxfg0l6,gxfth4v,gxfk1z0,gxf8tfh,gxfr94v,gxfbsw1,gxfombm,gxf7xtw,gxffnyf,gxffhmx,gxfsnpn,gxfmih8,gxflzim,gxft85t,gxfbswh,gxfr95e,gxfn7rn,gxetpuc,gxft6ol,gxfefpz,gxfkemw,gxfn7ru,gxfk4fz,gxf7xul,gxffnz4,gxfnx2a,gxft8pn,gxfih48,gxf66ne,gxfhy5p,gxfkkz3,gxft6w3,gxfrs51,gxfnojt,gxfla9k,gxfl3y4,gxflt8f,gxfsfqx,gxfp45z,gxfsnrf,gxfddu6,gxfk21b,gxfn7tc,gxfd175,gxeu2ja,gxfiqih,gxftjdl,gxfdsa2,gxfl1qn,gxf6vz1,gxf8gv6,gxf30sb,gxfqq91,gxf8ajt,gxf7xwq,gxf378u,gxfpoc0,gxfhrw3,gxfsnsk,gxfbszb,gxfr2wp,gxffuda,gxfl404,gxfk8ee,gxf8493,gxfn7uw,gxfjpft,gxfar2i,gxfn1jf,gxftd3g,gxfiavi,gxfn3hp,gxfmv80,gxf7ezc,gxft6sc,gxf8tk1,gxf9cis,gxfgjoq,gxfqcfm,gxevnhr,gxffhrl,gxf33y1,gxe903d,gxf8tkq,gxfi06t,gxfbmps,gxfhry9,gxfq7d6,gxfd7m6,gxf44u0,gxfthmd,gxfqdp3,gxf0kig,gxfpoey,gxf9ivv,gxfpuqk,gxf72e5,gxfa2p1,gxfhfc1,gxf9iw3,gxf7ld0,gxe6pyf,gxf6d48,gxft73p,gxfd5bb,gxf84r5,gxf6w33,gxfl439,gxf6wx5,gxfdwxl,gxfn7y5,gxf5q8w,gxeq7lt,gxfr9c2,gxf386j,gxfmvb8,gxfqdqm,gxfbmjc,gxfl4xa,gxf726x,gxfr9ct,gxfkxsw,gxfg74z,gxfoz6s,gxftd7p,gxfq7fq,gxfn1nr,gxfpoh4,gxfpusq,gxfs3nr,gxf7lf5,gxfcc3b,gxfn7zs,gxfde0p,gxfde0q,gxfovrz,gxfrfp9,gxfagqk,gxfpw83,gxfmipv,gxfbt52,gxfk8jr,gxftjkh,gxf6pub,gxfgdhw,gxfkejz,gxfhydy,gxflzrs,gxf49cr,gxexl5o,gxfnqzk,gxfo4e3,gxeufdw,gxf7f51,gxfmnpt,gxf5hm2,gxf3753,gxfr33h,gxfdkdr,gxfb3w4,gxf7lh1,gxfr9fb,gxfar96,gxfpuju,gxfp5ku,gxf6w73,gxfk8li,gxez62v,gxfm3dv,gxfnkpe,gxftdas,gxf3k45,gxfnxco,gxfbmvv,gxfbgkc,gxfbc0q,gxfqduo,gxfq7j5,gxfnr1g,gxfq17q,gxfln6w,gxf8h49,gxfsbe3,gxfnkq4,gxfqk6l,gxfjvzl,gxe9cxg,gxfqdvb,gxfnxdm,gxfpwnz,gxfsoqy,gxf7y67,gxfr9hd,gxfnef7,gxfnefb,gxfr362,gxfhyhh,gxfqdw6,gxfihg8,gxfjd25,gxfmvhe,gxfd7ty,gxfozc7,gxfp5a4,gxfbzl8,gxfbmy6,gxfmvhs,gxfrb8o,gxfniab,gxf8rbo,gxfg0zh,gxfnksf,gxfa8e8,gxf37k4,gxfqk93,gxfbabu,gxf4m4t,gxfm68g,gxe6jv5,gxfr382,gxfbac9,gxfi9pr,gxfnxge,gxetjxb,gxfsbhf,gxfnxgo,gxf72nd,gxfskjr,gxfqdyn,gxevnsh,gxfiu5u,gxfnom6,gxfneig,gxfneij,gxf6wcd,gxe796m,gxfmvk4,gxfqkat,gxfehtj,gxfmvkc,gxfsorm,gxfa24v,gxfpopc,gxfqkb5,gxfk8r6,gxflnbr,gxfos6y,gxetwm2,gxfnr6m,gxfn887,gxf84mh,gxf8twr,gxf84ml,gxfsbjk,gxf3wxd,gxfsuib,gxfcozb,gxfcozd,gxfqe0t,gxfihku,gxexf2g,gxfomc7,gxeon0t,gxfnxji,gxf8txt,gxfqchv,gxeww44,gxe8oeu,gxfbaft,gxfisp1,gxf4skk,gxf84o5,gxfemjq,gxfi4z0,gxfihm9,gxf83x9,gxfozhy,gxfa27v,gxflars,gxfposp,gxfjrb3,gxfr9ox,gxf8tzo,gxfij7y,gxfslmh,gxf8no9,gxfp9on,gxfbtgd,gxfkf6n,gxfcvwb,gxft78l,gxfk2jr,gxfqx2g,gxfma2k,gxfryzv,gxfrnwy,gxfa29a,gxf9iqw,gxfot82,gxf5hwz,gxfnenp,gxfhm2e,gxfs5br,gxepipk,gxfsum4,gxfeyp6,gxfomwu,gxfgv6d,gxf7s3m,gxfbn5v,gxf8u12,gxf677t,gxf5tmm,gxfnxn2,gxfr9qn,gxfnxn5,gxesoih,gxfjpy3,gxfo3z3,gxf7s4g,gxf5hy9,gxfbtia,gxfltsz,gxf6dk7,gxfp2j3,gxfnl0s,gxf7ygf,gxfn80o,gxff5rl,gxe6rxa,gxfjds9,gxfsoca,gxfbn7g,gxf679b,gxfhyrk,gxf8b42,gxfh9hf,gxf8u2t,gxfm05i,gxf60xz,gxfp8hj,gxemqme,gxfq7v3,gxfqe6r,gxf3e54,gxfn8ex,gxf67a2,gxfneqm,gxfqmip,gxeklsx,gxfnxpf,gxfhysk,gxfra8h,gxfomzw,gxfner2,gxfner4,gxfomau,gxfon06,gxfogop,gxfn8fw,gxfs2ol,gxf8u4g,gxftdoh,gxf8hhe,gxfnxqb,gxfnl39,gxfet1p,gxf7lw3,gxfs0m5,gxfotco,gxf8u55,gxfnl3v,gxfp5zx,gxfh38f,gxfoo0d,gxfay07,gxf7yjy,gxelhh1,gxf6qba,gxftk1k,gxetwvt,gxfd4cq,gxf6ywo,gxftaxs,gxfnete,gxfg7nr,gxfnxs8,gxfi572,gxevyz2,gxfii1f,gxfnl5g,gxfqead,gxfmvvd,gxfl4nv,gxf67do,gxfm6lf,gxf8u7d,gxf8hkc,gxf6wod,gxf6woh,gxf612r,gxffoqf,gxevbi5,gxfp62p,gxfd891,gxfgkcp,gxfqcwc,gxf3wif,gxfbaq4,gxfd1kt,gxfk2ry,gxfjq4x,gxfpp1v,gxfth8l,gxfk93u,gxfa8t9,gxfq80u,gxffv35,gxfefrv,gxfr3mu,gxfn0g7,gxeyhbg,gxfa2i5,gxfnxv2,gxf7egr,gxfnewg,gxfdelv,gxf9jjp,gxflu0n,gxfn5px,gxfam8a,gxfrr4b,gxfrvc1,gxfalhm,gxfg1g0,gxf9e8n,gxf6y32,gxf5bvi,gxfsokj,gxf7sdi,gxfiulo,gxfrsz1,gxesos2,gxfn8mr,gxfk2us,gxfqeet,gxfhsom,gxfl1in,gxffo16,gxfdkzj,gxfo0a3,gxfib82,gxehg92,gxfb4hz,gxfk2vd,gxfp0fn,gxezd01,gxealqq,gxfkyhf,gxf6wt0,gxf836h,gxfg4w4,gxfqxek,gxfsom7,gxfq1sx,gxet7s4,gxfoxhl,gxfk2wa,gxf1tik,gxf6wtq,gxfmntp,gxfrzcg,gxfoh54,gxf6wtu,gxfioc3,gxfqvku,gxfszdq,gxf6dvc,gxf9709,gxftk8r,gxfp39m,gxf90ow,gxfnlc2,gxf5byb,gxfdepw,gxfsnom,gxf5t0h,gxfoqmk,gxfjqa3,gxf6wup,gxfdfz7,gxfnf13,gxfbavu,gxfotln,gxf5om5,gxfn8pq,gxeb4rm,gxfm0h1,gxf5rja,gxfqei4,gxfsouq,gxf7tpk,gxf79iu,gxfmu5d,gxfc06w,gxf5ibe,gxftgtn,gxfjewa,gxfpr6d,gxf3r45,gxfo6m9,gxf7ytn,gxfegpb,gxf972s,gxfo52r,gxfi5g8,gxf6wws,gxffo1y,gxfqypd,gxf90rt,gxfinif,gxeh3qm,gxfgx81,gxf4mr9,gxf6wxj,gxfpqgk,gxfii4a,gxfny2j,gxfn3m1,gxfdrgg,gxfi5hf,gxfiog5,gxfn2h3,gxfra6g,gxfqc7v,gxf7gzy,gxf8o5v,gxfd26t,gxf79lj,gxf7fx5,gxf8uhm,gxfoopb,gxfqelf,gxezw4n,gxf8ui0,gxf8bjl,gxf67oz,gxeh3sk,gxfetfa,gxeuprd,gxf79mh,gxfbb0b,gxfpj0d,gxf5if3,gxfqem9,gxf7ma1,gxfrt6y,gxfr3wv,gxfk9e9,gxfr1vn,gxfmw7q,gxfonff,gxfr3xd,gxfnf6u,gxf6uyv,gxfii7g,gxfk9f1,gxfqxm6,gxfocbs,gxfp6eo,gxfg4xf,gxfsotw,gxflho9,gxfi5kw,gxf8o94,gxf8ukq,gxfayff,gxfny6k,gxf7sni,gxfs5w1,gxfnf82,gxfte51,gxflhp6,gxfcd0d,gxfdrkw,gxf7yzn,gxf73dy,gxfrwfw,gxfl52y,gxfhmnv,gxfnf9b diff --git a/tests/integration/models/reddit/test_comment.py b/tests/integration/models/reddit/test_comment.py index 5e8d08593..fcceb3610 100644 --- a/tests/integration/models/reddit/test_comment.py +++ b/tests/integration/models/reddit/test_comment.py @@ -26,7 +26,7 @@ async def test_award__not_enough_coins(self, reddit): gild_type="award_2385c499-a1fb-44ec-b9b7-d260f3dc55de" ) exception = excinfo.value - assert "INSUFFICIENT_COINS_WITH_AMOUNT" == exception.error_type + assert exception.error_type == "INSUFFICIENT_COINS_WITH_AMOUNT" async def test_award__self_gild(self, reddit): reddit.read_only = False @@ -35,7 +35,7 @@ async def test_award__self_gild(self, reddit): gild_type="award_2385c499-a1fb-44ec-b9b7-d260f3dc55de" ) exception = excinfo.value - assert "SELF_GILDING_NOT_ALLOWED" == exception.error_type + assert exception.error_type == "SELF_GILDING_NOT_ALLOWED" async def test_block(self, reddit): reddit.read_only = False @@ -44,7 +44,8 @@ async def test_block(self, reddit): comment = item break else: - assert False, "no comment found" + msg = "no comment found" + raise AssertionError(msg) await comment.block() async def test_clear_vote(self, reddit): @@ -161,23 +162,23 @@ async def test_refresh(self, reddit): async def test_refresh__deleted_comment(self, reddit): with pytest.raises(ClientException) as excinfo: await Comment(reddit, "d7ltvl0").refresh() - assert ( + assert excinfo.value.args == ( "This comment does not appear to be in the comment tree", - ) == excinfo.value.args + ) async def test_refresh__raises_exception(self, reddit): with pytest.raises(ClientException) as excinfo: await Comment(reddit, "fx1tgzm").refresh() - assert ( + assert excinfo.value.args == ( "This comment does not appear to be in the comment tree", - ) == excinfo.value.args + ) async def test_refresh__removed_comment(self, reddit): with pytest.raises(ClientException) as excinfo: await Comment(reddit, "fx1hmwb").refresh() - assert ( + assert excinfo.value.args == ( "This comment does not appear to be in the comment tree", - ) == excinfo.value.args + ) async def test_refresh__twice(self, reddit): comment = await Comment(reddit, "d81vwef").refresh() @@ -193,7 +194,8 @@ async def test_refresh__with_reply_sort_and_limit(self, reddit): for reply in replies: if isinstance(reply, Comment): if reply.created_utc > last_created: - assert False, "sort order incorrect" + msg = "sort order incorrect" + raise AssertionError(msg) last_created = reply.created_utc assert len(comment.replies) == 3 @@ -303,8 +305,8 @@ async def test_send_removal_message__error(self, reddit): with pytest.raises(RedditAPIException) as excinfo: await comment.mod.send_removal_message(message="message", title="a" * 51) exception = excinfo.value - assert "title" == exception.field - assert "TOO_LONG" == exception.error_type + assert exception.field == "title" + assert exception.error_type == "TOO_LONG" async def test_show(self, reddit): reddit.read_only = False diff --git a/tests/integration/models/reddit/test_message.py b/tests/integration/models/reddit/test_message.py index ab4888e95..158ef692e 100644 --- a/tests/integration/models/reddit/test_message.py +++ b/tests/integration/models/reddit/test_message.py @@ -36,7 +36,8 @@ async def test_block(self, reddit): message = item break else: - assert False, "no message found" + msg = "no message found" + raise AssertionError(msg) await message.block() async def test_delete(self, reddit): @@ -52,7 +53,8 @@ async def test_mark_read(self, reddit): message = item break else: - assert False, "no message found in unread" + msg = "no message found in unread" + raise AssertionError(msg) await message.mark_read() async def test_mark_unread(self, reddit): diff --git a/tests/integration/models/reddit/test_subreddit.py b/tests/integration/models/reddit/test_subreddit.py index bc470cac3..64d08a1ae 100644 --- a/tests/integration/models/reddit/test_subreddit.py +++ b/tests/integration/models/reddit/test_subreddit.py @@ -2,17 +2,19 @@ import socket import sys from asyncio import TimeoutError -from unittest.mock import AsyncMock, MagicMock +import aiofiles import pytest from aiohttp import ClientResponse from aiohttp.http_websocket import WebSocketError from asyncprawcore import BadRequest, Forbidden, NotFound, TooLarge if sys.version_info < (3, 8): - from asynctest import mock + from asynctest import CoroutineMock as AsyncMock + from asynctest import MagicMock, mock else: from unittest import mock + from unittest.mock import AsyncMock, MagicMock from asyncpraw.const import PNG_HEADER from asyncpraw.exceptions import ( @@ -1639,8 +1641,8 @@ async def patch_request(url, *args, **kwargs): reddit._core._requestor._http.post = patch_request fake_png = PNG_HEADER + b"\x1a" * 10 # Normally 1024 ** 2 * 20 (20 MB) - with open(tmp_path.joinpath("fake_img.png"), "wb") as tempfile: - tempfile.write(fake_png) + async with aiofiles.open(tmp_path.joinpath("fake_img.png"), "wb") as tempfile: + await tempfile.write(fake_png) with pytest.raises(TooLargeMediaException): subreddit = await reddit.subreddit("test") await subreddit.submit_image("test", tempfile.name) diff --git a/tests/integration/models/test_user.py b/tests/integration/models/test_user.py index 7f131989c..63f347a1b 100644 --- a/tests/integration/models/test_user.py +++ b/tests/integration/models/test_user.py @@ -165,12 +165,10 @@ async def test_pin__remove(self, reddit): async for post in (await reddit.user.me()).new(limit=4): await reddit.user.pin(post, state=False) unpinned_posts.add(post.title) - new_posts = set( - [ - submission.title - async for submission in (await reddit.user.me()).new(limit=4) - ] - ) + new_posts = { + submission.title + async for submission in (await reddit.user.me()).new(limit=4) + } assert unpinned_posts != new_posts async def test_pin__remove_num(self, reddit): diff --git a/tests/integration/test_github_actions.py b/tests/integration/test_github_actions.py index e926f76df..2555c2026 100644 --- a/tests/integration/test_github_actions.py +++ b/tests/integration/test_github_actions.py @@ -1,4 +1,4 @@ -"""A test that is run only by GitHub Actions +"""A test that is run only by GitHub Actions. This test makes real network requests, so environment variables should be specified in GitHub Actions. diff --git a/tests/integration/test_reddit.py b/tests/integration/test_reddit.py index c3cbddda9..1a92b3a35 100644 --- a/tests/integration/test_reddit.py +++ b/tests/integration/test_reddit.py @@ -1,6 +1,7 @@ """Test asyncpraw.reddit.""" from base64 import urlsafe_b64encode +import aiofiles import pytest from asyncprawcore.exceptions import BadRequest, ServerError diff --git a/tests/unit/models/reddit/test_collections.py b/tests/unit/models/reddit/test_collections.py index 72dbc26ba..8013469c6 100644 --- a/tests/unit/models/reddit/test_collections.py +++ b/tests/unit/models/reddit/test_collections.py @@ -29,7 +29,7 @@ def test_init_bad(self, reddit): with pytest.raises(TypeError): Collection(reddit) with pytest.raises(TypeError): - Collection(reddit, _data=dict(), collection_id="") + Collection(reddit, _data={}, collection_id="") with pytest.raises(TypeError): Collection(reddit, collection_id="fake_uuid", permalink="") with pytest.raises(TypeError): @@ -48,16 +48,16 @@ def test_neq(self, reddit): collection1 = Collection(reddit, collection_id="1") collection2 = Collection(reddit, collection_id="2") assert collection1 != collection2 - assert "1" != collection2 - assert "2" != collection1 + assert collection2 != "1" + assert collection1 != "2" def test_repr(self, reddit): collection = Collection(reddit, collection_id="fake_uuid") - assert "Collection(collection_id='fake_uuid')" == repr(collection) + assert repr(collection) == "Collection(collection_id='fake_uuid')" def test_str(self, reddit): collection = Collection(reddit, collection_id="fake_uuid") - assert "fake_uuid" == str(collection) + assert str(collection) == "fake_uuid" class TestSubredditCollections(UnitTest): diff --git a/tests/unit/models/reddit/test_rules.py b/tests/unit/models/reddit/test_rules.py index 52d1c10cd..8b218a769 100644 --- a/tests/unit/models/reddit/test_rules.py +++ b/tests/unit/models/reddit/test_rules.py @@ -27,7 +27,7 @@ def test_no_data(self, reddit): def test_no_subreddit(self, reddit): rule = Rule(reddit, short_name="test") with pytest.raises(ValueError) as excinfo: - getattr(rule, "subreddit") + rule.subreddit assert ( excinfo.value.args[0] == "The Rule is missing a subreddit. File a bug report at Async PRAW." diff --git a/tests/unit/models/reddit/test_widgets.py b/tests/unit/models/reddit/test_widgets.py index 6e8dc9a1d..6365c6acd 100644 --- a/tests/unit/models/reddit/test_widgets.py +++ b/tests/unit/models/reddit/test_widgets.py @@ -59,4 +59,4 @@ def test_good_encode(self, reddit): AsyncPRAWBase(reddit, _data={"_secret": "no", "3": 3}), Subreddit(reddit, "four"), ] - assert '[1, "two", {"3": 3}, "four"]' == dumps(data, cls=WidgetEncoder) + assert dumps(data, cls=WidgetEncoder) == '[1, "two", {"3": 3}, "four"]' diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 218fc2185..f7283f7a7 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,5 +1,6 @@ import os import sys +from pathlib import Path import pytest @@ -24,13 +25,13 @@ def _assert_config_read(environment, mock_config): del os.environ[env_name] os.environ[environment] = "/MOCK" - module_dir = os.path.dirname(sys.modules["asyncpraw"].__file__) - environ_path = os.path.join( - "/MOCK", ".config" if environment == "HOME" else "", "praw.ini" + module_dir = Path(sys.modules["asyncpraw"].__file__).parent + environ_path = ( + Path("/MOCK") / (".config" if environment == "HOME" else "") / "praw.ini" ) locations = [ - os.path.join(module_dir, "praw.ini"), - environ_path, + str(module_dir / "praw.ini"), + str(environ_path), "praw.ini", ] diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index b65d1fede..db3f6416b 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -1,4 +1,3 @@ -# coding: utf-8 import pytest from asyncpraw.exceptions import ( diff --git a/tests/unit/util/test_deprecate_args.py b/tests/unit/util/test_deprecate_args.py index edeee2c99..b48d6d5d7 100644 --- a/tests/unit/util/test_deprecate_args.py +++ b/tests/unit/util/test_deprecate_args.py @@ -11,45 +11,45 @@ keyword_only = { "args": [ - [("arg2", "arg1", "arg3", "arg0"), dict()], + [("arg2", "arg1", "arg3", "arg0"), {}], ("arg0", "arg1", "arg2", "arg3"), ], "kwargs": [ - [(), dict(arg2="arg2", arg1="arg1", arg3="arg3", arg0="arg0")], + [(), {"arg2": "arg2", "arg1": "arg1", "arg3": "arg3", "arg0": "arg0"}], ("arg0", "arg1", "arg2", "arg3"), ], "mix": [ - [("arg2",), dict(arg1="arg1")], + [("arg2",), {"arg1": "arg1"}], (None, "arg1", "arg2", None), ], "one_arg": [ - [("arg2",), dict()], + [("arg2",), {}], (None, None, "arg2", None), ], "one_kwarg": [ - [(), dict(arg0="arg0")], + [(), {"arg0": "arg0"}], ("arg0", None, None, None), ], } with_positional = { "args": [ - [("arg0", "arg2", "arg1", "arg3"), dict()], + [("arg0", "arg2", "arg1", "arg3"), {}], ("arg0", "arg1", "arg2", "arg3"), ], "kwargs": [ - [("arg0",), dict(arg2="arg2", arg1="arg1", arg3="arg3")], + [("arg0",), {"arg2": "arg2", "arg1": "arg1", "arg3": "arg3"}], ("arg0", "arg1", "arg2", "arg3"), ], "mix": [ - [("arg0", "arg2"), dict(arg1="arg1", arg3=None)], + [("arg0", "arg2"), {"arg1": "arg1", "arg3": None}], ("arg0", "arg1", "arg2", None), ], "one_arg": [ - [("arg0",), dict()], + [("arg0",), {}], ("arg0", None, None, None), ], "one_kwarg": [ - [(), dict(arg0="arg0")], + [(), {"arg0": "arg0"}], ("arg0", None, None, None), ], } diff --git a/tests/integration/utils.py b/tests/utils.py similarity index 92% rename from tests/integration/utils.py rename to tests/utils.py index 9e53956d2..a25238132 100644 --- a/tests/integration/utils.py +++ b/tests/utils.py @@ -19,10 +19,8 @@ def ensure_environment_variables(): "client_secret", ): if getattr(pytest.placeholders, key) == f"placeholder_{key}": - raise ValueError( - f"Environment variable 'prawtest_{key}' must be set for recording new" - " cassettes." - ) + msg = f"Environment variable 'prawtest_{key}' must be set for recording new cassettes." + raise ValueError(msg) auth_set = False for auth_keys in [["refresh_token"], ["username", "password"]]: if all( @@ -32,10 +30,8 @@ def ensure_environment_variables(): auth_set = True break if not auth_set: - raise ValueError( - "Environment variables 'prawtest_refresh_token' or 'prawtest_username' and" - " 'prawtest_password' must be set for new cassette recording." - ) + msg = "Environment variables 'prawtest_refresh_token' or 'prawtest_username' and 'prawtest_password' must be set for new cassette recording." + raise ValueError(msg) def ensure_integration_test(cassette): @@ -115,7 +111,7 @@ def save_cassette(cls, cassette_path, cassette_dict, serializer): class CustomSerializer: - """Custom serializer to handle binary objects in dict.""" + """Custom serializer to save in a prettified json format.""" @staticmethod def _serialize_file(file_name): diff --git a/tools/bump_version.py b/tools/bump_version.py index 893df3d57..8dab22ce3 100755 --- a/tools/bump_version.py +++ b/tools/bump_version.py @@ -12,6 +12,7 @@ def main(): ) return 1 print(line[len(COMMIT_PREFIX) : -1]) + return 0 if __name__ == "__main__": diff --git a/tools/check_documentation.py b/tools/check_documentation.py index 4050772b8..381944706 100644 --- a/tools/check_documentation.py +++ b/tools/check_documentation.py @@ -1,4 +1,4 @@ -"""Checks for attributes and code examples in RedditBase subclasses""" +"""Checks for attributes and code examples in RedditBase subclasses.""" import os import re diff --git a/tools/set_active_docs.py b/tools/set_active_docs.py old mode 100644 new mode 100755 index bf344e0da..f94e52634 --- a/tools/set_active_docs.py +++ b/tools/set_active_docs.py @@ -22,7 +22,7 @@ def fetch_versions(): versions = [ packaging.version.parse(slug["slug"].strip("v")) for slug in active_versions["results"] - if not slug["hidden"] and not slug["slug"] in ["stable", "latest"] + if not slug["hidden"] and slug["slug"] not in ["stable", "latest"] ] if versions is None: sys.stderr.write("Failed to get current active versions\n") @@ -88,6 +88,7 @@ def main(): else: sys.stderr.write(f"Failed to hide version {version!s}\n") return 1 + return 0 if __name__ == "__main__": diff --git a/tools/static_word_checks.py b/tools/static_word_checks.py old mode 100644 new mode 100755 index d97612e0a..74357f4b5 --- a/tools/static_word_checks.py +++ b/tools/static_word_checks.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 import argparse import os import re @@ -7,7 +8,7 @@ class StaticChecker: """Run simple checks on the entire document or specific lines.""" - def __init__(self, replace: bool): + def __init__(self, replace: bool) -> None: """Initialize a :class:`.StaticChecker` instance. :param replace: Whether to make replacements. @@ -98,7 +99,7 @@ def run_checks(self) -> bool: """ status = True directory = os.path.abspath(os.path.join(__file__, "..", "..", "asyncpraw")) - for current_directory, directories, filenames in os.walk(directory): + for current_directory, _directories, filenames in os.walk(directory): for filename in filenames: if not filename.endswith(".py"): continue diff --git a/tox.ini b/tox.ini index 2d4ac9203..ab6d35c7c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,9 @@ [tox] -envlist = py37,py38,py39,py310 -skip_missing_interpreters = false +envlist = py37,py38,py39,py310,py311 +skipsdist = true [testenv] +deps = + .[test] commands = pytest -extras = dev