-
Notifications
You must be signed in to change notification settings - Fork 5
Refactor: Add api client class #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
113a8b9
Add HttpClient class
glensc 15acd61
Add timeout to HttpClient
glensc 84b8673
Add Config class
glensc 153d7bb
Add config factory to core
glensc 46687f0
Export config factory
glensc f2ea711
Add TokenAuth class
glensc 155a85d
Add api factory
glensc ff737a2
Export api factory
glensc 24795af
Add BaseAdapter class
glensc 72a2d78
Add OAuthAdapter adapter
glensc 54fd899
Add PinAuthAdapter adapter
glensc 3d1e23d
Add DeviceAuthAdapter module
glensc 38b3d70
Add auth module
glensc 9b45253
Add decorators module
glensc cc0d449
Export decorators for backward compat
glensc df32767
Use updated init_auth in core.init()
glensc 3e5ad90
Drop unused Core class
glensc 7951438
Drop unused auth methods from core
glensc 1e12732
Cleanup unused imports in core
glensc 41ffb5a
Drop unused core constants
glensc c1790a8
Update MockCore to use HttpClient
glensc 0b1d0f2
Update base version to 4.0.0
glensc File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| __version__ = "3.4.0.dev0" | ||
| __version__ = "4.0.0.dev0" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| import json | ||
| import logging | ||
| from datetime import datetime, timedelta, timezone | ||
| from functools import lru_cache | ||
| from json import JSONDecodeError | ||
|
|
||
| from requests import Session | ||
| from requests.auth import AuthBase | ||
|
|
||
| from trakt import errors | ||
| from trakt.config import AuthConfig | ||
| from trakt.core import TIMEOUT | ||
| from trakt.errors import BadResponseException, OAuthException | ||
|
|
||
| __author__ = 'Elan Ruusamäe' | ||
|
|
||
|
|
||
| class HttpClient: | ||
| """Class for abstracting HTTP requests | ||
| """ | ||
|
|
||
| #: Default request HEADERS | ||
| headers = {'Content-Type': 'application/json', 'trakt-api-version': '2'} | ||
|
|
||
| def __init__(self, base_url: str, session: Session, timeout=None): | ||
| self.base_url = base_url | ||
| self.session = session | ||
| self.auth = None | ||
| self.timeout = timeout or TIMEOUT | ||
| self.logger = logging.getLogger('trakt.http_client') | ||
|
|
||
| def get(self, url: str): | ||
| return self.request('get', url) | ||
|
|
||
| def delete(self, url: str): | ||
| self.request('delete', url) | ||
|
|
||
| def post(self, url: str, data): | ||
| return self.request('post', url, data=data) | ||
|
|
||
| def put(self, url: str, data): | ||
| return self.request('put', url, data=data) | ||
|
|
||
| def set_auth(self, auth): | ||
| self.auth = auth | ||
|
|
||
| def request(self, method, url, data=None): | ||
| """Handle actually talking out to the trakt API, logging out debug | ||
| information, raising any relevant `TraktException` Exception types, | ||
| and extracting and returning JSON data | ||
|
|
||
| :param method: The HTTP method we're executing on. Will be one of | ||
| post, put, delete, get | ||
| :param url: The fully qualified url to send our request to | ||
| :param data: Optional data payload to send to the API | ||
| :return: The decoded JSON response from the Trakt API | ||
| :raises TraktException: If any non-200 return code is encountered | ||
| """ | ||
|
|
||
| url = self.base_url + url | ||
| self.logger.debug('REQUEST [%s] (%s)', method, url) | ||
| if method == 'get': # GETs need to pass data as params, not body | ||
| response = self.session.request(method, url, headers=self.headers, auth=self.auth, timeout=self.timeout, params=data) | ||
| else: | ||
| response = self.session.request(method, url, headers=self.headers, auth=self.auth, timeout=self.timeout, data=json.dumps(data)) | ||
| self.logger.debug('RESPONSE [%s] (%s): %s', method, url, str(response)) | ||
| if response.status_code == 204: # HTTP no content | ||
| return None | ||
| self.raise_if_needed(response) | ||
|
|
||
| return self.decode_response(response) | ||
|
|
||
| @staticmethod | ||
| def decode_response(response): | ||
| try: | ||
| return json.loads(response.content.decode('UTF-8', 'ignore')) | ||
| except JSONDecodeError as e: | ||
| raise BadResponseException(f"Unable to parse JSON: {e}") | ||
|
|
||
| def raise_if_needed(self, response): | ||
| if response.status_code in self.error_map: | ||
| raise self.error_map[response.status_code](response) | ||
|
|
||
| @property | ||
| @lru_cache(maxsize=None) | ||
| def error_map(self): | ||
| """Map HTTP response codes to exception types | ||
| """ | ||
|
|
||
| # Get all of our exceptions except the base exception | ||
| errs = [getattr(errors, att) for att in errors.__all__ | ||
| if att != 'TraktException'] | ||
|
|
||
| return {err.http_code: err for err in errs} | ||
|
|
||
|
|
||
| class TokenAuth(AuthBase): | ||
| """Attaches Trakt.tv token Authentication to the given Request object.""" | ||
|
|
||
| #: The OAuth2 Redirect URI for your OAuth Application | ||
| REDIRECT_URI: str = 'urn:ietf:wg:oauth:2.0:oob' | ||
|
|
||
| def __init__(self, client: HttpClient, config: AuthConfig): | ||
| super().__init__() | ||
| self.config = config | ||
| self.client = client | ||
| # OAuth token validity checked | ||
| self.OAUTH_TOKEN_VALID = None | ||
| self.logger = logging.getLogger('trakt.api.token_auth') | ||
|
|
||
| def __call__(self, r): | ||
| # Skip oauth requests | ||
| if r.path_url.startswith('/oauth/'): | ||
| return r | ||
|
|
||
| [client_id, client_token] = self.get_token() | ||
|
|
||
| r.headers.update({ | ||
| 'trakt-api-key': client_id, | ||
| 'Authorization': f'Bearer {client_token}', | ||
| }) | ||
| return r | ||
|
|
||
| def get_token(self): | ||
| """Return client_id, client_token pair needed for Trakt.tv authentication | ||
| """ | ||
|
|
||
| self.config.load() | ||
| # Check token validity and refresh token if needed | ||
| if not self.OAUTH_TOKEN_VALID and self.config.have_refresh_token(): | ||
| self.validate_token() | ||
|
|
||
| return [ | ||
| self.config.CLIENT_ID, | ||
| self.config.OAUTH_TOKEN, | ||
| ] | ||
|
|
||
| def validate_token(self): | ||
| """Check if current OAuth token has not expired""" | ||
|
|
||
| current = datetime.now(tz=timezone.utc) | ||
| expires_at = datetime.fromtimestamp(self.config.OAUTH_EXPIRES_AT, tz=timezone.utc) | ||
| if expires_at - current > timedelta(days=2): | ||
| self.OAUTH_TOKEN_VALID = True | ||
| else: | ||
| self.refresh_token() | ||
|
|
||
| def refresh_token(self): | ||
| """Request Trakt API for a new valid OAuth token using refresh_token""" | ||
|
|
||
| self.logger.info("OAuth token has expired, refreshing now...") | ||
| data = { | ||
| 'client_id': self.config.CLIENT_ID, | ||
| 'client_secret': self.config.CLIENT_SECRET, | ||
| 'refresh_token': self.config.OAUTH_REFRESH, | ||
| 'redirect_uri': self.REDIRECT_URI, | ||
| 'grant_type': 'refresh_token' | ||
| } | ||
|
|
||
| try: | ||
| response = self.client.post('/oauth/token', data) | ||
| except OAuthException: | ||
| self.logger.debug( | ||
| "Rejected - Unable to refresh expired OAuth token, " | ||
| "refresh_token is invalid" | ||
| ) | ||
| return | ||
|
|
||
| self.config.update( | ||
| OAUTH_TOKEN=response.get("access_token"), | ||
| OAUTH_REFRESH=response.get("refresh_token"), | ||
| OAUTH_EXPIRES_AT=response.get("created_at") + response.get("expires_in"), | ||
| ) | ||
| self.OAUTH_TOKEN_VALID = True | ||
|
|
||
| self.logger.info( | ||
| "OAuth token successfully refreshed, valid until {}".format( | ||
| datetime.fromtimestamp(self.config.OAUTH_EXPIRES_AT, tz=timezone.utc) | ||
| ) | ||
| ) | ||
| self.config.store() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| # -*- coding: utf-8 -*- | ||
| """Authentication methods""" | ||
|
|
||
| __author__ = 'Jon Nappi, Elan Ruusamäe' | ||
|
|
||
| from trakt import DEVICE_AUTH, OAUTH_AUTH, PIN_AUTH, api | ||
| from trakt import config as config_factory | ||
| from trakt.config import AuthConfig | ||
|
|
||
|
|
||
| def pin_auth(*args, config, **kwargs): | ||
| from trakt.auth.pin import PinAuthAdapter | ||
|
|
||
| return PinAuthAdapter(*args, client=api(), config=config, **kwargs).authenticate() | ||
|
|
||
|
|
||
| def oauth_auth(*args, config, **kwargs): | ||
| from trakt.auth.oauth import OAuthAdapter | ||
|
|
||
| return OAuthAdapter(*args, client=api(), config=config, **kwargs).authenticate() | ||
|
|
||
|
|
||
| def device_auth(config): | ||
| from trakt.auth.device import DeviceAuthAdapter | ||
|
|
||
| return DeviceAuthAdapter(client=api(), config=config).authenticate() | ||
|
|
||
|
|
||
| def get_client_info(app_id: bool, config: AuthConfig): | ||
| """Helper function to poll the user for Client ID and Client Secret | ||
| strings | ||
|
|
||
| :return: A 2-tuple of client_id, client_secret | ||
| """ | ||
| print('If you do not have a client ID and secret. Please visit the ' | ||
| 'following url to create them.') | ||
| print('https://trakt.tv/oauth/applications') | ||
| client_id = input('Please enter your client id: ') | ||
| client_secret = input('Please enter your client secret: ') | ||
| if app_id: | ||
| msg = f'Please enter your application ID ({config.APPLICATION_ID}): ' | ||
| user_input = input(msg) | ||
| if user_input: | ||
| config.APPLICATION_ID = user_input | ||
| return client_id, client_secret | ||
|
|
||
|
|
||
| def init_auth(method: str, *args, client_id=None, client_secret=None, store=False, **kwargs): | ||
| """Run the auth function specified by *AUTH_METHOD* | ||
|
|
||
| :param store: Boolean flag used to determine if your trakt api auth data | ||
| should be stored locally on the system. Default is :const:`False` for | ||
| the security conscious | ||
| """ | ||
|
|
||
| methods = { | ||
| PIN_AUTH: pin_auth, | ||
| OAUTH_AUTH: oauth_auth, | ||
| DEVICE_AUTH: device_auth, | ||
| } | ||
|
|
||
| config = config_factory() | ||
| adapter = methods.get(method, PIN_AUTH) | ||
|
|
||
| """ | ||
| Update client_id, client_secret from input or ask them interactively | ||
| """ | ||
| if client_id is None and client_secret is None: | ||
| client_id, client_secret = get_client_info(adapter.NEEDS_APPLICATION_ID, config) | ||
| config.CLIENT_ID, config.CLIENT_SECRET = client_id, client_secret | ||
|
|
||
| adapter(*args, config=config, **kwargs) | ||
|
|
||
| if store: | ||
| config.store() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| class BaseAdapter: | ||
| #: The OAuth2 Redirect URI for your OAuth Application | ||
| REDIRECT_URI: str = 'urn:ietf:wg:oauth:2.0:oob' | ||
|
|
||
| #: True if the Adapter needs APPLICATION_ID | ||
| NEEDS_APPLICATION_ID = False |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Improve exception handling on OAuth refresh failures
If the refresh token is invalid or expired, the
refresh_tokenmethod logs a debug message but continues without raising an error. This could cause the calling code to proceed incorrectly. Consider raising a specific exception to alert the caller that the refresh sequence failed.