diff --git a/CHANGES.rst b/CHANGES.rst index f770b1da6..7b9cdc7a5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,8 +4,20 @@ Change Log Unreleased ---------- +**Added** + +* :class:`.Reddit` keyword argument ``token_manager``. +* :class:`.FileTokenManager` and its parent abstract class :class:`.BaseTokenManager`. + **Deprecated** +* The configuration setting ``refresh_token`` is deprecated and its use will result in a + :py:class:`DeprecationWarning`. This deprecation applies in all ways of setting + configuration values, i.e., via ``praw.ini``, as a keyword argument when initializing + an instance of :class:`.Reddit`, and via the ``PRAW_REFRESH_TOKEN`` environment + variable. To be prepared for Async PRAW 8, use the new :class:`.Reddit` keyword + argument ``token_manager``. See :ref:`refresh_token` in Async PRAW's documentation for + an example. * :meth:`.me` will no longer return ``None`` when called in :attr:`.read_only` mode starting in Async PRAW 8. A :py:class:`DeprecationWarning` will be issued. To switch forward to the Async PRAW 8 behavior set ``praw8_raise_exception_on_me=True`` in your diff --git a/asyncpraw/config.py b/asyncpraw/config.py index c0de657d5..681c8fa48 100644 --- a/asyncpraw/config.py +++ b/asyncpraw/config.py @@ -4,6 +4,7 @@ import sys from threading import Lock from typing import Optional +from warnings import warn from .exceptions import ClientException @@ -84,11 +85,21 @@ def __init__( self.custom = dict(Config.CONFIG.items(site_name), **settings) self.client_id = self.client_secret = self.oauth_url = None - self.reddit_url = self.refresh_token = self.redirect_uri = None + self.reddit_url = self.redirect_uri = None self.password = self.user_agent = self.username = None self._initialize_attributes() + self._do_not_use_refresh_token = self._fetch_or_not_set("refresh_token") + if self._do_not_use_refresh_token != self.CONFIG_NOT_SET: + warn( + "The ``refresh_token`` configuration setting is deprecated and will be" + " removed in Async PRAW 8. Please use ``token_manager`` to manage your" + " refresh tokens.", + category=DeprecationWarning, + stacklevel=2, + ) + def _fetch(self, key): value = self.custom[key] del self.custom[key] @@ -130,7 +141,6 @@ def _initialize_attributes(self): "client_id", "client_secret", "redirect_uri", - "refresh_token", "password", "user_agent", "username", diff --git a/asyncpraw/models/auth.py b/asyncpraw/models/auth.py index c4ef0d3c9..a9d96a517 100644 --- a/asyncpraw/models/auth.py +++ b/asyncpraw/models/auth.py @@ -111,14 +111,21 @@ def url( :param duration: Either ``permanent`` or ``temporary`` (default: permanent). ``temporary`` authorizations generate access tokens that last only 1 hour. ``permanent`` authorizations additionally - generate a refresh token that can be indefinitely used to generate - new hour-long access tokens. This value is ignored when - ``implicit=True``. + ``permanent`` authorizations additionally generate a single-use refresh + token with a significantly longer expiration (~1 year) that is to be used to + fetch a new set of 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. + .. note:: + + Reddit's ``refresh_tokens`` currently are reusable, and do not expire. + However, that behavior is likely to change in the near future so it's best + to no longer rely upon it: + https://old.reddit.com/r/redditdev/comments/kvzaot/oauth2_api_changes_upcoming/ + """ authenticator = self._reddit._read_only_core._authorizer._authenticator if authenticator.redirect_uri is self._reddit.config.CONFIG_NOT_SET: diff --git a/asyncpraw/reddit.py b/asyncpraw/reddit.py index 939b64a06..ab3580766 100644 --- a/asyncpraw/reddit.py +++ b/asyncpraw/reddit.py @@ -40,6 +40,7 @@ RedditAPIException, ) from .objector import Objector +from .util.token_manager import BaseTokenManager try: from update_checker import update_check @@ -172,6 +173,8 @@ def __init__( config_interpolation: Optional[str] = None, requestor_class: Optional[Type[Requestor]] = None, requestor_kwargs: Dict[str, Any] = None, + *, + token_manager: Optional[BaseTokenManager] = None, **config_settings: Union[str, bool], ): # noqa: D207, D301 """Initialize a Reddit instance. @@ -188,6 +191,10 @@ def __init__( requestor. If not set, use ``asyncprawcore.Requestor`` (default: None). :param requestor_kwargs: Dictionary with additional keyword arguments used to initialize the requestor (default: None). + :param token_manager: When provided, the passed instance, a subclass of + :class:`.BaseTokenManager`, will manage tokens via two callback functions. + This parameter must be provided in order to work with refresh tokens + (default: None). Additional keyword arguments will be used to initialize the :class:`.Config` object. This can be used to specify configuration @@ -249,6 +256,7 @@ async def request(self, *args, **kwargs): """ self._core = self._authorized_core = self._read_only_core = None self._objector = None + self._token_manager = token_manager self._unique_counter = 0 self._validate_on_submit = False @@ -438,6 +446,29 @@ def _check_for_update(self): update_check(__package__, __version__) Reddit.update_checked = True + def _prepare_common_authorizer(self, authenticator): + if self._token_manager is not None: + if self.config._do_not_use_refresh_token != self.config.CONFIG_NOT_SET: + raise TypeError( + "legacy ``refresh_token`` setting cannot be provided when providing" + " ``token_manager``" + ) + + self._token_manager.reddit = self + authorizer = Authorizer( + authenticator, + post_refresh_callback=self._token_manager.post_refresh_callback, + pre_refresh_callback=self._token_manager.pre_refresh_callback, + ) + elif self.config._do_not_use_refresh_token != self.config.CONFIG_NOT_SET: + authorizer = Authorizer( + authenticator, refresh_token=self.config._do_not_use_refresh_token + ) + else: + self._core = self._read_only_core + return + self._core = self._authorized_core = session(authorizer) + def _prepare_objector(self): mappings = { self.config.kinds["comment"]: models.Comment, @@ -513,11 +544,8 @@ def _prepare_trusted_asyncprawcore(self, requestor): authenticator, self.config.username, self.config.password ) self._core = self._authorized_core = session(script_authorizer) - elif self.config.refresh_token: - authorizer = Authorizer(authenticator, self.config.refresh_token) - self._core = self._authorized_core = session(authorizer) else: - self._core = self._read_only_core + self._prepare_common_authorizer(authenticator) def _prepare_untrusted_asyncprawcore(self, requestor): authenticator = UntrustedAuthenticator( @@ -525,11 +553,7 @@ def _prepare_untrusted_asyncprawcore(self, requestor): ) read_only_authorizer = DeviceIDAuthorizer(authenticator) self._read_only_core = session(read_only_authorizer) - if self.config.refresh_token: - authorizer = Authorizer(authenticator, self.config.refresh_token) - self._core = self._authorized_core = session(authorizer) - else: - self._core = self._read_only_core + self._prepare_common_authorizer(authenticator) async def comment( self, # pylint: disable=invalid-name diff --git a/asyncpraw/util/token_manager.py b/asyncpraw/util/token_manager.py new file mode 100644 index 000000000..72ee61fad --- /dev/null +++ b/asyncpraw/util/token_manager.py @@ -0,0 +1,83 @@ +"""Token Manager classes. + +There should be a 1-to-1 mapping between an instance of a subclass of +:class:`.BaseTokenManager` and a :class:`.Reddit` instance. + +A few trivial token manager classes are provided here, but it is expected that Async +PRAW users will create their own token manager classes suitable for their needs. + +See ref:`using_refresh_tokens` for examples on how to leverage these classes. + +""" +import aiofiles + + +class BaseTokenManager: + """An abstract class for all token managers.""" + + def __init__(self): + """Prepare attributes needed by all token manager classes.""" + self._reddit = None + + @property + def reddit(self): + """Return the :class:`.Reddit` instance bound to the token manager.""" + return self._reddit + + @reddit.setter + def reddit(self, value): + if self._reddit is not None: + raise RuntimeError( + "``reddit`` can only be set once and is done automatically" + ) + self._reddit = value + + def post_refresh_callback(self, authorizer): + """Handle callback that is invoked after a refresh token is used. + + :param authorizer: The ``asyncprawcore.Authorizer`` instance used containing + ``access_token`` and ``refresh_token`` attributes. + + This function will be called after refreshing the access and refresh + tokens. This callback can be used for saving the updated + ``refresh_token``. + + """ + raise NotImplementedError("``post_refresh_callback`` must be extended.") + + def pre_refresh_callback(self, authorizer): + """Handle callback that is invoked before refreshing PRAW's authorization. + + :param authorizer: The ``asyncprawcore.Authorizer`` instance used containing + ``access_token`` and ``refresh_token`` attributes. + + This callback can be used to inspect and modify the attributes of the + ``asyncprawcore.Authorizer`` instance, such as setting the + ``refresh_token``. + + """ + raise NotImplementedError("``pre_refresh_callback`` must be extended.") + + +class FileTokenManager(BaseTokenManager): + """Provides a trivial single-file based token manager.""" + + def __init__(self, filename): + """Load and save refresh tokens from a file. + + :param filename: The file the contains the refresh token. + + """ + super().__init__() + self._filename = filename + + async def post_refresh_callback(self, authorizer): + """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): + """Load the refresh token from the file.""" + if authorizer.refresh_token is None: + async with aiofiles.open(self._filename) as fp: + authorizer.refresh_token = (await fp.read()).strip() diff --git a/docs/code_overview/other.rst b/docs/code_overview/other.rst index 8fceb9747..efd8d3c2a 100644 --- a/docs/code_overview/other.rst +++ b/docs/code_overview/other.rst @@ -122,5 +122,6 @@ instances of them bound to an attribute of one of the Async PRAW models. other/subredditremovalreasons other/subredditrules other/redditorstream + other/token_manager other/trophy other/util diff --git a/docs/code_overview/other/token_manager.rst b/docs/code_overview/other/token_manager.rst new file mode 100644 index 000000000..07acc5a0e --- /dev/null +++ b/docs/code_overview/other/token_manager.rst @@ -0,0 +1,5 @@ +Token Manager +============= + +.. automodule:: asyncpraw.util.token_manager + :inherited-members: diff --git a/docs/examples/lmgtfy_bot.py b/docs/examples/lmgtfy_bot.py old mode 100644 new mode 100755 index b3922cac7..1e05003ba --- a/docs/examples/lmgtfy_bot.py +++ b/docs/examples/lmgtfy_bot.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 import asyncio from urllib.parse import quote_plus diff --git a/docs/examples/obtain_refresh_token.py b/docs/examples/obtain_refresh_token.py index 27c36a24b..efa18d2b3 100755 --- a/docs/examples/obtain_refresh_token.py +++ b/docs/examples/obtain_refresh_token.py @@ -2,77 +2,51 @@ """This example demonstrates the flow for retrieving a refresh token. -In order for this example to work your application's redirect URI must be set -to http://localhost:8080. + This tool can be used to conveniently create refresh tokens for later use with your web application OAuth2 credentials. -""" -import asyncio -import random -import socket -import sys +To create a Reddit application visit the following link while logged into the account +you want to create a refresh token for: https://www.reddit.com/prefs/apps/ -import asyncpraw +Create a "web app" with the redirect uri set to: http://localhost:8080 +After the application is created, take note of: -def receive_connection(): - """Wait for and then return a connected socket.. +- REDDIT_CLIENT_ID; the line just under "web app" in the upper left of the Reddit + Application +- REDDIT_CLIENT_SECRET; the value to the right of "secret" - Opens a TCP connection on port 8080, and waits for a single client. +Usage: - """ - server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("localhost", 8080)) - server.listen(1) - client = server.accept()[0] - server.close() - return client + EXPORT praw_client_id= + EXPORT praw_client_secret= + python3 obtain_refresh_token.py +""" +import asyncio +import random +import socket +import sys -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.close() +import asyncpraw async def main(): """Provide the program's entry point when directly executed.""" - print( - "Go here while logged into the account you want to create a token for:" - " https://www.reddit.com/prefs/apps/" - ) - print( - "Click the create an app button. Put something in the name field and select the" - " script radio button." - ) - print("Put http://localhost:8080 in the redirect uri field and click create app") - client_id = input( - "Enter the client ID, it's the line just under Personal use script at the top: " - ) - client_secret = input("Enter the client secret, it's the line next to secret: ") - commaScopes = input( - "Now enter a comma separated list of scopes, or all for all tokens: " + scope_input = input( + "Enter a comma separated list of scopes, or `*` for all scopes: " ) - - if commaScopes.lower() == "all": - scopes = ["*"] - else: - scopes = commaScopes.strip().split(",") + scopes = [scope.strip() for scope in scope_input.strip().split(",")] reddit = asyncpraw.Reddit( - client_id=client_id.strip(), - client_secret=client_secret.strip(), redirect_uri="http://localhost:8080", - user_agent="praw_refresh_token_example", + user_agent="obtain_refresh_token/v0 by u/bboe", ) state = str(random.randint(0, 65000)) url = reddit.auth.url(scopes, state, "permanent") print(f"Now open this url in your browser: {url}") - sys.stdout.flush() client = receive_connection() data = client.recv(1024).decode("utf-8") @@ -97,6 +71,28 @@ async def main(): return 0 +def receive_connection(): + """Wait for and then return a connected socket.. + + Opens a TCP connection on port 8080, and waits for a single client. + + """ + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("localhost", 8080)) + server.listen(1) + client = server.accept()[0] + server.close() + return client + + +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.close() + + if __name__ == "__main__": loop = asyncio.get_event_loop() sys.exit(loop.run_until_complete(main())) diff --git a/docs/examples/use_file_token_manager.py b/docs/examples/use_file_token_manager.py new file mode 100755 index 000000000..de4c3d3ab --- /dev/null +++ b/docs/examples/use_file_token_manager.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""This example demonstrates using the file token manager for refresh tokens. + +In order to run this program, you will first need to obtain a valid refresh token. You +can use the `obtain_refresh_token.py` example to help. + +In this example, refresh tokens will be saved into a file `refresh_token.txt` relative +to your current working directory. If your current working directory is under version +control it is strongly encouraged you add `refresh_token.txt` to the version control +ignore list. + +Usage: + + EXPORT praw_client_id= + EXPORT praw_client_secret= + python3 use_file_token_manager.py + +""" +import asyncio +import os +import sys + +import aiofiles + +import asyncpraw +from asyncpraw.util.token_manager import FileTokenManager + +REFRESH_TOKEN_FILENAME = "refresh_token.txt" + + +async def initialize_refresh_token_file(): + if os.path.isfile(REFRESH_TOKEN_FILENAME): + return + + refresh_token = input("Initial refresh token value: ") + async with aiofiles.open(REFRESH_TOKEN_FILENAME, "w") as fp: + await fp.write(refresh_token) + + +async def main(): + if "praw_client_id" not in os.environ: + sys.stderr.write("Environment variable ``praw_client_id`` must be defined\n") + return 1 + if "praw_client_secret" not in os.environ: + sys.stderr.write( + "Environment variable ``praw_client_secret`` must be defined\n" + ) + return 1 + + await initialize_refresh_token_file() + + refresh_token_manager = FileTokenManager(REFRESH_TOKEN_FILENAME) + async with asyncpraw.Reddit( + token_manager=refresh_token_manager, + user_agent="use_file_token_manager/v0 by u/bboe", + ) as reddit: + + scopes = await reddit.auth.scopes() + if scopes == {"*"}: + print(f"{await reddit.user.me()} is authenticated with all scopes") + elif "identity" in scopes: + print( + f"{await reddit.user.me()} is authenticated with the following scopes: {scopes}" + ) + else: + print(f"You are authenticated with the following scopes: {scopes}") + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + sys.exit(loop.run_until_complete(main())) diff --git a/docs/getting_started/authentication.rst b/docs/getting_started/authentication.rst index e67a8bd8d..b9c5c3071 100644 --- a/docs/getting_started/authentication.rst +++ b/docs/getting_started/authentication.rst @@ -3,7 +3,7 @@ Authenticating via OAuth ======================== -Async PRAW supports the three types of applications that can be registered on +Async PRAW supports all three types of applications that can be registered on Reddit. Those are: * `Web Applications `_ @@ -14,10 +14,10 @@ Before you can use any one of these with Async PRAW, you must first `register `_ an application of the appropriate type on Reddit. -If your app does not require a user context, it is :ref:`read-only `. +If your application does not require a user context, it is :ref:`read-only `. Async PRAW supports the flows that each of these applications can use. The -following table defines which tables can use which flows: +following table defines which application types can use which flows: .. rst-class:: center_table_items @@ -129,12 +129,12 @@ When registering your application you must provide a valid redirect URI. If you are running a website you will want to enter the appropriate callback URL and configure that endpoint to complete the code flow. -If you aren't actually running a website, you can use the :ref:`refresh_token` -script to obtain ``refresh_tokens``. Enter ``http://localhost:8080`` as the -redirect URI when using this script. +If you aren't actually running a website, you can follow the :ref:`refresh_token` +tutorial to learn how to obtain and use the +initial refresh token. -Whether or not you use the script there are two processes involved in obtaining -access or refresh tokens. +Whether or not you follow the :ref:`refresh_token` tutorial there are two processes +involved in obtaining access or refresh tokens. .. _auth_url: @@ -152,13 +152,15 @@ URL. You can do that as follows: user_agent="testscript by u/fakebot3") print(reddit.auth.url(["identity"], "...", "permanent")) -The above will output an authorization URL for a permanent token that has only -the `identity` scope. See :meth:`.url` for more information on these +The above will output an authorization URL for a permanent token (i.e., the resulting +authorization will include both a short-lived ``access_token``, and a longer-lived, single +use ``refresh_token``)that has only +the ``identity`` scope. See :meth:`.url` for more information on these parameters. This URL should be accessed by the account that desires to authorize their Reddit access to your application. On completion of that flow, the user's -browser will be redirected to the specified ``redirect_uri``. After extracting +browser will be redirected to the specified ``redirect_uri``. After verifying the ``state`` and extracting the ``code`` you can obtain the refresh token via: @@ -168,10 +170,10 @@ token via: print(await reddit.user.me()) The first line of output is the ``refresh_token``. You can save this for later -use (see :ref:`using_refresh_token`). +use (see :ref:`using_refresh_tokens`). The second line of output reveals the name of the Redditor that completed the -code flow. It also indicates that the ``reddit`` instance is now associated +code flow. It also indicates that the :class:`.Reddit` instance is now associated with that account. The code flow can be used with an **installed** application just as described @@ -256,25 +258,3 @@ the end user could retrieve the ``client_id``. different users of your app from each other (as the supplied device id *should* be a unique string per both device (in the case of a web app, server) and user (in the case of a web app, browser session). - -.. _using_refresh_token: - -Using a Saved Refresh Token ---------------------------- - -A saved refresh token can be used to immediately obtain an authorized instance -of :class:`.Reddit` like so: - -.. code-block:: python - - reddit = asyncpraw.Reddit(client_id="SI8pN3DSbt0zor", - client_secret="xaxkj7HNh8kwg8e5t4m6KvSrbTI", - refresh_token="WeheY7PwgeCZj4S3QgUcLhKE5S2s4eAYdxM", - user_agent="testscript by u/fakebot3") - print(await reddit.auth.scopes()) - -The output from the above code displays which scopes are available on the -:class:`.Reddit` instance. - -.. note:: Observe that ``redirect_uri`` does not need to be provided in such - cases. It is only needed when :meth:`.url` is used. diff --git a/docs/getting_started/configuration/options.rst b/docs/getting_started/configuration/options.rst index cb213c6e3..8b6b03199 100644 --- a/docs/getting_started/configuration/options.rst +++ b/docs/getting_started/configuration/options.rst @@ -43,14 +43,6 @@ OAuth Configuration Options however, the value must be set to ``None`` for **installed** applications. -:refresh_token: For either **web** applications, or **installed** applications - using the code flow, you can directly provide a previously - obtained refresh token. Using a **web** application in - conjunction with this option is useful, for example, if you - prefer to not have your username and password available to your - program, as required for a **script** application. See: - :ref:`refresh_token` and :ref:`using_refresh_token` - :redirect_uri: The redirect URI associated with your registered Reddit application. This field is unused for **script** applications and is only needed for both **web** applications, and diff --git a/docs/tutorials/refresh_token.rst b/docs/tutorials/refresh_token.rst index 6ca32eb71..5670181ac 100644 --- a/docs/tutorials/refresh_token.rst +++ b/docs/tutorials/refresh_token.rst @@ -1,13 +1,103 @@ .. _refresh_token: -Obtaining a Refresh Token -========================= +Working with Refresh Tokens +=========================== -The following program can be used to obtain a refresh token with the desired -scopes. Such a token can be used in conjunction with the ``refresh_token`` -keyword argument using in initializing an instance of :class:`~asyncpraw.Reddit`. -A list of all possible scopes can be found in the `reddit API docs -`_ +.. note:: + + The process for using refresh tokens is in the process of changing on Reddit's end. + This documentation has been updated to be aligned with the future of how Reddit + handles refresh tokens, and will be the only supported method in PRAW 8+. For more + information please see: + https://old.reddit.com/r/redditdev/comments/kvzaot/oauth2_api_changes_upcoming/ + +Reddit OAuth2 Scopes +-------------------- + +Before working with refresh tokens you should decide which scopes your application requires. +If you want to use all scopes, you can use the special scope ``*``. + +To get an up-to-date listing of all Reddit scopes and their descriptions run the following: + +.. code-block:: python + + import requests + + response = requests.get( + "https://www.reddit.com/api/v1/scopes.json", + headers={"User-Agent": "fetch-scopes by u/bboe"} + ) + + for scope, data in sorted(response.json().items()): + print(f"{scope:>18s} {data['description']}") + +As of February 2021, the available scopes are: + + +================ ====================================================================== +Scope Description +================ ====================================================================== +account Update preferences and related account information. Will not have + access to your email or password. +creddits Spend my reddit gold creddits on giving gold to other users. +edit Edit and delete my comments and submissions. +flair Select my subreddit flair. Change link flair on my submissions. +history Access my voting history and comments or submissions I've saved or + hidden. +identity Access my reddit username and signup date. +livemanage Manage settings and contributors of live threads I contribute to. +modconfig Manage the configuration, sidebar, and CSS of subreddits I moderate. +modcontributors Add/remove users to approved user lists and ban/unban or mute/unmute + users from subreddits I moderate. +modflair Manage and assign flair in subreddits I moderate. +modlog Access the moderation log in subreddits I moderate. +modmail Access and manage modmail via mod.reddit.com. +modothers Invite or remove other moderators from subreddits I moderate. +modposts Approve, remove, mark nsfw, and distinguish content in subreddits I + moderate. +modself Accept invitations to moderate a subreddit. Remove myself as a + moderator or contributor of subreddits I moderate or contribute to. +modtraffic Access traffic stats in subreddits I moderate. +modwiki Change editors and visibility of wiki pages in subreddits I moderate. +mysubreddits Access the list of subreddits I moderate, contribute to, and subscribe + to. +privatemessages Access my inbox and send private messages to other users. +read Access posts and comments through my account. +report Report content for rules violations. Hide & show individual + submissions. +save Save and unsave comments and submissions. +structuredstyles Edit structured styles for a subreddit I moderate. +submit Submit links and comments from my account. +subscribe Manage my subreddit subscriptions. Manage "friends" - users whose + content I follow. +vote Submit and change my votes on comments and submissions. +wikiedit Edit wiki pages on my behalf +wikiread Read wiki pages through my account +================ ====================================================================== + +Obtaining Refresh Tokens +------------------------ + +The following program can be used to obtain a refresh token with the desired scopes: .. literalinclude:: ../examples/obtain_refresh_token.py - :language: python + :language: python + + +.. _using_refresh_tokens: + +Using and Updating Refresh Tokens +--------------------------------- + +Reddit refresh tokens can be used only once. When an authorization is refreshed the existing refresh +token is consumed and a new access token and refresh token will be issued. While PRAW automatically +handles refreshing tokens when needed, it does not automatically handle the storage of the refresh +tokens. However, PRAW provides the facilities for you to manage your refresh tokens via custom +subclasses of :class:`.BaseTokenManager`. For trivial examples, PRAW provides the +:class:`.FileTokenManager`. + +The following program demonstrates how to prepare a file with an initial refresh token, and configure +PRAW to both use that refresh token, and keep the file up-to-date with a valid refresh token. + +.. literalinclude:: ../examples/use_file_token_manager.py + :language: python diff --git a/setup.py b/setup.py index 1c2c0f358..357533c7c 100644 --- a/setup.py +++ b/setup.py @@ -64,7 +64,7 @@ " python package that allows for simple access to reddit's API." ), extras_require=extras, - install_requires=["asyncprawcore >=1.0.1, <2.0", "update_checker >=0.18"], + install_requires=["aiofiles", "asyncprawcore >=2, <3", "update_checker >=0.18"], keywords="reddit api wrapper asyncpraw praw async asynchronous", license="Simplified BSD License", long_description=README, diff --git a/tests/conftest.py b/tests/conftest.py index 0632a1b4e..6d816b72c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,12 +4,10 @@ import os from base64 import b64encode from datetime import datetime -from functools import wraps import pytest from _pytest.tmpdir import _mk_tmp from vcr import VCR -from vcr.cassette import Cassette from vcr.persisters.filesystem import FilesystemPersister from vcr.serialize import deserialize, serialize @@ -89,7 +87,7 @@ def serialize_list(data: list): x: env_default(x) for x in ( "auth_code client_id client_secret password redirect_uri test_subreddit" - " user_agent username refresh_token" + " user_agent username" ).split() } @@ -159,29 +157,6 @@ def use_cassette(self, path="", **kwargs): VCR.register_persister(CustomPersister) -def after_init(func, *args): - func(*args) - - -def add_init_hook(original_init): - """Wrap an __init__ method to also call some hooks.""" - - @wraps(original_init) - def wrapper(self, *args, **kwargs): - original_init(self, *args, **kwargs) - after_init(init_hook, self) - - return wrapper - - -Cassette.__init__ = add_init_hook(Cassette.__init__) - - -def init_hook(cassette): - if not cassette.requests: - pytest.set_up_record() # dynamically defined in __init__.py - - class Placeholders: def __init__(self, _dict): self.__dict__ = _dict diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index ad43df570..71bfa314e 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -17,7 +17,6 @@ class IntegrationTest(asynctest.TestCase): def setUp(self): """Setup runs before all test cases.""" - self._overrode_reddit_setup = True self.setup_reddit() self.setup_vcr() @@ -37,40 +36,16 @@ def setup_vcr(self): # Require tests to explicitly disable read_only mode. self.reddit.read_only = True - pytest.set_up_record = self.set_up_record # used in conftest.py - def setup_reddit(self): - self._overrode_reddit_setup = False - self._session = aiohttp.ClientSession() - if pytest.placeholders.refresh_token != "placeholder_refresh_token": - self.reddit = Reddit( - requestor_kwargs={"session": self._session}, - client_id=pytest.placeholders.client_id, - client_secret=pytest.placeholders.client_secret, - user_agent=pytest.placeholders.user_agent, - refresh_token=pytest.placeholders.refresh_token, - ) - else: - self.reddit = Reddit( - requestor_kwargs={"session": self._session}, - client_id=pytest.placeholders.client_id, - client_secret=pytest.placeholders.client_secret, - password=pytest.placeholders.password, - user_agent=pytest.placeholders.user_agent, - username=pytest.placeholders.username, - ) - - def set_up_record(self): - if not self._overrode_reddit_setup: - if pytest.placeholders.refresh_token != "placeholder_refresh_token": - self.reddit = Reddit( - requestor_kwargs={"session": self._session}, - client_id=pytest.placeholders.client_id, - client_secret=pytest.placeholders.client_secret, - user_agent=pytest.placeholders.user_agent, - refresh_token=pytest.placeholders.refresh_token, - ) + self.reddit = Reddit( + requestor_kwargs={"session": self._session}, + client_id=pytest.placeholders.client_id, + client_secret=pytest.placeholders.client_secret, + password=pytest.placeholders.password, + user_agent=pytest.placeholders.user_agent, + username=pytest.placeholders.username, + ) @staticmethod async def async_list(async_generator): diff --git a/tests/unit/test_deprecations.py b/tests/unit/test_deprecations.py index 6444da99f..531aa3931 100644 --- a/tests/unit/test_deprecations.py +++ b/tests/unit/test_deprecations.py @@ -90,3 +90,13 @@ def test_synchronous_context_manager(self): excinfo.value.args[0] == "Using this class as a synchronous context manager is deprecated and will be removed in the next release. Use this class as an asynchronous context manager instead." ) + + def test_reddit_refresh_token(self): + with pytest.raises(DeprecationWarning): + Reddit( + client_id="dummy", + client_secret=None, + redirect_uri="dummy", + refresh_token="dummy", + user_agent="dummy", + ) diff --git a/tests/unit/test_reddit.py b/tests/unit/test_reddit.py index 3e9001685..2900844e6 100644 --- a/tests/unit/test_reddit.py +++ b/tests/unit/test_reddit.py @@ -11,6 +11,7 @@ from asyncpraw.config import Config from asyncpraw.exceptions import ClientException, RedditAPIException from asyncpraw.models import Comment +from asyncpraw.util.token_manager import BaseTokenManager from . import UnitTest @@ -46,6 +47,18 @@ async def test_check_for_updates_update_checker_missing(self, mock_update_check) def test_comment(self): assert Comment(self.reddit, id="cklfmye").id == "cklfmye" + def test_conflicting_settings(self): + with pytest.raises(TypeError) as excinfo: + Reddit( + refresh_token="dummy", + token_manager="dummy", + **self.REQUIRED_DUMMY_SETTINGS, + ) + assert ( + str(excinfo.value) + == "legacy ``refresh_token`` setting cannot be provided when providing ``token_manager``" + ) + async def test_context_manager(self): async with Reddit(**self.REQUIRED_DUMMY_SETTINGS) as reddit: assert not reddit._validate_on_submit @@ -168,6 +181,19 @@ async def test_post_ratelimit(self, __, _): async def test_read_only__with_authenticated_core(self): async with Reddit( + password=None, + token_manager=BaseTokenManager(), + username=None, + **self.REQUIRED_DUMMY_SETTINGS, + ) as reddit: + assert not reddit.read_only + reddit.read_only = True + assert reddit.read_only + reddit.read_only = False + assert not reddit.read_only + + def test_read_only__with_authenticated_core__legacy_refresh_token(self): + with Reddit( password=None, refresh_token="refresh", username=None, @@ -184,8 +210,24 @@ async def test_read_only__with_authenticated_core__non_confidential(self): client_id="dummy", client_secret=None, redirect_uri="dummy", + token_manager=BaseTokenManager(), user_agent="dummy", + ) as reddit: + assert not reddit.read_only + reddit.read_only = True + assert reddit.read_only + reddit.read_only = False + assert not reddit.read_only + + def test_read_only__with_authenticated_core__non_confidential__legacy_refresh_token( + self, + ): + with Reddit( + client_id="dummy", + client_secret=None, + redirect_uri="dummy", refresh_token="dummy", + user_agent="dummy", ) as reddit: assert not reddit.read_only reddit.read_only = True diff --git a/tests/unit/util/test_token_manager.py b/tests/unit/util/test_token_manager.py new file mode 100644 index 000000000..a83b87d5e --- /dev/null +++ b/tests/unit/util/test_token_manager.py @@ -0,0 +1,98 @@ +"""Test asyncpraw.util.refresh_token_manager.""" +import aiofiles +import pytest +from asynctest import mock + +from asyncpraw.util.token_manager import BaseTokenManager, FileTokenManager + +from .. import UnitTest + + +class DummyAuthorizer: + def __init__(self, refresh_token): + self.refresh_token = refresh_token + + +class TestBaseTokenManager(UnitTest): + def test_post_refresh_token_callback__raises_not_implemented(self): + manager = BaseTokenManager() + with pytest.raises(NotImplementedError) as excinfo: + manager.post_refresh_callback(None) + assert str(excinfo.value) == "``post_refresh_callback`` must be extended." + + def test_pre_refresh_token_callback__raises_not_implemented(self): + manager = BaseTokenManager() + with pytest.raises(NotImplementedError) as excinfo: + manager.pre_refresh_callback(None) + assert str(excinfo.value) == "``pre_refresh_callback`` must be extended." + + def test_reddit(self): + manager = BaseTokenManager() + manager.reddit = "dummy" + assert manager.reddit == "dummy" + + def test_reddit__must_only_be_set_once(self): + manager = BaseTokenManager() + manager.reddit = "dummy" + with pytest.raises(RuntimeError) as excinfo: + manager.reddit = None + assert ( + str(excinfo.value) + == "``reddit`` can only be set once and is done automatically" + ) + + +class TestFileTokenManager(UnitTest): + def setUp(self): + aiofiles.threadpool.wrap.register(mock.MagicMock)( + lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase( + *args, **kwargs + ) + ) + super(TestFileTokenManager, self).setUp() + + async def test_post_refresh_token_callback__writes_to_file(self): + authorizer = DummyAuthorizer("token_value") + manager = FileTokenManager("mock/dummy_path") + mock_file = mock.MagicMock() + + with mock.patch( + "aiofiles.threadpool.sync_open", return_value=mock_file + ) as mock_open: + await manager.post_refresh_callback(authorizer) + + assert authorizer.refresh_token == "token_value" + mock_open.assert_called_once_with( + "mock/dummy_path", + mode="w", + buffering=-1, + encoding=None, + errors=None, + newline=None, + closefd=True, + opener=None, + ) + mock_open().write.assert_called_once_with("token_value") + + async def test_pre_refresh_token_callback__reads_from_file(self): + authorizer = DummyAuthorizer(None) + manager = FileTokenManager("mock/dummy_path") + mock_file = mock.MagicMock() + mock_file.read = mock.MagicMock(return_value="token_value\n") + + with mock.patch( + "aiofiles.threadpool.sync_open", return_value=mock_file + ) as mock_open: + await manager.pre_refresh_callback(authorizer) + + assert authorizer.refresh_token == "token_value" + mock_open.assert_called_once_with( + "mock/dummy_path", + mode="r", + buffering=-1, + encoding=None, + errors=None, + newline=None, + closefd=True, + opener=None, + )