deezer-python-gql 0.1.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.
- {deezer_python_gql-0.1.0/deezer_python_gql.egg-info → deezer_python_gql-0.3.0}/PKG-INFO +24 -12
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/README.md +20 -8
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/deezer_python_gql/base_client.py +65 -4
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0/deezer_python_gql.egg-info}/PKG-INFO +24 -12
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/deezer_python_gql.egg-info/requires.txt +3 -3
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/pyproject.toml +4 -4
- deezer_python_gql-0.3.0/tests/test_client.py +837 -0
- deezer_python_gql-0.1.0/tests/test_client.py +0 -406
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/LICENSE +0 -0
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/MANIFEST.in +0 -0
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/deezer_python_gql/__init__.py +0 -0
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/deezer_python_gql/py.typed +0 -0
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/deezer_python_gql.egg-info/SOURCES.txt +0 -0
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/deezer_python_gql.egg-info/not-zip-safe +0 -0
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/deezer_python_gql.egg-info/top_level.txt +0 -0
- {deezer_python_gql-0.1.0 → deezer_python_gql-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deezer-python-gql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -14,14 +14,14 @@ License-File: LICENSE
|
|
|
14
14
|
Requires-Dist: httpx>=0.27.0
|
|
15
15
|
Requires-Dist: pydantic>=2.0.0
|
|
16
16
|
Provides-Extra: test
|
|
17
|
-
Requires-Dist: codespell==2.4.
|
|
17
|
+
Requires-Dist: codespell==2.4.2; extra == "test"
|
|
18
18
|
Requires-Dist: mypy==1.19.1; extra == "test"
|
|
19
19
|
Requires-Dist: pre-commit==4.5.1; extra == "test"
|
|
20
20
|
Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
|
|
21
21
|
Requires-Dist: pytest==9.0.2; extra == "test"
|
|
22
22
|
Requires-Dist: pytest-asyncio==1.3.0; extra == "test"
|
|
23
|
-
Requires-Dist: pytest-cov==7.
|
|
24
|
-
Requires-Dist: ruff==0.15.
|
|
23
|
+
Requires-Dist: pytest-cov==7.1.0; extra == "test"
|
|
24
|
+
Requires-Dist: ruff==0.15.7; extra == "test"
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
|
|
27
27
|
Dynamic: license-file
|
|
@@ -79,14 +79,26 @@ asyncio.run(main())
|
|
|
79
79
|
|
|
80
80
|
## Available Queries
|
|
81
81
|
|
|
82
|
-
| Method
|
|
83
|
-
|
|
|
84
|
-
| `get_me()`
|
|
85
|
-
| `get_track(track_id)`
|
|
86
|
-
| `get_album(album_id)`
|
|
87
|
-
| `get_artist(artist_id)`
|
|
88
|
-
| `get_playlist(playlist_id)`
|
|
89
|
-
| `search(query, ...)`
|
|
82
|
+
| Method | Description |
|
|
83
|
+
| ------------------------------------------------ | ------------------------------------------------------------- |
|
|
84
|
+
| `get_me()` | Current authenticated user |
|
|
85
|
+
| `get_track(track_id)` | Full track details — ISRC, media tokens, lyrics, contributors |
|
|
86
|
+
| `get_album(album_id)` | Album with cover, label, paginated tracks, fallback |
|
|
87
|
+
| `get_artist(artist_id)` | Artist with bio, top tracks, albums (ordered by release date) |
|
|
88
|
+
| `get_playlist(playlist_id)` | Playlist with owner, picture, paginated tracks |
|
|
89
|
+
| `search(query, ...)` | Unified search across tracks, albums, artists, playlists |
|
|
90
|
+
| `get_flow()` | User's default Flow with tracks |
|
|
91
|
+
| `get_flow_configs(moods_first, genres_first)` | Mood & genre flow config lists for discovery |
|
|
92
|
+
| `get_flow_config_tracks(flow_config_id)` | Tracks for a specific mood/genre flow config |
|
|
93
|
+
| `get_made_for_me(first)` | "Made For You" SmartTracklist & Flow items |
|
|
94
|
+
| `get_smart_tracklist(smart_tracklist_id, first)` | Smart tracklist with paginated tracks |
|
|
95
|
+
| `get_charts(country_code, ...)` | Country charts — tracks, albums, artists, playlists |
|
|
96
|
+
| `get_recommendations(playlists_first, ...)` | Personalized recommendations across categories |
|
|
97
|
+
| `get_recently_played(first)` | Recently played mixed content (albums, playlists, artists...) |
|
|
98
|
+
| `get_favorite_artists(first, after)` | Paginated favorite artists |
|
|
99
|
+
| `get_favorite_albums(first, after)` | Paginated favorite albums |
|
|
100
|
+
| `get_favorite_tracks(first, after)` | Paginated favorite tracks |
|
|
101
|
+
| `get_favorite_playlists(first, after)` | Paginated favorite playlists |
|
|
90
102
|
|
|
91
103
|
All methods return fully-typed Pydantic models generated from the GraphQL schema.
|
|
92
104
|
|
|
@@ -51,14 +51,26 @@ asyncio.run(main())
|
|
|
51
51
|
|
|
52
52
|
## Available Queries
|
|
53
53
|
|
|
54
|
-
| Method
|
|
55
|
-
|
|
|
56
|
-
| `get_me()`
|
|
57
|
-
| `get_track(track_id)`
|
|
58
|
-
| `get_album(album_id)`
|
|
59
|
-
| `get_artist(artist_id)`
|
|
60
|
-
| `get_playlist(playlist_id)`
|
|
61
|
-
| `search(query, ...)`
|
|
54
|
+
| Method | Description |
|
|
55
|
+
| ------------------------------------------------ | ------------------------------------------------------------- |
|
|
56
|
+
| `get_me()` | Current authenticated user |
|
|
57
|
+
| `get_track(track_id)` | Full track details — ISRC, media tokens, lyrics, contributors |
|
|
58
|
+
| `get_album(album_id)` | Album with cover, label, paginated tracks, fallback |
|
|
59
|
+
| `get_artist(artist_id)` | Artist with bio, top tracks, albums (ordered by release date) |
|
|
60
|
+
| `get_playlist(playlist_id)` | Playlist with owner, picture, paginated tracks |
|
|
61
|
+
| `search(query, ...)` | Unified search across tracks, albums, artists, playlists |
|
|
62
|
+
| `get_flow()` | User's default Flow with tracks |
|
|
63
|
+
| `get_flow_configs(moods_first, genres_first)` | Mood & genre flow config lists for discovery |
|
|
64
|
+
| `get_flow_config_tracks(flow_config_id)` | Tracks for a specific mood/genre flow config |
|
|
65
|
+
| `get_made_for_me(first)` | "Made For You" SmartTracklist & Flow items |
|
|
66
|
+
| `get_smart_tracklist(smart_tracklist_id, first)` | Smart tracklist with paginated tracks |
|
|
67
|
+
| `get_charts(country_code, ...)` | Country charts — tracks, albums, artists, playlists |
|
|
68
|
+
| `get_recommendations(playlists_first, ...)` | Personalized recommendations across categories |
|
|
69
|
+
| `get_recently_played(first)` | Recently played mixed content (albums, playlists, artists...) |
|
|
70
|
+
| `get_favorite_artists(first, after)` | Paginated favorite artists |
|
|
71
|
+
| `get_favorite_albums(first, after)` | Paginated favorite albums |
|
|
72
|
+
| `get_favorite_tracks(first, after)` | Paginated favorite tracks |
|
|
73
|
+
| `get_favorite_playlists(first, after)` | Paginated favorite playlists |
|
|
62
74
|
|
|
63
75
|
All methods return fully-typed Pydantic models generated from the GraphQL schema.
|
|
64
76
|
|
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import logging
|
|
6
7
|
import time
|
|
7
8
|
from base64 import urlsafe_b64decode
|
|
8
|
-
from typing import Any, cast
|
|
9
|
+
from typing import Any, ClassVar, cast
|
|
9
10
|
|
|
10
11
|
import httpx
|
|
11
12
|
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
12
15
|
|
|
13
16
|
class GraphQLClientError(Exception):
|
|
14
17
|
"""Base exception for GraphQL client errors."""
|
|
@@ -110,6 +113,7 @@ class DeezerBaseClient:
|
|
|
110
113
|
:param variables: Optional query variables.
|
|
111
114
|
:param kwargs: Additional keyword arguments passed to httpx.
|
|
112
115
|
"""
|
|
116
|
+
logger.debug("GQL execute: %s (variables=%s)", operation_name or "<unnamed>", variables)
|
|
113
117
|
jwt = await self._ensure_jwt()
|
|
114
118
|
|
|
115
119
|
headers: dict[str, str] = kwargs.pop("headers", None) or {}
|
|
@@ -120,16 +124,29 @@ class DeezerBaseClient:
|
|
|
120
124
|
if operation_name:
|
|
121
125
|
payload["operationName"] = operation_name
|
|
122
126
|
if variables:
|
|
123
|
-
|
|
127
|
+
# Filter out UNSET sentinel values that are not JSON-serializable.
|
|
128
|
+
# Inline import avoids depending on generated code at module level.
|
|
129
|
+
from deezer_python_gql.generated.base_model import UnsetType # noqa: PLC0415
|
|
130
|
+
|
|
131
|
+
payload["variables"] = {
|
|
132
|
+
k: v for k, v in variables.items() if not isinstance(v, UnsetType)
|
|
133
|
+
}
|
|
124
134
|
|
|
125
135
|
client = self._http_client or httpx.AsyncClient()
|
|
126
136
|
try:
|
|
127
|
-
|
|
137
|
+
resp = await client.post(
|
|
128
138
|
self.url,
|
|
129
139
|
json=payload,
|
|
130
140
|
headers=headers,
|
|
131
141
|
**kwargs,
|
|
132
142
|
)
|
|
143
|
+
logger.debug(
|
|
144
|
+
"GQL response: %s status=%s length=%s",
|
|
145
|
+
operation_name or "<unnamed>",
|
|
146
|
+
resp.status_code,
|
|
147
|
+
len(resp.content),
|
|
148
|
+
)
|
|
149
|
+
return resp
|
|
133
150
|
finally:
|
|
134
151
|
if not self._http_client:
|
|
135
152
|
await client.aclose()
|
|
@@ -159,10 +176,52 @@ class DeezerBaseClient:
|
|
|
159
176
|
errors = response_json.get("errors")
|
|
160
177
|
|
|
161
178
|
if errors:
|
|
162
|
-
|
|
179
|
+
if data:
|
|
180
|
+
# Partial success — some items failed (e.g. deleted albums in favorites).
|
|
181
|
+
# Log the errors but return the valid data.
|
|
182
|
+
logger.warning(
|
|
183
|
+
"GraphQL response contained %d error(s): %s",
|
|
184
|
+
len(errors),
|
|
185
|
+
[e.get("message", "Unknown error") for e in errors],
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
raise GraphQLClientGraphQLMultiError.from_errors_dicts(
|
|
189
|
+
errors_dicts=errors, data=data
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# The Deezer API omits __typename for single-member union types
|
|
193
|
+
# (e.g. Contributor = Artist). Pydantic discriminated unions require it,
|
|
194
|
+
# so we inject the missing field before model validation.
|
|
195
|
+
self._inject_missing_typenames(data)
|
|
163
196
|
|
|
164
197
|
return cast("dict[str, Any]", data)
|
|
165
198
|
|
|
199
|
+
# Map of parent key → child key → __typename value for single-member unions
|
|
200
|
+
# where the Deezer API omits the discriminator.
|
|
201
|
+
_TYPENAME_PATCHES: ClassVar[dict[str, dict[str, str]]] = {
|
|
202
|
+
"contributors": {"node": "Artist"},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@classmethod
|
|
206
|
+
def _inject_missing_typenames(cls, obj: Any) -> None:
|
|
207
|
+
"""Recursively inject __typename into nodes where the API omits it."""
|
|
208
|
+
if isinstance(obj, dict):
|
|
209
|
+
for parent_key, patches in cls._TYPENAME_PATCHES.items():
|
|
210
|
+
if parent_key in obj:
|
|
211
|
+
container = obj[parent_key]
|
|
212
|
+
if isinstance(container, dict) and "edges" in container:
|
|
213
|
+
for edge in container["edges"]:
|
|
214
|
+
if isinstance(edge, dict):
|
|
215
|
+
for child_key, typename in patches.items():
|
|
216
|
+
node = edge.get(child_key)
|
|
217
|
+
if isinstance(node, dict) and "__typename" not in node:
|
|
218
|
+
node["__typename"] = typename
|
|
219
|
+
for value in obj.values():
|
|
220
|
+
cls._inject_missing_typenames(value)
|
|
221
|
+
elif isinstance(obj, list):
|
|
222
|
+
for item in obj:
|
|
223
|
+
cls._inject_missing_typenames(item)
|
|
224
|
+
|
|
166
225
|
async def _ensure_jwt(self) -> str:
|
|
167
226
|
"""Acquire or refresh the JWT token from ARL cookie.
|
|
168
227
|
|
|
@@ -174,6 +233,7 @@ class DeezerBaseClient:
|
|
|
174
233
|
if self._jwt and now < (self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS):
|
|
175
234
|
return self._jwt
|
|
176
235
|
|
|
236
|
+
logger.debug("JWT expired or missing, refreshing from ARL")
|
|
177
237
|
params = {"jo": "p", "rto": "c", "i": "c"}
|
|
178
238
|
|
|
179
239
|
async with httpx.AsyncClient() as http:
|
|
@@ -194,5 +254,6 @@ class DeezerBaseClient:
|
|
|
194
254
|
padded = payload_segment + "=" * (-len(payload_segment) % 4)
|
|
195
255
|
payload = json.loads(urlsafe_b64decode(padded))
|
|
196
256
|
self._jwt_expires_at = float(payload["exp"])
|
|
257
|
+
logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
|
|
197
258
|
|
|
198
259
|
return self._jwt
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deezer-python-gql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -14,14 +14,14 @@ License-File: LICENSE
|
|
|
14
14
|
Requires-Dist: httpx>=0.27.0
|
|
15
15
|
Requires-Dist: pydantic>=2.0.0
|
|
16
16
|
Provides-Extra: test
|
|
17
|
-
Requires-Dist: codespell==2.4.
|
|
17
|
+
Requires-Dist: codespell==2.4.2; extra == "test"
|
|
18
18
|
Requires-Dist: mypy==1.19.1; extra == "test"
|
|
19
19
|
Requires-Dist: pre-commit==4.5.1; extra == "test"
|
|
20
20
|
Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
|
|
21
21
|
Requires-Dist: pytest==9.0.2; extra == "test"
|
|
22
22
|
Requires-Dist: pytest-asyncio==1.3.0; extra == "test"
|
|
23
|
-
Requires-Dist: pytest-cov==7.
|
|
24
|
-
Requires-Dist: ruff==0.15.
|
|
23
|
+
Requires-Dist: pytest-cov==7.1.0; extra == "test"
|
|
24
|
+
Requires-Dist: ruff==0.15.7; extra == "test"
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
|
|
27
27
|
Dynamic: license-file
|
|
@@ -79,14 +79,26 @@ asyncio.run(main())
|
|
|
79
79
|
|
|
80
80
|
## Available Queries
|
|
81
81
|
|
|
82
|
-
| Method
|
|
83
|
-
|
|
|
84
|
-
| `get_me()`
|
|
85
|
-
| `get_track(track_id)`
|
|
86
|
-
| `get_album(album_id)`
|
|
87
|
-
| `get_artist(artist_id)`
|
|
88
|
-
| `get_playlist(playlist_id)`
|
|
89
|
-
| `search(query, ...)`
|
|
82
|
+
| Method | Description |
|
|
83
|
+
| ------------------------------------------------ | ------------------------------------------------------------- |
|
|
84
|
+
| `get_me()` | Current authenticated user |
|
|
85
|
+
| `get_track(track_id)` | Full track details — ISRC, media tokens, lyrics, contributors |
|
|
86
|
+
| `get_album(album_id)` | Album with cover, label, paginated tracks, fallback |
|
|
87
|
+
| `get_artist(artist_id)` | Artist with bio, top tracks, albums (ordered by release date) |
|
|
88
|
+
| `get_playlist(playlist_id)` | Playlist with owner, picture, paginated tracks |
|
|
89
|
+
| `search(query, ...)` | Unified search across tracks, albums, artists, playlists |
|
|
90
|
+
| `get_flow()` | User's default Flow with tracks |
|
|
91
|
+
| `get_flow_configs(moods_first, genres_first)` | Mood & genre flow config lists for discovery |
|
|
92
|
+
| `get_flow_config_tracks(flow_config_id)` | Tracks for a specific mood/genre flow config |
|
|
93
|
+
| `get_made_for_me(first)` | "Made For You" SmartTracklist & Flow items |
|
|
94
|
+
| `get_smart_tracklist(smart_tracklist_id, first)` | Smart tracklist with paginated tracks |
|
|
95
|
+
| `get_charts(country_code, ...)` | Country charts — tracks, albums, artists, playlists |
|
|
96
|
+
| `get_recommendations(playlists_first, ...)` | Personalized recommendations across categories |
|
|
97
|
+
| `get_recently_played(first)` | Recently played mixed content (albums, playlists, artists...) |
|
|
98
|
+
| `get_favorite_artists(first, after)` | Paginated favorite artists |
|
|
99
|
+
| `get_favorite_albums(first, after)` | Paginated favorite albums |
|
|
100
|
+
| `get_favorite_tracks(first, after)` | Paginated favorite tracks |
|
|
101
|
+
| `get_favorite_playlists(first, after)` | Paginated favorite playlists |
|
|
90
102
|
|
|
91
103
|
All methods return fully-typed Pydantic models generated from the GraphQL schema.
|
|
92
104
|
|
|
@@ -5,11 +5,11 @@ pydantic>=2.0.0
|
|
|
5
5
|
ariadne-codegen[subscriptions]
|
|
6
6
|
|
|
7
7
|
[test]
|
|
8
|
-
codespell==2.4.
|
|
8
|
+
codespell==2.4.2
|
|
9
9
|
mypy==1.19.1
|
|
10
10
|
pre-commit==4.5.1
|
|
11
11
|
pre-commit-hooks==6.0.0
|
|
12
12
|
pytest==9.0.2
|
|
13
13
|
pytest-asyncio==1.3.0
|
|
14
|
-
pytest-cov==7.
|
|
15
|
-
ruff==0.15.
|
|
14
|
+
pytest-cov==7.1.0
|
|
15
|
+
ruff==0.15.7
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "deezer-python-gql"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Async typed Python client for Deezer's Pipe GraphQL API."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -22,14 +22,14 @@ text = "Apache-2.0"
|
|
|
22
22
|
|
|
23
23
|
[project.optional-dependencies]
|
|
24
24
|
test = [
|
|
25
|
-
"codespell==2.4.
|
|
25
|
+
"codespell==2.4.2",
|
|
26
26
|
"mypy==1.19.1",
|
|
27
27
|
"pre-commit==4.5.1",
|
|
28
28
|
"pre-commit-hooks==6.0.0",
|
|
29
29
|
"pytest==9.0.2",
|
|
30
30
|
"pytest-asyncio==1.3.0",
|
|
31
|
-
"pytest-cov==7.
|
|
32
|
-
"ruff==0.15.
|
|
31
|
+
"pytest-cov==7.1.0",
|
|
32
|
+
"ruff==0.15.7",
|
|
33
33
|
]
|
|
34
34
|
dev = [
|
|
35
35
|
"ariadne-codegen[subscriptions]",
|