prawcore-0.13.0/0000755000076600000240000000000013215346026013452 5ustar bboestaff00000000000000prawcore-0.13.0/AUTHORS.rst0000644000076600000240000000055613061116251015331 0ustar bboestaff00000000000000prawcore is written and maintained by Bryce Boe and various contributors: Maintainers =========== - Bryce Boe `@bboe `_ Contributors ============ - nmtake `@nmtake `_ - elnuno `@elnuno `_ - Add "Name and github profile link" above this line. prawcore-0.13.0/CHANGES.rst0000644000076600000240000001303113151704122015244 0ustar bboestaff00000000000000Change Log ========== prawcore follows `semantic versioning `_ with the exception that deprecations will not be announced by a minor release. 0.12.0 (2017-08-30) ------------------- **Added** * ``BadJSON`` exception for the rare cases that a response that should contain valid JSON has unparsable JSON. 0.11.0 (2017-05-27) ------------------- **Added** * ``Conflict`` exception is raised when response status 409 is returned. 0.10.1 (2017-04-10) ------------------- **Fixed** * ``InvalidToken`` is again raised on 401 when a non-refreshable application is in use. 0.10.0 (2017-04-10) ------------------- **Added** * ``ConnectionError`` exceptions are automatically retried. This handles ``Connection Reset by Peer`` issues that appear to occur somewhat frequently when running on Amazon EC2. **Changed** * Calling ``RateLimiter`` now requires a second positional argument, ``set_header_callback``. * In the event a 401 unauthorized occurs, the access token is cleared and the request is retried. **Fixed** * Check if the access token is expired immediately before every authorized request, rather than just before the request flow. This new approach accounts for failure retries, and rate limiter delay. 0.9.0 (2017-03-11) ------------------ **Added** * Add ``session`` parameter to Requestor to ease support of custom sessions (e.g. caching or mock ones). 0.8.0 (2017-01-29) ------------------ **Added** * Handle 413 Request entity too large responses. * ``reset_timestamp`` to ``RateLimiter``. **Fixed** * Avoid modifying passed in ``data`` and ``params`` to ``Session.request``. 0.7.0 (2017-01-16) ------------------ **Added** ``ChunkedEncodingError`` is automatically retried like the server errors. 0.6.0 (2016-12-24) ------------------ **Added** * Handle 500 responses. * Handle Cloudflair 520 responses. 0.5.0 (2016-12-13) ------------------ **Added** All network requests now have a 16 second timeout by default. The environment variable ``prawcore_timeout`` can be used to adjust the value. 0.4.0 (2016-12-09) ------------------ **Changed** * Prevent '(None)' from appearing in OAuthException message. 0.3.0 (2016-11-20) ------------------ **Added** * Add ``files`` parameter to ``Session.request`` to support image upload operations. * Add ``duration`` and ``implicit`` parameters to ``UntrustedAuthenticator.authorization_url`` so that the method also supports the code grant flow. **Fixed** * ``Authorizer`` class can be used with ``UntrustedAuthenticator``. 0.2.1 (2016-08-07) ------------------ **Fixed** * ``session`` works with ``DeviceIDAuthorizer`` and ``ImplicitAuthorizer``. 0.2.0 (2016-08-07) ------------------ **Added** * Add ``ImplicitAuthorizer``. **Changed** * Split ``Authenticator`` into ``TrustedAuthenticator`` and ``UntrustedAuthenticator``. 0.1.1 (2016-08-06) ------------------ **Added** * Add ``DeviceIDAuthorizer`` that permits installed application access to the API. 0.1.0 (2016-08-05) ------------------ **Added** * ``RequestException`` which wraps all exceptions that occur from ``requests.request`` in a ``prawcore.RequestException``. **Changed** * What was previously ``RequestException`` is now ``ResponseException``. 0.0.15 (2016-08-02) ------------------- **Added** * Handle Cloudflair 522 responses. 0.0.14 (2016-07-25) ------------------- **Added** * Add ``ServerError`` exception for 502, 503, and 504 HTTP status codes that is only raised after three failed attempts to make the request. * Add ``json`` parameter to ``Session.request``. 0.0.13 (2016-07-24) ------------------- **Added** * Automatically attempt to refresh access tokens when making a request if the access token is expired. **Fixed** * Consider access tokens expired slightly earlier than allowed for to prevent InvalidToken exceptions from occuring. 0.0.12 (2016-07-17) ------------------- **Added** * Handle 0-byte HTTP 200 responses. 0.0.11 (2016-07-16) ------------------- **Added** * Add a ``NotFound`` exception. * Support 404 "Not Found" HTTP responses. 0.0.10 (2016-07-10) ------------------- **Added** * Add a ``BadRequest`` exception. * Support 400 "Bad Request" HTTP responses. * Support 204 "No Content" HTTP responses. 0.0.9 (2016-07-09) ------------------ **Added** * Support 201 "Created" HTTP responses used in some v1 endpoints. 0.0.8 (2016-03-21) ------------------ **Added** * Sort ``Session.request`` ``data`` values. Sorting the values permits betamax body matcher to work as expected. 0.0.7 (2016-03-18) ------------------ **Added** * Added ``data`` parameter to ``Session.request``. 0.0.6 (2016-03-14) ------------------ **Fixed** * prawcore objects can be pickled. 0.0.5 (2016-03-12) ------------------ **Added** * 302 redirects result in a ``Redirect`` exception. 0.0.4 (2016-03-12) ------------------ **Added** * Add a generic ``Forbidden`` exception for 403 responses without the ``www-authenticate`` header. 0.0.3 (2016-02-29) ------------------ **Added** * Added ``params`` parameter to ``Session.request``. * Log requests to the ``prawcore`` logger in debug mode. 0.0.2 (2016-02-21) ------------------ **Fixed** * README.rst for display purposes on pypi. 0.0.1 (2016-02-17) [YANKED] --------------------------- **Added** * Dynamic rate limiting based on reddit's response headers. * Authorization URL generation. * Retrieval of access and refresh tokens from authorization grants. * Access and refresh token revocation. * Retrieval of read-only access tokens. * Retrieval of script-app tokens. * Three examples in the ``examples/`` directory. prawcore-0.13.0/CODE_OF_CONDUCT.md0000644000076600000240000000623212655457046016270 0ustar bboestaff00000000000000# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at bbzbryce@gmail.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ prawcore-0.13.0/LICENSE.txt0000644000076600000240000000242012655442637015307 0ustar bboestaff00000000000000Copyright (c) 2016, Bryce Boe All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. prawcore-0.13.0/MANIFEST.in0000644000076600000240000000003112757335300015205 0ustar bboestaff00000000000000include *.md *.rst *.txt prawcore-0.13.0/PKG-INFO0000644000076600000240000001027713215346026014556 0ustar bboestaff00000000000000Metadata-Version: 1.1 Name: prawcore Version: 0.13.0 Summary: Low-level communication layer for PRAW 4+. Home-page: https://github.com/praw-dev/prawcore Author: Bryce Boe Author-email: bbzbryce@gmail.com License: Simplified BSD License Description: .. _main_page: prawcore ======== .. image:: https://img.shields.io/pypi/v/prawcore.svg :alt: Latest prawcore Version :target: https://pypi.python.org/pypi/prawcore .. image:: https://travis-ci.org/praw-dev/prawcore.svg?branch=master :target: https://travis-ci.org/praw-dev/prawcore .. image:: https://coveralls.io/repos/github/praw-dev/prawcore/badge.svg?branch=master :target: https://coveralls.io/github/praw-dev/prawcore?branch=master .. image:: https://badges.gitter.im/praw-dev/praw.svg :alt: Join the chat at https://gitter.im/praw-dev/praw :target: https://gitter.im/praw-dev/praw prawcore is a low-level communication layer for PRAW 4+. Installation ------------ Install prawcore using ``pip`` via: .. code-block:: console pip install prawcore Execution Example ----------------- The following example demonstrates how to use prawcore to obtain the list of trophies for a given user using the script-app type. This example assumes you have the environment variables ``PRAWCORE_CLIENT_ID`` and ``PRAWCORE_CLIENT_SECRET`` set to the appropriate values for your application. .. code-block:: python #!/usr/bin/env python import os import pprint import prawcore authenticator = prawcore.TrustedAuthenticator( prawcore.Requestor('YOUR_VALID_USER_AGENT'), os.environ['PRAWCORE_CLIENT_ID'], os.environ['PRAWCORE_CLIENT_SECRET']) authorizer = prawcore.ReadOnlyAuthorizer(authenticator) authorizer.refresh() with prawcore.session(authorizer) as session: pprint.pprint(session.request('GET', '/api/v1/user/bboe/trophies')) Save the above as ``trophies.py`` and then execute via: .. code-block:: console python trophies.py Additional examples can be found at: https://github.com/praw-dev/prawcore/tree/master/examples Depending on prawcore --------------------- prawcore follows `semantic versioning `_ with the exception that deprecations will not be preceded by a minor release. In essense, expect only major versions to introduce breaking changes to prawcore's public interface. As a result, if you depend on prawcore then it is a good idea to specify not only the minimum version of prawcore your package requires, but to also limit the major version. Below are two examples of how you may want to specify your prawcore dependency: setup.py ~~~~~~~~ .. code-block:: python setup(..., install_requires=['prawcore >=0.1, <1'], ...) requirements.txt ~~~~~~~~~~~~~~~~ .. code-block:: text prawcore >=1.5.1, <2 Keywords: praw reddit api Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: BSD License Classifier: Operating System :: OS Independent Classifier: Natural Language :: English Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: Implementation :: CPython prawcore-0.13.0/prawcore/0000755000076600000240000000000013215346026015274 5ustar bboestaff00000000000000prawcore-0.13.0/prawcore/__init__.py0000644000076600000240000000077012751563037017420 0ustar bboestaff00000000000000"""prawcore: Low-level communication layer for PRAW 4+.""" import logging from .auth import (Authorizer, DeviceIDAuthorizer, ReadOnlyAuthorizer, # NOQA ImplicitAuthorizer, ScriptAuthorizer, TrustedAuthenticator, UntrustedAuthenticator) from .const import __version__ # noqa from .exceptions import * # noqa from .requestor import Requestor # noqa from .sessions import Session, session # noqa logging.getLogger(__package__).addHandler(logging.NullHandler()) prawcore-0.13.0/prawcore/auth.py0000644000076600000240000003205313037326460016614 0ustar bboestaff00000000000000"""Provides Authentication and Authorization classes.""" import time from . import const from .exceptions import InvalidInvocation, OAuthException, ResponseException from requests import Request from requests.status_codes import codes class BaseAuthenticator(object): """Provide the base authenticator object that stores OAuth2 credentials.""" def __init__(self, requestor, client_id, redirect_uri=None): """Represent a single authentication to Reddit's API. :param requestor: An instance of :class:`Requestor`. :param client_id: The OAuth2 client ID to use with the session. :param redirect_uri: (optional) The redirect URI exactly as specified in your OAuth application settings on Reddit. This parameter is required if you want to use the ``authorize_url`` method, or the ``authorize`` method of the ``Authorizer`` class. """ self._requestor = requestor self.client_id = client_id self.redirect_uri = redirect_uri def _post(self, url, success_status=codes['ok'], **data): response = self._requestor.request('post', url, auth=self._auth(), data=sorted(data.items())) if response.status_code != success_status: raise ResponseException(response) return response def authorize_url(self, duration, scopes, state, implicit=False): """Return the URL used out-of-band to grant access to your application. :param duration: Either ``permanent`` or ``temporary``. ``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. Only ``temporary`` can be specified if ``implicit`` is set to ``True``. :param scopes: A list of OAuth scopes to request authorization for. :param state: A string that will be reflected in the callback to ``redirect_uri``. This value should be temporarily unique to the client for whom the URL was generated for. :param implicit: (optional) Use the implicit grant flow (default: False). This flow is only available for UntrustedAuthenticators. """ if self.redirect_uri is None: raise InvalidInvocation('redirect URI not provided') if implicit and not isinstance(self, UntrustedAuthenticator): raise InvalidInvocation('Only UntrustedAuthentictor instances can ' 'use the implicit grant flow.') if implicit and duration != 'temporary': raise InvalidInvocation('The implicit grant flow only supports ' 'temporary access tokens.') params = {'client_id': self.client_id, 'duration': duration, 'redirect_uri': self.redirect_uri, 'response_type': 'token' if implicit else 'code', 'scope': ' '.join(scopes), 'state': state} url = self._requestor.reddit_url + const.AUTHORIZATION_PATH request = Request('GET', url, params=params) return request.prepare().url def revoke_token(self, token, token_type=None): """Ask Reddit to revoke the provided token. :param token: The access or refresh token to revoke. :param token_type: (Optional) When provided, hint to Reddit what the token type is for a possible efficiency gain. The value can be either ``access_token`` or ``refresh_token``. """ data = {'token': token} if token_type is not None: data['token_type_hint'] = token_type url = self._requestor.reddit_url + const.REVOKE_TOKEN_PATH self._post(url, success_status=codes['no_content'], **data) class TrustedAuthenticator(BaseAuthenticator): """Store OAuth2 authentication credentials for web, or script type apps.""" RESPONSE_TYPE = 'code' def __init__(self, requestor, client_id, client_secret, redirect_uri=None): """Represent a single authentication to Reddit's API. :param requestor: An instance of :class:`Requestor`. :param client_id: The OAuth2 client ID to use with the session. :param client_secret: The OAuth2 client secret to use with the session. :param redirect_uri: (optional) The redirect URI exactly as specified in your OAuth application settings on Reddit. This parameter is required if you want to use the ``authorize_url`` method, or the ``authorize`` method of the ``Authorizer`` class. """ super(TrustedAuthenticator, self).__init__(requestor, client_id, redirect_uri) self.client_secret = client_secret def _auth(self): return (self.client_id, self.client_secret) class UntrustedAuthenticator(BaseAuthenticator): """Store OAuth2 authentication credentials for installed applications.""" def _auth(self): return (self.client_id, '') class BaseAuthorizer(object): """Superclass for OAuth2 authorization tokens and scopes.""" def __init__(self, authenticator): """Represent a single authorization to Reddit's API. :param authenticator: An instance of :class:`BaseAuthenticator`. """ self._authenticator = authenticator self._clear_access_token() self._validate_authenticator() def _clear_access_token(self): self._expiration_timestamp = None self.access_token = None self.scopes = None def _request_token(self, **data): url = (self._authenticator._requestor.reddit_url + const.ACCESS_TOKEN_PATH) pre_request_time = time.time() response = self._authenticator._post(url, **data) payload = response.json() if 'error' in payload: # Why are these OKAY responses? raise OAuthException(response, payload['error'], payload.get('error_description')) self._expiration_timestamp = (pre_request_time - 10 + payload['expires_in']) self.access_token = payload['access_token'] if 'refresh_token' in payload: self.refresh_token = payload['refresh_token'] self.scopes = set(payload['scope'].split(' ')) def _validate_authenticator(self): if not isinstance(self._authenticator, self.AUTHENTICATOR_CLASS): raise InvalidInvocation('Must use a authenticator of type {}.' .format(self.AUTHENTICATOR_CLASS.__name__)) def is_valid(self): """Return whether or not the Authorizer is ready to authorize requests. A ``True`` return value does not guarantee that the access_token is actually valid on the server side. """ return self.access_token is not None \ and time.time() < self._expiration_timestamp def revoke(self): """Revoke the current Authorization.""" if self.access_token is None: raise InvalidInvocation('no token available to revoke') self._authenticator.revoke_token(self.access_token, 'access_token') self._clear_access_token() class Authorizer(BaseAuthorizer): """Manages OAuth2 authorization tokens and scopes.""" AUTHENTICATOR_CLASS = BaseAuthenticator def __init__(self, authenticator, refresh_token=None): """Represent a single authorization to Reddit's API. :param authenticator: An instance of a subclass of :class:`BaseAuthenticator`. :param refresh_token: (Optional) Enables the ability to refresh the authorization. """ super(Authorizer, self).__init__(authenticator) self.refresh_token = refresh_token def authorize(self, code): """Obtain and set authorization tokens based on ``code``. :param code: The code obtained by an out-of-band authorization request to Reddit. """ if self._authenticator.redirect_uri is None: raise InvalidInvocation('redirect URI not provided') self._request_token(code=code, grant_type='authorization_code', redirect_uri=self._authenticator.redirect_uri) def refresh(self): """Obtain a new access token from the refresh_token.""" if self.refresh_token is None: raise InvalidInvocation('refresh token not provided') self._request_token(grant_type='refresh_token', refresh_token=self.refresh_token) def revoke(self, only_access=False): """Revoke the current Authorization. :param only_access: (Optional) When explicitly set to True, do not evict the refresh token if one is set. Revoking a refresh token will in-turn revoke all access tokens associated with that authorization. """ if only_access or self.refresh_token is None: super(Authorizer, self).revoke() else: self._authenticator.revoke_token(self.refresh_token, 'refresh_token') self._clear_access_token() self.refresh_token = None class DeviceIDAuthorizer(BaseAuthorizer): """Manages app-only OAuth2 for 'installed' applications. While the '*' scope will be available, some endpoints simply will not work due to the lack of an associated Reddit account. """ AUTHENTICATOR_CLASS = UntrustedAuthenticator def __init__(self, authenticator, device_id='DO_NOT_TRACK_THIS_DEVICE'): """Represent an app-only OAuth2 authorization for 'installed' apps. :param authenticator: An instance of :class:`UntrustedAuthenticator`. :param device_id: (optional) A unique ID (20-30 character ASCII string) (default DO_NOT_TRACK_THIS_DEVICE). For more information about this parameter, see: https://github.com/reddit/reddit/wiki/OAuth2#application-only-oauth """ super(DeviceIDAuthorizer, self).__init__(authenticator) self._device_id = device_id def refresh(self): """Obtain a new access token.""" grant_type = 'https://oauth.reddit.com/grants/installed_client' self._request_token(grant_type=grant_type, device_id=self._device_id) class ImplicitAuthorizer(BaseAuthorizer): """Manages implicit installed-app type authorizations.""" AUTHENTICATOR_CLASS = UntrustedAuthenticator def __init__(self, authenticator, access_token, expires_in, scope): """Represent a single implicit authorization to Reddit's API. :param authenticator: An instance of :class:`UntrustedAuthenticator`. :param access_token: The access_token obtained from Reddit via callback to the authenticator's redirect_uri. :param expires_in: The number of seconds the ``access_token`` is valid for. The origin of this value was returned from Reddit via callback to the authenticator's redirect uri. Note, you may need to subtract an offset before passing in this number to account for a delay between when Reddit prepared the response, and when you make this function call. :param scope: A space-delimited string of Reddit OAuth2 scope names as returned from Reddit in the callback to the authenticator's redirect uri. """ super(ImplicitAuthorizer, self).__init__(authenticator) self._expiration_timestamp = time.time() + expires_in self.access_token = access_token self.scopes = set(scope.split(' ')) class ReadOnlyAuthorizer(Authorizer): """Manages authorizations that are not associated with a Reddit account. While the '*' scope will be available, some endpoints simply will not work due to the lack of an associated Reddit account. """ AUTHENTICATOR_CLASS = TrustedAuthenticator def refresh(self): """Obtain a new ReadOnly access token.""" self._request_token(grant_type='client_credentials') class ScriptAuthorizer(Authorizer): """Manages personal-use script type authorizations. Only users who are listed as developers for the application will be granted access tokens. """ AUTHENTICATOR_CLASS = TrustedAuthenticator def __init__(self, authenticator, username, password): """Represent a single personal-use authorization to Reddit's API. :param authenticator: An instance of :class:`TrustedAuthenticator`. :param username: The Reddit username of one of the application's developers. :param password: The password associated with ``username``. """ super(ScriptAuthorizer, self).__init__(authenticator) self._username = username self._password = password def refresh(self): """Obtain a new personal-use script type access token.""" self._request_token(grant_type='password', username=self._username, password=self._password) prawcore-0.13.0/prawcore/const.py0000644000076600000240000000040413215346007016771 0ustar bboestaff00000000000000"""Constants for the prawcore package.""" import os __version__ = '0.13.0' ACCESS_TOKEN_PATH = '/api/v1/access_token' AUTHORIZATION_PATH = '/api/v1/authorize' REVOKE_TOKEN_PATH = '/api/v1/revoke_token' TIMEOUT = float(os.environ.get('prawcore_timeout', 16)) prawcore-0.13.0/prawcore/exceptions.py0000644000076600000240000000770413215346007020036 0ustar bboestaff00000000000000"""Provide exception classes for the prawcore package.""" import sys if sys.version_info[0] == 2: from urlparse import urlparse else: from urllib.parse import urlparse class PrawcoreException(Exception): """Base exception class for exceptions that occur within this package.""" class InvalidInvocation(PrawcoreException): """Indicate that the code to execute cannot be completed.""" class RequestException(PrawcoreException): """Indicate that there was an error with the incomplete HTTP request.""" def __init__(self, original_exception, request_args, request_kwargs): """Initialize a RequestException instance. :param original_exception: The original exception that occurred. :param request_args: The arguments to the request function. :param request_kwargs: The keyword arguments to the request function. """ self.original_exception = original_exception self.request_args = request_args self.request_kwargs = request_kwargs super(RequestException, self).__init__('error with request {}' .format(original_exception)) class ResponseException(PrawcoreException): """Indicate that there was an error with the completed HTTP request.""" def __init__(self, response): """Initialize a RequestException instance. :param response: A requests.response instance. """ self.response = response super(ResponseException, self).__init__('received {} HTTP response' .format(response.status_code)) class OAuthException(PrawcoreException): """Indicate that there was an OAuth2 related error with the request.""" def __init__(self, response, error, description): """Intialize a OAuthException instance. :param response: A requests.response instance. :param error: The error type returned by reddit. :param description: A description of the error when provided. """ self.error = error self.description = description self.response = response message = '{} error processing request'.format(error) if description: message += ' ({})'.format(description) PrawcoreException.__init__(self, message) class BadJSON(ResponseException): """Indicate the response did not contain valid JSON.""" class BadRequest(ResponseException): """Indicate invalid parameters for the request.""" class Conflict(ResponseException): """Indicate a conflicting change in the target resource.""" class Forbidden(ResponseException): """Indicate the authentication is not permitted for the request.""" class InsufficientScope(ResponseException): """Indicate that the request requires a different scope.""" class InvalidToken(ResponseException): """Indicate that the request used an invalid access token.""" class UnavailableForLegalReasons(ResponseException): """Indicate that the requested URL is unavilable due to legal reasons.""" class NotFound(ResponseException): """Indicate that the requested URL was not found.""" class Redirect(ResponseException): """Indicate the request resulted in a redirect. This class adds the attribute ``path``, which is the path to which the response redirects. """ def __init__(self, response): """Initialize a Redirect exception instance.. :param response: A requests.response instance containing a location header. """ path = urlparse(response.headers['location']).path self.path = path[:-5] if path.endswith('.json') else path self.response = response PrawcoreException.__init__(self, 'Redirect to {}'.format(self.path)) class ServerError(ResponseException): """Indicate issues on the server end preventing request fulfillment.""" class TooLarge(ResponseException): """Indicate that the request data exceeds the allowed limit.""" prawcore-0.13.0/prawcore/rate_limit.py0000644000076600000240000000550413066126662020011 0ustar bboestaff00000000000000"""Provide the RateLimiter class.""" import time class RateLimiter(object): """Facilitates the rate limiting of requests to reddit. Rate limits are controlled based on feedback from requests to reddit. """ def __init__(self): """Create an instance of the RateLimit class.""" self.remaining = None self.next_request_timestamp = None self.reset_timestamp = None self.used = None def call(self, request_function, set_header_callback, *args, **kwargs): """Rate limit the call to request_function. :param request_function: A function call that returns an HTTP response object. :param set_header_callback: A callback function used to set the request headers. This callback is called after any necessary sleep time occurs. :param *args: The positional arguments to ``request_function``. :param **kwargs: The keyword arguments to ``request_function``. """ self.delay() kwargs['headers'] = set_header_callback() response = request_function(*args, **kwargs) self.update(response.headers) return response def delay(self): """Sleep for an amount of time to remain under the rate limit.""" if self.next_request_timestamp is None: return sleep_seconds = self.next_request_timestamp - time.time() if sleep_seconds <= 0: return time.sleep(sleep_seconds) def update(self, response_headers): """Update the state of the rate limiter based on the response headers. This method should only be called following a HTTP request to reddit. Response headers that do not contain x-ratelimit fields will be treated as a single request. This behavior is to error on the safe-side as such responses should trigger exceptions that indicate invalid behavior. """ if 'x-ratelimit-remaining' not in response_headers: if self.remaining is not None: self.remaining -= 1 self.used += 1 return now = time.time() prev_remaining = self.remaining seconds_to_reset = int(response_headers['x-ratelimit-reset']) self.remaining = float(response_headers['x-ratelimit-remaining']) self.used = int(response_headers['x-ratelimit-used']) self.reset_timestamp = now + seconds_to_reset if self.remaining <= 0: self.next_request_timestamp = self.reset_timestamp return if prev_remaining is not None and prev_remaining > self.remaining: estimated_clients = prev_remaining - self.remaining else: estimated_clients = 1.0 self.next_request_timestamp = now + ( estimated_clients * seconds_to_reset / self.remaining) prawcore-0.13.0/prawcore/requestor.py0000644000076600000240000000374013062407311017676 0ustar bboestaff00000000000000"""Provides the HTTP request handling interface.""" import requests from .const import __version__, TIMEOUT from .exceptions import InvalidInvocation, RequestException class Requestor(object): """Requestor provides an interface to HTTP requests.""" def __getattr__(self, attribute): """Pass all undefined attributes to the _http attribute.""" if attribute.startswith('__'): raise AttributeError return getattr(self._http, attribute) def __init__(self, user_agent, oauth_url='https://oauth.reddit.com', reddit_url='https://www.reddit.com', session=None): """Create an instance of the Requestor class. :param user_agent: The user-agent for your application. Please follow reddit's user-agent guidlines: https://github.com/reddit/reddit/wiki/API#rules :param oauth_url: (Optional) The URL used to make OAuth requests to the reddit site. (Default: https://oauth.reddit.com) :param reddit_url: (Optional) The URL used when obtaining access tokens. (Default: https://www.reddit.com) :param session: (Optional) A session to handle requests, compatible with requests.Session(). (Default: None) """ if user_agent is None or len(user_agent) < 7: raise InvalidInvocation('user_agent is not descriptive') self._http = session or requests.Session() self._http.headers['User-Agent'] = '{} prawcore/{}'.format( user_agent, __version__) self.oauth_url = oauth_url self.reddit_url = reddit_url def close(self): """Call close on the underlying session.""" return self._http.close() def request(self, *args, **kwargs): """Issue the HTTP request capturing any errors that may occur.""" try: return self._http.request(*args, timeout=TIMEOUT, **kwargs) except Exception as exc: raise RequestException(exc, args, kwargs) prawcore-0.13.0/prawcore/sessions.py0000644000076600000240000001730213215346007017516 0ustar bboestaff00000000000000"""prawcore.sessions: Provides prawcore.Session and prawcore.session.""" from copy import deepcopy import logging import random import time from requests.compat import urljoin from requests.exceptions import ChunkedEncodingError, ConnectionError from requests.status_codes import codes from .auth import BaseAuthorizer from .rate_limit import RateLimiter from .exceptions import (BadJSON, BadRequest, Conflict, InvalidInvocation, NotFound, Redirect, RequestException, ServerError, TooLarge, UnavailableForLegalReasons) from .util import authorization_error_class log = logging.getLogger(__package__) class Session(object): """The low-level connection interface to reddit's API.""" RETRY_EXCEPTIONS = (ChunkedEncodingError, ConnectionError) RETRY_STATUSES = {520, 522, codes['bad_gateway'], codes['gateway_timeout'], codes['internal_server_error'], codes['service_unavailable']} STATUS_EXCEPTIONS = {codes['bad_gateway']: ServerError, codes['bad_request']: BadRequest, codes['conflict']: Conflict, codes['found']: Redirect, codes['forbidden']: authorization_error_class, codes['gateway_timeout']: ServerError, codes['internal_server_error']: ServerError, codes['not_found']: NotFound, codes['request_entity_too_large']: TooLarge, codes['service_unavailable']: ServerError, codes['unauthorized']: authorization_error_class, codes['unavailable_for_legal_reasons']: UnavailableForLegalReasons, # CloudFlare status (not named in requests) 520: ServerError, 522: ServerError} SUCCESS_STATUSES = {codes['created'], codes['ok']} @staticmethod def _log_request(data, method, params, url): log.debug('Fetching: {} {}'.format(method, url)) log.debug('Data: {}'.format(data)) log.debug('Params: {}'.format(params)) @staticmethod def _retry_sleep(retries): if retries < 3: base = 0 if retries == 2 else 2 sleep_time = base + 2 * random.random() log.debug('Sleeping: {:0.2f} seconds'.format(sleep_time)) time.sleep(sleep_time) def __init__(self, authorizer): """Preprare the connection to reddit's API. :param authorizer: An instance of :class:`Authorizer`. """ if not isinstance(authorizer, BaseAuthorizer): raise InvalidInvocation('invalid Authorizer: {}' .format(authorizer)) self._authorizer = authorizer self._rate_limiter = RateLimiter() def __enter__(self): """Allow this object to be used as a context manager.""" return self def __exit__(self, *_args): """Allow this object to be used as a context manager.""" self.close() def _do_retry(self, data, files, json, method, params, response, retries, saved_exception, url): if saved_exception: status = repr(saved_exception) else: status = response.status_code log.warning('Retrying due to {} status: {} {}' .format(status, method, url)) return self._request_with_retries( data=data, files=files, json=json, method=method, params=params, url=url, retries=retries - 1) def _make_request(self, data, files, json, method, params, retries, url): try: response = self._rate_limiter.call( self._requestor.request, self._set_header_callback, method, url, allow_redirects=False, data=data, files=files, json=json, params=params) log.debug('Response: {} ({} bytes)'.format( response.status_code, response.headers.get('content-length'))) return response, None except RequestException as exception: if retries <= 1 or not isinstance(exception.original_exception, self.RETRY_EXCEPTIONS): raise return None, exception.original_exception def _request_with_retries(self, data, files, json, method, params, url, retries=3): self._retry_sleep(retries) self._log_request(data, method, params, url) response, saved_exception = self._make_request( data, files, json, method, params, retries, url) do_retry = False if response is not None and \ response.status_code == codes['unauthorized']: self._authorizer._clear_access_token() if hasattr(self._authorizer, 'refresh'): do_retry = True if retries > 1 and (do_retry or response is None or response.status_code in self.RETRY_STATUSES): return self._do_retry(data, files, json, method, params, response, retries, saved_exception, url) elif response.status_code in self.STATUS_EXCEPTIONS: raise self.STATUS_EXCEPTIONS[response.status_code](response) elif response.status_code == codes['no_content']: return assert response.status_code in self.SUCCESS_STATUSES, \ 'Unexpected status code: {}'.format(response.status_code) if response.headers.get('content-length') == '0': return '' try: return response.json() except ValueError: raise BadJSON(response) def _set_header_callback(self): if not self._authorizer.is_valid() and hasattr(self._authorizer, 'refresh'): self._authorizer.refresh() return {'Authorization': 'bearer {}' .format(self._authorizer.access_token)} @property def _requestor(self): return self._authorizer._authenticator._requestor def close(self): """Close the session and perform any clean up.""" self._requestor.close() def request(self, method, path, data=None, files=None, json=None, params=None): """Return the json content from the resource at ``path``. :param method: The request verb. E.g., get, post, put. :param path: The path of the request. This path will be combined with the ``oauth_url`` of the Requestor. :param data: Dictionary, bytes, or file-like object to send in the body of the request. :param files: Dictionary, mapping ``filename`` to file-like object. :param json: Object to be serialized to JSON in the body of the request. :param params: The query parameters to send with the request. Automatically refreshes the access token if it becomes invalid and a refresh token is available. Raises InvalidInvocation in such a case if a refresh token is not available. """ params = deepcopy(params) or {} params['raw_json'] = 1 if isinstance(data, dict): data = deepcopy(data) data['api_type'] = 'json' data = sorted(data.items()) url = urljoin(self._requestor.oauth_url, path) return self._request_with_retries( data=data, files=files, json=json, method=method, params=params, url=url) def session(authorizer=None): """Return a :class:`Session` instance. :param authorizer: An instance of :class:`Authorizer`. """ return Session(authorizer=authorizer) prawcore-0.13.0/prawcore/util.py0000644000076600000240000000126612751013006016621 0ustar bboestaff00000000000000"""Provide utility for the prawcore package.""" from .exceptions import Forbidden, InsufficientScope, InvalidToken _auth_error_mapping = {403: Forbidden, 'insufficient_scope': InsufficientScope, 'invalid_token': InvalidToken} def authorization_error_class(response): """Return an exception instance that maps to the OAuth Error. :param response: The HTTP response containing a www-authenticate error. """ message = response.headers.get('www-authenticate') if message: error = message.replace('"', '').rsplit('=', 1)[1] else: error = response.status_code return _auth_error_mapping[error](response) prawcore-0.13.0/prawcore.egg-info/0000755000076600000240000000000013215346026016766 5ustar bboestaff00000000000000prawcore-0.13.0/prawcore.egg-info/dependency_links.txt0000644000076600000240000000000113215346026023034 0ustar bboestaff00000000000000 prawcore-0.13.0/prawcore.egg-info/PKG-INFO0000644000076600000240000001027713215346026020072 0ustar bboestaff00000000000000Metadata-Version: 1.1 Name: prawcore Version: 0.13.0 Summary: Low-level communication layer for PRAW 4+. Home-page: https://github.com/praw-dev/prawcore Author: Bryce Boe Author-email: bbzbryce@gmail.com License: Simplified BSD License Description: .. _main_page: prawcore ======== .. image:: https://img.shields.io/pypi/v/prawcore.svg :alt: Latest prawcore Version :target: https://pypi.python.org/pypi/prawcore .. image:: https://travis-ci.org/praw-dev/prawcore.svg?branch=master :target: https://travis-ci.org/praw-dev/prawcore .. image:: https://coveralls.io/repos/github/praw-dev/prawcore/badge.svg?branch=master :target: https://coveralls.io/github/praw-dev/prawcore?branch=master .. image:: https://badges.gitter.im/praw-dev/praw.svg :alt: Join the chat at https://gitter.im/praw-dev/praw :target: https://gitter.im/praw-dev/praw prawcore is a low-level communication layer for PRAW 4+. Installation ------------ Install prawcore using ``pip`` via: .. code-block:: console pip install prawcore Execution Example ----------------- The following example demonstrates how to use prawcore to obtain the list of trophies for a given user using the script-app type. This example assumes you have the environment variables ``PRAWCORE_CLIENT_ID`` and ``PRAWCORE_CLIENT_SECRET`` set to the appropriate values for your application. .. code-block:: python #!/usr/bin/env python import os import pprint import prawcore authenticator = prawcore.TrustedAuthenticator( prawcore.Requestor('YOUR_VALID_USER_AGENT'), os.environ['PRAWCORE_CLIENT_ID'], os.environ['PRAWCORE_CLIENT_SECRET']) authorizer = prawcore.ReadOnlyAuthorizer(authenticator) authorizer.refresh() with prawcore.session(authorizer) as session: pprint.pprint(session.request('GET', '/api/v1/user/bboe/trophies')) Save the above as ``trophies.py`` and then execute via: .. code-block:: console python trophies.py Additional examples can be found at: https://github.com/praw-dev/prawcore/tree/master/examples Depending on prawcore --------------------- prawcore follows `semantic versioning `_ with the exception that deprecations will not be preceded by a minor release. In essense, expect only major versions to introduce breaking changes to prawcore's public interface. As a result, if you depend on prawcore then it is a good idea to specify not only the minimum version of prawcore your package requires, but to also limit the major version. Below are two examples of how you may want to specify your prawcore dependency: setup.py ~~~~~~~~ .. code-block:: python setup(..., install_requires=['prawcore >=0.1, <1'], ...) requirements.txt ~~~~~~~~~~~~~~~~ .. code-block:: text prawcore >=1.5.1, <2 Keywords: praw reddit api Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: BSD License Classifier: Operating System :: OS Independent Classifier: Natural Language :: English Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: Implementation :: CPython prawcore-0.13.0/prawcore.egg-info/requires.txt0000644000076600000240000000002513215346026021363 0ustar bboestaff00000000000000requests<3.0,>=2.6.0 prawcore-0.13.0/prawcore.egg-info/SOURCES.txt0000644000076600000240000000064113215346026020653 0ustar bboestaff00000000000000AUTHORS.rst CHANGES.rst CODE_OF_CONDUCT.md LICENSE.txt MANIFEST.in README.rst setup.cfg setup.py prawcore/__init__.py prawcore/auth.py prawcore/const.py prawcore/exceptions.py prawcore/rate_limit.py prawcore/requestor.py prawcore/sessions.py prawcore/util.py prawcore.egg-info/PKG-INFO prawcore.egg-info/SOURCES.txt prawcore.egg-info/dependency_links.txt prawcore.egg-info/requires.txt prawcore.egg-info/top_level.txtprawcore-0.13.0/prawcore.egg-info/top_level.txt0000644000076600000240000000001113215346026021510 0ustar bboestaff00000000000000prawcore prawcore-0.13.0/README.rst0000644000076600000240000000505612751563037015156 0ustar bboestaff00000000000000.. _main_page: prawcore ======== .. image:: https://img.shields.io/pypi/v/prawcore.svg :alt: Latest prawcore Version :target: https://pypi.python.org/pypi/prawcore .. image:: https://travis-ci.org/praw-dev/prawcore.svg?branch=master :target: https://travis-ci.org/praw-dev/prawcore .. image:: https://coveralls.io/repos/github/praw-dev/prawcore/badge.svg?branch=master :target: https://coveralls.io/github/praw-dev/prawcore?branch=master .. image:: https://badges.gitter.im/praw-dev/praw.svg :alt: Join the chat at https://gitter.im/praw-dev/praw :target: https://gitter.im/praw-dev/praw prawcore is a low-level communication layer for PRAW 4+. Installation ------------ Install prawcore using ``pip`` via: .. code-block:: console pip install prawcore Execution Example ----------------- The following example demonstrates how to use prawcore to obtain the list of trophies for a given user using the script-app type. This example assumes you have the environment variables ``PRAWCORE_CLIENT_ID`` and ``PRAWCORE_CLIENT_SECRET`` set to the appropriate values for your application. .. code-block:: python #!/usr/bin/env python import os import pprint import prawcore authenticator = prawcore.TrustedAuthenticator( prawcore.Requestor('YOUR_VALID_USER_AGENT'), os.environ['PRAWCORE_CLIENT_ID'], os.environ['PRAWCORE_CLIENT_SECRET']) authorizer = prawcore.ReadOnlyAuthorizer(authenticator) authorizer.refresh() with prawcore.session(authorizer) as session: pprint.pprint(session.request('GET', '/api/v1/user/bboe/trophies')) Save the above as ``trophies.py`` and then execute via: .. code-block:: console python trophies.py Additional examples can be found at: https://github.com/praw-dev/prawcore/tree/master/examples Depending on prawcore --------------------- prawcore follows `semantic versioning `_ with the exception that deprecations will not be preceded by a minor release. In essense, expect only major versions to introduce breaking changes to prawcore's public interface. As a result, if you depend on prawcore then it is a good idea to specify not only the minimum version of prawcore your package requires, but to also limit the major version. Below are two examples of how you may want to specify your prawcore dependency: setup.py ~~~~~~~~ .. code-block:: python setup(..., install_requires=['prawcore >=0.1, <1'], ...) requirements.txt ~~~~~~~~~~~~~~~~ .. code-block:: text prawcore >=1.5.1, <2 prawcore-0.13.0/setup.cfg0000644000076600000240000000007513215346026015275 0ustar bboestaff00000000000000[wheel] universal = 1 [egg_info] tag_build = tag_date = 0 prawcore-0.13.0/setup.py0000644000076600000240000000341413151703705015166 0ustar bboestaff00000000000000"""prawcore setup.py.""" import re from codecs import open from os import path from setuptools import setup PACKAGE_NAME = 'prawcore' 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("__version__ = '([^']+)'", fp.read()).group(1) setup(name=PACKAGE_NAME, author='Bryce Boe', author_email='bbzbryce@gmail.com', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Natural Language :: English', 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: Implementation :: CPython'], description='Low-level communication layer for PRAW 4+.', install_requires=['requests >=2.6.0, <3.0'], keywords='praw reddit api', license='Simplified BSD License', long_description=README, packages=[PACKAGE_NAME], tests_require=['betamax >=0.8, <0.9', 'betamax_matchers >=0.4.0, <0.5', 'betamax-serializers >=0.2.0, <0.3', 'mock >=0.8, <3', 'testfixtures >4.13.2, <6'], test_suite='tests', url='https://github.com/praw-dev/prawcore', version=VERSION)