Skip to content

Commit

Permalink
update for aiorequestful v0.2.0 changes
Browse files Browse the repository at this point in the history
  • Loading branch information
geo-martino committed Jul 8, 2024
1 parent 32241ac commit eab4d15
Show file tree
Hide file tree
Showing 14 changed files with 105 additions and 138 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ For more detailed guides, check out the [documentation](https://geo-martino.gith
spotify_api = SpotifyAPI(
client_id="<YOUR CLIENT ID>",
client_secret="<YOUR CLIENT SECRET>",
scopes=[
scope=[
"user-library-read",
"user-follow-read",
"playlist-read-collaborative",
Expand Down
2 changes: 1 addition & 1 deletion README.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ For more detailed guides, check out the [documentation](https://{program_owner_u
spotify_api = SpotifyAPI(
client_id="<YOUR CLIENT ID>",
client_secret="<YOUR CLIENT SECRET>",
scopes=[
scope=[
"user-library-read",
"user-follow-read",
"playlist-read-collaborative",
Expand Down
2 changes: 1 addition & 1 deletion docs/_howto/scripts/spotify.api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
api = SpotifyAPI(
client_id="<YOUR CLIENT ID>",
client_secret="<YOUR CLIENT SECRET>",
scopes=[
scope=[
"user-library-read",
"user-follow-read",
"playlist-read-collaborative",
Expand Down
17 changes: 7 additions & 10 deletions musify/libraries/remote/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
from collections.abc import Collection, MutableMapping, Mapping, Sequence, Iterable
from typing import Any, Self

from yarl import URL

from aiorequestful.authorise import APIAuthoriser
from aiorequestful.auth import Authoriser
from aiorequestful.cache.backend.base import ResponseCache
from aiorequestful.exception import CacheError
from aiorequestful.request import RequestHandler
from aiorequestful.types import ImmutableJSON
from yarl import URL

from musify.libraries.remote.core import RemoteResponse
from musify.libraries.remote.core.types import APIInputValueSingle, APIInputValueMulti, RemoteIDType, RemoteObjectType
from musify.libraries.remote.core.wrangle import RemoteDataWrangler
Expand All @@ -26,7 +26,7 @@
class RemoteAPI(metaclass=ABCMeta):
"""
Collection of endpoints for a remote API.
See :py:class:`RequestHandler` and :py:class:`APIAuthoriser`
See :py:class:`RequestHandler` and :py:class:`Authoriser`
for more info on which params to pass to authorise and execute requests.
:param wrangler: The :py:class:`RemoteDataWrangler` for this API type.
Expand Down Expand Up @@ -76,7 +76,7 @@ def source(self) -> str:
"""The name of the API service"""
return self.wrangler.source

def __init__(self, authoriser: APIAuthoriser, wrangler: RemoteDataWrangler, cache: ResponseCache | None = None):
def __init__(self, authoriser: Authoriser, wrangler: RemoteDataWrangler, cache: ResponseCache | None = None):
# noinspection PyTypeChecker
#: The :py:class:`MusifyLogger` for this object
self.logger: MusifyLogger = logging.getLogger(__name__)
Expand Down Expand Up @@ -113,17 +113,14 @@ async def _setup_cache(self) -> None:
"""Set up the repositories and repository getter on the self.handler.session's cache."""
raise NotImplementedError

async def authorise(self, force_load: bool = False, force_new: bool = False) -> Self:
async def authorise(self) -> Self:
"""
Main method for authorisation, tests/refreshes/reauthorises as needed
:param force_load: Reloads the token even if it's already been loaded into the object.
Ignored when force_new is True.
:param force_new: Ignore saved/loaded token and generate new token.
:return: Self.
:raise APIError: If the token cannot be validated.
"""
await self.handler.authorise(force_load=force_load, force_new=force_new)
await self.handler.authorise()
return self

async def close(self) -> None:
Expand Down
108 changes: 45 additions & 63 deletions musify/libraries/remote/spotify/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,64 +3,23 @@
Also includes the default arguments to be used when requesting authorisation from the Spotify API.
"""
import base64
from collections.abc import Iterable
from copy import deepcopy
from pathlib import Path

from yarl import URL

from musify import PROGRAM_NAME
from aiorequestful.authorise import APIAuthoriser
from aiohttp import ClientResponse
from aiorequestful.auth import AuthRequest
from aiorequestful.auth.oauth2 import AuthorisationCodeFlow
from aiorequestful.cache.backend.base import ResponseCache, ResponseRepository
from aiorequestful.cache.session import CachedSession
from aiorequestful.types import Method
from yarl import URL

from musify.libraries.remote.core.exception import APIError
from musify.libraries.remote.spotify.api.cache import SpotifyRequestSettings, SpotifyPaginatedRequestSettings
from musify.libraries.remote.spotify.api.item import SpotifyAPIItems
from musify.libraries.remote.spotify.api.misc import SpotifyAPIMisc
from musify.libraries.remote.spotify.api.playlist import SpotifyAPIPlaylists
from musify.libraries.remote.spotify.wrangle import SpotifyDataWrangler
from musify.utils import safe_format_map, merge_maps

URL_AUTH = "https://accounts.spotify.com"

# user authenticated access with scopes
SPOTIFY_API_AUTH_ARGS = {
"auth_args": {
"url": f"{URL_AUTH}/api/token",
"data": {
"grant_type": "authorization_code",
},
"headers": {
"content-type": "application/x-www-form-urlencoded",
"Authorization": "Basic {client_base64}"
},
},
"user_args": {
"url": f"{URL_AUTH}/authorize",
"params": {
"client_id": "{client_id}",
"response_type": "code",
"state": PROGRAM_NAME,
"scope": "{scopes}",
"show_dialog": False,
},
},
"refresh_args": {
"url": f"{URL_AUTH}/api/token",
"data": {
"grant_type": "refresh_token",
},
"headers": {
"content-type": "application/x-www-form-urlencoded",
"Authorization": "Basic {client_base64}"
},
},
"test_args": {"url": "{url}/me"},
"test_condition": lambda r: SpotifyAPI.url_key in r and "display_name" in r,
"test_expiry": 600,
"token_key_path": ["access_token"],
"header_extra": {"Accept": "application/json", "Content-Type": "application/json"},
}
from musify.types import UnitIterable


class SpotifyAPI(SpotifyAPIMisc, SpotifyAPIItems, SpotifyAPIPlaylists):
Expand All @@ -69,9 +28,9 @@ class SpotifyAPI(SpotifyAPIMisc, SpotifyAPIItems, SpotifyAPIPlaylists):
:param client_id: The client ID to use when authorising requests.
:param client_secret: The client secret to use when authorising requests.
:param scopes: The scopes to request access to.
:param scope: The scopes to request access to.
:param cache: When given, attempt to use this cache for certain request types before calling the API.
:param auth_kwargs: Optionally, provide kwargs to use when instantiating the :py:class:`APIAuthoriser`.
:param auth_kwargs: Optionally, provide kwargs to use when instantiating the :py:class:`Authoriser`.
"""

__slots__ = ()
Expand All @@ -96,30 +55,53 @@ def user_name(self) -> str | None:
)
return self.user_data["display_name"]

_url_auth = URL.build(scheme="https", host="accounts.spotify.com")

def __init__(
self,
client_id: str | None = None,
client_secret: str | None = None,
scopes: Iterable[str] = (),
scope: UnitIterable[str] = (),
cache: ResponseCache | None = None,
**auth_kwargs
token_file_path: str | Path = None,
):
wrangler = SpotifyDataWrangler()

format_map = {
"client_id": client_id,
"client_base64": base64.b64encode(f"{client_id}:{client_secret}".encode()).decode(),
"scopes": " ".join(scopes),
"url": str(wrangler.url_api)
authoriser = AuthorisationCodeFlow.create_with_encoded_credentials(
service_name=wrangler.source,
user_request_url=self._url_auth.with_path("authorize"),
token_request_url=self._url_auth.with_path("api/token"),
refresh_request_url=self._url_auth.with_path("api/token"),
client_id=client_id,
client_secret=client_secret,
scope=scope,
)

if not hasattr(authoriser.token_request, "headers"):
authoriser.token_request.headers = {}
authoriser.token_request.headers["content-type"] = "application/x-www-form-urlencoded"

if not hasattr(authoriser.refresh_request, "headers"):
authoriser.refresh_request.headers = {}
authoriser.refresh_request.headers["content-type"] = "application/x-www-form-urlencoded"

if token_file_path:
authoriser.response_handler.file_path = Path(token_file_path)
authoriser.response_handler.additional_headers = {
"Accept": "application/json", "Content-Type": "application/json"
}
auth_kwargs = merge_maps(deepcopy(SPOTIFY_API_AUTH_ARGS), auth_kwargs, extend=False, overwrite=True)
safe_format_map(auth_kwargs, format_map=format_map)

auth_kwargs.pop("name", None)
authoriser = APIAuthoriser(name=wrangler.source, **auth_kwargs)
authoriser.response_tester.request = AuthRequest(
method=Method.GET, url=wrangler.url_api.joinpath("me")
)
authoriser.response_tester.response_test = self._response_test
authoriser.response_tester.max_expiry = 600

super().__init__(authoriser=authoriser, wrangler=wrangler, cache=cache)

async def _response_test(self, response: ClientResponse) -> bool:
r = await response.json()
return self.url_key in r and "display_name" in r

# noinspection PyAsyncCall
async def _setup_cache(self) -> None:
if not isinstance(self.handler.session, CachedSession):
Expand Down
4 changes: 2 additions & 2 deletions musify/libraries/remote/spotify/api/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from collections.abc import Collection, MutableMapping, Iterable
from typing import Any

from yarl import URL

from aiorequestful.cache.backend.base import ResponseRepository
from aiorequestful.cache.session import CachedSession
from aiorequestful.exception import CacheError
from yarl import URL

from musify.libraries.remote.core.api import RemoteAPI
from musify.libraries.remote.core.types import RemoteObjectType

Expand Down
2 changes: 1 addition & 1 deletion musify/libraries/remote/spotify/api/cache.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from typing import Any

from aiorequestful.cache.backend.base import RequestSettings
from yarl import URL

from aiorequestful.cache.backend.base import RequestSettings
from musify.libraries.remote.core.types import RemoteIDType
from musify.libraries.remote.core.exception import RemoteObjectTypeError
from musify.libraries.remote.spotify.wrangle import SpotifyDataWrangler
Expand Down
2 changes: 1 addition & 1 deletion musify/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
from pathlib import Path
from typing import Any

from aiorequestful.types import ImmutableJSON, JSON, JSON_VALUE
from yarl import URL

from aiorequestful.types import ImmutableJSON, JSON, JSON_VALUE
from musify.types import UnitIterable
from musify.utils import to_collection

Expand Down
13 changes: 4 additions & 9 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from _pytest.fixtures import SubRequest
# noinspection PyProtectedMember
from _pytest.logging import LogCaptureHandler, _remove_ansi_escape_sequences
from aioresponses import aioresponses

from musify import MODULE_ROOT
from musify.libraries.remote.core.types import RemoteObjectType
Expand Down Expand Up @@ -330,13 +329,6 @@ def log_capturer() -> LogCapturer:
return LogCapturer()


@pytest.fixture
def requests_mock():
"""Yields an initialised :py:class:`aioresponses` object for mocking aiohttp requests as a pytest.fixture."""
with aioresponses() as m:
yield m


@pytest.fixture
def path(request: pytest.FixtureRequest | SubRequest, tmp_path: Path) -> Path:
"""
Expand Down Expand Up @@ -384,7 +376,10 @@ async def spotify_api(spotify_mock: SpotifyMock) -> SpotifyAPI:
"""Yield an authorised :py:class:`SpotifyAPI` object"""
token = {"access_token": "fake access token", "token_type": "Bearer", "scope": "test-read"}
# disable any token tests by settings test_* kwargs as appropriate
api = SpotifyAPI(token=token, test_args=None, test_expiry=0, test_condition=None)
api = SpotifyAPI()
api.handler.authoriser.response_handler.response = token
api.handler.authoriser.response_tester.response_test = None
api.handler.authoriser.response_tester.max_expiry = 0

# force almost no backoff/wait settings
api.handler.backoff_start = 0.001
Expand Down
12 changes: 6 additions & 6 deletions tests/libraries/remote/core/processors/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ def token_file_path(self, path: Path) -> Path:
@staticmethod
async def test_make_temp_playlist(checker: RemoteItemChecker, api_mock: RemoteMock, token_file_path: Path):
# force auth test to fail and reload from token
checker.api.handler.authoriser.token = None
checker.api.handler.authoriser.token_file_path = token_file_path
checker.api.handler.authoriser.response_handler.response = None
checker.api.handler.authoriser.response_handler.file_path = token_file_path

collection = BasicCollection(name=random_str(30, 50), items=random_tracks())
for item in collection:
Expand All @@ -96,7 +96,7 @@ async def test_make_temp_playlist(checker: RemoteItemChecker, api_mock: RemoteMo
item.uri = random_uri()

await checker._create_playlist(collection=collection)
assert checker.api.handler.authoriser.token is not None
assert checker.api.handler.authoriser.response_handler.response is not None
assert collection.name in checker._playlist_originals
assert checker._playlist_check_collections[collection.name] == collection
assert api_mock.total_requests >= 2
Expand All @@ -110,8 +110,8 @@ async def test_delete_temp_playlists(
token_file_path: Path
):
# force auth test to fail and reload from token
checker.api.handler.authoriser.token = None
checker.api.handler.authoriser.token_file_path = token_file_path
checker.api.handler.authoriser.response_handler.response = None
checker.api.handler.authoriser.response_handler.file_path = token_file_path

for pl in sample(playlists, k=len(playlists) // 2):
pl.clear()
Expand All @@ -125,7 +125,7 @@ async def test_delete_temp_playlists(
checker._playlist_check_collections = {collection.name: collection for collection in collections}

await checker._delete_playlists()
assert checker.api.handler.authoriser.token is not None # re-authorised
assert checker.api.handler.authoriser.response_handler.response is not None # re-authorised
assert not checker._playlist_originals
assert not checker._playlist_check_collections

Expand Down
Loading

0 comments on commit eab4d15

Please sign in to comment.