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.
Files changed (45) hide show
  1. {spotifyify-0.2.0/spotifyify.egg-info → spotifyify-0.3.0}/PKG-INFO +14 -5
  2. spotifyify-0.2.0/PKG-INFO → spotifyify-0.3.0/README.md +13 -14
  3. {spotifyify-0.2.0 → spotifyify-0.3.0}/pyproject.toml +1 -1
  4. spotifyify-0.3.0/spotifyify/auth.py +9 -0
  5. spotifyify-0.3.0/spotifyify/client.py +189 -0
  6. spotifyify-0.3.0/spotifyify/http/__init__.py +21 -0
  7. spotifyify-0.3.0/spotifyify/http/response.py +47 -0
  8. spotifyify-0.3.0/spotifyify/http/retry_policy.py +54 -0
  9. spotifyify-0.3.0/spotifyify/http/serialization.py +26 -0
  10. spotifyify-0.3.0/spotifyify/http/transport.py +70 -0
  11. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/playlists.py +31 -6
  12. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/oauth2/oauth2.py +1 -1
  13. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/spotifyify.py +4 -0
  14. spotifyify-0.2.0/README.md → spotifyify-0.3.0/spotifyify.egg-info/PKG-INFO +23 -4
  15. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify.egg-info/SOURCES.txt +6 -0
  16. spotifyify-0.3.0/tests/test_client.py +90 -0
  17. spotifyify-0.2.0/spotifyify/client.py +0 -279
  18. spotifyify-0.2.0/tests/test_client.py +0 -131
  19. {spotifyify-0.2.0 → spotifyify-0.3.0}/setup.cfg +0 -0
  20. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/__init__.py +0 -0
  21. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/cache_handler.py +0 -0
  22. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/credentials.py +0 -0
  23. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/exceptions.py +0 -0
  24. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/__init__.py +0 -0
  25. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/albums.py +0 -0
  26. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/artists.py +0 -0
  27. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/episodes.py +0 -0
  28. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/library.py +0 -0
  29. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/player.py +0 -0
  30. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/shows.py +0 -0
  31. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/tracks.py +0 -0
  32. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/namespaces/users.py +0 -0
  33. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/oauth2/__init__.py +0 -0
  34. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/oauth2/views.py +0 -0
  35. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/schemas.py +0 -0
  36. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify/utils.py +0 -0
  37. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify.egg-info/dependency_links.txt +0 -0
  38. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify.egg-info/requires.txt +0 -0
  39. {spotifyify-0.2.0 → spotifyify-0.3.0}/spotifyify.egg-info/top_level.txt +0 -0
  40. {spotifyify-0.2.0 → spotifyify-0.3.0}/tests/test_cache_handler.py +0 -0
  41. {spotifyify-0.2.0 → spotifyify-0.3.0}/tests/test_credentials.py +0 -0
  42. {spotifyify-0.2.0 → spotifyify-0.3.0}/tests/test_exceptions.py +0 -0
  43. {spotifyify-0.2.0 → spotifyify-0.3.0}/tests/test_oauth2.py +0 -0
  44. {spotifyify-0.2.0 → spotifyify-0.3.0}/tests/test_scopes.py +0 -0
  45. {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.2.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 generated from the [official Spotify OpenAPI specification](https://developer.spotify.com/reference/web-api/open-api-schema.yaml).
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 tracks to a playlist |
136
- | `remove(playlist_id, uris)` | Remove tracks from a playlist |
137
- | `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder tracks |
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 generated from the [official Spotify OpenAPI specification](https://developer.spotify.com/reference/web-api/open-api-schema.yaml).
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 tracks to a playlist |
136
- | `remove(playlist_id, uris)` | Remove tracks from a playlist |
137
- | `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder tracks |
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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spotifyify"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Async wrapper for spotipy with a focus on integration with MCP agents."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -0,0 +1,9 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class AccessTokenProvider(Protocol):
5
+ async def get_access_token(
6
+ self,
7
+ require_user: bool,
8
+ scope: str | list[str] | tuple[str, ...] | None = None,
9
+ ) -> str: ...
@@ -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}/tracks", payload=payload)
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 = {"tracks": [{"uri": uri} for uri in coalesce_items(uris)]}
150
+ payload = {"items": [{"uri": uri} for uri in coalesce_items(uris)]}
126
151
  data = (
127
- await self._http.delete(f"/playlists/{playlist_id}/tracks", payload=payload)
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}/tracks", payload=payload)
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 generated from the [official Spotify OpenAPI specification](https://developer.spotify.com/reference/web-api/open-api-schema.yaml).
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 tracks to a playlist |
126
- | `remove(playlist_id, uris)` | Remove tracks from a playlist |
127
- | `reorder(playlist_id, *, range_start, insert_before, range_length, snapshot_id)` | Reorder tracks |
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