diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 96b767e..695592e 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -4,7 +4,6 @@ on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest env: SPOTIPY_CLIENT_ID: ${{ secrets.SPOTIPY_CLIENT_ID }} diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index acf6e21..7f6d199 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -4,7 +4,6 @@ on: [push, pull_request] jobs: build: - runs-on: ubuntu-20.04 strategy: matrix: diff --git a/CHANGELOG.md b/CHANGELOG.md index a022ff1..015802a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,22 +6,31 @@ 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 + Add your changes below. ### Added + - Added examples for audiobooks, shows and episodes methods to examples directory - Use newer string formatters (https://pyformat.info) - Marked `recommendation_genre_seeds` as deprecated ### Fixed + - Fixed scripts in examples directory that didn't run correctly - Updated documentation for `Client.current_user_top_artists` to indicate maximum number of artists limit +### Changed + +- Updated get_cached_token and save_token_to_cache methods to utilize Python's Context Management Protocol +- Added except clause to get_cached_token method to handle json decode errors + ### Removed ## [2.25.0] - 2025-03-01 ### Added + - Added unit tests for queue functions - Added detailed function docstrings to 'util.py', including descriptions and special sections that lists arguments, returns, and raises. - Updated order of instructions for Python and pip package manager installation in TUTORIAL.md @@ -44,53 +53,66 @@ Add your changes below. ### Changed - Split test and lint workflows +- Updated get_cached_token and save_token_to_cache methods to utilize Python's Context Management Protocol +- Added except clause to get_cached_token method to handle json decode errors ### Fixed + - Audiobook integration tests - Edited docstrings for certain functions in client.py for functions that are no longer in use and have been replaced. - `current_user_unfollow_playlist()` now supports playlist IDs, URLs, and URIs rather than previously where it only supported playlist IDs. ### Removed + - `mock` no longer listed as a test dependency. Only built-in `unittest.mock` is actually used. ## [2.24.0] - 2024-05-30 ### Added + - Added `MemcacheCacheHandler`, a cache handler that stores the token info using pymemcache. - Added support for audiobook endpoints: `get_audiobook`, `get_audiobooks`, and `get_audiobook_chapters`. - Added integration tests for audiobook endpoints. - Added `update` field to `current_user_follow_playlist`. ### Changed + +- Updated get_cached_token and save_token_to_cache methods to utilize Python's Context Management Protocol +- Added except clause to get_cached_token method to handle json decode errors + - Fixed error obfuscation when Spotify class is being inherited and an error is raised in the Child's `__init__` - Replaced `artist_albums(album_type=...)` with `artist_albums(include_groups=...)` due to an API change. - Updated `_regex_spotify_url` to ignore `/intl-` in Spotify links - Improved README, docs and examples ### Fixed + - Readthedocs build - Split `test_current_user_save_and_usave_tracks` unit test ### Removed + - Drop support for EOL Python 3.7 ## [2.23.0] - 2023-04-07 ### Added + - Added optional `encoder_cls` argument to `CacheFileHandler`, which overwrite default encoder for token before writing to disk - Integration tests for searching multiple types in multiple markets (non-user endpoints) - Publish to PyPI action ### Fixed + - Fixed the regex for matching playlist URIs with the format spotify:user:USERNAME:playlist:PLAYLISTID. -- `search_markets` now factors the counts of all types in the `total` rather than just the first type ([#534](https://github.com/spotipy-dev/spotipy/issues/534)) +- `search_markets` now factors the counts of all types in the `total` rather than just the first type ([#534](https://github.com/spotipy-dev/spotipy/issues/534)) ## [2.22.1] - 2023-01-23 ### Added - Add alternative module installation instruction to README -- Added Comment to README - Getting Started for user to add URI to app in Spotify Developer Dashboard. +- Added Comment to README - Getting Started for user to add URI to app in Spotify Developer Dashboard. - Added playlist_add_tracks.py to example folder ### Changed @@ -170,11 +192,11 @@ Add your changes below. - 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` + - `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 @@ -240,7 +262,7 @@ Add your changes below. ### Added - `SpotifyPKCE.parse_auth_response_url`, mirroring that method in - `SpotifyOAuth` + `SpotifyOAuth` ### Changed @@ -249,7 +271,7 @@ Add your changes below. ### Fixed - Using `SpotifyPKCE.get_authorization_url` will now generate a code - challenge if needed + challenge if needed ## [2.14.0] - 2020-08-29 @@ -257,9 +279,9 @@ Add your changes below. - (experimental) Support to search multiple/all markets at once. - Support to test whether the current user is following certain - users or artists + users or artists - Proper replacements for all deprecated playlist endpoints - (See https://developer.spotify.com/community/news/2018/06/12/changes-to-playlist-uris/ and below) + (See https://developer.spotify.com/community/news/2018/06/12/changes-to-playlist-uris/ and below) - Allow for OAuth 2.0 authorization by instructing the user to open the URL in a browser instead of opening the browser. - Reason for 403 error in SpotifyException - Support for the PKCE Auth Flow @@ -277,11 +299,11 @@ Add your changes below. - `user_playlist_replace_tracks` in favor of `playlist_replace_items` - `user_playlist_reorder_tracks` in favor of `playlist_reorder_items` - `user_playlist_remove_all_occurrences_of_tracks` in favor of - `playlist_remove_all_occurrences_of_items` + `playlist_remove_all_occurrences_of_items` - `user_playlist_remove_specific_occurrences_of_tracks` in favor of - `playlist_remove_specific_occurrences_of_items` + `playlist_remove_specific_occurrences_of_items` - `user_playlist_follow_playlist` in favor of - `current_user_follow_playlist` + `current_user_follow_playlist` - `user_playlist_is_following` in favor of `playlist_is_following` - `playlist_tracks` in favor of `playlist_items` @@ -294,12 +316,12 @@ Add your changes below. ### Added - Added `SpotifyImplicitGrant` as an auth manager option. It provides - user authentication without a client secret but sacrifices the ability - to refresh the token without user input. (However, read the class - docstring for security advisory.) + user authentication without a client secret but sacrifices the ability + to refresh the token without user input. (However, read the class + docstring for security advisory.) - Added built-in verification of the `state` query parameter - Added two new attributes: error and error_description to `SpotifyOauthError` exception class to show - authorization/authentication web api errors details. + authorization/authentication web api errors details. - Added `SpotifyStateError` subclass of `SpotifyOauthError` - Allow extending `SpotifyClientCredentials` and `SpotifyOAuth` - Added the market parameter to `album_tracks` @@ -323,10 +345,10 @@ Add your changes below. ### Changed - Updated the documentation to give more details on the authorization process and reflect - 2020 Spotify Application jargon and practices. + 2020 Spotify Application jargon and practices. - The local webserver is only started for localhost redirect_uri which specify a port, - i.e. it is started for `http://localhost:8080` or `http://127.0.0.1:8080`, not for `http://localhost`. + i.e. it is started for `http://localhost:8080` or `http://127.0.0.1:8080`, not for `http://localhost`. ### Fixed @@ -349,10 +371,10 @@ Add your changes below. - Client retry logic has changed as it now uses urllib3's `Retry` in conjunction with requests `Session` - The session is customizable as it allows for: - - status_forcelist - - retries - - status_retries - - backoff_factor + - status_forcelist + - retries + - status_retries + - backoff_factor - Spin up a local webserver to autofill authentication URL - Use session in SpotifyAuthBase - Logging used instead of print statements @@ -367,9 +389,9 @@ Add your changes below. ### Added - Support for `add_to_queue` - - **Parameters:** - - track uri, id, or url - - device id. If None, then the active device is used. + - **Parameters:** + - track uri, id, or url + - device id. If None, then the active device is used. - Add CHANGELOG and LICENSE to released package ## [2.9.0] - 2020-02-15 @@ -440,7 +462,7 @@ Add your changes below. ### Fixed - Fixed inconsistent behaviour with some API methods when - a full HTTP URL is passed. + a full HTTP URL is passed. - Fixed invalid calls to logging warn method ### Removed @@ -466,6 +488,7 @@ Add your changes below. ### Changed - Made instructions in the CONTRIBUTING.md file more clear such that it is easier to onboard and there are no conflicts with TUTORIAL.md + ## [2.5.0] - 2020-01-11 Added follow and player endpoints diff --git a/examples b/examples deleted file mode 160000 index c610a79..0000000 --- a/examples +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c610a79705ef4aa55e4d61572a012f77b6f7245d diff --git a/spotipy/cache_handler.py b/spotipy/cache_handler.py index 7b3b2c3..4a1ee83 100644 --- a/spotipy/cache_handler.py +++ b/spotipy/cache_handler.py @@ -41,7 +41,6 @@ class CacheHandler(): Save a token_info dictionary object to the cache and return None. """ raise NotImplementedError() - return None class CacheFileHandler(CacheHandler): @@ -77,24 +76,24 @@ class CacheFileHandler(CacheHandler): token_info = None try: - f = open(self.cache_path) - token_info_string = f.read() - f.close() + with open(self.cache_path, encoding='utf-8') as f: + token_info_string = f.read() token_info = json.loads(token_info_string) except OSError as error: if error.errno == errno.ENOENT: logger.debug(f"cache does not exist at: {self.cache_path}") else: - logger.warning(f"Couldn't read cache at: {self.cache_path}") + logger.warning("Couldn't read cache at: %s", self.cache_path) + except json.JSONDecodeError: + logger.warning("Couldn't decode JSON from cache at: %s", self.cache_path) return token_info def save_token_to_cache(self, token_info): try: - f = open(self.cache_path, "w") - f.write(json.dumps(token_info, cls=self.encoder_cls)) - f.close() + with open(self.cache_path, "w", encoding='utf-8') as f: + f.write(json.dumps(token_info, cls=self.encoder_cls)) except OSError: logger.warning(f"Couldn't write token to cache at: {self.cache_path}") @@ -214,6 +213,7 @@ class RedisCacheHandler(CacheHandler): class MemcacheCacheHandler(CacheHandler): """A Cache handler that stores the token info in Memcache using the pymemcache client """ + def __init__(self, memcache, key=None) -> None: """ Parameters: diff --git a/spotipy/util.py b/spotipy/util.py index 069a75c..4aeef09 100644 --- a/spotipy/util.py +++ b/spotipy/util.py @@ -153,6 +153,7 @@ class Retry(urllib3.Retry): """ Custom class for printing a warning when a rate/request limit is reached. """ + def increment( self, method: str | None = None, diff --git a/tests/unit/test_oauth.py b/tests/unit/test_oauth.py index c737a65..84bb0b4 100644 --- a/tests/unit/test_oauth.py +++ b/tests/unit/test_oauth.py @@ -52,18 +52,21 @@ class OAuthCacheTest(unittest.TestCase): @patch('spotipy.cache_handler.open', create=True) def test_gets_from_cache_path(self, opener, is_token_expired, refresh_access_token): + """Test that the token is retrieved from the cache path.""" scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) - - opener.return_value = _token_file(json.dumps(tok, ensure_ascii=False)) + token_file = _token_file(json.dumps(tok, ensure_ascii=False)) + opener.return_value = token_file + opener.return_value.__enter__ = mock.Mock(return_value=token_file) + opener.return_value.__exit__ = mock.Mock(return_value=False) is_token_expired.return_value = False spot = _make_oauth(scope, path) cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) cached_tok_legacy = spot.get_cached_token() - opener.assert_called_with(path) + opener.assert_called_with(path, encoding='utf-8') self.assertIsNotNone(cached_tok) self.assertIsNotNone(cached_tok_legacy) self.assertEqual(refresh_access_token.call_count, 0) @@ -73,13 +76,15 @@ class OAuthCacheTest(unittest.TestCase): @patch('spotipy.cache_handler.open', create=True) def test_expired_token_refreshes(self, opener, is_token_expired, refresh_access_token): + """Test that an expired token is refreshed.""" scope = "playlist-modify-private" path = ".cache-username" expired_tok = _make_fake_token(0, None, scope) fresh_tok = _make_fake_token(1, 1, scope) token_file = _token_file(json.dumps(expired_tok, ensure_ascii=False)) - opener.return_value = token_file + opener.return_value.__enter__ = mock.Mock(return_value=token_file) + opener.return_value.__exit__ = mock.Mock(return_value=False) refresh_access_token.return_value = fresh_tok spot = _make_oauth(scope, path) @@ -87,7 +92,7 @@ class OAuthCacheTest(unittest.TestCase): is_token_expired.assert_called_with(expired_tok) refresh_access_token.assert_called_with(expired_tok['refresh_token']) - opener.assert_any_call(path) + opener.assert_any_call(path, encoding='utf-8') @patch.multiple(SpotifyOAuth, is_token_expired=DEFAULT, refresh_access_token=DEFAULT) @@ -99,29 +104,35 @@ class OAuthCacheTest(unittest.TestCase): path = ".cache-username" tok = _make_fake_token(1, 1, token_scope) - opener.return_value = _token_file(json.dumps(tok, ensure_ascii=False)) + token_file = _token_file(json.dumps(tok, ensure_ascii=False)) + opener.return_value = token_file + opener.return_value.__enter__ = mock.Mock(return_value=token_file) + opener.return_value.__exit__ = mock.Mock(return_value=False) is_token_expired.return_value = False spot = _make_oauth(requested_scope, path) cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) - opener.assert_called_with(path) + opener.assert_called_with(path, encoding='utf-8') self.assertIsNone(cached_tok) self.assertEqual(refresh_access_token.call_count, 0) @patch('spotipy.cache_handler.open', create=True) def test_saves_to_cache_path(self, opener): + """Test that the token is saved to the cache path.""" scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) fi = _fake_file() opener.return_value = fi + opener.return_value.__enter__ = mock.Mock(return_value=fi) + opener.return_value.__exit__ = mock.Mock(return_value=False) spot = SpotifyOAuth("CLID", "CLISEC", "REDIR", "STATE", scope, path) spot.cache_handler.save_token_to_cache(tok) - opener.assert_called_with(path, 'w') + opener.assert_called_with(path, 'w', encoding='utf-8') self.assertTrue(fi.write.called) @patch('spotipy.cache_handler.open', create=True) @@ -132,11 +143,13 @@ class OAuthCacheTest(unittest.TestCase): fi = _fake_file() opener.return_value = fi + opener.return_value.__enter__ = mock.Mock(return_value=fi) + opener.return_value.__exit__ = mock.Mock(return_value=False) spot = SpotifyOAuth("CLID", "CLISEC", "REDIR", "STATE", scope, path) spot._save_token_info(tok) - opener.assert_called_with(path, 'w') + opener.assert_called_with(path, 'w', encoding='utf-8') self.assertTrue(fi.write.called) def test_cache_handler(self): @@ -252,32 +265,38 @@ class ImplicitGrantCacheTest(unittest.TestCase): path = ".cache-username" tok = _make_fake_token(1, 1, scope) - opener.return_value = _token_file(json.dumps(tok, ensure_ascii=False)) + token_file = _token_file(json.dumps(tok, ensure_ascii=False)) + opener.return_value = token_file + opener.return_value.__enter__ = mock.Mock(return_value=token_file) + opener.return_value.__exit__ = mock.Mock(return_value=False) is_token_expired.return_value = False spot = _make_implicitgrantauth(scope, path) cached_tok = spot.cache_handler.get_cached_token() cached_tok_legacy = spot.get_cached_token() - opener.assert_called_with(path) + opener.assert_called_with(path, encoding='utf-8') self.assertIsNotNone(cached_tok) self.assertIsNotNone(cached_tok_legacy) @patch.object(SpotifyImplicitGrant, "is_token_expired", DEFAULT) @patch('spotipy.cache_handler.open', create=True) def test_expired_token_returns_none(self, opener, is_token_expired): + """Test that an expired token returns None.""" scope = "playlist-modify-private" path = ".cache-username" expired_tok = _make_fake_token(0, None, scope) token_file = _token_file(json.dumps(expired_tok, ensure_ascii=False)) opener.return_value = token_file + opener.return_value.__enter__ = mock.Mock(return_value=token_file) + opener.return_value.__exit__ = mock.Mock(return_value=False) spot = _make_implicitgrantauth(scope, path) cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) is_token_expired.assert_called_with(expired_tok) - opener.assert_any_call(path) + opener.assert_any_call(path, encoding='utf-8') self.assertIsNone(cached_tok) @patch.object(SpotifyImplicitGrant, "is_token_expired", DEFAULT) @@ -288,13 +307,16 @@ class ImplicitGrantCacheTest(unittest.TestCase): path = ".cache-username" tok = _make_fake_token(1, 1, token_scope) - opener.return_value = _token_file(json.dumps(tok, ensure_ascii=False)) + token_file = _token_file(json.dumps(tok, ensure_ascii=False)) + opener.return_value = token_file + opener.return_value.__enter__ = mock.Mock(return_value=token_file) + opener.return_value.__exit__ = mock.Mock(return_value=False) is_token_expired.return_value = False spot = _make_implicitgrantauth(requested_scope, path) cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) - opener.assert_called_with(path) + opener.assert_called_with(path, encoding='utf-8') self.assertIsNone(cached_tok) @patch('spotipy.cache_handler.open', create=True) @@ -306,10 +328,12 @@ class ImplicitGrantCacheTest(unittest.TestCase): fi = _fake_file() opener.return_value = fi + opener.return_value.__enter__ = mock.Mock(return_value=fi) + opener.return_value.__exit__ = mock.Mock(return_value=False) spot = SpotifyImplicitGrant("CLID", "REDIR", "STATE", scope, path) spot.cache_handler.save_token_to_cache(tok) - opener.assert_called_with(path, 'w') + opener.assert_called_with(path, 'w', encoding='utf-8') self.assertTrue(fi.write.called) @patch('spotipy.cache_handler.open', create=True) @@ -320,11 +344,13 @@ class ImplicitGrantCacheTest(unittest.TestCase): fi = _fake_file() opener.return_value = fi + opener.return_value.__enter__ = mock.Mock(return_value=fi) + opener.return_value.__exit__ = mock.Mock(return_value=False) spot = SpotifyImplicitGrant("CLID", "REDIR", "STATE", scope, path) spot._save_token_info(tok) - opener.assert_called_with(path, 'w') + opener.assert_called_with(path, 'w', encoding='utf-8') self.assertTrue(fi.write.called) @@ -389,14 +415,17 @@ class SpotifyPKCECacheTest(unittest.TestCase): path = ".cache-username" tok = _make_fake_token(1, 1, scope) - opener.return_value = _token_file(json.dumps(tok, ensure_ascii=False)) + token_file = _token_file(json.dumps(tok, ensure_ascii=False)) + opener.return_value = token_file + opener.return_value.__enter__ = mock.Mock(return_value=token_file) + opener.return_value.__exit__ = mock.Mock(return_value=False) is_token_expired.return_value = False spot = _make_pkceauth(scope, path) cached_tok = spot.cache_handler.get_cached_token() cached_tok_legacy = spot.get_cached_token() - opener.assert_called_with(path) + opener.assert_called_with(path, encoding='utf-8') self.assertIsNotNone(cached_tok) self.assertIsNotNone(cached_tok_legacy) self.assertEqual(refresh_access_token.call_count, 0) @@ -412,7 +441,8 @@ class SpotifyPKCECacheTest(unittest.TestCase): fresh_tok = _make_fake_token(1, 1, scope) token_file = _token_file(json.dumps(expired_tok, ensure_ascii=False)) - opener.return_value = token_file + opener.return_value.__enter__ = mock.Mock(return_value=token_file) + opener.return_value.__exit__ = mock.Mock(return_value=False) refresh_access_token.return_value = fresh_tok spot = _make_pkceauth(scope, path) @@ -420,7 +450,7 @@ class SpotifyPKCECacheTest(unittest.TestCase): is_token_expired.assert_called_with(expired_tok) refresh_access_token.assert_called_with(expired_tok['refresh_token']) - opener.assert_any_call(path) + opener.assert_any_call(path, encoding='utf-8') @patch.multiple(SpotifyPKCE, is_token_expired=DEFAULT, refresh_access_token=DEFAULT) @@ -432,13 +462,16 @@ class SpotifyPKCECacheTest(unittest.TestCase): path = ".cache-username" tok = _make_fake_token(1, 1, token_scope) - opener.return_value = _token_file(json.dumps(tok, ensure_ascii=False)) + token_file = _token_file(json.dumps(tok, ensure_ascii=False)) + opener.return_value = token_file + opener.return_value.__enter__ = mock.Mock(return_value=token_file) + opener.return_value.__exit__ = mock.Mock(return_value=False) is_token_expired.return_value = False spot = _make_pkceauth(requested_scope, path) cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) - opener.assert_called_with(path) + opener.assert_called_with(path, encoding='utf-8') self.assertIsNone(cached_tok) self.assertEqual(refresh_access_token.call_count, 0) @@ -450,11 +483,12 @@ class SpotifyPKCECacheTest(unittest.TestCase): fi = _fake_file() opener.return_value = fi - + opener.return_value.__enter__ = mock.Mock(return_value=fi) + opener.return_value.__exit__ = mock.Mock(return_value=False) spot = SpotifyPKCE("CLID", "REDIR", "STATE", scope, path) spot.cache_handler.save_token_to_cache(tok) - opener.assert_called_with(path, 'w') + opener.assert_called_with(path, 'w', encoding='utf-8') self.assertTrue(fi.write.called) @patch('spotipy.cache_handler.open', create=True) @@ -465,11 +499,13 @@ class SpotifyPKCECacheTest(unittest.TestCase): fi = _fake_file() opener.return_value = fi + opener.return_value.__enter__ = mock.Mock(return_value=fi) + opener.return_value.__exit__ = mock.Mock(return_value=False) spot = SpotifyPKCE("CLID", "REDIR", "STATE", scope, path) spot._save_token_info(tok) - opener.assert_called_with(path, 'w') + opener.assert_called_with(path, 'w', encoding='utf-8') self.assertTrue(fi.write.called)