Skip to content

Commit

Permalink
V3 (#665)
Browse files Browse the repository at this point in the history
* 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

* 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 <[email protected]>

* Made `CacheHandler` an abstract base class

Added:

* `Scope` - An enum which contains all of the authorization scopes (see [here](#652 (comment))).

* Added the following endpoints
    * `Spotify.current_user_saved_episodes`
    * `Spotify.current_user_saved_episodes_add`
    * `Spotify.current_user_saved_episodes_delete`
    * `Spotify.current_user_saved_episodes_contains`
    * `Spotify.available_markets

* Fixed formatting issues. Removed python 2.7 from github workflows.

* Added python 3.9 to github workflows. The type hints for set now uses the generic typing.Set instead of builtins.set.

* Changed f-string to percent-formatted string.

* Fixed the duplicate "###Changed" section in the change log.

Co-authored-by: Stéphane Bruckert <[email protected]>
  • Loading branch information
2 people authored and dieser-niko committed Oct 8, 2024
1 parent 39650ec commit 3f5eeca
Show file tree
Hide file tree
Showing 7 changed files with 232 additions and 48 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/pythonapp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ jobs:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
Expand Down
1 change: 1 addition & 0 deletions spotipy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
from .exceptions import * # noqa
from .oauth2 import * # noqa
from .util import * # noqa
from .scope import * # noqa
15 changes: 7 additions & 8 deletions spotipy/cache_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,32 @@
from spotipy.util import CLIENT_CREDS_ENV_VARS

from redis import RedisError
from abc import ABC, abstractmethod

logger = logging.getLogger(__name__)


class CacheHandler():
class CacheHandler(ABC):
"""
An abstraction layer for handling the caching and retrieval of
authorization tokens.
Custom extensions of this class must implement get_cached_token
and save_token_to_cache methods with the same input and output
structure as the CacheHandler class.
Clients are expected to subclass this class and override the
get_cached_token and save_token_to_cache methods with the same
type signatures of this class.
"""

@abstractmethod
def get_cached_token(self):
"""
Get and return a token_info dictionary object.
"""
# return token_info
raise NotImplementedError()

@abstractmethod
def save_token_to_cache(self, token_info):
"""
Save a token_info dictionary object to the cache and return None.
"""
raise NotImplementedError()
return None


class CacheFileHandler(CacheHandler):
Expand Down
50 changes: 32 additions & 18 deletions spotipy/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ def _ensure_value(value, env_key):
env_val = CLIENT_CREDS_ENV_VARS[env_key]
_val = value or os.getenv(env_val)
if _val is None:
msg = f"No {env_key}. Pass it or set a {env_val} environment variable."
msg = "No %s. Pass it or set a %s environment variable." % (
env_key,
env_val,
)
raise SpotifyOauthError(msg)
return _val

Expand Down Expand Up @@ -115,7 +118,9 @@ def _handle_oauth_error(self, http_error):
error_description = None

raise SpotifyOauthError(
f'error: {error}, error_description: {error_description}',
'error: {0}, error_description: {1}'.format(
error, error_description
),
error=error,
error_description=error_description
)
Expand Down Expand Up @@ -165,7 +170,7 @@ def __init__(
"""

super().__init__(requests_session)
super(SpotifyClientCredentials, self).__init__(requests_session)

self.client_id = client_id
self.client_secret = client_secret
Expand Down Expand Up @@ -288,15 +293,15 @@ def __init__(
* requests_session: A Requests session
* requests_timeout: Optional, tell Requests to stop waiting for a response after
a given number of seconds
* open_browser: Optional, whether the web browser should be opened to
* open_browser: Optional, whether or not the web browser should be opened to
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().__init__(requests_session)
super(SpotifyOAuth, self).__init__(requests_session)

self.client_id = client_id
self.client_secret = client_secret
Expand Down Expand Up @@ -371,7 +376,7 @@ def get_authorize_url(self, state=None):

urlparams = urllibparse.urlencode(payload)

return f"{self.OAUTH_AUTHORIZE_URL}?{urlparams}"
return "%s?%s" % (self.OAUTH_AUTHORIZE_URL, urlparams)

def parse_response_code(self, url):
""" Parse the response code in the given response url
Expand All @@ -390,7 +395,8 @@ def parse_auth_response_url(url):
query_s = urlparse(url).query
form = dict(parse_qsl(query_s))
if "error" in form:
raise SpotifyOauthError(f"Received error from auth server: {form['error']}",
raise SpotifyOauthError("Received error from auth server: "
"{}".format(form["error"]),
error=form["error"])
return tuple(form.get(param) for param in ["state", "code"])

Expand Down Expand Up @@ -597,7 +603,7 @@ class SpotifyPKCE(SpotifyAuthBase):
""" Implements PKCE Authorization Flow for client apps
This auth manager enables *user and non-user* endpoints with only
a client ID, redirect URI, and username. When the app requests
a client secret, redirect uri, and username. When the app requests
an access token for the first time, the user is prompted to
authorize the new client app. After authorizing the app, the client
app is then given both access and refresh tokens. This is the
Expand Down Expand Up @@ -637,15 +643,15 @@ def __init__(self,
* requests_timeout: Optional, tell Requests to stop waiting for a response after
a given number of seconds
* requests_session: A Requests session
* open_browser: Optional, whether the web browser should be opened to
* open_browser: Optional, whether or not the web browser should be opened to
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().__init__(requests_session)
super(SpotifyPKCE, self).__init__(requests_session)
self.client_id = client_id
self.redirect_uri = redirect_uri
self.state = state
Expand Down Expand Up @@ -695,8 +701,15 @@ def _get_code_verifier(self):
length = random.randint(33, 96)

# The seeded length generates between a 44 and 128 base64 characters encoded string
import secrets
return secrets.token_urlsafe(length)
try:
import secrets
verifier = secrets.token_urlsafe(length)
except ImportError: # For python 3.5 support
import base64
import os
rand_bytes = os.urandom(length)
verifier = base64.urlsafe_b64encode(rand_bytes).decode('utf-8').replace('=', '')
return verifier

def _get_code_challenge(self):
""" Spotify PCKE code challenge - See step 1 of the reference guide below
Expand Down Expand Up @@ -727,7 +740,7 @@ def get_authorize_url(self, state=None):
if state is not None:
payload["state"] = state
urlparams = urllibparse.urlencode(payload)
return f"{self.OAUTH_AUTHORIZE_URL}?{urlparams}"
return "%s?%s" % (self.OAUTH_AUTHORIZE_URL, urlparams)

def _open_auth_url(self, state=None):
auth_url = self.get_authorize_url(state)
Expand Down Expand Up @@ -778,7 +791,7 @@ def _get_auth_response_local_server(self, redirect_port):
if server.auth_code is not None:
return server.auth_code
elif server.error is not None:
raise SpotifyOauthError(f"Received error from OAuth server: {server.error}")
raise SpotifyOauthError("Received error from OAuth server: {}".format(server.error))
else:
raise SpotifyOauthError("Server listening on localhost has not been accessed")

Expand Down Expand Up @@ -973,7 +986,7 @@ class SpotifyImplicitGrant(SpotifyAuthBase):
Authentication Code flow. Use the SpotifyPKCE auth manager instead
of SpotifyImplicitGrant.
SpotifyPKCE contains all the functionality of
SpotifyPKCE contains all of the functionality of
SpotifyImplicitGrant, plus automatic response retrieval and
refreshable tokens. Only a few replacements need to be made:
Expand Down Expand Up @@ -1121,7 +1134,7 @@ def get_authorize_url(self, state=None):

urlparams = urllibparse.urlencode(payload)

return f"{self.OAUTH_AUTHORIZE_URL}?{urlparams}"
return "%s?%s" % (self.OAUTH_AUTHORIZE_URL, urlparams)

def parse_response_token(self, url, state=None):
""" Parse the response code in the given response url """
Expand All @@ -1141,7 +1154,8 @@ def parse_auth_response_url(url):
form = dict(i.split('=') for i
in (fragment_s or query_s or url).split('&'))
if "error" in form:
raise SpotifyOauthError(f"Received error from auth server: {form['error']}",
raise SpotifyOauthError("Received error from auth server: "
"{}".format(form["error"]),
state=form["state"])
if "expires_in" in form:
form["expires_in"] = int(form["expires_in"])
Expand Down Expand Up @@ -1233,7 +1247,7 @@ def do_GET(self):
if self.server.auth_code:
status = "successful"
elif self.server.error:
status = f"failed ({self.server.error})"
status = "failed ({})".format(self.server.error)
else:
self._write("<html><body><h1>Invalid request</h1></body></html>")
return
Expand Down
85 changes: 85 additions & 0 deletions spotipy/scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-

__all__ = ["Scope"]

from enum import Enum
import re
from typing import Iterable, Set


class Scope(Enum):
"""
The Spotify authorization scopes
Create a Scope from a string:
scope = Scope("playlist-modify-private")
Create a set of scopes:
scopes = {
Scope.user_read_currently_playing,
Scope.playlist_read_collaborative,
Scope.playlist_modify_public
}
"""

user_read_currently_playing = "user-read-currently-playing"
playlist_read_collaborative = "playlist-read-collaborative"
playlist_modify_private = "playlist-modify-private"
user_read_playback_position = "user-read-playback-position"
user_library_modify = "user-library-modify"
user_top_read = "user-top-read"
user_read_playback_state = "user-read-playback-state"
user_read_email = "user-read-email"
ugc_image_upload = "ugc-image-upload"
user_read_private = "user-read-private"
playlist_modify_public = "playlist-modify-public"
user_library_read = "user-library-read"
streaming = "streaming"
user_read_recently_played = "user-read-recently-played"
user_follow_read = "user-follow-read"
user_follow_modify = "user-follow-modify"
app_remote_control = "app-remote-control"
playlist_read_private = "playlist-read-private"
user_modify_playback_state = "user-modify-playback-state"

@staticmethod
def all() -> Set['Scope']:
"""Returns all of the authorization scopes"""

return set(Scope)

@staticmethod
def make_string(scopes: Iterable['Scope']) -> str:
"""
Converts an iterable of scopes to a space-separated string.
* scopes: An iterable of scopes.
returns: a space-separated string of scopes
"""
return " ".join([scope.value for scope in scopes])

@staticmethod
def from_string(scope_string: str) -> Set['Scope']:
"""
Converts a string of (usuallly space-separated) scopes into a
set of scopes
Any scope-strings that do not match any of the known scopes are
ignored.
* scope_string: a string of scopes
returns: a set of scopes.
"""
scope_string_list = re.split(pattern=r"[^\w-]+", string=scope_string)
scopes = set()
for scope_string in sorted(scope_string_list):
try:
scope = Scope(scope_string)
scopes.add(scope)
except ValueError:
pass
return scopes
35 changes: 15 additions & 20 deletions spotipy/util.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations
# -*- coding: utf-8 -*-

""" Shows a user's playlists. This needs to be authenticated via OAuth. """
""" Shows a user's playlists (need to be authenticated via oauth) """

__all__ = ["CLIENT_CREDS_ENV_VARS", "prompt_for_user_token"]

Expand All @@ -11,8 +11,6 @@

import spotipy

import urllib3

LOGGER = logging.getLogger(__name__)

CLIENT_CREDS_ENV_VARS = {
Expand Down Expand Up @@ -40,18 +38,21 @@ def prompt_for_user_token(
" spotipy.Spotify(auth_manager=auth_manager)",
DeprecationWarning
)
"""Prompt the user to login if necessary and returns a user token
suitable for use with the spotipy.Spotify constructor.
""" prompts the user to login if necessary and returns
the user token suitable for use with the spotipy.Spotify
constructor
Parameters:
- username - the Spotify username. (optional)
- scope - the desired scope of the request. (optional)
- client_id - the client ID of your app. (required)
- client_secret - the client secret of your app. (required)
- redirect_uri - the redirect URI of your app. (required)
- cache_path - path to location to save tokens. (required)
- oauth_manager - OAuth manager object. (optional)
- show_dialog - If True, a login prompt always shows or defaults to False. (optional)
- username - the Spotify username (optional)
- scope - the desired scope of the request (optional)
- client_id - the client id of your app (required)
- client_secret - the client secret of your app (required)
- redirect_uri - the redirect URI of your app (required)
- cache_path - path to location to save tokens (optional)
- oauth_manager - Oauth manager object (optional)
- show_dialog - If true, a login prompt always shows (optional, defaults to False)
"""
if not oauth_manager:
if not client_id:
Expand Down Expand Up @@ -109,12 +110,6 @@ def prompt_for_user_token(


def get_host_port(netloc):
""" Split the network location string into host and port and returns a tuple
where the host is a string and the the port is an integer.
Parameters:
- netloc - a string representing the network location.
"""
if ":" in netloc:
host, port = netloc.split(":", 1)
port = int(port)
Expand Down
Loading

0 comments on commit 3f5eeca

Please sign in to comment.