pomice/pomice/spotify/oauth.py

203 lines
5.7 KiB
Python

from urllib.parse import quote_plus as quote
from typing import Optional, Dict, Iterable, Union, Set, Callable, Tuple, Any
VALID_SCOPES = (
# Playlists
"playlist-read-collaborative"
"playlist-modify-private"
"playlist-modify-public"
"playlist-read-private"
# Spotify Connect
"user-modify-playback-state"
"user-read-currently-playing"
"user-read-playback-state"
# Users
"user-read-private"
"user-read-email"
# Library
"user-library-modify"
"user-library-read"
# Follow
"user-follow-modify"
"user-follow-read"
# Listening History
"user-read-recently-played"
"user-top-read"
# Playback
"streaming"
"app-remote-control"
)
def set_required_scopes(*scopes: Optional[str]) -> Callable:
"""A decorator that lets you attach metadata to functions.
Parameters
----------
scopes : :class:`str`
A series of scopes that are required.
Returns
-------
decorator : :class:`typing.Callable`
The decorator that sets the scope metadata.
"""
def decorate(func) -> Callable:
func.__requires_spotify_scopes__ = tuple(scopes)
return func
return decorate
def get_required_scopes(func: Callable[..., Any]) -> Tuple[str, ...]:
"""Get the required scopes for a function.
Parameters
----------
func : Callable[..., Any]
The function to inspect.
Returns
-------
scopes : Tuple[:class:`str`, ...]
A tuple of scopes required for a call to succeed.
"""
if not hasattr(func, "__requires_spotify_scopes__"):
raise AttributeError("Scope metadata has not been set for this object!")
return func.__requires_spotify_scopes__ # type: ignore
class OAuth2:
"""Helper object for Spotify OAuth2 operations.
At a very basic level you can you oauth2 only for authentication.
>>> oauth2 = OAuth2(client, 'some://redirect/uri')
>>> print(oauth2.url)
Working with scopes:
>>> oauth2 = OAuth2(client, 'some://redirect/uri', scopes=['user-read-currently-playing'])
>>> oauth2.set_scopes(user_read_playback_state=True)
Parameters
----------
client_id : :class:`str`
The client id provided by spotify for the app.
redirect_uri : :class:`str`
The URI Spotify should redirect to.
scopes : Optional[Iterable[:class:`str`], Dict[:class:`str`, :class:`bool`]]
The scopes to be requested.
state : Optional[:class:`str`]
The state used to secure sessions.
Attributes
----------
attrs : Dict[:class:`str`, :class:`str`]
The attributes used when constructing url parameters
parameters : :class:`str`
The URL parameters used
url : :class:`str`
The URL for OAuth2
"""
_BASE = "https://accounts.spotify.com/authorize/?response_type=code&{parameters}"
def __init__(
self,
client_id: str,
redirect_uri: str,
*,
scopes: Optional[Union[Iterable[str], Dict[str, bool]]] = None,
state: str = None,
):
self.client_id = client_id
self.redirect_uri = redirect_uri
self.state = state
self.__scopes: Set[str] = set()
if scopes is not None:
if not isinstance(scopes, dict) and hasattr(scopes, "__iter__"):
scopes = {scope: True for scope in scopes}
if isinstance(scopes, dict):
self.set_scopes(**scopes)
else:
raise TypeError(
f"scopes must be an iterable of strings or a dict of string to bools"
)
def __repr__(self):
return f"<spotfy.OAuth2: client_id={self.client_id!r}, scope={self.scopes!r}>"
def __str__(self):
return self.url
# Alternate constructors
@classmethod
def from_client(cls, client, *args, **kwargs):
"""Construct a OAuth2 object from a `spotify.Client`."""
return cls(client.http.client_id, *args, **kwargs)
@staticmethod
def url_only(**kwargs) -> str:
"""Construct a OAuth2 URL instead of an OAuth2 object."""
oauth = OAuth2(**kwargs)
return oauth.url
# Properties
@property
def scopes(self):
""":class:`frozenset` - A frozenset of the current scopes"""
return frozenset(self.__scopes)
@property
def attributes(self):
"""Attributes used when constructing url parameters."""
data = {"client_id": self.client_id, "redirect_uri": quote(self.redirect_uri)}
if self.scopes:
data["scope"] = quote(" ".join(self.scopes))
if self.state is not None:
data["state"] = self.state
return data
attrs = attributes
@property
def parameters(self) -> str:
""":class:`str` - The formatted url parameters that are used."""
return "&".join("{0}={1}".format(*item) for item in self.attributes.items())
@property
def url(self) -> str:
""":class:`str` - The formatted oauth url used for authorization."""
return self._BASE.format(parameters=self.parameters)
# Public api
def set_scopes(self, **scopes: Dict[str, bool]) -> None:
r"""Modify the scopes for the current oauth2 object.
Add or remove certain scopes from this oauth2 instance.
Since hypens are not allowed, replace the _ with -.
>>> oauth2.set_scopes(user_read_playback_state=True)
Parameters
----------
\*\*scopes: Dict[:class:`str`, :class:`bool`]
The scopes to enable or disable.
"""
for scope_name, state in scopes.items():
scope_name = scope_name.replace("_","-")
if state:
self.__scopes.add(scope_name)
else:
self.__scopes.remove(scope_name)