spotifyify 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {spotifyify-0.2.0/spotifyify.egg-info → spotifyify-0.3.0}/PKG-INFO +14 -5
- spotifyify-0.2.0/PKG-INFO → spotifyify-0.3.0/README.md +13 -14
- {spotifyify-0.2.0 → spotifyify-0.3.0}/pyproject.toml +1 -1
- spotifyify-0.3.0/spotifyify/auth.py +9 -0
- spotifyify-0.3.0/spotifyify/client.py +189 -0
- spotifyify-0.3.0/spotifyify/http/__init__.py +21 -0
- spotifyify-0.3.0/spotifyify/http/response.py +47 -0
- spotifyify-0.3.0/spotifyify/http/retry_policy.py +54 -0
- spotifyify-0.3.0/spotifyify/http/serialization.py +26 -0
- spotifyify-0.3.0/spotifyify/http/transport.py +70 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/playlists.py +31 -6
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/oauth2/oauth2.py +1 -1
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/spotifyify.py +4 -0
- spotifyify-0.2.0/README.md → spotifyify-0.3.0/spotifyify.egg-info/PKG-INFO +23 -4
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify.egg-info/SOURCES.txt +6 -0
- spotifyify-0.3.0/tests/test_client.py +90 -0
- spotifyify-0.2.0/spotifyify/client.py +0 -279
- spotifyify-0.2.0/tests/test_client.py +0 -131
- {spotifyify-0.2.0 → spotifyify-0.3.0}/setup.cfg +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/__init__.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/cache_handler.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/credentials.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/exceptions.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/__init__.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/albums.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/artists.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/episodes.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/library.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/player.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/shows.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/tracks.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/users.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/oauth2/__init__.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/oauth2/views.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/schemas.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/utils.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify.egg-info/dependency_links.txt +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify.egg-info/requires.txt +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify.egg-info/top_level.txt +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/tests/test_cache_handler.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/tests/test_credentials.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/tests/test_exceptions.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/tests/test_oauth2.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/tests/test_scopes.py +0 -0
- {spotifyify-0.2.0 → spotifyify-0.3.0}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spotifyify
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Async wrapper for spotipy with a focus on integration with MCP agents.
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -10,7 +10,7 @@ Requires-Dist: pydantic-settings>=2.12.0
|
|
|
10
10
|
|
|
11
11
|
# spotifyify
|
|
12
12
|
|
|
13
|
-
An async-first Spotify client with a namespaced API and fully typed response models
|
|
13
|
+
An async-first Spotify client with a namespaced API and fully typed response models maintained in the codebase and aligned with the [official Spotify OpenAPI specification](https://developer.spotify.com/reference/web-api/open-api-schema.yaml).
|
|
14
14
|
|
|
15
15
|
## Requirements
|
|
16
16
|
|
|
@@ -130,11 +130,12 @@ Spotifyify
|
|
|
130
130
|
| `find(query, *, limit, offset)` | Search for playlists |
|
|
131
131
|
| `get(playlist_id, *, market)` | Get a single playlist |
|
|
132
132
|
| `list(*, user_id, limit, offset)` | Current user's (or another user's) playlists |
|
|
133
|
+
| `tracks(playlist_id, *, market, fields, limit, offset, additional_types)` | Get playlist tracks |
|
|
133
134
|
| `create(name, *, public, collaborative, description, user_id)` | Create a playlist |
|
|
134
135
|
| `update(playlist_id, *, name, public, collaborative, description)` | Update playlist details |
|
|
135
|
-
| `add(playlist_id, uris, *, position)` | Add
|
|
136
|
-
| `remove(playlist_id, uris)` | Remove
|
|
137
|
-
| `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder
|
|
136
|
+
| `add(playlist_id, uris, *, position)` | Add items to a playlist |
|
|
137
|
+
| `remove(playlist_id, uris)` | Remove items from a playlist |
|
|
138
|
+
| `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder items |
|
|
138
139
|
| `cover_image(playlist_id)` | Get playlist cover images |
|
|
139
140
|
|
|
140
141
|
### Player — `sp.player`
|
|
@@ -209,6 +210,14 @@ Spotifyify
|
|
|
209
210
|
| `unfollow(type, ids)` | Unfollow artists or users |
|
|
210
211
|
| `check_following(type, ids)` | Check if following artists or users |
|
|
211
212
|
|
|
213
|
+
## Retries
|
|
214
|
+
|
|
215
|
+
Spotify API requests automatically retry rate limits (`429`) and temporary server
|
|
216
|
+
errors (`500`, `502`, `503`, `504`). Rate limits honor Spotify's `Retry-After`
|
|
217
|
+
header. Server errors are only retried for idempotent HTTP methods to avoid
|
|
218
|
+
duplicating mutations. Configure the defaults with `max_retries` and
|
|
219
|
+
`retry_backoff_seconds` when constructing `Spotifyify`.
|
|
220
|
+
|
|
212
221
|
## Scopes
|
|
213
222
|
|
|
214
223
|
Use `SpotifyScope` to declare the OAuth scopes your app requires:
|
|
@@ -1,16 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: spotifyify
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: Async wrapper for spotipy with a focus on integration with MCP agents.
|
|
5
|
-
Requires-Python: >=3.13
|
|
6
|
-
Description-Content-Type: text/markdown
|
|
7
|
-
Requires-Dist: httpx>=0.28.0
|
|
8
|
-
Requires-Dist: pydantic>=2.12.0
|
|
9
|
-
Requires-Dist: pydantic-settings>=2.12.0
|
|
10
|
-
|
|
11
1
|
# spotifyify
|
|
12
2
|
|
|
13
|
-
An async-first Spotify client with a namespaced API and fully typed response models
|
|
3
|
+
An async-first Spotify client with a namespaced API and fully typed response models maintained in the codebase and aligned with the [official Spotify OpenAPI specification](https://developer.spotify.com/reference/web-api/open-api-schema.yaml).
|
|
14
4
|
|
|
15
5
|
## Requirements
|
|
16
6
|
|
|
@@ -130,11 +120,12 @@ Spotifyify
|
|
|
130
120
|
| `find(query, *, limit, offset)` | Search for playlists |
|
|
131
121
|
| `get(playlist_id, *, market)` | Get a single playlist |
|
|
132
122
|
| `list(*, user_id, limit, offset)` | Current user's (or another user's) playlists |
|
|
123
|
+
| `tracks(playlist_id, *, market, fields, limit, offset, additional_types)` | Get playlist tracks |
|
|
133
124
|
| `create(name, *, public, collaborative, description, user_id)` | Create a playlist |
|
|
134
125
|
| `update(playlist_id, *, name, public, collaborative, description)` | Update playlist details |
|
|
135
|
-
| `add(playlist_id, uris, *, position)` | Add
|
|
136
|
-
| `remove(playlist_id, uris)` | Remove
|
|
137
|
-
| `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder
|
|
126
|
+
| `add(playlist_id, uris, *, position)` | Add items to a playlist |
|
|
127
|
+
| `remove(playlist_id, uris)` | Remove items from a playlist |
|
|
128
|
+
| `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder items |
|
|
138
129
|
| `cover_image(playlist_id)` | Get playlist cover images |
|
|
139
130
|
|
|
140
131
|
### Player — `sp.player`
|
|
@@ -209,6 +200,14 @@ Spotifyify
|
|
|
209
200
|
| `unfollow(type, ids)` | Unfollow artists or users |
|
|
210
201
|
| `check_following(type, ids)` | Check if following artists or users |
|
|
211
202
|
|
|
203
|
+
## Retries
|
|
204
|
+
|
|
205
|
+
Spotify API requests automatically retry rate limits (`429`) and temporary server
|
|
206
|
+
errors (`500`, `502`, `503`, `504`). Rate limits honor Spotify's `Retry-After`
|
|
207
|
+
header. Server errors are only retried for idempotent HTTP methods to avoid
|
|
208
|
+
duplicating mutations. Configure the defaults with `max_retries` and
|
|
209
|
+
`retry_backoff_seconds` when constructing `Spotifyify`.
|
|
210
|
+
|
|
212
211
|
## Scopes
|
|
213
212
|
|
|
214
213
|
Use `SpotifyScope` to declare the OAuth scopes your app requires:
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from typing import Any, Self
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from spotifyify.auth import AccessTokenProvider
|
|
8
|
+
from spotifyify.http import (
|
|
9
|
+
HttpMethod,
|
|
10
|
+
HttpTransport,
|
|
11
|
+
JsonPayload,
|
|
12
|
+
QueryParams,
|
|
13
|
+
RetryPolicy,
|
|
14
|
+
dump_params,
|
|
15
|
+
dump_payload,
|
|
16
|
+
parse_response,
|
|
17
|
+
validate_response_model,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SpotifyClient:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
token_provider: AccessTokenProvider,
|
|
25
|
+
scopes: Iterable[str] | None,
|
|
26
|
+
*,
|
|
27
|
+
base_url: str,
|
|
28
|
+
timeout: float = 10.0,
|
|
29
|
+
max_retries: int = 3,
|
|
30
|
+
retry_backoff_seconds: float = 1.0,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._token_provider = token_provider
|
|
33
|
+
self._scopes = list(scopes or [])
|
|
34
|
+
self._transport = HttpTransport(
|
|
35
|
+
base_url=base_url,
|
|
36
|
+
timeout=timeout,
|
|
37
|
+
retry_policy=RetryPolicy(
|
|
38
|
+
max_retries=max_retries,
|
|
39
|
+
backoff_seconds=retry_backoff_seconds,
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async def open(self) -> None:
|
|
44
|
+
await self._transport.open()
|
|
45
|
+
|
|
46
|
+
async def __aenter__(self) -> Self:
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
50
|
+
await self.close()
|
|
51
|
+
|
|
52
|
+
async def close(self) -> None:
|
|
53
|
+
await self._transport.close()
|
|
54
|
+
|
|
55
|
+
async def _request_json(
|
|
56
|
+
self,
|
|
57
|
+
method: HttpMethod,
|
|
58
|
+
path: str,
|
|
59
|
+
*,
|
|
60
|
+
params: QueryParams | BaseModel | dict[str, Any] | None = None,
|
|
61
|
+
payload: JsonPayload = None,
|
|
62
|
+
content: str | bytes | None = None,
|
|
63
|
+
require_user: bool = True,
|
|
64
|
+
headers: dict[str, str] | None = None,
|
|
65
|
+
response_model: type[BaseModel] | None = None,
|
|
66
|
+
) -> Any:
|
|
67
|
+
token = await self._token_provider.get_access_token(
|
|
68
|
+
require_user=require_user,
|
|
69
|
+
scope=self._scopes,
|
|
70
|
+
)
|
|
71
|
+
request_headers = {"Authorization": f"Bearer {token}"}
|
|
72
|
+
if headers:
|
|
73
|
+
request_headers.update(headers)
|
|
74
|
+
|
|
75
|
+
response = await self._transport.request(
|
|
76
|
+
method,
|
|
77
|
+
path,
|
|
78
|
+
headers=request_headers,
|
|
79
|
+
params=dump_params(params),
|
|
80
|
+
json=None if content is not None else dump_payload(payload),
|
|
81
|
+
content=content,
|
|
82
|
+
)
|
|
83
|
+
return validate_response_model(parse_response(response), response_model)
|
|
84
|
+
|
|
85
|
+
async def get(
|
|
86
|
+
self,
|
|
87
|
+
path: str,
|
|
88
|
+
*,
|
|
89
|
+
params: QueryParams | BaseModel | dict[str, Any] | None = None,
|
|
90
|
+
require_user: bool = True,
|
|
91
|
+
headers: dict[str, str] | None = None,
|
|
92
|
+
response_model: type[BaseModel] | None = None,
|
|
93
|
+
) -> Any:
|
|
94
|
+
return await self._request_json(
|
|
95
|
+
HttpMethod.GET,
|
|
96
|
+
path,
|
|
97
|
+
params=params,
|
|
98
|
+
require_user=require_user,
|
|
99
|
+
headers=headers,
|
|
100
|
+
response_model=response_model,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async def post(
|
|
104
|
+
self,
|
|
105
|
+
path: str,
|
|
106
|
+
*,
|
|
107
|
+
params: QueryParams | BaseModel | dict[str, Any] | None = None,
|
|
108
|
+
payload: JsonPayload = None,
|
|
109
|
+
content: str | bytes | None = None,
|
|
110
|
+
require_user: bool = True,
|
|
111
|
+
headers: dict[str, str] | None = None,
|
|
112
|
+
response_model: type[BaseModel] | None = None,
|
|
113
|
+
) -> Any:
|
|
114
|
+
return await self._request_json(
|
|
115
|
+
HttpMethod.POST,
|
|
116
|
+
path,
|
|
117
|
+
params=params,
|
|
118
|
+
payload=payload,
|
|
119
|
+
content=content,
|
|
120
|
+
require_user=require_user,
|
|
121
|
+
headers=headers,
|
|
122
|
+
response_model=response_model,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def put(
|
|
126
|
+
self,
|
|
127
|
+
path: str,
|
|
128
|
+
*,
|
|
129
|
+
params: QueryParams | BaseModel | dict[str, Any] | None = None,
|
|
130
|
+
payload: JsonPayload = None,
|
|
131
|
+
content: str | bytes | None = None,
|
|
132
|
+
require_user: bool = True,
|
|
133
|
+
headers: dict[str, str] | None = None,
|
|
134
|
+
response_model: type[BaseModel] | None = None,
|
|
135
|
+
) -> Any:
|
|
136
|
+
return await self._request_json(
|
|
137
|
+
HttpMethod.PUT,
|
|
138
|
+
path,
|
|
139
|
+
params=params,
|
|
140
|
+
payload=payload,
|
|
141
|
+
content=content,
|
|
142
|
+
require_user=require_user,
|
|
143
|
+
headers=headers,
|
|
144
|
+
response_model=response_model,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async def patch(
|
|
148
|
+
self,
|
|
149
|
+
path: str,
|
|
150
|
+
*,
|
|
151
|
+
params: QueryParams | BaseModel | dict[str, Any] | None = None,
|
|
152
|
+
payload: JsonPayload = None,
|
|
153
|
+
content: str | bytes | None = None,
|
|
154
|
+
require_user: bool = True,
|
|
155
|
+
headers: dict[str, str] | None = None,
|
|
156
|
+
response_model: type[BaseModel] | None = None,
|
|
157
|
+
) -> Any:
|
|
158
|
+
return await self._request_json(
|
|
159
|
+
HttpMethod.PATCH,
|
|
160
|
+
path,
|
|
161
|
+
params=params,
|
|
162
|
+
payload=payload,
|
|
163
|
+
content=content,
|
|
164
|
+
require_user=require_user,
|
|
165
|
+
headers=headers,
|
|
166
|
+
response_model=response_model,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async def delete(
|
|
170
|
+
self,
|
|
171
|
+
path: str,
|
|
172
|
+
*,
|
|
173
|
+
params: QueryParams | BaseModel | dict[str, Any] | None = None,
|
|
174
|
+
payload: JsonPayload = None,
|
|
175
|
+
content: str | bytes | None = None,
|
|
176
|
+
require_user: bool = True,
|
|
177
|
+
headers: dict[str, str] | None = None,
|
|
178
|
+
response_model: type[BaseModel] | None = None,
|
|
179
|
+
) -> Any:
|
|
180
|
+
return await self._request_json(
|
|
181
|
+
HttpMethod.DELETE,
|
|
182
|
+
path,
|
|
183
|
+
params=params,
|
|
184
|
+
payload=payload,
|
|
185
|
+
content=content,
|
|
186
|
+
require_user=require_user,
|
|
187
|
+
headers=headers,
|
|
188
|
+
response_model=response_model,
|
|
189
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from spotifyify.http.response import parse_response, validate_response_model
|
|
2
|
+
from spotifyify.http.retry_policy import HttpMethod, RetryPolicy
|
|
3
|
+
from spotifyify.http.serialization import (
|
|
4
|
+
JsonPayload,
|
|
5
|
+
QueryParams,
|
|
6
|
+
dump_params,
|
|
7
|
+
dump_payload,
|
|
8
|
+
)
|
|
9
|
+
from spotifyify.http.transport import HttpTransport
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"HttpMethod",
|
|
13
|
+
"HttpTransport",
|
|
14
|
+
"JsonPayload",
|
|
15
|
+
"QueryParams",
|
|
16
|
+
"RetryPolicy",
|
|
17
|
+
"dump_params",
|
|
18
|
+
"dump_payload",
|
|
19
|
+
"parse_response",
|
|
20
|
+
"validate_response_model",
|
|
21
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from spotifyify.exceptions import SpotifyAPIError
|
|
7
|
+
|
|
8
|
+
JsonResponse = dict[str, Any] | list[Any] | None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_response(response: httpx.Response) -> JsonResponse:
|
|
12
|
+
if response.status_code == 204:
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
if response.status_code >= 400:
|
|
16
|
+
try:
|
|
17
|
+
data = response.json()
|
|
18
|
+
error = data.get("error", data) if isinstance(data, dict) else data
|
|
19
|
+
message = (
|
|
20
|
+
error.get("message", response.text)
|
|
21
|
+
if isinstance(error, dict)
|
|
22
|
+
else str(error)
|
|
23
|
+
)
|
|
24
|
+
except ValueError:
|
|
25
|
+
data = None
|
|
26
|
+
message = response.text
|
|
27
|
+
raise SpotifyAPIError(
|
|
28
|
+
response.status_code,
|
|
29
|
+
message,
|
|
30
|
+
data if isinstance(data, dict) else None,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if not response.content:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
return response.json()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_response_model(
|
|
40
|
+
parsed: JsonResponse,
|
|
41
|
+
response_model: type[BaseModel] | None,
|
|
42
|
+
) -> Any:
|
|
43
|
+
if response_model is None or parsed is None:
|
|
44
|
+
return parsed
|
|
45
|
+
if isinstance(parsed, list):
|
|
46
|
+
raise SpotifyAPIError(500, "Expected object response but got list")
|
|
47
|
+
return response_model.model_validate(parsed)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
_RETRYABLE_SERVER_ERROR_STATUS_CODES = frozenset({500, 502, 503, 504})
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HttpMethod(StrEnum):
|
|
7
|
+
GET = "GET"
|
|
8
|
+
POST = "POST"
|
|
9
|
+
PUT = "PUT"
|
|
10
|
+
PATCH = "PATCH"
|
|
11
|
+
DELETE = "DELETE"
|
|
12
|
+
HEAD = "HEAD"
|
|
13
|
+
OPTIONS = "OPTIONS"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_IDEMPOTENT_METHODS = frozenset(
|
|
17
|
+
{
|
|
18
|
+
HttpMethod.GET,
|
|
19
|
+
HttpMethod.PUT,
|
|
20
|
+
HttpMethod.DELETE,
|
|
21
|
+
HttpMethod.HEAD,
|
|
22
|
+
HttpMethod.OPTIONS,
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RetryPolicy:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
max_retries: int = 3,
|
|
31
|
+
backoff_seconds: float = 1.0,
|
|
32
|
+
) -> None:
|
|
33
|
+
if max_retries < 0:
|
|
34
|
+
raise ValueError("max_retries must be greater than or equal to 0")
|
|
35
|
+
if backoff_seconds < 0:
|
|
36
|
+
raise ValueError("backoff_seconds must be greater than or equal to 0")
|
|
37
|
+
self.max_retries = max_retries
|
|
38
|
+
self.backoff_seconds = backoff_seconds
|
|
39
|
+
|
|
40
|
+
def should_retry(self, method: HttpMethod, status_code: int) -> bool:
|
|
41
|
+
if status_code == 429:
|
|
42
|
+
return True
|
|
43
|
+
return (
|
|
44
|
+
method in _IDEMPOTENT_METHODS
|
|
45
|
+
and status_code in _RETRYABLE_SERVER_ERROR_STATUS_CODES
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def retry_delay(self, attempt: int, *, retry_after: str | None = None) -> float:
|
|
49
|
+
if retry_after is not None:
|
|
50
|
+
try:
|
|
51
|
+
return max(0.0, float(retry_after))
|
|
52
|
+
except ValueError:
|
|
53
|
+
pass
|
|
54
|
+
return self.backoff_seconds * (2**attempt)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
type JsonPayload = BaseModel | dict[str, Any] | list[Any] | str | None
|
|
6
|
+
type SerializedJsonPayload = dict[str, Any] | list[Any] | str | None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class QueryParams(BaseModel):
|
|
10
|
+
model_config = ConfigDict(extra="allow")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def dump_params(
|
|
14
|
+
params: QueryParams | BaseModel | dict[str, Any] | None,
|
|
15
|
+
) -> dict[str, Any] | None:
|
|
16
|
+
if params is None:
|
|
17
|
+
return None
|
|
18
|
+
if isinstance(params, BaseModel):
|
|
19
|
+
return params.model_dump(mode="json", exclude_none=True)
|
|
20
|
+
return QueryParams.model_validate(params).model_dump(mode="json", exclude_none=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def dump_payload(payload: JsonPayload) -> SerializedJsonPayload:
|
|
24
|
+
if isinstance(payload, BaseModel):
|
|
25
|
+
return payload.model_dump(mode="json", exclude_none=True)
|
|
26
|
+
return payload
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from spotifyify.http.retry_policy import HttpMethod, RetryPolicy
|
|
7
|
+
from spotifyify.http.serialization import SerializedJsonPayload
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HttpTransport:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
base_url: str,
|
|
15
|
+
timeout: float,
|
|
16
|
+
retry_policy: RetryPolicy,
|
|
17
|
+
) -> None:
|
|
18
|
+
self._base_url = base_url
|
|
19
|
+
self._timeout = timeout
|
|
20
|
+
self._retry_policy = retry_policy
|
|
21
|
+
self._client: httpx.AsyncClient | None = None
|
|
22
|
+
|
|
23
|
+
async def open(self) -> None:
|
|
24
|
+
if self._client is None:
|
|
25
|
+
self._client = httpx.AsyncClient(
|
|
26
|
+
base_url=self._base_url,
|
|
27
|
+
timeout=self._timeout,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
async def close(self) -> None:
|
|
31
|
+
if self._client is None:
|
|
32
|
+
return
|
|
33
|
+
await self._client.aclose()
|
|
34
|
+
self._client = None
|
|
35
|
+
|
|
36
|
+
async def request(
|
|
37
|
+
self,
|
|
38
|
+
method: HttpMethod,
|
|
39
|
+
path: str,
|
|
40
|
+
*,
|
|
41
|
+
headers: dict[str, str],
|
|
42
|
+
params: dict[str, Any] | None,
|
|
43
|
+
json: SerializedJsonPayload,
|
|
44
|
+
content: str | bytes | None,
|
|
45
|
+
) -> httpx.Response:
|
|
46
|
+
await self.open()
|
|
47
|
+
if self._client is None:
|
|
48
|
+
raise RuntimeError("HTTP client was not initialized")
|
|
49
|
+
|
|
50
|
+
for attempt in range(self._retry_policy.max_retries + 1):
|
|
51
|
+
response = await self._client.request(
|
|
52
|
+
method.value,
|
|
53
|
+
path,
|
|
54
|
+
headers=headers,
|
|
55
|
+
params=params,
|
|
56
|
+
json=json,
|
|
57
|
+
content=content,
|
|
58
|
+
)
|
|
59
|
+
if attempt == self._retry_policy.max_retries or not (
|
|
60
|
+
self._retry_policy.should_retry(method, response.status_code)
|
|
61
|
+
):
|
|
62
|
+
return response
|
|
63
|
+
await asyncio.sleep(
|
|
64
|
+
self._retry_policy.retry_delay(
|
|
65
|
+
attempt,
|
|
66
|
+
retry_after=response.headers.get("Retry-After"),
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
raise RuntimeError("unreachable")
|
|
@@ -2,8 +2,8 @@ from typing import TYPE_CHECKING, Any
|
|
|
2
2
|
|
|
3
3
|
from collections.abc import Iterable
|
|
4
4
|
|
|
5
|
-
from spotifyify.schemas import Image, PagingPlaylist, Playlist
|
|
6
|
-
from spotifyify.utils import coalesce_items
|
|
5
|
+
from spotifyify.schemas import Image, PagingPlaylist, PagingPlaylistTrack, Playlist
|
|
6
|
+
from spotifyify.utils import coalesce_csv, coalesce_items
|
|
7
7
|
|
|
8
8
|
if TYPE_CHECKING:
|
|
9
9
|
from spotifyify.spotifyify import Spotifyify
|
|
@@ -58,6 +58,31 @@ class Playlists:
|
|
|
58
58
|
data = await self._http.get(path, params=params) or {}
|
|
59
59
|
return PagingPlaylist.model_validate(data)
|
|
60
60
|
|
|
61
|
+
async def tracks(
|
|
62
|
+
self,
|
|
63
|
+
playlist_id: str,
|
|
64
|
+
*,
|
|
65
|
+
market: str | None = None,
|
|
66
|
+
fields: str | None = None,
|
|
67
|
+
limit: int = 20,
|
|
68
|
+
offset: int = 0,
|
|
69
|
+
additional_types: Iterable[str] | None = None,
|
|
70
|
+
) -> PagingPlaylistTrack:
|
|
71
|
+
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
72
|
+
if market:
|
|
73
|
+
params["market"] = market
|
|
74
|
+
if fields:
|
|
75
|
+
params["fields"] = fields
|
|
76
|
+
if additional_types is not None:
|
|
77
|
+
params["additional_types"] = coalesce_csv(additional_types)
|
|
78
|
+
# The legacy read route still exposes public playlists to app-only tokens.
|
|
79
|
+
data = await self._http.get(
|
|
80
|
+
f"/playlists/{playlist_id}/tracks",
|
|
81
|
+
params=params,
|
|
82
|
+
require_user=False,
|
|
83
|
+
)
|
|
84
|
+
return PagingPlaylistTrack.model_validate(data or {})
|
|
85
|
+
|
|
61
86
|
async def create(
|
|
62
87
|
self,
|
|
63
88
|
name: str,
|
|
@@ -116,15 +141,15 @@ class Playlists:
|
|
|
116
141
|
if position is not None:
|
|
117
142
|
payload["position"] = position
|
|
118
143
|
data = (
|
|
119
|
-
await self._http.post(f"/playlists/{playlist_id}/
|
|
144
|
+
await self._http.post(f"/playlists/{playlist_id}/items", payload=payload)
|
|
120
145
|
or {}
|
|
121
146
|
)
|
|
122
147
|
return data.get("snapshot_id") if isinstance(data, dict) else None
|
|
123
148
|
|
|
124
149
|
async def remove(self, playlist_id: str, uris: Iterable[str]) -> str | None:
|
|
125
|
-
payload = {"
|
|
150
|
+
payload = {"items": [{"uri": uri} for uri in coalesce_items(uris)]}
|
|
126
151
|
data = (
|
|
127
|
-
await self._http.delete(f"/playlists/{playlist_id}/
|
|
152
|
+
await self._http.delete(f"/playlists/{playlist_id}/items", payload=payload)
|
|
128
153
|
or {}
|
|
129
154
|
)
|
|
130
155
|
return data.get("snapshot_id") if isinstance(data, dict) else None
|
|
@@ -146,7 +171,7 @@ class Playlists:
|
|
|
146
171
|
if snapshot_id:
|
|
147
172
|
payload["snapshot_id"] = snapshot_id
|
|
148
173
|
data = (
|
|
149
|
-
await self._http.put(f"/playlists/{playlist_id}/
|
|
174
|
+
await self._http.put(f"/playlists/{playlist_id}/items", payload=payload)
|
|
150
175
|
or {}
|
|
151
176
|
)
|
|
152
177
|
return data.get("snapshot_id") if isinstance(data, dict) else None
|
|
@@ -14,8 +14,8 @@ import httpx
|
|
|
14
14
|
|
|
15
15
|
from spotifyify.credentials import SpotifyCredentials
|
|
16
16
|
from spotifyify.exceptions import SpotifyAuthError
|
|
17
|
-
from spotifyify.client import parse_response
|
|
18
17
|
from spotifyify.oauth2.views import TokenFormPayload
|
|
18
|
+
from spotifyify.http import parse_response
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class SpotifyifyOAuth:
|
|
@@ -31,6 +31,8 @@ class Spotifyify:
|
|
|
31
31
|
scopes: Iterable[SpotifyScope] | None = None,
|
|
32
32
|
cache_handler: CacheHandler | None = None,
|
|
33
33
|
timeout: float = 10.0,
|
|
34
|
+
max_retries: int = 3,
|
|
35
|
+
retry_backoff_seconds: float = 1.0,
|
|
34
36
|
) -> None:
|
|
35
37
|
self._credentials = credentials or SpotifyCredentials()
|
|
36
38
|
self._scopes = [scope for scope in (scopes or ())]
|
|
@@ -44,6 +46,8 @@ class Spotifyify:
|
|
|
44
46
|
scopes=self._scopes,
|
|
45
47
|
base_url=self._SPOTIFY_API_BASE_URL,
|
|
46
48
|
timeout=timeout,
|
|
49
|
+
max_retries=max_retries,
|
|
50
|
+
retry_backoff_seconds=retry_backoff_seconds,
|
|
47
51
|
)
|
|
48
52
|
|
|
49
53
|
self._tracks: Tracks | None = None
|
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spotifyify
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Async wrapper for spotipy with a focus on integration with MCP agents.
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.28.0
|
|
8
|
+
Requires-Dist: pydantic>=2.12.0
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.12.0
|
|
10
|
+
|
|
1
11
|
# spotifyify
|
|
2
12
|
|
|
3
|
-
An async-first Spotify client with a namespaced API and fully typed response models
|
|
13
|
+
An async-first Spotify client with a namespaced API and fully typed response models maintained in the codebase and aligned with the [official Spotify OpenAPI specification](https://developer.spotify.com/reference/web-api/open-api-schema.yaml).
|
|
4
14
|
|
|
5
15
|
## Requirements
|
|
6
16
|
|
|
@@ -120,11 +130,12 @@ Spotifyify
|
|
|
120
130
|
| `find(query, *, limit, offset)` | Search for playlists |
|
|
121
131
|
| `get(playlist_id, *, market)` | Get a single playlist |
|
|
122
132
|
| `list(*, user_id, limit, offset)` | Current user's (or another user's) playlists |
|
|
133
|
+
| `tracks(playlist_id, *, market, fields, limit, offset, additional_types)` | Get playlist tracks |
|
|
123
134
|
| `create(name, *, public, collaborative, description, user_id)` | Create a playlist |
|
|
124
135
|
| `update(playlist_id, *, name, public, collaborative, description)` | Update playlist details |
|
|
125
|
-
| `add(playlist_id, uris, *, position)` | Add
|
|
126
|
-
| `remove(playlist_id, uris)` | Remove
|
|
127
|
-
| `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder
|
|
136
|
+
| `add(playlist_id, uris, *, position)` | Add items to a playlist |
|
|
137
|
+
| `remove(playlist_id, uris)` | Remove items from a playlist |
|
|
138
|
+
| `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder items |
|
|
128
139
|
| `cover_image(playlist_id)` | Get playlist cover images |
|
|
129
140
|
|
|
130
141
|
### Player — `sp.player`
|
|
@@ -199,6 +210,14 @@ Spotifyify
|
|
|
199
210
|
| `unfollow(type, ids)` | Unfollow artists or users |
|
|
200
211
|
| `check_following(type, ids)` | Check if following artists or users |
|
|
201
212
|
|
|
213
|
+
## Retries
|
|
214
|
+
|
|
215
|
+
Spotify API requests automatically retry rate limits (`429`) and temporary server
|
|
216
|
+
errors (`500`, `502`, `503`, `504`). Rate limits honor Spotify's `Retry-After`
|
|
217
|
+
header. Server errors are only retried for idempotent HTTP methods to avoid
|
|
218
|
+
duplicating mutations. Configure the defaults with `max_retries` and
|
|
219
|
+
`retry_backoff_seconds` when constructing `Spotifyify`.
|
|
220
|
+
|
|
202
221
|
## Scopes
|
|
203
222
|
|
|
204
223
|
Use `SpotifyScope` to declare the OAuth scopes your app requires:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
README.md
|
|
2
2
|
pyproject.toml
|
|
3
3
|
spotifyify/__init__.py
|
|
4
|
+
spotifyify/auth.py
|
|
4
5
|
spotifyify/cache_handler.py
|
|
5
6
|
spotifyify/client.py
|
|
6
7
|
spotifyify/credentials.py
|
|
@@ -13,6 +14,11 @@ spotifyify.egg-info/SOURCES.txt
|
|
|
13
14
|
spotifyify.egg-info/dependency_links.txt
|
|
14
15
|
spotifyify.egg-info/requires.txt
|
|
15
16
|
spotifyify.egg-info/top_level.txt
|
|
17
|
+
spotifyify/http/__init__.py
|
|
18
|
+
spotifyify/http/response.py
|
|
19
|
+
spotifyify/http/retry_policy.py
|
|
20
|
+
spotifyify/http/serialization.py
|
|
21
|
+
spotifyify/http/transport.py
|
|
16
22
|
spotifyify/namespaces/__init__.py
|
|
17
23
|
spotifyify/namespaces/albums.py
|
|
18
24
|
spotifyify/namespaces/artists.py
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import AsyncMock
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from spotifyify.client import SpotifyClient
|
|
8
|
+
from spotifyify.http.retry_policy import HttpMethod
|
|
9
|
+
from spotifyify.http.transport import HttpTransport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestSpotifyClient(unittest.IsolatedAsyncioTestCase):
|
|
13
|
+
def _make_client(self):
|
|
14
|
+
token_provider = AsyncMock()
|
|
15
|
+
token_provider.get_access_token.return_value = "fake-token"
|
|
16
|
+
client = SpotifyClient(
|
|
17
|
+
token_provider=token_provider,
|
|
18
|
+
scopes=["user-read-playback-state"],
|
|
19
|
+
base_url="https://api.spotify.com/v1",
|
|
20
|
+
)
|
|
21
|
+
client._transport = AsyncMock(spec=HttpTransport)
|
|
22
|
+
return client, token_provider
|
|
23
|
+
|
|
24
|
+
async def test_open_delegates_to_transport(self):
|
|
25
|
+
client, _ = self._make_client()
|
|
26
|
+
|
|
27
|
+
await client.open()
|
|
28
|
+
|
|
29
|
+
client._transport.open.assert_awaited_once()
|
|
30
|
+
|
|
31
|
+
async def test_close_delegates_to_transport(self):
|
|
32
|
+
client, _ = self._make_client()
|
|
33
|
+
|
|
34
|
+
await client.close()
|
|
35
|
+
|
|
36
|
+
client._transport.close.assert_awaited_once()
|
|
37
|
+
|
|
38
|
+
async def test_context_manager_closes_transport(self):
|
|
39
|
+
client, _ = self._make_client()
|
|
40
|
+
|
|
41
|
+
async with client:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
client._transport.close.assert_awaited_once()
|
|
45
|
+
|
|
46
|
+
async def test_get_adds_token_serializes_params_and_parses_response(self):
|
|
47
|
+
client, token_provider = self._make_client()
|
|
48
|
+
client._transport.request.return_value = httpx.Response(
|
|
49
|
+
200, json={"tracks": []}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
result = await client.get(
|
|
53
|
+
"/tracks",
|
|
54
|
+
params={"offset": None},
|
|
55
|
+
require_user=False,
|
|
56
|
+
headers={"X-Test": "value"},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
self.assertEqual(result, {"tracks": []})
|
|
60
|
+
token_provider.get_access_token.assert_awaited_once_with(
|
|
61
|
+
require_user=False,
|
|
62
|
+
scope=["user-read-playback-state"],
|
|
63
|
+
)
|
|
64
|
+
client._transport.request.assert_awaited_once_with(
|
|
65
|
+
HttpMethod.GET,
|
|
66
|
+
"/tracks",
|
|
67
|
+
headers={"Authorization": "Bearer fake-token", "X-Test": "value"},
|
|
68
|
+
params={},
|
|
69
|
+
json=None,
|
|
70
|
+
content=None,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def test_post_sends_serialized_payload(self):
|
|
74
|
+
class Payload(BaseModel):
|
|
75
|
+
name: str
|
|
76
|
+
description: str | None = None
|
|
77
|
+
|
|
78
|
+
client, _ = self._make_client()
|
|
79
|
+
client._transport.request.return_value = httpx.Response(204)
|
|
80
|
+
|
|
81
|
+
await client.post("/playlists", payload=Payload(name="test"))
|
|
82
|
+
|
|
83
|
+
client._transport.request.assert_awaited_once_with(
|
|
84
|
+
HttpMethod.POST,
|
|
85
|
+
"/playlists",
|
|
86
|
+
headers={"Authorization": "Bearer fake-token"},
|
|
87
|
+
params=None,
|
|
88
|
+
json={"name": "test"},
|
|
89
|
+
content=None,
|
|
90
|
+
)
|
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
from typing import Any, Protocol, Self
|
|
2
|
-
|
|
3
|
-
from collections.abc import Iterable
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
from pydantic import BaseModel, ConfigDict
|
|
7
|
-
|
|
8
|
-
from spotifyify.exceptions import SpotifyAPIError
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class QueryParams(BaseModel):
|
|
12
|
-
model_config = ConfigDict(extra="allow")
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class RequestPayload(BaseModel):
|
|
16
|
-
model_config = ConfigDict(extra="allow")
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class AccessTokenProvider(Protocol):
|
|
20
|
-
async def get_access_token(
|
|
21
|
-
self,
|
|
22
|
-
require_user: bool,
|
|
23
|
-
scope: str | list[str] | tuple[str, ...] | None = None,
|
|
24
|
-
) -> str: ...
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def parse_response(response: httpx.Response) -> dict[str, Any] | list[Any] | None:
|
|
28
|
-
if response.status_code == 204:
|
|
29
|
-
return None
|
|
30
|
-
|
|
31
|
-
if response.status_code >= 400:
|
|
32
|
-
try:
|
|
33
|
-
data = response.json()
|
|
34
|
-
err = data.get("error", data) if isinstance(data, dict) else data
|
|
35
|
-
message = (
|
|
36
|
-
err.get("message", response.text) if isinstance(err, dict) else str(err)
|
|
37
|
-
)
|
|
38
|
-
except ValueError:
|
|
39
|
-
data = None
|
|
40
|
-
message = response.text
|
|
41
|
-
raise SpotifyAPIError(
|
|
42
|
-
response.status_code,
|
|
43
|
-
message,
|
|
44
|
-
data if isinstance(data, dict) else None,
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
if not response.content:
|
|
48
|
-
return None
|
|
49
|
-
|
|
50
|
-
return response.json()
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class SpotifyClient:
|
|
54
|
-
def __init__(
|
|
55
|
-
self,
|
|
56
|
-
token_provider: AccessTokenProvider,
|
|
57
|
-
scopes: Iterable[str] | None,
|
|
58
|
-
*,
|
|
59
|
-
base_url: str,
|
|
60
|
-
timeout: float = 10.0,
|
|
61
|
-
) -> None:
|
|
62
|
-
self._token_provider = token_provider
|
|
63
|
-
self._scopes = list(scopes or [])
|
|
64
|
-
self._base_url = base_url
|
|
65
|
-
self._timeout = timeout
|
|
66
|
-
self._client: httpx.AsyncClient | None = None
|
|
67
|
-
|
|
68
|
-
async def open(self) -> None:
|
|
69
|
-
if self._client is None:
|
|
70
|
-
self._client = httpx.AsyncClient(
|
|
71
|
-
base_url=self._base_url, timeout=self._timeout
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
async def __aenter__(self) -> Self:
|
|
75
|
-
return self
|
|
76
|
-
|
|
77
|
-
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
78
|
-
await self.close()
|
|
79
|
-
|
|
80
|
-
async def close(self) -> None:
|
|
81
|
-
if self._client is None:
|
|
82
|
-
return
|
|
83
|
-
await self._client.aclose()
|
|
84
|
-
self._client = None
|
|
85
|
-
|
|
86
|
-
@staticmethod
|
|
87
|
-
def _dump_params(
|
|
88
|
-
params: QueryParams | BaseModel | dict[str, Any] | None,
|
|
89
|
-
) -> dict[str, Any] | None:
|
|
90
|
-
if params is None:
|
|
91
|
-
return None
|
|
92
|
-
if isinstance(params, BaseModel):
|
|
93
|
-
return params.model_dump(mode="json", exclude_none=True)
|
|
94
|
-
return QueryParams.model_validate(params).model_dump(
|
|
95
|
-
mode="json", exclude_none=True
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
@staticmethod
|
|
99
|
-
def _dump_payload(
|
|
100
|
-
payload: RequestPayload | BaseModel | dict[str, Any] | list[Any] | str | None,
|
|
101
|
-
) -> dict[str, Any] | list[Any] | str | None:
|
|
102
|
-
if payload is None:
|
|
103
|
-
return None
|
|
104
|
-
if isinstance(payload, BaseModel):
|
|
105
|
-
return payload.model_dump(mode="json", exclude_none=True)
|
|
106
|
-
if isinstance(payload, (dict, list, str)):
|
|
107
|
-
return payload
|
|
108
|
-
raise TypeError("payload must be a Pydantic model, dict, list, str, or None")
|
|
109
|
-
|
|
110
|
-
async def request_json(
|
|
111
|
-
self,
|
|
112
|
-
method: str,
|
|
113
|
-
path: str,
|
|
114
|
-
*,
|
|
115
|
-
params: QueryParams | BaseModel | dict[str, Any] | None = None,
|
|
116
|
-
payload: RequestPayload
|
|
117
|
-
| BaseModel
|
|
118
|
-
| dict[str, Any]
|
|
119
|
-
| list[Any]
|
|
120
|
-
| str
|
|
121
|
-
| None = None,
|
|
122
|
-
content: str | bytes | None = None,
|
|
123
|
-
require_user: bool = True,
|
|
124
|
-
headers: dict[str, str] | None = None,
|
|
125
|
-
response_model: type[BaseModel] | None = None,
|
|
126
|
-
) -> Any:
|
|
127
|
-
await self.open()
|
|
128
|
-
token = await self._token_provider.get_access_token(
|
|
129
|
-
require_user=require_user,
|
|
130
|
-
scope=self._scopes,
|
|
131
|
-
)
|
|
132
|
-
request_headers = {"Authorization": f"Bearer {token}"}
|
|
133
|
-
if headers:
|
|
134
|
-
request_headers.update(headers)
|
|
135
|
-
if self._client is None:
|
|
136
|
-
raise RuntimeError("HTTP client was not initialized")
|
|
137
|
-
|
|
138
|
-
json_payload = None if content is not None else self._dump_payload(payload)
|
|
139
|
-
response = await self._client.request(
|
|
140
|
-
method,
|
|
141
|
-
path,
|
|
142
|
-
headers=request_headers,
|
|
143
|
-
params=self._dump_params(params),
|
|
144
|
-
json=json_payload,
|
|
145
|
-
content=content,
|
|
146
|
-
)
|
|
147
|
-
parsed = parse_response(response)
|
|
148
|
-
|
|
149
|
-
if response_model is None or parsed is None:
|
|
150
|
-
return parsed
|
|
151
|
-
if isinstance(parsed, list):
|
|
152
|
-
raise SpotifyAPIError(500, "Expected object response but got list")
|
|
153
|
-
return response_model.model_validate(parsed)
|
|
154
|
-
|
|
155
|
-
async def get(
|
|
156
|
-
self,
|
|
157
|
-
path: str,
|
|
158
|
-
*,
|
|
159
|
-
params: QueryParams | BaseModel | dict[str, Any] | None = None,
|
|
160
|
-
require_user: bool = True,
|
|
161
|
-
headers: dict[str, str] | None = None,
|
|
162
|
-
response_model: type[BaseModel] | None = None,
|
|
163
|
-
) -> Any:
|
|
164
|
-
return await self.request_json(
|
|
165
|
-
"GET",
|
|
166
|
-
path,
|
|
167
|
-
params=params,
|
|
168
|
-
require_user=require_user,
|
|
169
|
-
headers=headers,
|
|
170
|
-
response_model=response_model,
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
async def post(
|
|
174
|
-
self,
|
|
175
|
-
path: str,
|
|
176
|
-
*,
|
|
177
|
-
params: QueryParams | BaseModel | dict[str, Any] | None = None,
|
|
178
|
-
payload: RequestPayload
|
|
179
|
-
| BaseModel
|
|
180
|
-
| dict[str, Any]
|
|
181
|
-
| list[Any]
|
|
182
|
-
| str
|
|
183
|
-
| None = None,
|
|
184
|
-
content: str | bytes | None = None,
|
|
185
|
-
require_user: bool = True,
|
|
186
|
-
headers: dict[str, str] | None = None,
|
|
187
|
-
response_model: type[BaseModel] | None = None,
|
|
188
|
-
) -> Any:
|
|
189
|
-
return await self.request_json(
|
|
190
|
-
"POST",
|
|
191
|
-
path,
|
|
192
|
-
params=params,
|
|
193
|
-
payload=payload,
|
|
194
|
-
content=content,
|
|
195
|
-
require_user=require_user,
|
|
196
|
-
headers=headers,
|
|
197
|
-
response_model=response_model,
|
|
198
|
-
)
|
|
199
|
-
|
|
200
|
-
async def put(
|
|
201
|
-
self,
|
|
202
|
-
path: str,
|
|
203
|
-
*,
|
|
204
|
-
params: QueryParams | BaseModel | dict[str, Any] | None = None,
|
|
205
|
-
payload: RequestPayload
|
|
206
|
-
| BaseModel
|
|
207
|
-
| dict[str, Any]
|
|
208
|
-
| list[Any]
|
|
209
|
-
| str
|
|
210
|
-
| None = None,
|
|
211
|
-
content: str | bytes | None = None,
|
|
212
|
-
require_user: bool = True,
|
|
213
|
-
headers: dict[str, str] | None = None,
|
|
214
|
-
response_model: type[BaseModel] | None = None,
|
|
215
|
-
) -> Any:
|
|
216
|
-
return await self.request_json(
|
|
217
|
-
"PUT",
|
|
218
|
-
path,
|
|
219
|
-
params=params,
|
|
220
|
-
payload=payload,
|
|
221
|
-
content=content,
|
|
222
|
-
require_user=require_user,
|
|
223
|
-
headers=headers,
|
|
224
|
-
response_model=response_model,
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
async def patch(
|
|
228
|
-
self,
|
|
229
|
-
path: str,
|
|
230
|
-
*,
|
|
231
|
-
params: QueryParams | BaseModel | dict[str, Any] | None = None,
|
|
232
|
-
payload: RequestPayload
|
|
233
|
-
| BaseModel
|
|
234
|
-
| dict[str, Any]
|
|
235
|
-
| list[Any]
|
|
236
|
-
| str
|
|
237
|
-
| None = None,
|
|
238
|
-
content: str | bytes | None = None,
|
|
239
|
-
require_user: bool = True,
|
|
240
|
-
headers: dict[str, str] | None = None,
|
|
241
|
-
response_model: type[BaseModel] | None = None,
|
|
242
|
-
) -> Any:
|
|
243
|
-
return await self.request_json(
|
|
244
|
-
"PATCH",
|
|
245
|
-
path,
|
|
246
|
-
params=params,
|
|
247
|
-
payload=payload,
|
|
248
|
-
content=content,
|
|
249
|
-
require_user=require_user,
|
|
250
|
-
headers=headers,
|
|
251
|
-
response_model=response_model,
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
async def delete(
|
|
255
|
-
self,
|
|
256
|
-
path: str,
|
|
257
|
-
*,
|
|
258
|
-
params: QueryParams | BaseModel | dict[str, Any] | None = None,
|
|
259
|
-
payload: RequestPayload
|
|
260
|
-
| BaseModel
|
|
261
|
-
| dict[str, Any]
|
|
262
|
-
| list[Any]
|
|
263
|
-
| str
|
|
264
|
-
| None = None,
|
|
265
|
-
content: str | bytes | None = None,
|
|
266
|
-
require_user: bool = True,
|
|
267
|
-
headers: dict[str, str] | None = None,
|
|
268
|
-
response_model: type[BaseModel] | None = None,
|
|
269
|
-
) -> Any:
|
|
270
|
-
return await self.request_json(
|
|
271
|
-
"DELETE",
|
|
272
|
-
path,
|
|
273
|
-
params=params,
|
|
274
|
-
payload=payload,
|
|
275
|
-
content=content,
|
|
276
|
-
require_user=require_user,
|
|
277
|
-
headers=headers,
|
|
278
|
-
response_model=response_model,
|
|
279
|
-
)
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
from unittest.mock import AsyncMock, MagicMock
|
|
3
|
-
|
|
4
|
-
import httpx
|
|
5
|
-
|
|
6
|
-
from spotifyify.client import SpotifyClient, QueryParams, RequestPayload, parse_response
|
|
7
|
-
from spotifyify.exceptions import SpotifyAPIError
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class TestParseResponse(unittest.TestCase):
|
|
11
|
-
def _make_response(self, status_code, json_data=None, content=b"", text=""):
|
|
12
|
-
resp = MagicMock(spec=httpx.Response)
|
|
13
|
-
resp.status_code = status_code
|
|
14
|
-
resp.content = content
|
|
15
|
-
resp.text = text
|
|
16
|
-
if json_data is not None:
|
|
17
|
-
resp.json.return_value = json_data
|
|
18
|
-
resp.content = b'{"data": true}'
|
|
19
|
-
else:
|
|
20
|
-
resp.json.side_effect = ValueError("No JSON")
|
|
21
|
-
return resp
|
|
22
|
-
|
|
23
|
-
def test_204_returns_none(self):
|
|
24
|
-
resp = self._make_response(204)
|
|
25
|
-
self.assertIsNone(parse_response(resp))
|
|
26
|
-
|
|
27
|
-
def test_empty_content_returns_none(self):
|
|
28
|
-
resp = self._make_response(200, content=b"")
|
|
29
|
-
resp.json.side_effect = ValueError
|
|
30
|
-
self.assertIsNone(parse_response(resp))
|
|
31
|
-
|
|
32
|
-
def test_200_with_json(self):
|
|
33
|
-
resp = self._make_response(200, json_data={"tracks": []})
|
|
34
|
-
result = parse_response(resp)
|
|
35
|
-
self.assertEqual(result, {"tracks": []})
|
|
36
|
-
|
|
37
|
-
def test_400_raises_spotify_api_error_with_error_object(self):
|
|
38
|
-
resp = self._make_response(
|
|
39
|
-
400,
|
|
40
|
-
json_data={"error": {"message": "bad request"}},
|
|
41
|
-
)
|
|
42
|
-
with self.assertRaises(SpotifyAPIError) as ctx:
|
|
43
|
-
parse_response(resp)
|
|
44
|
-
self.assertEqual(ctx.exception.status_code, 400)
|
|
45
|
-
self.assertEqual(ctx.exception.message, "bad request")
|
|
46
|
-
|
|
47
|
-
def test_400_raises_with_plain_message(self):
|
|
48
|
-
resp = self._make_response(400)
|
|
49
|
-
resp.text = "server error"
|
|
50
|
-
with self.assertRaises(SpotifyAPIError) as ctx:
|
|
51
|
-
parse_response(resp)
|
|
52
|
-
self.assertEqual(ctx.exception.message, "server error")
|
|
53
|
-
|
|
54
|
-
def test_400_with_non_dict_error(self):
|
|
55
|
-
resp = self._make_response(400, json_data={"error": "simple string"})
|
|
56
|
-
with self.assertRaises(SpotifyAPIError) as ctx:
|
|
57
|
-
parse_response(resp)
|
|
58
|
-
self.assertEqual(ctx.exception.message, "simple string")
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class TestDumpParams(unittest.TestCase):
|
|
62
|
-
def test_none_returns_none(self):
|
|
63
|
-
self.assertIsNone(SpotifyClient._dump_params(None))
|
|
64
|
-
|
|
65
|
-
def test_dict_input(self):
|
|
66
|
-
result = SpotifyClient._dump_params({"limit": 10, "offset": None})
|
|
67
|
-
self.assertEqual(result, {"limit": 10})
|
|
68
|
-
|
|
69
|
-
def test_pydantic_model_input(self):
|
|
70
|
-
params = QueryParams(limit=5)
|
|
71
|
-
result = SpotifyClient._dump_params(params)
|
|
72
|
-
self.assertEqual(result, {"limit": 5})
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
class TestDumpPayload(unittest.TestCase):
|
|
76
|
-
def test_none_returns_none(self):
|
|
77
|
-
self.assertIsNone(SpotifyClient._dump_payload(None))
|
|
78
|
-
|
|
79
|
-
def test_dict_passthrough(self):
|
|
80
|
-
d = {"name": "test"}
|
|
81
|
-
self.assertIs(SpotifyClient._dump_payload(d), d)
|
|
82
|
-
|
|
83
|
-
def test_list_passthrough(self):
|
|
84
|
-
lst = [1, 2, 3]
|
|
85
|
-
self.assertIs(SpotifyClient._dump_payload(lst), lst)
|
|
86
|
-
|
|
87
|
-
def test_string_passthrough(self):
|
|
88
|
-
self.assertIs(SpotifyClient._dump_payload("hello"), "hello")
|
|
89
|
-
|
|
90
|
-
def test_pydantic_model(self):
|
|
91
|
-
payload = RequestPayload(name="test")
|
|
92
|
-
result = SpotifyClient._dump_payload(payload)
|
|
93
|
-
self.assertEqual(result, {"name": "test"})
|
|
94
|
-
|
|
95
|
-
def test_invalid_type_raises(self):
|
|
96
|
-
with self.assertRaises(TypeError):
|
|
97
|
-
SpotifyClient._dump_payload(42)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
class TestSpotifyClientLifecycle(unittest.IsolatedAsyncioTestCase):
|
|
101
|
-
def _make_client(self):
|
|
102
|
-
token_provider = AsyncMock()
|
|
103
|
-
token_provider.get_access_token.return_value = "fake-token"
|
|
104
|
-
return SpotifyClient(
|
|
105
|
-
token_provider=token_provider,
|
|
106
|
-
scopes=["user-read-playback-state"],
|
|
107
|
-
base_url="https://api.spotify.com/v1",
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
async def test_open_creates_httpx_client(self):
|
|
111
|
-
client = self._make_client()
|
|
112
|
-
self.assertIsNone(client._client)
|
|
113
|
-
await client.open()
|
|
114
|
-
self.assertIsNotNone(client._client)
|
|
115
|
-
await client.close()
|
|
116
|
-
|
|
117
|
-
async def test_close_sets_client_to_none(self):
|
|
118
|
-
client = self._make_client()
|
|
119
|
-
await client.open()
|
|
120
|
-
await client.close()
|
|
121
|
-
self.assertIsNone(client._client)
|
|
122
|
-
|
|
123
|
-
async def test_close_idempotent(self):
|
|
124
|
-
client = self._make_client()
|
|
125
|
-
await client.close() # should not raise
|
|
126
|
-
|
|
127
|
-
async def test_context_manager(self):
|
|
128
|
-
client = self._make_client()
|
|
129
|
-
async with client:
|
|
130
|
-
pass
|
|
131
|
-
self.assertIsNone(client._client)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|