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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.1.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.1; extra == "test"
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.0.0; extra == "test"
24
- Requires-Dist: ruff==0.15.1; extra == "test"
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 | 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 |
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 | 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 |
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
- payload["variables"] = variables
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
- return await client.post(
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
- raise GraphQLClientGraphQLMultiError.from_errors_dicts(errors_dicts=errors, data=data)
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.1.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.1; extra == "test"
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.0.0; extra == "test"
24
- Requires-Dist: ruff==0.15.1; extra == "test"
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 | 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 |
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.1
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.0.0
15
- ruff==0.15.1
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.1.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.1",
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.0.0",
32
- "ruff==0.15.1",
31
+ "pytest-cov==7.1.0",
32
+ "ruff==0.15.7",
33
33
  ]
34
34
  dev = [
35
35
  "ariadne-codegen[subscriptions]",