deezer-python-gql 0.17.0__tar.gz → 0.17.1__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.
- {deezer_python_gql-0.17.0/deezer_python_gql.egg-info → deezer_python_gql-0.17.1}/PKG-INFO +9 -5
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/README.md +5 -1
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/__init__.py +2 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/base_client.py +99 -42
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/base_client.py +99 -42
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1/deezer_python_gql.egg-info}/PKG-INFO +9 -5
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/requires.txt +3 -3
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/pyproject.toml +4 -4
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/tests/test_client.py +234 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/LICENSE +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/MANIFEST.in +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/__init__.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_album_to_favorite.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_artist_to_favorite.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_audiobook_to_favorite.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_playlist_to_favorite.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_podcast_to_favorite.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_track_to_favorite.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/add_tracks_to_playlist.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/base_model.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/bookmark_podcast_episode.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/client.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/create_playlist.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/delete_playlist.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/enums.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/fragments.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_album.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_artist.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_artist_mix.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_audiobook.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_audiobook_chapter.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_charts.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_albums.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_artists.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_audiobooks.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_playlists.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_podcasts.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_favorite_tracks.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_batch.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_config_tracks.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_configs.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_livestream.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_made_for_me.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_me.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_music_together_affinity.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_music_together_group.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_music_together_groups.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_personal_tracks.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_playlist.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast_episode.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_recently_played.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_recommendations.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_similar_artists.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_similar_tracks.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_smart_tracklist.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_track.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_track_mix.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_user_charts.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_user_playlists.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/input_types.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/mark_as_not_played_podcast_episode.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/mark_as_played_podcast_episode.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_create_group.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_generate_group_name.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_join_group.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_leave_group.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/music_together_update_group_settings.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_album_from_favorite.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_artist_from_favorite.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_audiobook_from_favorite.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_playlist_from_favorite.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_podcast_from_favorite.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_track_from_favorite.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/remove_tracks_from_playlist.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/search.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/search_flows.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/unbookmark_podcast_episode.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/update_playlist.py +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/py.typed +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/SOURCES.txt +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/not-zip-safe +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/top_level.txt +0 -0
- {deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deezer-python-gql
|
|
3
|
-
Version: 0.17.
|
|
3
|
+
Version: 0.17.1
|
|
4
4
|
Summary: Async typed Python client for Deezer's Pipe GraphQL API.
|
|
5
5
|
Author-email: Julian Daberkow <jdaberkow@users.noreply.github.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -18,10 +18,10 @@ Requires-Dist: codespell==2.4.2; extra == "test"
|
|
|
18
18
|
Requires-Dist: mypy==2.1.0; extra == "test"
|
|
19
19
|
Requires-Dist: pre-commit==4.6.0; extra == "test"
|
|
20
20
|
Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
|
|
21
|
-
Requires-Dist: pytest==9.
|
|
22
|
-
Requires-Dist: pytest-asyncio==1.
|
|
21
|
+
Requires-Dist: pytest==9.1.1; extra == "test"
|
|
22
|
+
Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
|
|
23
23
|
Requires-Dist: pytest-cov==7.1.0; extra == "test"
|
|
24
|
-
Requires-Dist: ruff==0.15.
|
|
24
|
+
Requires-Dist: ruff==0.15.20; extra == "test"
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
|
|
27
27
|
Dynamic: license-file
|
|
@@ -189,7 +189,7 @@ asyncio.run(main())
|
|
|
189
189
|
|
|
190
190
|
| Method | Description |
|
|
191
191
|
| -------------------------------- | ---------------------------------------------------------------- |
|
|
192
|
-
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (
|
|
192
|
+
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (aliased queries, chunked at 50 IDs) |
|
|
193
193
|
|
|
194
194
|
All methods return fully-typed Pydantic models generated from the GraphQL schema.
|
|
195
195
|
|
|
@@ -252,6 +252,10 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
|
|
|
252
252
|
handles token acquisition and refresh automatically — you only need to supply a
|
|
253
253
|
valid ARL value.
|
|
254
254
|
|
|
255
|
+
If the ARL is rejected (bad or expired), the client raises
|
|
256
|
+
`GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
|
|
257
|
+
Transient server errors raise `GraphQLClientHttpError` instead.
|
|
258
|
+
|
|
255
259
|
## License
|
|
256
260
|
|
|
257
261
|
Apache-2.0
|
|
@@ -161,7 +161,7 @@ asyncio.run(main())
|
|
|
161
161
|
|
|
162
162
|
| Method | Description |
|
|
163
163
|
| -------------------------------- | ---------------------------------------------------------------- |
|
|
164
|
-
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (
|
|
164
|
+
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (aliased queries, chunked at 50 IDs) |
|
|
165
165
|
|
|
166
166
|
All methods return fully-typed Pydantic models generated from the GraphQL schema.
|
|
167
167
|
|
|
@@ -224,6 +224,10 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
|
|
|
224
224
|
handles token acquisition and refresh automatically — you only need to supply a
|
|
225
225
|
valid ARL value.
|
|
226
226
|
|
|
227
|
+
If the ARL is rejected (bad or expired), the client raises
|
|
228
|
+
`GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
|
|
229
|
+
Transient server errors raise `GraphQLClientHttpError` instead.
|
|
230
|
+
|
|
227
231
|
## License
|
|
228
232
|
|
|
229
233
|
Apache-2.0
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from deezer_python_gql.generated.base_client import (
|
|
4
4
|
GQLResponse,
|
|
5
|
+
GraphQLClientAuthError,
|
|
5
6
|
GraphQLClientError,
|
|
6
7
|
GraphQLClientGraphQLError,
|
|
7
8
|
GraphQLClientGraphQLMultiError,
|
|
@@ -13,6 +14,7 @@ from deezer_python_gql.generated.client import DeezerGQLClient
|
|
|
13
14
|
__all__ = [
|
|
14
15
|
"DeezerGQLClient",
|
|
15
16
|
"GQLResponse",
|
|
17
|
+
"GraphQLClientAuthError",
|
|
16
18
|
"GraphQLClientError",
|
|
17
19
|
"GraphQLClientGraphQLError",
|
|
18
20
|
"GraphQLClientGraphQLMultiError",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import json
|
|
6
7
|
import logging
|
|
7
8
|
import time
|
|
@@ -21,17 +22,31 @@ class GQLResponse:
|
|
|
21
22
|
|
|
22
23
|
aiohttp responses cannot escape their context manager, so we capture
|
|
23
24
|
the essential fields inside the ``async with`` block and return this.
|
|
25
|
+
Carries the originating operation name and variables so error handling
|
|
26
|
+
and logging stay correct under concurrent requests.
|
|
24
27
|
"""
|
|
25
28
|
|
|
26
29
|
status: int
|
|
27
30
|
data: bytes
|
|
28
31
|
is_success: bool
|
|
32
|
+
operation_name: str | None = None
|
|
33
|
+
variables: dict[str, Any] | None = None
|
|
29
34
|
|
|
30
35
|
|
|
31
36
|
class GraphQLClientError(Exception):
|
|
32
37
|
"""Base exception for GraphQL client errors."""
|
|
33
38
|
|
|
34
39
|
|
|
40
|
+
class GraphQLClientAuthError(GraphQLClientError):
|
|
41
|
+
"""
|
|
42
|
+
Raised when ARL → JWT authentication definitively fails.
|
|
43
|
+
|
|
44
|
+
Indicates a bad or expired ARL cookie (or an unexpected auth response),
|
|
45
|
+
not a transient network/server problem — consumers should treat this as
|
|
46
|
+
"re-check your ARL token" rather than retry.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
35
50
|
class GraphQLClientHttpError(GraphQLClientError):
|
|
36
51
|
"""Raised when the HTTP response indicates an error."""
|
|
37
52
|
|
|
@@ -109,6 +124,8 @@ class DeezerBaseClient:
|
|
|
109
124
|
PIPE_URL = "https://pipe.deezer.com/api"
|
|
110
125
|
AUTH_URL = "https://auth.deezer.com/login/arl"
|
|
111
126
|
JWT_REFRESH_MARGIN_SECONDS = 30
|
|
127
|
+
REQUEST_TIMEOUT_SECONDS = 30
|
|
128
|
+
AUDIOBOOK_CHECK_CHUNK_SIZE = 50
|
|
112
129
|
|
|
113
130
|
def __init__(
|
|
114
131
|
self,
|
|
@@ -122,8 +139,7 @@ class DeezerBaseClient:
|
|
|
122
139
|
self._owns_session = session is None
|
|
123
140
|
self._jwt: str | None = None
|
|
124
141
|
self._jwt_expires_at: float = 0
|
|
125
|
-
self.
|
|
126
|
-
self._last_variables: dict[str, Any] | None = None
|
|
142
|
+
self._jwt_lock = asyncio.Lock()
|
|
127
143
|
|
|
128
144
|
def _get_session(self) -> ClientSession:
|
|
129
145
|
"""Return the HTTP session, creating an internal one if needed."""
|
|
@@ -167,13 +183,12 @@ class DeezerBaseClient:
|
|
|
167
183
|
:param kwargs: Additional keyword arguments passed to the session request.
|
|
168
184
|
"""
|
|
169
185
|
logger.debug("GQL execute: %s (variables=%s)", operation_name or "<unnamed>", variables)
|
|
170
|
-
self._last_operation_name = operation_name
|
|
171
|
-
self._last_variables = variables
|
|
172
186
|
jwt = await self._ensure_jwt()
|
|
173
187
|
|
|
174
188
|
headers: dict[str, str] = kwargs.pop("headers", None) or {}
|
|
175
189
|
headers["Authorization"] = f"Bearer {jwt}"
|
|
176
190
|
headers["Content-Type"] = "application/json"
|
|
191
|
+
kwargs.setdefault("timeout", ClientTimeout(total=self.REQUEST_TIMEOUT_SECONDS))
|
|
177
192
|
|
|
178
193
|
payload: dict[str, Any] = {"query": query}
|
|
179
194
|
if operation_name:
|
|
@@ -194,6 +209,8 @@ class DeezerBaseClient:
|
|
|
194
209
|
status=resp.status,
|
|
195
210
|
data=body,
|
|
196
211
|
is_success=resp.ok,
|
|
212
|
+
operation_name=operation_name,
|
|
213
|
+
variables=variables,
|
|
197
214
|
)
|
|
198
215
|
|
|
199
216
|
logger.debug(
|
|
@@ -227,8 +244,8 @@ class DeezerBaseClient:
|
|
|
227
244
|
errors = response_json.get("errors")
|
|
228
245
|
|
|
229
246
|
if errors:
|
|
230
|
-
op =
|
|
231
|
-
variables =
|
|
247
|
+
op = response.operation_name or "<unknown>"
|
|
248
|
+
variables = response.variables
|
|
232
249
|
if data:
|
|
233
250
|
# Partial success — some items failed (e.g. deleted albums in favorites).
|
|
234
251
|
# Log the errors but return the valid data.
|
|
@@ -280,65 +297,105 @@ class DeezerBaseClient:
|
|
|
280
297
|
for item in obj:
|
|
281
298
|
cls._inject_missing_typenames(item)
|
|
282
299
|
|
|
300
|
+
def _jwt_is_valid(self) -> bool:
|
|
301
|
+
"""Return True if the current JWT exists and is not about to expire."""
|
|
302
|
+
return self._jwt is not None and time.time() < (
|
|
303
|
+
self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS
|
|
304
|
+
)
|
|
305
|
+
|
|
283
306
|
async def _ensure_jwt(self) -> str:
|
|
284
307
|
"""Acquire or refresh the JWT token from ARL cookie."""
|
|
285
|
-
|
|
286
|
-
if self._jwt and now < (self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS):
|
|
308
|
+
if self._jwt is not None and self._jwt_is_valid():
|
|
287
309
|
return self._jwt
|
|
288
310
|
|
|
289
|
-
|
|
290
|
-
|
|
311
|
+
# Serialize refreshes so concurrent expired-JWT requests trigger
|
|
312
|
+
# exactly one auth call.
|
|
313
|
+
async with self._jwt_lock:
|
|
314
|
+
if self._jwt is not None and self._jwt_is_valid():
|
|
315
|
+
return self._jwt
|
|
316
|
+
|
|
317
|
+
logger.debug("JWT expired or missing, refreshing from ARL")
|
|
318
|
+
params = {"jo": "p", "rto": "c", "i": "c"}
|
|
319
|
+
|
|
320
|
+
session = self._get_session()
|
|
321
|
+
async with session.post(
|
|
322
|
+
self.AUTH_URL,
|
|
323
|
+
params=params,
|
|
324
|
+
cookies={"arl": self._arl},
|
|
325
|
+
timeout=ClientTimeout(total=10),
|
|
326
|
+
) as resp:
|
|
327
|
+
status = resp.status
|
|
328
|
+
is_success = resp.ok
|
|
329
|
+
# Response body is text/plain containing JSON
|
|
330
|
+
text = await resp.text()
|
|
331
|
+
|
|
332
|
+
if not is_success:
|
|
333
|
+
if 400 <= status < 500:
|
|
334
|
+
msg = f"ARL authentication rejected by auth.deezer.com (HTTP {status})"
|
|
335
|
+
raise GraphQLClientAuthError(msg)
|
|
336
|
+
raise GraphQLClientHttpError(
|
|
337
|
+
status_code=status,
|
|
338
|
+
response=GQLResponse(status=status, data=text.encode(), is_success=False),
|
|
339
|
+
)
|
|
291
340
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
payload = json.loads(urlsafe_b64decode(padded))
|
|
311
|
-
self._jwt_expires_at = float(payload["exp"])
|
|
312
|
-
logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
|
|
313
|
-
|
|
314
|
-
return self._jwt
|
|
341
|
+
try:
|
|
342
|
+
data = json.loads(text)
|
|
343
|
+
jwt: str = data["jwt"]
|
|
344
|
+
# Decode expiration from JWT payload (second segment, base64url-encoded)
|
|
345
|
+
payload_segment = jwt.split(".")[1]
|
|
346
|
+
# Add padding for base64 decoding
|
|
347
|
+
padded = payload_segment + "=" * (-len(payload_segment) % 4)
|
|
348
|
+
payload = json.loads(urlsafe_b64decode(padded))
|
|
349
|
+
expires_at = float(payload["exp"])
|
|
350
|
+
except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
|
|
351
|
+
msg = "Unexpected response from auth.deezer.com (invalid or missing JWT)"
|
|
352
|
+
raise GraphQLClientAuthError(msg) from exc
|
|
353
|
+
|
|
354
|
+
self._jwt = jwt
|
|
355
|
+
self._jwt_expires_at = expires_at
|
|
356
|
+
logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
|
|
357
|
+
|
|
358
|
+
return jwt
|
|
315
359
|
|
|
316
360
|
async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
|
|
317
361
|
"""
|
|
318
362
|
Check which album IDs are also valid audiobooks on Deezer.
|
|
319
363
|
|
|
364
|
+
Queries are chunked to stay below the API's query complexity limit.
|
|
365
|
+
|
|
320
366
|
:param album_ids: List of Deezer album/audiobook IDs to check.
|
|
321
367
|
"""
|
|
322
|
-
|
|
323
|
-
|
|
368
|
+
audiobook_ids: set[str] = set()
|
|
369
|
+
for start in range(0, len(album_ids), self.AUDIOBOOK_CHECK_CHUNK_SIZE):
|
|
370
|
+
chunk = album_ids[start : start + self.AUDIOBOOK_CHECK_CHUNK_SIZE]
|
|
371
|
+
audiobook_ids |= await self._check_audiobook_ids_chunk(chunk)
|
|
372
|
+
return audiobook_ids
|
|
324
373
|
|
|
374
|
+
async def _check_audiobook_ids_chunk(self, album_ids: list[str]) -> set[str]:
|
|
375
|
+
"""Check a single chunk of album IDs via one aliased query."""
|
|
325
376
|
# Query displayTitle alongside id — querying only { id } echoes back the
|
|
326
377
|
# input without validating that the ID is actually an audiobook.
|
|
378
|
+
# json.dumps escapes the IDs so they cannot break out of the string
|
|
379
|
+
# literal (this is a public library API — IDs may come from anywhere).
|
|
327
380
|
parts = [
|
|
328
|
-
f
|
|
381
|
+
f"a{i}: audiobook(audiobookId: {json.dumps(aid)}) {{ id displayTitle }}"
|
|
329
382
|
for i, aid in enumerate(album_ids)
|
|
330
383
|
]
|
|
331
384
|
query = "query CheckAudiobookIds { " + " ".join(parts) + " }"
|
|
332
385
|
|
|
333
386
|
resp = await self.execute(query, operation_name="CheckAudiobookIds")
|
|
334
|
-
self._last_variables = {"album_ids": album_ids}
|
|
335
387
|
try:
|
|
336
388
|
data = self.get_data(resp)
|
|
337
|
-
except GraphQLClientGraphQLMultiError:
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
|
|
341
|
-
|
|
389
|
+
except GraphQLClientGraphQLMultiError as err:
|
|
390
|
+
# Only swallow errors scoped to our aliased audiobook fields —
|
|
391
|
+
# that is the legitimate "these IDs are not audiobooks" case.
|
|
392
|
+
# Anything else (complexity limit, transient API failure) must
|
|
393
|
+
# surface, otherwise audiobooks silently degrade to albums.
|
|
394
|
+
aliases = {f"a{i}" for i in range(len(album_ids))}
|
|
395
|
+
if all(e.path and e.path[0] in aliases for e in err.errors):
|
|
396
|
+
logger.debug("CheckAudiobookIds: all %d IDs returned errors", len(album_ids))
|
|
397
|
+
return set()
|
|
398
|
+
raise
|
|
342
399
|
|
|
343
400
|
audiobook_ids: set[str] = set()
|
|
344
401
|
for i, aid in enumerate(album_ids):
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/base_client.py
RENAMED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import asyncio
|
|
7
8
|
import json
|
|
8
9
|
import logging
|
|
9
10
|
import time
|
|
@@ -23,17 +24,31 @@ class GQLResponse:
|
|
|
23
24
|
|
|
24
25
|
aiohttp responses cannot escape their context manager, so we capture
|
|
25
26
|
the essential fields inside the ``async with`` block and return this.
|
|
27
|
+
Carries the originating operation name and variables so error handling
|
|
28
|
+
and logging stay correct under concurrent requests.
|
|
26
29
|
"""
|
|
27
30
|
|
|
28
31
|
status: int
|
|
29
32
|
data: bytes
|
|
30
33
|
is_success: bool
|
|
34
|
+
operation_name: str | None = None
|
|
35
|
+
variables: dict[str, Any] | None = None
|
|
31
36
|
|
|
32
37
|
|
|
33
38
|
class GraphQLClientError(Exception):
|
|
34
39
|
"""Base exception for GraphQL client errors."""
|
|
35
40
|
|
|
36
41
|
|
|
42
|
+
class GraphQLClientAuthError(GraphQLClientError):
|
|
43
|
+
"""
|
|
44
|
+
Raised when ARL → JWT authentication definitively fails.
|
|
45
|
+
|
|
46
|
+
Indicates a bad or expired ARL cookie (or an unexpected auth response),
|
|
47
|
+
not a transient network/server problem — consumers should treat this as
|
|
48
|
+
"re-check your ARL token" rather than retry.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
37
52
|
class GraphQLClientHttpError(GraphQLClientError):
|
|
38
53
|
"""Raised when the HTTP response indicates an error."""
|
|
39
54
|
|
|
@@ -111,6 +126,8 @@ class DeezerBaseClient:
|
|
|
111
126
|
PIPE_URL = "https://pipe.deezer.com/api"
|
|
112
127
|
AUTH_URL = "https://auth.deezer.com/login/arl"
|
|
113
128
|
JWT_REFRESH_MARGIN_SECONDS = 30
|
|
129
|
+
REQUEST_TIMEOUT_SECONDS = 30
|
|
130
|
+
AUDIOBOOK_CHECK_CHUNK_SIZE = 50
|
|
114
131
|
|
|
115
132
|
def __init__(
|
|
116
133
|
self,
|
|
@@ -124,8 +141,7 @@ class DeezerBaseClient:
|
|
|
124
141
|
self._owns_session = session is None
|
|
125
142
|
self._jwt: str | None = None
|
|
126
143
|
self._jwt_expires_at: float = 0
|
|
127
|
-
self.
|
|
128
|
-
self._last_variables: dict[str, Any] | None = None
|
|
144
|
+
self._jwt_lock = asyncio.Lock()
|
|
129
145
|
|
|
130
146
|
def _get_session(self) -> ClientSession:
|
|
131
147
|
"""Return the HTTP session, creating an internal one if needed."""
|
|
@@ -169,13 +185,12 @@ class DeezerBaseClient:
|
|
|
169
185
|
:param kwargs: Additional keyword arguments passed to the session request.
|
|
170
186
|
"""
|
|
171
187
|
logger.debug("GQL execute: %s (variables=%s)", operation_name or "<unnamed>", variables)
|
|
172
|
-
self._last_operation_name = operation_name
|
|
173
|
-
self._last_variables = variables
|
|
174
188
|
jwt = await self._ensure_jwt()
|
|
175
189
|
|
|
176
190
|
headers: dict[str, str] = kwargs.pop("headers", None) or {}
|
|
177
191
|
headers["Authorization"] = f"Bearer {jwt}"
|
|
178
192
|
headers["Content-Type"] = "application/json"
|
|
193
|
+
kwargs.setdefault("timeout", ClientTimeout(total=self.REQUEST_TIMEOUT_SECONDS))
|
|
179
194
|
|
|
180
195
|
payload: dict[str, Any] = {"query": query}
|
|
181
196
|
if operation_name:
|
|
@@ -196,6 +211,8 @@ class DeezerBaseClient:
|
|
|
196
211
|
status=resp.status,
|
|
197
212
|
data=body,
|
|
198
213
|
is_success=resp.ok,
|
|
214
|
+
operation_name=operation_name,
|
|
215
|
+
variables=variables,
|
|
199
216
|
)
|
|
200
217
|
|
|
201
218
|
logger.debug(
|
|
@@ -229,8 +246,8 @@ class DeezerBaseClient:
|
|
|
229
246
|
errors = response_json.get("errors")
|
|
230
247
|
|
|
231
248
|
if errors:
|
|
232
|
-
op =
|
|
233
|
-
variables =
|
|
249
|
+
op = response.operation_name or "<unknown>"
|
|
250
|
+
variables = response.variables
|
|
234
251
|
if data:
|
|
235
252
|
# Partial success — some items failed (e.g. deleted albums in favorites).
|
|
236
253
|
# Log the errors but return the valid data.
|
|
@@ -282,65 +299,105 @@ class DeezerBaseClient:
|
|
|
282
299
|
for item in obj:
|
|
283
300
|
cls._inject_missing_typenames(item)
|
|
284
301
|
|
|
302
|
+
def _jwt_is_valid(self) -> bool:
|
|
303
|
+
"""Return True if the current JWT exists and is not about to expire."""
|
|
304
|
+
return self._jwt is not None and time.time() < (
|
|
305
|
+
self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS
|
|
306
|
+
)
|
|
307
|
+
|
|
285
308
|
async def _ensure_jwt(self) -> str:
|
|
286
309
|
"""Acquire or refresh the JWT token from ARL cookie."""
|
|
287
|
-
|
|
288
|
-
if self._jwt and now < (self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS):
|
|
310
|
+
if self._jwt is not None and self._jwt_is_valid():
|
|
289
311
|
return self._jwt
|
|
290
312
|
|
|
291
|
-
|
|
292
|
-
|
|
313
|
+
# Serialize refreshes so concurrent expired-JWT requests trigger
|
|
314
|
+
# exactly one auth call.
|
|
315
|
+
async with self._jwt_lock:
|
|
316
|
+
if self._jwt is not None and self._jwt_is_valid():
|
|
317
|
+
return self._jwt
|
|
318
|
+
|
|
319
|
+
logger.debug("JWT expired or missing, refreshing from ARL")
|
|
320
|
+
params = {"jo": "p", "rto": "c", "i": "c"}
|
|
321
|
+
|
|
322
|
+
session = self._get_session()
|
|
323
|
+
async with session.post(
|
|
324
|
+
self.AUTH_URL,
|
|
325
|
+
params=params,
|
|
326
|
+
cookies={"arl": self._arl},
|
|
327
|
+
timeout=ClientTimeout(total=10),
|
|
328
|
+
) as resp:
|
|
329
|
+
status = resp.status
|
|
330
|
+
is_success = resp.ok
|
|
331
|
+
# Response body is text/plain containing JSON
|
|
332
|
+
text = await resp.text()
|
|
333
|
+
|
|
334
|
+
if not is_success:
|
|
335
|
+
if 400 <= status < 500:
|
|
336
|
+
msg = f"ARL authentication rejected by auth.deezer.com (HTTP {status})"
|
|
337
|
+
raise GraphQLClientAuthError(msg)
|
|
338
|
+
raise GraphQLClientHttpError(
|
|
339
|
+
status_code=status,
|
|
340
|
+
response=GQLResponse(status=status, data=text.encode(), is_success=False),
|
|
341
|
+
)
|
|
293
342
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
payload = json.loads(urlsafe_b64decode(padded))
|
|
313
|
-
self._jwt_expires_at = float(payload["exp"])
|
|
314
|
-
logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
|
|
315
|
-
|
|
316
|
-
return self._jwt
|
|
343
|
+
try:
|
|
344
|
+
data = json.loads(text)
|
|
345
|
+
jwt: str = data["jwt"]
|
|
346
|
+
# Decode expiration from JWT payload (second segment, base64url-encoded)
|
|
347
|
+
payload_segment = jwt.split(".")[1]
|
|
348
|
+
# Add padding for base64 decoding
|
|
349
|
+
padded = payload_segment + "=" * (-len(payload_segment) % 4)
|
|
350
|
+
payload = json.loads(urlsafe_b64decode(padded))
|
|
351
|
+
expires_at = float(payload["exp"])
|
|
352
|
+
except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
|
|
353
|
+
msg = "Unexpected response from auth.deezer.com (invalid or missing JWT)"
|
|
354
|
+
raise GraphQLClientAuthError(msg) from exc
|
|
355
|
+
|
|
356
|
+
self._jwt = jwt
|
|
357
|
+
self._jwt_expires_at = expires_at
|
|
358
|
+
logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
|
|
359
|
+
|
|
360
|
+
return jwt
|
|
317
361
|
|
|
318
362
|
async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
|
|
319
363
|
"""
|
|
320
364
|
Check which album IDs are also valid audiobooks on Deezer.
|
|
321
365
|
|
|
366
|
+
Queries are chunked to stay below the API's query complexity limit.
|
|
367
|
+
|
|
322
368
|
:param album_ids: List of Deezer album/audiobook IDs to check.
|
|
323
369
|
"""
|
|
324
|
-
|
|
325
|
-
|
|
370
|
+
audiobook_ids: set[str] = set()
|
|
371
|
+
for start in range(0, len(album_ids), self.AUDIOBOOK_CHECK_CHUNK_SIZE):
|
|
372
|
+
chunk = album_ids[start : start + self.AUDIOBOOK_CHECK_CHUNK_SIZE]
|
|
373
|
+
audiobook_ids |= await self._check_audiobook_ids_chunk(chunk)
|
|
374
|
+
return audiobook_ids
|
|
326
375
|
|
|
376
|
+
async def _check_audiobook_ids_chunk(self, album_ids: list[str]) -> set[str]:
|
|
377
|
+
"""Check a single chunk of album IDs via one aliased query."""
|
|
327
378
|
# Query displayTitle alongside id — querying only { id } echoes back the
|
|
328
379
|
# input without validating that the ID is actually an audiobook.
|
|
380
|
+
# json.dumps escapes the IDs so they cannot break out of the string
|
|
381
|
+
# literal (this is a public library API — IDs may come from anywhere).
|
|
329
382
|
parts = [
|
|
330
|
-
f
|
|
383
|
+
f"a{i}: audiobook(audiobookId: {json.dumps(aid)}) {{ id displayTitle }}"
|
|
331
384
|
for i, aid in enumerate(album_ids)
|
|
332
385
|
]
|
|
333
386
|
query = "query CheckAudiobookIds { " + " ".join(parts) + " }"
|
|
334
387
|
|
|
335
388
|
resp = await self.execute(query, operation_name="CheckAudiobookIds")
|
|
336
|
-
self._last_variables = {"album_ids": album_ids}
|
|
337
389
|
try:
|
|
338
390
|
data = self.get_data(resp)
|
|
339
|
-
except GraphQLClientGraphQLMultiError:
|
|
340
|
-
#
|
|
341
|
-
#
|
|
342
|
-
|
|
343
|
-
|
|
391
|
+
except GraphQLClientGraphQLMultiError as err:
|
|
392
|
+
# Only swallow errors scoped to our aliased audiobook fields —
|
|
393
|
+
# that is the legitimate "these IDs are not audiobooks" case.
|
|
394
|
+
# Anything else (complexity limit, transient API failure) must
|
|
395
|
+
# surface, otherwise audiobooks silently degrade to albums.
|
|
396
|
+
aliases = {f"a{i}" for i in range(len(album_ids))}
|
|
397
|
+
if all(e.path and e.path[0] in aliases for e in err.errors):
|
|
398
|
+
logger.debug("CheckAudiobookIds: all %d IDs returned errors", len(album_ids))
|
|
399
|
+
return set()
|
|
400
|
+
raise
|
|
344
401
|
|
|
345
402
|
audiobook_ids: set[str] = set()
|
|
346
403
|
for i, aid in enumerate(album_ids):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deezer-python-gql
|
|
3
|
-
Version: 0.17.
|
|
3
|
+
Version: 0.17.1
|
|
4
4
|
Summary: Async typed Python client for Deezer's Pipe GraphQL API.
|
|
5
5
|
Author-email: Julian Daberkow <jdaberkow@users.noreply.github.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -18,10 +18,10 @@ Requires-Dist: codespell==2.4.2; extra == "test"
|
|
|
18
18
|
Requires-Dist: mypy==2.1.0; extra == "test"
|
|
19
19
|
Requires-Dist: pre-commit==4.6.0; extra == "test"
|
|
20
20
|
Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
|
|
21
|
-
Requires-Dist: pytest==9.
|
|
22
|
-
Requires-Dist: pytest-asyncio==1.
|
|
21
|
+
Requires-Dist: pytest==9.1.1; extra == "test"
|
|
22
|
+
Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
|
|
23
23
|
Requires-Dist: pytest-cov==7.1.0; extra == "test"
|
|
24
|
-
Requires-Dist: ruff==0.15.
|
|
24
|
+
Requires-Dist: ruff==0.15.20; extra == "test"
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
|
|
27
27
|
Dynamic: license-file
|
|
@@ -189,7 +189,7 @@ asyncio.run(main())
|
|
|
189
189
|
|
|
190
190
|
| Method | Description |
|
|
191
191
|
| -------------------------------- | ---------------------------------------------------------------- |
|
|
192
|
-
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (
|
|
192
|
+
| `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (aliased queries, chunked at 50 IDs) |
|
|
193
193
|
|
|
194
194
|
All methods return fully-typed Pydantic models generated from the GraphQL schema.
|
|
195
195
|
|
|
@@ -252,6 +252,10 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
|
|
|
252
252
|
handles token acquisition and refresh automatically — you only need to supply a
|
|
253
253
|
valid ARL value.
|
|
254
254
|
|
|
255
|
+
If the ARL is rejected (bad or expired), the client raises
|
|
256
|
+
`GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
|
|
257
|
+
Transient server errors raise `GraphQLClientHttpError` instead.
|
|
258
|
+
|
|
255
259
|
## License
|
|
256
260
|
|
|
257
261
|
Apache-2.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "deezer-python-gql"
|
|
3
|
-
version = "0.17.
|
|
3
|
+
version = "0.17.1"
|
|
4
4
|
description = "Async typed Python client for Deezer's Pipe GraphQL API."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -26,10 +26,10 @@ test = [
|
|
|
26
26
|
"mypy==2.1.0",
|
|
27
27
|
"pre-commit==4.6.0",
|
|
28
28
|
"pre-commit-hooks==6.0.0",
|
|
29
|
-
"pytest==9.
|
|
30
|
-
"pytest-asyncio==1.
|
|
29
|
+
"pytest==9.1.1",
|
|
30
|
+
"pytest-asyncio==1.4.0",
|
|
31
31
|
"pytest-cov==7.1.0",
|
|
32
|
-
"ruff==0.15.
|
|
32
|
+
"ruff==0.15.20",
|
|
33
33
|
]
|
|
34
34
|
dev = [
|
|
35
35
|
"ariadne-codegen[subscriptions]",
|
|
@@ -9,6 +9,7 @@ Organized into three layers:
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import asyncio
|
|
12
13
|
import base64
|
|
13
14
|
import json
|
|
14
15
|
import time
|
|
@@ -22,6 +23,7 @@ from deezer_python_gql import DeezerGQLClient
|
|
|
22
23
|
from deezer_python_gql.base_client import (
|
|
23
24
|
DeezerBaseClient,
|
|
24
25
|
GQLResponse,
|
|
26
|
+
GraphQLClientAuthError,
|
|
25
27
|
GraphQLClientGraphQLMultiError,
|
|
26
28
|
GraphQLClientHttpError,
|
|
27
29
|
GraphQLClientInvalidResponseError,
|
|
@@ -431,6 +433,114 @@ async def test_auth_parses_text_plain_response() -> None:
|
|
|
431
433
|
assert client._jwt_expires_at > 0 # noqa: SLF001
|
|
432
434
|
|
|
433
435
|
|
|
436
|
+
@pytest.mark.asyncio
|
|
437
|
+
async def test_auth_rejected_arl_raises_auth_error() -> None:
|
|
438
|
+
"""Verify a 4xx from auth.deezer.com raises GraphQLClientAuthError."""
|
|
439
|
+
client = DeezerBaseClient(arl="bad_arl")
|
|
440
|
+
auth_cm = _mock_post_context_manager(b"Forbidden", status=403)
|
|
441
|
+
|
|
442
|
+
with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
|
|
443
|
+
mock_instance = MagicMock()
|
|
444
|
+
mock_instance.post = MagicMock(return_value=auth_cm)
|
|
445
|
+
mock_instance.close = AsyncMock()
|
|
446
|
+
mock_cls.return_value = mock_instance
|
|
447
|
+
|
|
448
|
+
with pytest.raises(GraphQLClientAuthError, match="HTTP 403"):
|
|
449
|
+
await client._ensure_jwt() # noqa: SLF001
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@pytest.mark.asyncio
|
|
453
|
+
async def test_auth_server_error_raises_http_error() -> None:
|
|
454
|
+
"""Verify a 5xx from auth.deezer.com raises GraphQLClientHttpError, not AuthError."""
|
|
455
|
+
client = DeezerBaseClient(arl="test_arl")
|
|
456
|
+
auth_cm = _mock_post_context_manager(b"Service Unavailable", status=503)
|
|
457
|
+
|
|
458
|
+
with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
|
|
459
|
+
mock_instance = MagicMock()
|
|
460
|
+
mock_instance.post = MagicMock(return_value=auth_cm)
|
|
461
|
+
mock_instance.close = AsyncMock()
|
|
462
|
+
mock_cls.return_value = mock_instance
|
|
463
|
+
|
|
464
|
+
with pytest.raises(GraphQLClientHttpError) as exc_info:
|
|
465
|
+
await client._ensure_jwt() # noqa: SLF001
|
|
466
|
+
assert exc_info.value.status_code == 503
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@pytest.mark.asyncio
|
|
470
|
+
async def test_auth_invalid_json_raises_auth_error() -> None:
|
|
471
|
+
"""Verify a non-JSON auth response raises GraphQLClientAuthError."""
|
|
472
|
+
client = DeezerBaseClient(arl="test_arl")
|
|
473
|
+
auth_cm = _mock_post_context_manager(b"<html>maintenance</html>")
|
|
474
|
+
|
|
475
|
+
with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
|
|
476
|
+
mock_instance = MagicMock()
|
|
477
|
+
mock_instance.post = MagicMock(return_value=auth_cm)
|
|
478
|
+
mock_instance.close = AsyncMock()
|
|
479
|
+
mock_cls.return_value = mock_instance
|
|
480
|
+
|
|
481
|
+
with pytest.raises(GraphQLClientAuthError):
|
|
482
|
+
await client._ensure_jwt() # noqa: SLF001
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@pytest.mark.asyncio
|
|
486
|
+
async def test_auth_missing_jwt_key_raises_auth_error() -> None:
|
|
487
|
+
"""Verify an auth response without a 'jwt' key raises GraphQLClientAuthError."""
|
|
488
|
+
client = DeezerBaseClient(arl="test_arl")
|
|
489
|
+
auth_cm = _mock_post_context_manager(json.dumps({"error": "invalid arl"}).encode())
|
|
490
|
+
|
|
491
|
+
with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
|
|
492
|
+
mock_instance = MagicMock()
|
|
493
|
+
mock_instance.post = MagicMock(return_value=auth_cm)
|
|
494
|
+
mock_instance.close = AsyncMock()
|
|
495
|
+
mock_cls.return_value = mock_instance
|
|
496
|
+
|
|
497
|
+
with pytest.raises(GraphQLClientAuthError):
|
|
498
|
+
await client._ensure_jwt() # noqa: SLF001
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@pytest.mark.asyncio
|
|
502
|
+
async def test_auth_concurrent_refresh_single_auth_call() -> None:
|
|
503
|
+
"""Verify concurrent expired-JWT requests trigger exactly one auth call."""
|
|
504
|
+
client = DeezerBaseClient(arl="test_arl")
|
|
505
|
+
auth_cm = _mock_auth_cm()
|
|
506
|
+
|
|
507
|
+
with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
|
|
508
|
+
mock_instance = MagicMock()
|
|
509
|
+
mock_instance.post = MagicMock(return_value=auth_cm)
|
|
510
|
+
mock_instance.close = AsyncMock()
|
|
511
|
+
mock_cls.return_value = mock_instance
|
|
512
|
+
|
|
513
|
+
results = await asyncio.gather(
|
|
514
|
+
client._ensure_jwt(), # noqa: SLF001
|
|
515
|
+
client._ensure_jwt(), # noqa: SLF001
|
|
516
|
+
client._ensure_jwt(), # noqa: SLF001
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
assert mock_instance.post.call_count == 1
|
|
520
|
+
assert len(set(results)) == 1
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@pytest.mark.asyncio
|
|
524
|
+
async def test_execute_sets_default_timeout() -> None:
|
|
525
|
+
"""Verify execute() applies the 30s default request timeout."""
|
|
526
|
+
client = DeezerBaseClient(arl="test_arl")
|
|
527
|
+
client._jwt = _make_jwt(exp=time.time() + 600) # noqa: SLF001
|
|
528
|
+
client._jwt_expires_at = time.time() + 600 # noqa: SLF001
|
|
529
|
+
|
|
530
|
+
gql_cm = _mock_post_context_manager(json.dumps({"data": {"me": {"id": "1"}}}).encode())
|
|
531
|
+
|
|
532
|
+
with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
|
|
533
|
+
mock_instance = MagicMock()
|
|
534
|
+
mock_instance.post = MagicMock(return_value=gql_cm)
|
|
535
|
+
mock_instance.close = AsyncMock()
|
|
536
|
+
mock_cls.return_value = mock_instance
|
|
537
|
+
|
|
538
|
+
await client.execute(query="{ me { id } }")
|
|
539
|
+
|
|
540
|
+
timeout = mock_instance.post.call_args.kwargs["timeout"]
|
|
541
|
+
assert timeout.total == DeezerBaseClient.REQUEST_TIMEOUT_SECONDS
|
|
542
|
+
|
|
543
|
+
|
|
434
544
|
# ---------------------------------------------------------------------------
|
|
435
545
|
# 4. Error handling (mocked HTTP)
|
|
436
546
|
# ---------------------------------------------------------------------------
|
|
@@ -556,6 +666,130 @@ async def test_check_audiobook_ids_empty_input() -> None:
|
|
|
556
666
|
assert result == set()
|
|
557
667
|
|
|
558
668
|
|
|
669
|
+
@pytest.mark.asyncio
|
|
670
|
+
async def test_check_audiobook_ids_chunks_large_input() -> None:
|
|
671
|
+
"""Verify large ID lists are split into multiple aliased queries."""
|
|
672
|
+
client = DeezerBaseClient(arl="test_arl")
|
|
673
|
+
client._jwt = _make_jwt(exp=time.time() + 600) # noqa: SLF001
|
|
674
|
+
client._jwt_expires_at = time.time() + 600 # noqa: SLF001
|
|
675
|
+
|
|
676
|
+
chunk_size = DeezerBaseClient.AUDIOBOOK_CHECK_CHUNK_SIZE
|
|
677
|
+
album_ids = [str(n) for n in range(chunk_size * 2 + 10)]
|
|
678
|
+
|
|
679
|
+
def _chunk_response(size: int) -> AsyncMock:
|
|
680
|
+
data = {f"a{i}": {"id": "x", "displayTitle": "Book"} for i in range(size)}
|
|
681
|
+
return _mock_post_context_manager(json.dumps({"data": data}).encode())
|
|
682
|
+
|
|
683
|
+
with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
|
|
684
|
+
mock_instance = MagicMock()
|
|
685
|
+
mock_instance.post = MagicMock(
|
|
686
|
+
side_effect=[
|
|
687
|
+
_chunk_response(chunk_size),
|
|
688
|
+
_chunk_response(chunk_size),
|
|
689
|
+
_chunk_response(10),
|
|
690
|
+
]
|
|
691
|
+
)
|
|
692
|
+
mock_instance.close = AsyncMock()
|
|
693
|
+
mock_cls.return_value = mock_instance
|
|
694
|
+
|
|
695
|
+
result = await client.check_audiobook_ids(album_ids)
|
|
696
|
+
|
|
697
|
+
assert mock_instance.post.call_count == 3
|
|
698
|
+
assert result == set(album_ids)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@pytest.mark.asyncio
|
|
702
|
+
async def test_check_audiobook_ids_escapes_ids() -> None:
|
|
703
|
+
"""Verify IDs are JSON-escaped and cannot break out of the query string."""
|
|
704
|
+
client = DeezerBaseClient(arl="test_arl")
|
|
705
|
+
client._jwt = _make_jwt(exp=time.time() + 600) # noqa: SLF001
|
|
706
|
+
client._jwt_expires_at = time.time() + 600 # noqa: SLF001
|
|
707
|
+
|
|
708
|
+
gql_cm = _mock_post_context_manager(json.dumps({"data": {"a0": None}}).encode())
|
|
709
|
+
|
|
710
|
+
with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
|
|
711
|
+
mock_instance = MagicMock()
|
|
712
|
+
mock_instance.post = MagicMock(return_value=gql_cm)
|
|
713
|
+
mock_instance.close = AsyncMock()
|
|
714
|
+
mock_cls.return_value = mock_instance
|
|
715
|
+
|
|
716
|
+
await client.check_audiobook_ids(['123") { id } evil: me { id'])
|
|
717
|
+
|
|
718
|
+
query = mock_instance.post.call_args.kwargs["json"]["query"]
|
|
719
|
+
assert 'audiobookId: "123\\") { id } evil: me { id"' in query
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
@pytest.mark.asyncio
|
|
723
|
+
async def test_check_audiobook_ids_swallows_alias_scoped_errors() -> None:
|
|
724
|
+
"""Verify per-alias not-found errors yield an empty set (none are audiobooks)."""
|
|
725
|
+
client = DeezerBaseClient(arl="test_arl")
|
|
726
|
+
client._jwt = _make_jwt(exp=time.time() + 600) # noqa: SLF001
|
|
727
|
+
client._jwt_expires_at = time.time() + 600 # noqa: SLF001
|
|
728
|
+
|
|
729
|
+
gql_cm = _mock_post_context_manager(
|
|
730
|
+
json.dumps(
|
|
731
|
+
{
|
|
732
|
+
"data": None,
|
|
733
|
+
"errors": [
|
|
734
|
+
{"message": "Audiobook not found", "path": ["a0"]},
|
|
735
|
+
{"message": "Audiobook not found", "path": ["a1"]},
|
|
736
|
+
],
|
|
737
|
+
}
|
|
738
|
+
).encode(),
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
|
|
742
|
+
mock_instance = MagicMock()
|
|
743
|
+
mock_instance.post = MagicMock(return_value=gql_cm)
|
|
744
|
+
mock_instance.close = AsyncMock()
|
|
745
|
+
mock_cls.return_value = mock_instance
|
|
746
|
+
|
|
747
|
+
result = await client.check_audiobook_ids(["111", "222"])
|
|
748
|
+
|
|
749
|
+
assert result == set()
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
@pytest.mark.asyncio
|
|
753
|
+
async def test_check_audiobook_ids_raises_on_query_level_errors() -> None:
|
|
754
|
+
"""Verify non-alias errors (e.g. complexity limit) surface instead of being swallowed."""
|
|
755
|
+
client = DeezerBaseClient(arl="test_arl")
|
|
756
|
+
client._jwt = _make_jwt(exp=time.time() + 600) # noqa: SLF001
|
|
757
|
+
client._jwt_expires_at = time.time() + 600 # noqa: SLF001
|
|
758
|
+
|
|
759
|
+
gql_cm = _mock_post_context_manager(
|
|
760
|
+
json.dumps(
|
|
761
|
+
{
|
|
762
|
+
"data": None,
|
|
763
|
+
"errors": [{"message": "Query complexity limit exceeded"}],
|
|
764
|
+
}
|
|
765
|
+
).encode(),
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
with patch("deezer_python_gql.base_client.ClientSession") as mock_cls:
|
|
769
|
+
mock_instance = MagicMock()
|
|
770
|
+
mock_instance.post = MagicMock(return_value=gql_cm)
|
|
771
|
+
mock_instance.close = AsyncMock()
|
|
772
|
+
mock_cls.return_value = mock_instance
|
|
773
|
+
|
|
774
|
+
with pytest.raises(GraphQLClientGraphQLMultiError):
|
|
775
|
+
await client.check_audiobook_ids(["111", "222"])
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def test_get_data_error_uses_response_operation_name() -> None:
|
|
779
|
+
"""Verify GraphQL errors are attributed to the operation carried by the response."""
|
|
780
|
+
client = DeezerBaseClient(arl="test")
|
|
781
|
+
response = GQLResponse(
|
|
782
|
+
status=200,
|
|
783
|
+
data=json.dumps({"data": None, "errors": [{"message": "boom"}]}).encode(),
|
|
784
|
+
is_success=True,
|
|
785
|
+
operation_name="GetTrack",
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
with pytest.raises(GraphQLClientGraphQLMultiError) as exc_info:
|
|
789
|
+
client.get_data(response)
|
|
790
|
+
assert exc_info.value.operation_name == "GetTrack"
|
|
791
|
+
|
|
792
|
+
|
|
559
793
|
# ---------------------------------------------------------------------------
|
|
560
794
|
# 6. Model smoke tests (one per query — fixture-based)
|
|
561
795
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/base_model.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/create_playlist.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/delete_playlist.py
RENAMED
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/fragments.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_album.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_artist.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_artist_mix.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_audiobook.py
RENAMED
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_charts.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_flow_batch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_livestream.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_made_for_me.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_playlist.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_podcast.py
RENAMED
|
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
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_track.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_track_mix.py
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/get_user_charts.py
RENAMED
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/input_types.py
RENAMED
|
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
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/search_flows.py
RENAMED
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql/generated/update_playlist.py
RENAMED
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/not-zip-safe
RENAMED
|
File without changes
|
{deezer_python_gql-0.17.0 → deezer_python_gql-0.17.1}/deezer_python_gql.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|