Merge branch 'v3' into master

This commit is contained in:
Peter Schorn 2021-04-14 12:15:47 -05:00 committed by GitHub
commit aa9652f1fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 313 additions and 62 deletions

View File

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [2.7, 3.5, 3.6, 3.7, 3.8]
python-version: [3.5, 3.6, 3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2

View File

@ -5,7 +5,24 @@ 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
## Unreleased [3.0.0-alpha]
While this is unreleased, please only add v3 features here. Rebasing master onto v3 doesn't require a changelog update.
### Added
* `Scope` - An enum which contains all of the authorization scopes (see [here](https://github.com/plamere/spotipy/issues/652#issuecomment-797461311)).
### Changed
* Made `CacheHandler` an abstract base class
* Modified the return structure of the `audio_features` function (wrapping the [Get Audio Features for Several Tracks](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-audio-features) API) to conform to the return structure of the similar methods listed below. The functions wrapping these APIs do not unwrap the single key JSON response, and this is currently the only function that does this.
* [Get Several Tracks](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-tracks)
* [Get Multiple Artists](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-artists)
* [Get Multiple Albums](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-albums)
## Unreleased [2.x.x]
### Added
@ -138,6 +155,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `playlist_tracks` in favor of `playlist_items`
### Fixed
- fixed issue where episode URIs were being converted to track URIs in playlist calls
## [2.13.0] - 2020-06-25
@ -246,6 +264,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optional `show_dialog` parameter to be passed to `SpotifyOAuth`
### Changed
- Both `SpotifyClientCredentials` and `SpotifyOAuth` inherit from a common `SpotifyAuthBase` which handles common parameters and logics.
## [2.7.1] - 2020-01-20
@ -289,16 +308,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [2.6.1] - 2020-01-13
### Fixed
- Fixed inconsistent behaviour with some API methods when
a full HTTP URL is passed.
- Fixed invalid calls to logging warn method
### Removed
- `mock` no longer needed for install. Only used in `tox`.
## [2.6.0] - 2020-01-12
### Added
- Support for `playlist` to get a playlist without specifying a user
- Support for `current_user_saved_albums_delete`
- Support for `current_user_saved_albums_contains`
@ -307,95 +329,126 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Lint with flake8 using Github action
### Changed
- Fix typos in doc
- Start following [SemVer](https://semver.org) properly
## [2.5.0] - 2020-01-11
Added follow and player endpoints
## [2.4.4] - 2017-01-04
Python 3 fix
## [2.4.3] - 2017-01-02
Fixed proxy issue in standard auth flow
## [2.4.2] - 2017-01-02
Support getting audio features for a single track
## [2.4.1] - 2017-01-02
Incorporated proxy support
## [2.4.0] - 2016-12-31
Incorporated a number of PRs
## [2.3.8] - 2016-03-31
Added recs, audio features, user top lists
## [2.3.7] - 2015-08-10
Added current_user_followed_artists
## [2.3.6] - 2015-06-03
Support for offset/limit with album_tracks API
## [2.3.5] - 2015-04-28
Fixed bug in auto retry logic
## [2.3.3] - 2015-04-01
Aadded client credential flow
## [2.3.2] - 2015-03-31
Added auto retry logic
## [2.3.0] - 2015-01-05
Added session support added by akx.
## [2.2.0] - 2014-11-15
Added support for user_playlist_tracks
## [2.1.0] - 2014-10-25
Added support for new_releases and featured_playlists
## [2.0.2] - 2014-08-25
Moved to spotipy at pypi
## [1.2.0] - 2014-08-22
Upgraded APIs and docs to make it be a real library
## [1.310.0] - 2014-08-20
Added playlist replace and remove methods. Added auth tests. Improved API docs
## [1.301.0] - 2014-08-19
Upgraded version number to take precedence over previously botched release (sigh)
## [1.50.0] - 2014-08-14
Refactored util out of examples and into the main package
## [1.49.0] - 2014-07-23
Support for "Your Music" tracks (add, delete, get), with examples
## [1.45.0] - 2014-07-07
Support for related artists endpoint. Don't use cache auth codes when scope changes
## [1.44.0] - 2014-07-03
Added show tracks.py example
## [1.43.0] - 2014-06-27
Fixed JSON handling issue
## [1.42.0] - 2014-06-19
Removed dependency on simplejson
## [1.40.0] - 2014-06-12
Initial public release.
## [1.4.2] - 2014-06-21
Added support for retrieving starred playlists
## [1.1.0] - 2014-06-17
Updates to match released API
## [1.1.0] - 2014-05-18
Repackaged for saner imports
## [1.0.0] - 2017-04-05
Initial release

View File

@ -18,7 +18,7 @@ extra_reqs = {
setup(
name='spotipy',
version='2.18.0',
version='3.0.0-alpha',
description='A light weight Python library for the Spotify Web API',
long_description=long_description,
long_description_content_type="text/markdown",

View File

@ -3,3 +3,4 @@ from .client import * # noqa
from .exceptions import * # noqa
from .oauth2 import * # noqa
from .util import * # noqa
from .scope import * # noqa

View File

@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
__all__ = ['CacheHandler', 'CacheFileHandler', 'MemoryCacheHandler']
import errno
@ -5,33 +7,32 @@ import json
import logging
import os
from spotipy.util import CLIENT_CREDS_ENV_VARS
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):

View File

@ -1221,7 +1221,6 @@ class Spotify(object):
- 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, market=market)
@ -1347,6 +1346,7 @@ class Spotify(object):
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))
@ -1675,11 +1675,6 @@ class Spotify(object):
else:
tlist = [self._get_id("track", t) for t in tracks]
results = self._get("audio-features/?ids=" + ",".join(tlist))
# the response has changed, look for the new style first, and if
# its not there, fallback on the old style
if "audio_features" in results:
return results["audio_features"]
else:
return results
def devices(self):

View File

@ -24,7 +24,10 @@ 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.util import CLIENT_CREDS_ENV_VARS, get_host_port, normalize_scope
from spotipy.util import CLIENT_CREDS_ENV_VARS, get_host_port
from spotipy.scope import Scope
from typing import Iterable
import re
logger = logging.getLogger(__name__)
@ -72,6 +75,7 @@ def _ensure_value(value, env_key):
class SpotifyAuthBase(object):
def __init__(self, requests_session):
if isinstance(requests_session, requests.Session):
self._session = requests_session
@ -83,7 +87,40 @@ class SpotifyAuthBase(object):
self._session = api
def _normalize_scope(self, scope):
return normalize_scope(scope)
"""
Accepts a string of scopes, or an iterable with elements of type
`Scope` or `str` and returns a space-separated string of scopes.
Returns `None` if the argument is `None`.
"""
# TODO: do we need to sort the scopes?
if isinstance(scope, str):
# allow for any separator(s) between the scopes other than a word
# character or a hyphen
scopes = re.split(pattern=r"[^\w-]+", string=scope)
return " ".join(sorted(scopes))
if isinstance(scope, Iterable):
# Assume all of the iterable's elements are of the same type.
# If the iterable is empty, then return None.
first_element = next(iter(scope), None)
if isinstance(first_element, str):
return " ".join(sorted(scope))
if isinstance(first_element, Scope):
return Scope.make_string(scope)
if first_element is None:
return ""
elif scope is None:
return None
raise TypeError(
"Unsupported type for scopes: %s. Expected either a string of scopes, or "
"an Iterable with elements of type `Scope` or `str`." % type(scope)
)
@property
def client_id(self):
@ -289,7 +326,11 @@ class SpotifyOAuth(SpotifyAuthBase):
* 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.
* scope: Optional, either a string of scopes, or an iterable with elements of type
`Scope` or `str`. E.g.,
{Scope.user_modify_playback_state, Scope.user_library_read}
iterable of scopes or comma separated string of scopes.
e.g, "playlist-read-private,playlist-read-collaborative"
* cache_path: (deprecated) Optional, will otherwise be generated
(takes precedence over `username`)
@ -650,8 +691,9 @@ class SpotifyPKCE(SpotifyAuthBase):
* client_id: 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"
* scope: Optional, either a string of scopes, or an iterable with elements of type
`Scope` or `str`. E.g.,
{Scope.user_modify_playback_state, Scope.user_library_read}
* cache_path: (deprecated) Optional, will otherwise be generated
(takes precedence over `username`)
* username: (deprecated) Optional or set as environment variable
@ -1050,8 +1092,9 @@ class SpotifyImplicitGrant(SpotifyAuthBase):
* client_id: Must be supplied or set as environment variable
* redirect_uri: Must be supplied or set as environment variable
* state: May be supplied, 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"
* scope: Optional, either a string of scopes, or an iterable with elements of type
`Scope` or `str`. E.g.,
{Scope.user_modify_playback_state, Scope.user_library_read}
* cache_handler: An instance of the `CacheHandler` class to handle
getting and saving cached authorization tokens.
May be supplied, will otherwise use `CacheFileHandler`.

85
spotipy/scope.py Normal file
View File

@ -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

View File

@ -7,7 +7,6 @@ __all__ = ["CLIENT_CREDS_ENV_VARS", "prompt_for_user_token"]
import logging
import os
import warnings
import spotipy
LOGGER = logging.getLogger(__name__)
@ -117,19 +116,3 @@ def get_host_port(netloc):
port = None
return host, port
def normalize_scope(scope):
if scope:
if isinstance(scope, str):
scopes = scope.split(',')
elif isinstance(scope, list) or isinstance(scope, tuple):
scopes = scope
else:
raise Exception(
"Unsupported scope value, please either provide a list of scopes, "
"or a string of scopes separated by commas"
)
return " ".join(sorted(scopes))
else:
return None

View File

@ -67,19 +67,19 @@ class AuthTestSpotipy(unittest.TestCase):
def test_audio_features(self):
results = self.spotify.audio_features(self.four_tracks)
self.assertTrue(len(results) == len(self.four_tracks))
for track in results:
self.assertTrue(len(results['audio_features']) == len(self.four_tracks))
for track in results['audio_features']:
assert('speechiness' in track)
def test_audio_features_with_bad_track(self):
bad_tracks = ['spotify:track:bad']
input = self.four_tracks + bad_tracks
results = self.spotify.audio_features(input)
self.assertTrue(len(results) == len(input))
for track in results[:-1]:
self.assertTrue(len(results['audio_features']) == len(input))
for track in results['audio_features'][:-1]:
if track is not None:
assert('speechiness' in track)
self.assertTrue(results[-1] is None)
self.assertTrue(results['audio_features'][-1] is None)
def test_recommendations(self):
results = self.spotify.recommendations(

90
tests/unit/test_scopes.py Normal file
View File

@ -0,0 +1,90 @@
from unittest import TestCase
from spotipy.scope import Scope
from spotipy.oauth2 import SpotifyAuthBase
class SpotipyScopeTest(TestCase):
@classmethod
def setUpClass(cls):
cls.auth_manager = SpotifyAuthBase(requests_session=True)
def normalize_scope(self, scope):
return self.auth_manager._normalize_scope(scope)
def test_empty_scope(self):
scopes = set()
scope_string = Scope.make_string(scopes)
normalized_scope_string = self.normalize_scope(scopes)
normalized_scope_string_2 = self.normalize_scope(scope_string)
self.assertEqual(scope_string, "")
self.assertEqual(normalized_scope_string, "")
self.assertEqual(normalized_scope_string_2, "")
converted_scopes = Scope.from_string(scope_string)
self.assertEqual(converted_scopes, set())
def test_scopes(self):
scopes = {
Scope.playlist_modify_public,
Scope.playlist_read_collaborative,
Scope.user_read_playback_state,
Scope.ugc_image_upload
}
normalized_scope_string = self.normalize_scope(scopes)
scope_string = Scope.make_string(scopes)
self.assertEqual(scope_string, normalized_scope_string)
normalized_scope_string_2 = self.normalize_scope(scope_string)
converted_scopes = Scope.from_string(scope_string)
normalized_converted_scope = Scope.from_string(normalized_scope_string)
normalized_converted_scope_2 = Scope.from_string(normalized_scope_string_2)
self.assertEqual(scopes, converted_scopes)
self.assertEqual(scopes, normalized_converted_scope)
self.assertEqual(scopes, normalized_converted_scope_2)
def test_single_scope(self):
scope_string = "user-modify-playback-state"
scope = Scope(scope_string)
self.assertEqual(scope, Scope.user_modify_playback_state)
self.assertEqual(scope_string, scope.value)
def test_scope_string(self):
scope_string = (
"user-read-currently-playing playlist-read-collaborative,user-library-read "
"playlist-read-private user-read-email"
)
expected_scopes = {
Scope.user_read_currently_playing,
Scope.playlist_read_collaborative,
Scope.user_library_read,
Scope.playlist_read_private,
Scope.user_read_email
}
parsed_scopes = Scope.from_string(scope_string)
normalized_scope_string = self.normalize_scope(scope_string)
normalized_parsed_scopes = Scope.from_string(normalized_scope_string)
self.assertEqual(parsed_scopes, expected_scopes)
self.assertEqual(normalized_parsed_scopes, expected_scopes)
def test_invalid_types(self):
numbers = [1, 2, 3]
with self.assertRaises(TypeError):
self.normalize_scope(numbers)
with self.assertRaises(TypeError):
self.normalize_scope(True)
def test_normalize_scope(self):
normalized_scope_string = self.normalize_scope([])
self.assertEqual(normalized_scope_string, "")
normalized_scope_string_2 = self.normalize_scope(())
self.assertEqual(normalized_scope_string_2, "")
self.assertIsNone(self.normalize_scope(None))