From dc89a00113e98b26b4932b582cb4f7b4449fcd4d Mon Sep 17 00:00:00 2001 From: Peter Schorn Date: Sat, 13 Mar 2021 07:34:52 -0600 Subject: [PATCH 1/7] Added cache handler to `SpotifyClientCredentials` and fixed a bug in refresh tokens methods that raised the wrong exception (#655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- CHANGELOG.md | 14 ++++- spotipy/oauth2.py | 130 ++++++++++++++++++++++++++-------------------- 2 files changed, 86 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dbbf74..f5fd7b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ## Unreleased ### Added + - Enabled using both short and long IDs for playlist_change_details +- Added a cache handler to `SpotifyClientCredentials` ### Changed + - 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 ### Fixed diff --git a/spotipy/oauth2.py b/spotipy/oauth2.py index 4309eb2..c711ce0 100644 --- a/spotipy/oauth2.py +++ b/spotipy/oauth2.py @@ -24,7 +24,6 @@ from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer from six.moves.urllib_parse import parse_qsl, urlparse 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 logger = logging.getLogger(__name__) @@ -139,27 +138,57 @@ class SpotifyAuthBase(object): class SpotifyClientCredentials(SpotifyAuthBase): OAUTH_TOKEN_URL = "https://accounts.spotify.com/api/token" - def __init__(self, - client_id=None, - client_secret=None, - proxies=None, - requests_session=True, - requests_timeout=None): + def __init__( + self, + client_id=None, + client_secret=None, + proxies=None, + requests_session=True, + 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 constructor or set SPOTIPY_CLIENT_ID and SPOTIPY_CLIENT_SECRET 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) self.client_id = client_id self.client_secret = client_secret - self.token_info = None self.proxies = proxies 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 Else feches a new token and returns it @@ -179,13 +208,15 @@ class SpotifyClientCredentials(SpotifyAuthBase): stacklevel=2, ) - if self.token_info and not self.is_token_expired(self.token_info): - return self.token_info if as_dict else self.token_info["access_token"] + if check_cache: + 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._add_custom_values_to_token_info(token_info) - self.token_info = token_info - return self.token_info["access_token"] + self.cache_handler.save_token_to_cache(token_info) + return token_info if as_dict else token_info["access_token"] def _request_access_token(self): """Gets client credentials access token """ @@ -260,20 +291,21 @@ class SpotifyOAuth(SpotifyAuthBase): * state: Optional, no verification is performed * scope: Optional, either a list of scopes or comma separated string of scopes. 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 (takes precedence over `username`) * username: (deprecated) Optional or set as environment variable (will set `cache_path` to `.cache-{username}`) - * show_dialog: Optional, interpreted as boolean * 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 a given number of seconds * 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(SpotifyOAuth, self).__init__(requests_session) @@ -414,7 +446,7 @@ class SpotifyOAuth(SpotifyAuthBase): if server.auth_code is not None: return server.auth_code elif server.error is not None: - raise SpotifyOauthError("Received error from OAuth server: {}".format(server.error)) + raise server.error else: raise SpotifyOauthError("Server listening on localhost has not been accessed") @@ -432,7 +464,7 @@ class SpotifyOAuth(SpotifyAuthBase): open_browser = self.open_browser if ( - (open_browser or self.open_browser) + open_browser and redirect_host in ("127.0.0.1", "localhost") and redirect_info.scheme == "http" ): @@ -539,20 +571,13 @@ class SpotifyOAuth(SpotifyAuthBase): timeout=self.requests_timeout, ) - try: - response.raise_for_status() - except BaseException: - logger.error('Couldn\'t refresh token. Response Status Code: %s ' - 'Reason: %s', response.status_code, response.reason) - - message = "Couldn't refresh token: code:%d reason:%s" % ( - response.status_code, - response.reason, - ) - raise SpotifyException(response.status_code, - -1, - message, - headers) + if response.status_code != 200: + error_payload = response.json() + raise SpotifyOauthError( + 'error: {0}, error_description: {1}'.format( + error_payload['error'], error_payload['error_description']), + error=error_payload['error'], + error_description=error_payload['error_description']) token_info = response.json() token_info = self._add_custom_values_to_token_info(token_info) @@ -623,25 +648,24 @@ class SpotifyPKCE(SpotifyAuthBase): Parameters: * 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 * state: Optional, no verification is performed * scope: Optional, either a list of scopes or comma separated string of scopes. 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 (takes precedence over `username`) * username: (deprecated) Optional or set as environment variable (will set `cache_path` to `.cache-{username}`) - * show_dialog: Optional, interpreted as boolean * proxies: Optional, proxy for the requests library to route through * 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, thether 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(SpotifyPKCE, self).__init__(requests_session) @@ -921,20 +945,13 @@ class SpotifyPKCE(SpotifyAuthBase): timeout=self.requests_timeout, ) - try: - response.raise_for_status() - except BaseException: - logger.error('Couldn\'t refresh token. Response Status Code: %s ' - 'Reason: %s', response.status_code, response.reason) - - message = "Couldn't refresh token: code:%d reason:%s" % ( - response.status_code, - response.reason, - ) - raise SpotifyException(response.status_code, - -1, - message, - headers) + if response.status_code != 200: + error_payload = response.json() + raise SpotifyOauthError( + 'error: {0}, error_description: {1}'.format( + error_payload['error'], error_payload['error_description']), + error=error_payload['error'], + error_description=error_payload['error_description']) token_info = response.json() 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) self.server.state = state self.server.auth_code = auth_code - except SpotifyOauthError as err: - self.server.state = err.state - self.server.error = err.error + except SpotifyOauthError as error: + self.server.error = error self.send_response(200) self.send_header("Content-Type", "text/html") From 8db120f98567082ac37ae8ef8b666948e821a0d9 Mon Sep 17 00:00:00 2001 From: yashsinha848 <65448758+yashsinha848@users.noreply.github.com> Date: Sun, 4 Apr 2021 16:21:55 +0530 Subject: [PATCH 2/7] Update simple3.py (#663) --- examples/simple3.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/simple3.py b/examples/simple3.py index ef3df9b..86ca6d5 100644 --- a/examples/simple3.py +++ b/examples/simple3.py @@ -1,3 +1,4 @@ +#Shows the name of the artist/band and their image by giving a link import sys from spotipy.oauth2 import SpotifyClientCredentials From f42024737e5f7334ad7506ff5e989b47da81e457 Mon Sep 17 00:00:00 2001 From: yashsinha848 <65448758+yashsinha848@users.noreply.github.com> Date: Sun, 4 Apr 2021 16:22:02 +0530 Subject: [PATCH 3/7] Update artist_discography.py (#664) --- examples/artist_discography.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/artist_discography.py b/examples/artist_discography.py index d3a39ad..6095a36 100644 --- a/examples/artist_discography.py +++ b/examples/artist_discography.py @@ -1,3 +1,4 @@ +#Shows the list of all songs sung by the artist or the band import argparse import logging From 25f9df2dd5a223903299aa1c0519186dc6d3b18e Mon Sep 17 00:00:00 2001 From: Peter Schorn Date: Sat, 10 Apr 2021 08:53:03 -0500 Subject: [PATCH 4/7] Added new endpoints (#666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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` Added tests for the above endpoints. * updated CHANGELOG * Fixed flake8 issue Co-authored-by: Stéphane Bruckert --- CHANGELOG.md | 6 + spotipy/client.py | 295 +++++++++++-------- tests/integration/test_non_user_endpoints.py | 6 + tests/integration/test_user_endpoints.py | 19 ++ 4 files changed, 208 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5fd7b2..3b12bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Enabled using both short and long IDs for playlist_change_details - Added a cache handler to `SpotifyClientCredentials` +- 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` ### Changed diff --git a/spotipy/client.py b/spotipy/client.py index 95e2aa7..8c1fc8a 100644 --- a/spotipy/client.py +++ b/spotipy/client.py @@ -1171,16 +1171,184 @@ class Spotify(object): """ return self._get("me/player/currently-playing") - def current_user_saved_tracks(self, limit=20, offset=0): + def current_user_saved_albums(self, limit=20, offset=0, market=None): + """ Gets a list of the albums saved in the current authorized user's + "Your Music" library + + Parameters: + - limit - the number of albums to return + - offset - the index of the first album to return + - market - an ISO 3166-1 alpha-2 country code. + + """ + return self._get("me/albums", limit=limit, offset=offset, market=market) + + def current_user_saved_albums_add(self, albums=[]): + """ Add one or more albums to the current user's + "Your Music" library. + Parameters: + - albums - a list of album URIs, URLs or IDs + """ + + alist = [self._get_id("album", a) for a in albums] + return self._put("me/albums?ids=" + ",".join(alist)) + + def current_user_saved_albums_delete(self, albums=[]): + """ Remove one or more albums from the current user's + "Your Music" library. + + Parameters: + - albums - a list of album URIs, URLs or IDs + """ + alist = [self._get_id("album", a) for a in albums] + return self._delete("me/albums/?ids=" + ",".join(alist)) + + def current_user_saved_albums_contains(self, albums=[]): + """ Check if one or more albums is already saved in + the current Spotify user’s “Your Music” library. + + Parameters: + - albums - a list of album URIs, URLs or IDs + """ + alist = [self._get_id("album", a) for a in albums] + return self._get("me/albums/contains?ids=" + ",".join(alist)) + + def current_user_saved_tracks(self, limit=20, offset=0, market=None): """ Gets a list of the tracks saved in the current authorized user's "Your Music" library Parameters: - limit - the number of tracks to return - offset - the index of the first track to return + - market - an ISO 3166-1 alpha-2 country code """ - return self._get("me/tracks", limit=limit, offset=offset) + return self._get("me/tracks", limit=limit, offset=offset, market=market) + + def current_user_saved_tracks_add(self, tracks=None): + """ Add one or more tracks to the current user's + "Your Music" library. + + Parameters: + - tracks - a list of track URIs, URLs or IDs + """ + tlist = [] + if tracks is not None: + tlist = [self._get_id("track", t) for t in tracks] + return self._put("me/tracks/?ids=" + ",".join(tlist)) + + def current_user_saved_tracks_delete(self, tracks=None): + """ Remove one or more tracks from the current user's + "Your Music" library. + + Parameters: + - tracks - a list of track URIs, URLs or IDs + """ + tlist = [] + if tracks is not None: + tlist = [self._get_id("track", t) for t in tracks] + return self._delete("me/tracks/?ids=" + ",".join(tlist)) + + def current_user_saved_tracks_contains(self, tracks=None): + """ Check if one or more tracks is already saved in + the current Spotify user’s “Your Music” library. + + Parameters: + - tracks - a list of track URIs, URLs or IDs + """ + tlist = [] + if tracks is not None: + tlist = [self._get_id("track", t) for t in tracks] + return self._get("me/tracks/contains?ids=" + ",".join(tlist)) + + def current_user_saved_episodes(self, limit=20, offset=0, market=None): + """ Gets a list of the episodes saved in the current authorized user's + "Your Music" library + + Parameters: + - limit - the number of episodes to return + - offset - the index of the first episode to return + - market - an ISO 3166-1 alpha-2 country code + + """ + return self._get("me/episodes", limit=limit, offset=offset, market=market) + + def current_user_saved_episodes_add(self, episodes=None): + """ Add one or more episodes to the current user's + "Your Music" library. + + Parameters: + - episodes - a list of episode URIs, URLs or IDs + """ + elist = [] + if episodes is not None: + elist = [self._get_id("episode", e) for e in episodes] + return self._put("me/episodes/?ids=" + ",".join(elist)) + + def current_user_saved_episodes_delete(self, episodes=None): + """ Remove one or more episodes from the current user's + "Your Music" library. + + Parameters: + - episodes - a list of episode URIs, URLs or IDs + """ + elist = [] + if episodes is not None: + elist = [self._get_id("episode", e) for e in episodes] + return self._delete("me/episodes/?ids=" + ",".join(elist)) + + def current_user_saved_episodes_contains(self, episodes=None): + """ Check if one or more episodes is already saved in + the current Spotify user’s “Your Music” library. + + Parameters: + - episodes - a list of episode URIs, URLs or IDs + """ + elist = [] + if episodes is not None: + elist = [self._get_id("episode", e) for e in episodes] + return self._get("me/episodes/contains?ids=" + ",".join(elist)) + + def current_user_saved_shows(self, limit=20, offset=0, market=None): + """ Gets a list of the shows saved in the current authorized user's + "Your Music" library + + Parameters: + - limit - the number of shows to return + - offset - the index of the first show to return + - market - an ISO 3166-1 alpha-2 country code + + """ + return self._get("me/shows", limit=limit, offset=offset, market=market) + + def current_user_saved_shows_add(self, shows=[]): + """ Add one or more albums to the current user's + "Your Music" library. + Parameters: + - shows - a list of show URIs, URLs or IDs + """ + slist = [self._get_id("show", s) for s in shows] + return self._put("me/shows?ids=" + ",".join(slist)) + + def current_user_saved_shows_delete(self, shows=[]): + """ Remove one or more shows from the current user's + "Your Music" library. + + Parameters: + - shows - a list of show URIs, URLs or IDs + """ + slist = [self._get_id("show", s) for s in shows] + return self._delete("me/shows/?ids=" + ",".join(slist)) + + def current_user_saved_shows_contains(self, shows=[]): + """ Check if one or more shows is already saved in + the current Spotify user’s “Your Music” library. + + Parameters: + - shows - a list of show URIs, URLs or IDs + """ + slist = [self._get_id("show", s) for s in shows] + return self._get("me/shows/contains?ids=" + ",".join(slist)) def current_user_followed_artists(self, limit=20, after=None): """ Gets a list of the artists followed by the current authorized user @@ -1225,42 +1393,6 @@ class Spotify(object): "me/following/contains", ids=",".join(idlist), type="user" ) - def current_user_saved_tracks_delete(self, tracks=None): - """ Remove one or more tracks from the current user's - "Your Music" library. - - Parameters: - - tracks - a list of track URIs, URLs or IDs - """ - tlist = [] - if tracks is not None: - tlist = [self._get_id("track", t) for t in tracks] - return self._delete("me/tracks/?ids=" + ",".join(tlist)) - - def current_user_saved_tracks_contains(self, tracks=None): - """ Check if one or more tracks is already saved in - the current Spotify user’s “Your Music” library. - - Parameters: - - tracks - a list of track URIs, URLs or IDs - """ - tlist = [] - if tracks is not None: - tlist = [self._get_id("track", t) for t in tracks] - return self._get("me/tracks/contains?ids=" + ",".join(tlist)) - - def current_user_saved_tracks_add(self, tracks=None): - """ Add one or more tracks to the current user's - "Your Music" library. - - Parameters: - - tracks - a list of track URIs, URLs or IDs - """ - tlist = [] - if tracks is not None: - tlist = [self._get_id("track", t) for t in tracks] - return self._put("me/tracks/?ids=" + ",".join(tlist)) - def current_user_top_artists( self, limit=20, offset=0, time_range="medium_term" ): @@ -1310,86 +1442,6 @@ class Spotify(object): before=before, ) - def current_user_saved_albums(self, limit=20, offset=0): - """ Gets a list of the albums saved in the current authorized user's - "Your Music" library - - Parameters: - - limit - the number of albums to return - - offset - the index of the first album to return - - """ - return self._get("me/albums", limit=limit, offset=offset) - - def current_user_saved_albums_contains(self, albums=[]): - """ Check if one or more albums is already saved in - the current Spotify user’s “Your Music” library. - - Parameters: - - albums - a list of album URIs, URLs or IDs - """ - alist = [self._get_id("album", a) for a in albums] - return self._get("me/albums/contains?ids=" + ",".join(alist)) - - def current_user_saved_albums_add(self, albums=[]): - """ Add one or more albums to the current user's - "Your Music" library. - Parameters: - - albums - a list of album URIs, URLs or IDs - """ - alist = [self._get_id("album", a) for a in albums] - return self._put("me/albums?ids=" + ",".join(alist)) - - def current_user_saved_albums_delete(self, albums=[]): - """ Remove one or more albums from the current user's - "Your Music" library. - - Parameters: - - albums - a list of album URIs, URLs or IDs - """ - alist = [self._get_id("album", a) for a in albums] - return self._delete("me/albums/?ids=" + ",".join(alist)) - - def current_user_saved_shows(self, limit=50, offset=0): - """ Gets a list of the shows saved in the current authorized user's - "Your Music" library - - Parameters: - - limit - the number of shows to return - - offset - the index of the first show to return - - """ - return self._get("me/shows", limit=limit, offset=offset) - - def current_user_saved_shows_contains(self, shows=[]): - """ Check if one or more shows is already saved in - the current Spotify user’s “Your Music” library. - - Parameters: - - shows - a list of show URIs, URLs or IDs - """ - slist = [self._get_id("show", s) for s in shows] - return self._get("me/shows/contains?ids=" + ",".join(slist)) - - def current_user_saved_shows_add(self, shows=[]): - """ Add one or more albums to the current user's - "Your Music" library. - Parameters: - - shows - a list of show URIs, URLs or IDs - """ - slist = [self._get_id("show", s) for s in shows] - return self._put("me/shows?ids=" + ",".join(slist)) - - def current_user_saved_shows_delete(self, shows=[]): - """ Remove one or more shows from the current user's - "Your Music" library. - - Parameters: - - shows - a list of show URIs, URLs or IDs - """ - slist = [self._get_id("show", s) for s in shows] - return self._delete("me/shows/?ids=" + ",".join(slist)) - def user_follow_artists(self, ids=[]): """ Follow one or more artists Parameters: @@ -1829,6 +1881,13 @@ class Spotify(object): return self._post(endpoint) + def available_markets(self): + """ Get the list of markets where Spotify is available. + Returns a list of the countries in which Spotify is available, identified by their + ISO 3166-1 alpha-2 country code with additional country codes for special territories. + """ + return self._get("markets") + def _append_device_id(self, path, device_id): """ Append device ID to API path. diff --git a/tests/integration/test_non_user_endpoints.py b/tests/integration/test_non_user_endpoints.py index abfafc3..dc17ab3 100644 --- a/tests/integration/test_non_user_endpoints.py +++ b/tests/integration/test_non_user_endpoints.py @@ -364,3 +364,9 @@ class AuthTestSpotipy(unittest.TestCase): self.assertNotIsInstance(with_no_session._session, requests.Session) user = with_no_session.user(user="akx") self.assertEqual(user["uri"], "spotify:user:akx") + + def test_available_markets(self): + markets = self.spotify.available_markets()["markets"] + self.assertTrue(isinstance(markets, list)) + self.assertIn("US", markets) + self.assertIn("GB", markets) diff --git a/tests/integration/test_user_endpoints.py b/tests/integration/test_user_endpoints.py index a6c8921..206b419 100644 --- a/tests/integration/test_user_endpoints.py +++ b/tests/integration/test_user_endpoints.py @@ -209,6 +209,10 @@ class SpotipyLibraryApiTests(unittest.TestCase): "http://open.spotify.com/track/3cySlItpiPiIAzU3NyHCJf"] cls.album_ids = ["spotify:album:6kL09DaURb7rAoqqaA51KU", "spotify:album:6RTzC0rDbvagTSJLlY7AKl"] + cls.episode_ids = [ + "spotify:episode:3OEdPEYB69pfXoBrhvQYeC", + "spotify:episode:5LEFdZ9pYh99wSz7Go2D0g" + ] cls.username = os.getenv(CCEV['client_username']) scope = ( @@ -267,6 +271,21 @@ class SpotipyLibraryApiTests(unittest.TestCase): resp = self.spotify.current_user_saved_albums_contains(self.album_ids) self.assertEqual(resp, [False, False]) + def test_current_user_saved_episodes(self): + # Add + self.spotify.current_user_saved_episodes_add(self.episode_ids) + episodes = self.spotify.current_user_saved_episodes(market="US") + self.assertGreaterEqual(len(episodes['items']), 2) + + # Contains + resp = self.spotify.current_user_saved_episodes_contains(self.episode_ids) + self.assertEqual(resp, [True, True]) + + # Remove + self.spotify.current_user_saved_episodes_delete(self.episode_ids) + resp = self.spotify.current_user_saved_episodes_contains(self.episode_ids) + self.assertEqual(resp, [False, False]) + class SpotipyUserApiTests(unittest.TestCase): @classmethod From 36bdeb0a65e2c1a3b274929151d9804c6cbb069c Mon Sep 17 00:00:00 2001 From: Stephane Bruckert Date: Tue, 13 Apr 2021 21:32:51 +0100 Subject: [PATCH 5/7] Bump to 2.18.0 --- CHANGELOG.md | 8 ++++---- setup.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b12bb2..3eb975a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +// Add your changes here and then delete this line + +## [2.18.0] - 2021-04-13 + ### Added - Enabled using both short and long IDs for playlist_change_details @@ -25,13 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 ### Fixed diff --git a/setup.py b/setup.py index b1a4165..69bfff9 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ extra_reqs = { setup( name='spotipy', - version='2.17.1', + version='2.18.0', description='A light weight Python library for the Spotify Web API', long_description=long_description, long_description_content_type="text/markdown", From 0b6ede68766f095b97e5d5370cf6bd6b264dc720 Mon Sep 17 00:00:00 2001 From: Peter-Schorn Date: Wed, 14 Apr 2021 10:03:58 -0500 Subject: [PATCH 6/7] Added `MemoryCacheHandler`, a cache handler that simply stores the token info in memory as an instance attribute of this class. --- CHANGELOG.md | 4 +++- spotipy/cache_handler.py | 23 ++++++++++++++++++++++- tests/unit/test_oauth.py | 16 ++-------------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eb975a..80eaecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -// Add your changes here and then delete this line +### Added + +* Added `MemoryCacheHandler`, a cache handler that simply stores the token info in memory as an instance attribute of this class. ## [2.18.0] - 2021-04-13 diff --git a/spotipy/cache_handler.py b/spotipy/cache_handler.py index 3ba3987..ae3cae8 100644 --- a/spotipy/cache_handler.py +++ b/spotipy/cache_handler.py @@ -1,4 +1,4 @@ -__all__ = ['CacheHandler', 'CacheFileHandler'] +__all__ = ['CacheHandler', 'CacheFileHandler', 'MemoryCacheHandler'] import errno import json @@ -82,3 +82,24 @@ class CacheFileHandler(CacheHandler): except IOError: logger.warning('Couldn\'t write token to cache at: %s', self.cache_path) + + +class MemoryCacheHandler(CacheHandler): + """ + A cache handler that simply stores the token info in memory as an + instance attribute of this class. The token info will be lost when this + instance is freed. + """ + + def __init__(self, token_info=None): + """ + Parameters: + * token_info: The token info to store in memory. Can be None. + """ + self.token_info = token_info + + def get_cached_token(self): + return self.token_info + + def save_token_to_cache(self, token_info): + self.token_info = token_info diff --git a/tests/unit/test_oauth.py b/tests/unit/test_oauth.py index 9acdcc9..192daa6 100644 --- a/tests/unit/test_oauth.py +++ b/tests/unit/test_oauth.py @@ -6,7 +6,7 @@ import unittest import six.moves.urllib.parse as urllibparse from spotipy import SpotifyOAuth, SpotifyImplicitGrant, SpotifyPKCE -from spotipy.cache_handler import CacheHandler +from spotipy.cache_handler import MemoryCacheHandler from spotipy.oauth2 import SpotifyClientCredentials, SpotifyOauthError from spotipy.oauth2 import SpotifyStateError @@ -51,18 +51,6 @@ def _make_pkceauth(*args, **kwargs): return SpotifyPKCE("CLID", "REDIR", "STATE", *args, **kwargs) -class MemoryCache(CacheHandler): - def __init__(self, token_info=None): - self.token_info = token_info - - def get_cached_token(self): - return self.token_info - - def save_token_to_cache(self, token_info): - self.token_info = token_info - return None - - class OAuthCacheTest(unittest.TestCase): @patch.multiple(SpotifyOAuth, @@ -161,7 +149,7 @@ class OAuthCacheTest(unittest.TestCase): scope = "playlist-modify-private" tok = _make_fake_token(1, 1, scope) - spot = _make_oauth(scope, cache_handler=MemoryCache()) + spot = _make_oauth(scope, cache_handler=MemoryCacheHandler()) spot.cache_handler.save_token_to_cache(tok) cached_tok = spot.cache_handler.get_cached_token() From bc7343c2061ee01e039b80b07ae39609726f962c Mon Sep 17 00:00:00 2001 From: Peter-Schorn Date: Wed, 14 Apr 2021 10:26:59 -0500 Subject: [PATCH 7/7] Fixed a bug in `CacheFileHandler.__init__`: The documentation says that the username will be retrieved from the environment, but it wasn't. --- CHANGELOG.md | 4 ++++ spotipy/cache_handler.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80eaecc..0d60d2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added `MemoryCacheHandler`, a cache handler that simply stores the token info in memory as an instance attribute of this class. +### Fixed + +* Fixed a bug in `CacheFileHandler.__init__`: The documentation says that the username will be retrieved from the environment, but it wasn't. + ## [2.18.0] - 2021-04-13 ### Added diff --git a/spotipy/cache_handler.py b/spotipy/cache_handler.py index ae3cae8..544316b 100644 --- a/spotipy/cache_handler.py +++ b/spotipy/cache_handler.py @@ -3,6 +3,8 @@ __all__ = ['CacheHandler', 'CacheFileHandler', 'MemoryCacheHandler'] import errno import json import logging +import os +from spotipy.util import CLIENT_CREDS_ENV_VARS logger = logging.getLogger(__name__) @@ -53,6 +55,7 @@ class CacheFileHandler(CacheHandler): self.cache_path = cache_path else: cache_path = ".cache" + username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) if username: cache_path += "-" + str(username) self.cache_path = cache_path