Added cache handler to SpotifyClientCredentials and fixed a bug in refresh tokens methods that raised the wrong exception (#655)

* Added an exception clause that catches `FileNotFoundError` and logs a debug message in `SpotifyOAuth.get_cached_token`, `SpotifyPKCE.get_cached_token` and `SpotifyImplicitGrant.get_cached_token`.

* Changed docs for `auth` parameter of `Spotify.init` to `access token` instead of `authorization token`. In issue #599, a user confused the access token with the authorization code.

* Updated CHANGELOG.md

* Removed `FileNotFoundError` because it does not exist in python 2.7 (*sigh*) and replaced it with a call to `os.path.exists`.

* Replaced ` os.path.exists` with `error.errno == errno.ENOENT` to supress errors when the cache file does not exist.

* Changed docs for `search` to mention that you can provide multiple multiple types to search for. The query parameters of requests are now logged. Added log messages for when the access token and refresh tokens are retrieved and when they are refreshed. Other small grammar fixes.

* Removed duplicate word "multiple" from CHANGELOG

* * Fixed the bugs in `SpotifyOAuth.refresh_access_token` and `SpotifyPKCE.refresh_access_token` which raised the incorrect exception upon receiving an error response from the server. This addresses #645.
* Fixed a bug in `RequestHandler.do_GET` in which the non-existent `state` attribute of  `SpotifyOauthError` is accessed. This bug occurs when the user clicks "cancel" in the permissions dialog that opens in the browser.
* Cleaned up the documentation for `SpotifyClientCredentials.__init__`, `SpotifyOAuth.__init__`, and `SpotifyPKCE.__init__`.

* Removed unneeded import

Co-authored-by: Stéphane Bruckert <stephane.bruckert@gmail.com>
This commit is contained in:
Peter Schorn 2021-03-13 07:34:52 -06:00 committed by GitHub
parent cb5c61881e
commit dc89a00113
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 86 additions and 58 deletions

View File

@ -5,15 +5,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## Unreleased
### Added ### Added
- Enabled using both short and long IDs for playlist_change_details - Enabled using both short and long IDs for playlist_change_details
- Added a cache handler to `SpotifyClientCredentials`
### Changed ### Changed
- Add support for a list of scopes rather than just a comma separated string of scopes - Add support for a list of scopes rather than just a comma separated string of scopes
### Fixed
* Fixed the bugs in `SpotifyOAuth.refresh_access_token` and `SpotifyPKCE.refresh_access_token` which raised the incorrect exception upon receiving an error response from the server. This addresses #645.
* Fixed a bug in `RequestHandler.do_GET` in which the non-existent `state` attribute of `SpotifyOauthError` is accessed. This bug occurs when the user clicks "cancel" in the permissions dialog that opens in the browser.
* Cleaned up the documentation for `SpotifyClientCredentials.__init__`, `SpotifyOAuth.__init__`, and `SpotifyPKCE.__init__`.
## [2.17.1] - 2021-02-28 ## [2.17.1] - 2021-02-28
### Fixed ### Fixed

View File

@ -24,7 +24,6 @@ from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
from six.moves.urllib_parse import parse_qsl, urlparse from six.moves.urllib_parse import parse_qsl, urlparse
from spotipy.cache_handler import CacheFileHandler, CacheHandler from spotipy.cache_handler import CacheFileHandler, CacheHandler
from spotipy.exceptions import SpotifyException
from spotipy.util import CLIENT_CREDS_ENV_VARS, get_host_port, normalize_scope from spotipy.util import CLIENT_CREDS_ENV_VARS, get_host_port, normalize_scope
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -139,27 +138,57 @@ class SpotifyAuthBase(object):
class SpotifyClientCredentials(SpotifyAuthBase): class SpotifyClientCredentials(SpotifyAuthBase):
OAUTH_TOKEN_URL = "https://accounts.spotify.com/api/token" OAUTH_TOKEN_URL = "https://accounts.spotify.com/api/token"
def __init__(self, def __init__(
self,
client_id=None, client_id=None,
client_secret=None, client_secret=None,
proxies=None, proxies=None,
requests_session=True, requests_session=True,
requests_timeout=None): requests_timeout=None,
cache_handler=None
):
""" """
Creates a Client Credentials Flow Manager.
The Client Credentials flow is used in server-to-server authentication.
Only endpoints that do not access user information can be accessed.
This means that endpoints that require authorization scopes cannot be accessed.
The advantage, however, of this authorization flow is that it does not require any
user interaction
You can either provide a client_id and client_secret to the You can either provide a client_id and client_secret to the
constructor or set SPOTIPY_CLIENT_ID and SPOTIPY_CLIENT_SECRET constructor or set SPOTIPY_CLIENT_ID and SPOTIPY_CLIENT_SECRET
environment variables environment variables
Parameters:
* client_id: Must be supplied or set as environment variable
* client_secret: Must be supplied or set as environment variable
* proxies: Optional, proxy for the requests library to route through
* requests_session: A Requests session
* requests_timeout: Optional, tell Requests to stop waiting for a response after
a given number of seconds
* cache_handler: An instance of the `CacheHandler` class to handle
getting and saving cached authorization tokens.
Optional, will otherwise use `CacheFileHandler`.
(takes precedence over `cache_path` and `username`)
""" """
super(SpotifyClientCredentials, self).__init__(requests_session) super(SpotifyClientCredentials, self).__init__(requests_session)
self.client_id = client_id self.client_id = client_id
self.client_secret = client_secret self.client_secret = client_secret
self.token_info = None
self.proxies = proxies self.proxies = proxies
self.requests_timeout = requests_timeout self.requests_timeout = requests_timeout
if cache_handler:
assert issubclass(cache_handler.__class__, CacheHandler), \
"cache_handler must be a subclass of CacheHandler: " + str(type(cache_handler)) \
+ " != " + str(CacheHandler)
self.cache_handler = cache_handler
else:
self.cache_handler = CacheFileHandler()
def get_access_token(self, as_dict=True): def get_access_token(self, as_dict=True, check_cache=True):
""" """
If a valid access token is in memory, returns it If a valid access token is in memory, returns it
Else feches a new token and returns it Else feches a new token and returns it
@ -179,13 +208,15 @@ class SpotifyClientCredentials(SpotifyAuthBase):
stacklevel=2, stacklevel=2,
) )
if self.token_info and not self.is_token_expired(self.token_info): if check_cache:
return self.token_info if as_dict else self.token_info["access_token"] token_info = self.cache_handler.get_cached_token()
if token_info and not self.is_token_expired(token_info):
return token_info if as_dict else token_info["access_token"]
token_info = self._request_access_token() token_info = self._request_access_token()
token_info = self._add_custom_values_to_token_info(token_info) token_info = self._add_custom_values_to_token_info(token_info)
self.token_info = token_info self.cache_handler.save_token_to_cache(token_info)
return self.token_info["access_token"] return token_info if as_dict else token_info["access_token"]
def _request_access_token(self): def _request_access_token(self):
"""Gets client credentials access token """ """Gets client credentials access token """
@ -260,20 +291,21 @@ class SpotifyOAuth(SpotifyAuthBase):
* state: Optional, no verification is performed * state: Optional, no verification is performed
* scope: Optional, either a list of scopes or comma separated string of scopes. * scope: Optional, either a list of scopes or comma separated string of scopes.
e.g, "playlist-read-private,playlist-read-collaborative" e.g, "playlist-read-private,playlist-read-collaborative"
* cache_handler: An instance of the `CacheHandler` class to handle
getting and saving cached authorization tokens.
Optional, will otherwise use `CacheFileHandler`.
(takes precedence over `cache_path` and `username`)
* cache_path: (deprecated) Optional, will otherwise be generated * cache_path: (deprecated) Optional, will otherwise be generated
(takes precedence over `username`) (takes precedence over `username`)
* username: (deprecated) Optional or set as environment variable * username: (deprecated) Optional or set as environment variable
(will set `cache_path` to `.cache-{username}`) (will set `cache_path` to `.cache-{username}`)
* show_dialog: Optional, interpreted as boolean
* proxies: Optional, proxy for the requests library to route through * proxies: Optional, proxy for the requests library to route through
* show_dialog: Optional, interpreted as boolean
* requests_session: A Requests session
* requests_timeout: Optional, tell Requests to stop waiting for a response after * requests_timeout: Optional, tell Requests to stop waiting for a response after
a given number of seconds a given number of seconds
* open_browser: Optional, whether or not the web browser should be opened to * open_browser: Optional, whether or not the web browser should be opened to
authorize a user authorize a user
* cache_handler: An instance of the `CacheHandler` class to handle
getting and saving cached authorization tokens.
Optional, will otherwise use `CacheFileHandler`.
(takes precedence over `cache_path` and `username`)
""" """
super(SpotifyOAuth, self).__init__(requests_session) super(SpotifyOAuth, self).__init__(requests_session)
@ -414,7 +446,7 @@ class SpotifyOAuth(SpotifyAuthBase):
if server.auth_code is not None: if server.auth_code is not None:
return server.auth_code return server.auth_code
elif server.error is not None: elif server.error is not None:
raise SpotifyOauthError("Received error from OAuth server: {}".format(server.error)) raise server.error
else: else:
raise SpotifyOauthError("Server listening on localhost has not been accessed") raise SpotifyOauthError("Server listening on localhost has not been accessed")
@ -432,7 +464,7 @@ class SpotifyOAuth(SpotifyAuthBase):
open_browser = self.open_browser open_browser = self.open_browser
if ( if (
(open_browser or self.open_browser) open_browser
and redirect_host in ("127.0.0.1", "localhost") and redirect_host in ("127.0.0.1", "localhost")
and redirect_info.scheme == "http" and redirect_info.scheme == "http"
): ):
@ -539,20 +571,13 @@ class SpotifyOAuth(SpotifyAuthBase):
timeout=self.requests_timeout, timeout=self.requests_timeout,
) )
try: if response.status_code != 200:
response.raise_for_status() error_payload = response.json()
except BaseException: raise SpotifyOauthError(
logger.error('Couldn\'t refresh token. Response Status Code: %s ' 'error: {0}, error_description: {1}'.format(
'Reason: %s', response.status_code, response.reason) error_payload['error'], error_payload['error_description']),
error=error_payload['error'],
message = "Couldn't refresh token: code:%d reason:%s" % ( error_description=error_payload['error_description'])
response.status_code,
response.reason,
)
raise SpotifyException(response.status_code,
-1,
message,
headers)
token_info = response.json() token_info = response.json()
token_info = self._add_custom_values_to_token_info(token_info) token_info = self._add_custom_values_to_token_info(token_info)
@ -623,25 +648,24 @@ class SpotifyPKCE(SpotifyAuthBase):
Parameters: Parameters:
* client_id: Must be supplied or set as environment variable * client_id: Must be supplied or set as environment variable
* client_secret: Must be supplied or set as environment variable
* redirect_uri: Must be supplied or set as environment variable * redirect_uri: Must be supplied or set as environment variable
* state: Optional, no verification is performed * state: Optional, no verification is performed
* scope: Optional, either a list of scopes or comma separated string of scopes. * scope: Optional, either a list of scopes or comma separated string of scopes.
e.g, "playlist-read-private,playlist-read-collaborative" e.g, "playlist-read-private,playlist-read-collaborative"
* cache_handler: An instance of the `CacheHandler` class to handle
getting and saving cached authorization tokens.
Optional, will otherwise use `CacheFileHandler`.
(takes precedence over `cache_path` and `username`)
* cache_path: (deprecated) Optional, will otherwise be generated * cache_path: (deprecated) Optional, will otherwise be generated
(takes precedence over `username`) (takes precedence over `username`)
* username: (deprecated) Optional or set as environment variable * username: (deprecated) Optional or set as environment variable
(will set `cache_path` to `.cache-{username}`) (will set `cache_path` to `.cache-{username}`)
* show_dialog: Optional, interpreted as boolean
* proxies: Optional, proxy for the requests library to route through * proxies: Optional, proxy for the requests library to route through
* requests_timeout: Optional, tell Requests to stop waiting for a response after * requests_timeout: Optional, tell Requests to stop waiting for a response after
a given number of seconds a given number of seconds
* requests_session: A Requests session
* open_browser: Optional, thether or not the web browser should be opened to * open_browser: Optional, thether or not the web browser should be opened to
authorize a user authorize a user
* cache_handler: An instance of the `CacheHandler` class to handle
getting and saving cached authorization tokens.
Optional, will otherwise use `CacheFileHandler`.
(takes precedence over `cache_path` and `username`)
""" """
super(SpotifyPKCE, self).__init__(requests_session) super(SpotifyPKCE, self).__init__(requests_session)
@ -921,20 +945,13 @@ class SpotifyPKCE(SpotifyAuthBase):
timeout=self.requests_timeout, timeout=self.requests_timeout,
) )
try: if response.status_code != 200:
response.raise_for_status() error_payload = response.json()
except BaseException: raise SpotifyOauthError(
logger.error('Couldn\'t refresh token. Response Status Code: %s ' 'error: {0}, error_description: {1}'.format(
'Reason: %s', response.status_code, response.reason) error_payload['error'], error_payload['error_description']),
error=error_payload['error'],
message = "Couldn't refresh token: code:%d reason:%s" % ( error_description=error_payload['error_description'])
response.status_code,
response.reason,
)
raise SpotifyException(response.status_code,
-1,
message,
headers)
token_info = response.json() token_info = response.json()
token_info = self._add_custom_values_to_token_info(token_info) token_info = self._add_custom_values_to_token_info(token_info)
@ -1246,9 +1263,8 @@ class RequestHandler(BaseHTTPRequestHandler):
state, auth_code = SpotifyOAuth.parse_auth_response_url(self.path) state, auth_code = SpotifyOAuth.parse_auth_response_url(self.path)
self.server.state = state self.server.state = state
self.server.auth_code = auth_code self.server.auth_code = auth_code
except SpotifyOauthError as err: except SpotifyOauthError as error:
self.server.state = err.state self.server.error = error
self.server.error = err.error
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/html") self.send_header("Content-Type", "text/html")