deezer-python-gql 0.6.0__tar.gz → 0.8.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.6.0
3
+ Version: 0.8.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
@@ -184,6 +184,12 @@ asyncio.run(main())
184
184
  | `music_together_update_group_settings(...)` | Update group settings (name, family mode) |
185
185
  | `music_together_generate_group_name()` | Generate a random group name |
186
186
 
187
+ ### Utilities
188
+
189
+ | Method | Description |
190
+ | -------------------------------- | ---------------------------------------------------------------- |
191
+ | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (single GraphQL call) |
192
+
187
193
  All methods return fully-typed Pydantic models generated from the GraphQL schema.
188
194
 
189
195
  ## Development
@@ -156,6 +156,12 @@ asyncio.run(main())
156
156
  | `music_together_update_group_settings(...)` | Update group settings (name, family mode) |
157
157
  | `music_together_generate_group_name()` | Generate a random group name |
158
158
 
159
+ ### Utilities
160
+
161
+ | Method | Description |
162
+ | -------------------------------- | ---------------------------------------------------------------- |
163
+ | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (single GraphQL call) |
164
+
159
165
  All methods return fully-typed Pydantic models generated from the GraphQL schema.
160
166
 
161
167
  ## Development
@@ -6,7 +6,7 @@ import json
6
6
  import logging
7
7
  import time
8
8
  from base64 import urlsafe_b64decode
9
- from typing import Any, ClassVar, cast
9
+ from typing import Any, ClassVar, Self, cast
10
10
 
11
11
  import httpx
12
12
 
@@ -76,9 +76,13 @@ class DeezerBaseClient:
76
76
  Handles the ARL cookie → JWT token exchange and automatic refresh.
77
77
  This class is used as the base client for ariadne-codegen's generated client.
78
78
 
79
+ Manages its own httpx connection pool by default. Pass an external
80
+ ``http_client`` only if you need to share a pool across multiple clients.
81
+
79
82
  :param arl: Deezer ARL cookie value for authentication.
80
83
  :param url: GraphQL endpoint URL (defaults to Pipe API).
81
84
  :param http_client: Optional pre-configured httpx.AsyncClient.
85
+ If provided, the caller is responsible for closing it.
82
86
  """
83
87
 
84
88
  PIPE_URL = "https://pipe.deezer.com/api"
@@ -94,9 +98,35 @@ class DeezerBaseClient:
94
98
  self.url = url
95
99
  self._arl = arl
96
100
  self._http_client = http_client
101
+ self._owns_http_client = http_client is None
97
102
  self._jwt: str | None = None
98
103
  self._jwt_expires_at: float = 0
99
104
 
105
+ def _get_http_client(self) -> httpx.AsyncClient:
106
+ """Return the HTTP client, creating an internal one if needed."""
107
+ if self._http_client is None:
108
+ self._http_client = httpx.AsyncClient()
109
+ self._owns_http_client = True
110
+ return self._http_client
111
+
112
+ async def close(self) -> None:
113
+ """Close the internal HTTP client if we own it.
114
+
115
+ Safe to call multiple times. Does nothing if an external
116
+ ``http_client`` was provided at construction time.
117
+ """
118
+ if self._owns_http_client and self._http_client is not None:
119
+ await self._http_client.aclose()
120
+ self._http_client = None
121
+
122
+ async def __aenter__(self) -> Self:
123
+ """Enter the async context manager."""
124
+ return self
125
+
126
+ async def __aexit__(self, *args: object) -> None:
127
+ """Exit the async context manager, closing internal resources."""
128
+ await self.close()
129
+
100
130
  async def execute(
101
131
  self,
102
132
  query: str,
@@ -132,24 +162,20 @@ class DeezerBaseClient:
132
162
  k: v for k, v in variables.items() if not isinstance(v, UnsetType)
133
163
  }
134
164
 
135
- client = self._http_client or httpx.AsyncClient()
136
- try:
137
- resp = await client.post(
138
- self.url,
139
- json=payload,
140
- headers=headers,
141
- **kwargs,
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
150
- finally:
151
- if not self._http_client:
152
- await client.aclose()
165
+ client = self._get_http_client()
166
+ resp = await client.post(
167
+ self.url,
168
+ json=payload,
169
+ headers=headers,
170
+ **kwargs,
171
+ )
172
+ logger.debug(
173
+ "GQL response: %s status=%s length=%s",
174
+ operation_name or "<unnamed>",
175
+ resp.status_code,
176
+ len(resp.content),
177
+ )
178
+ return resp
153
179
 
154
180
  def get_data(self, response: httpx.Response) -> dict[str, Any]:
155
181
  """Parse a GraphQL response and return the data dict.
@@ -236,13 +262,13 @@ class DeezerBaseClient:
236
262
  logger.debug("JWT expired or missing, refreshing from ARL")
237
263
  params = {"jo": "p", "rto": "c", "i": "c"}
238
264
 
239
- async with httpx.AsyncClient() as http:
240
- resp = await http.post(
241
- self.AUTH_URL,
242
- params=params,
243
- cookies={"arl": self._arl},
244
- )
245
- resp.raise_for_status()
265
+ client = self._get_http_client()
266
+ resp = await client.post(
267
+ self.AUTH_URL,
268
+ params=params,
269
+ cookies={"arl": self._arl},
270
+ )
271
+ resp.raise_for_status()
246
272
 
247
273
  # Response body is text/plain containing JSON
248
274
  data = json.loads(resp.text)
@@ -257,3 +283,35 @@ class DeezerBaseClient:
257
283
  logger.debug("JWT acquired, expires at %s", self._jwt_expires_at)
258
284
 
259
285
  return self._jwt
286
+
287
+ async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
288
+ """Check which album IDs are also valid audiobooks on Deezer.
289
+
290
+ Uses GraphQL aliases to batch-check many IDs in a single request.
291
+ Returns the subset of IDs that are audiobooks (i.e., the audiobook
292
+ query returns non-null for them).
293
+
294
+ :param album_ids: List of Deezer album/audiobook IDs to check.
295
+ """
296
+ if not album_ids:
297
+ return set()
298
+
299
+ # Query displayTitle alongside id — querying only { id } echoes back the
300
+ # input without validating that the ID is actually an audiobook.
301
+ parts = [
302
+ f'a{i}: audiobook(audiobookId: "{aid}") {{ id displayTitle }}'
303
+ for i, aid in enumerate(album_ids)
304
+ ]
305
+ query = "{ " + " ".join(parts) + " }"
306
+
307
+ resp = await self.execute(query)
308
+ data = self.get_data(resp)
309
+
310
+ audiobook_ids: set[str] = set()
311
+ for i, aid in enumerate(album_ids):
312
+ node = data.get(f"a{i}")
313
+ # The API echoes back {id} for any valid album, so we must check
314
+ # a real audiobook field like displayTitle to distinguish.
315
+ if node is not None and node.get("displayTitle") is not None:
316
+ audiobook_ids.add(aid)
317
+ return audiobook_ids
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.6.0
3
+ Version: 0.8.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
@@ -184,6 +184,12 @@ asyncio.run(main())
184
184
  | `music_together_update_group_settings(...)` | Update group settings (name, family mode) |
185
185
  | `music_together_generate_group_name()` | Generate a random group name |
186
186
 
187
+ ### Utilities
188
+
189
+ | Method | Description |
190
+ | -------------------------------- | ---------------------------------------------------------------- |
191
+ | `check_audiobook_ids(album_ids)` | Batch-check which album IDs are audiobooks (single GraphQL call) |
192
+
187
193
  All methods return fully-typed Pydantic models generated from the GraphQL schema.
188
194
 
189
195
  ## Development
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deezer-python-gql"
3
- version = "0.6.0"
3
+ version = "0.8.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"
@@ -67,6 +67,9 @@ from deezer_python_gql.generated.get_podcast_episode import GetPodcastEpisode
67
67
  from deezer_python_gql.generated.get_podcast_episode_bookmarks import (
68
68
  GetPodcastEpisodeBookmarks,
69
69
  )
70
+ from deezer_python_gql.generated.get_podcast_episodes_by_ids import (
71
+ GetPodcastEpisodesByIds,
72
+ )
70
73
  from deezer_python_gql.generated.get_recently_played import GetRecentlyPlayed
71
74
  from deezer_python_gql.generated.get_recommendations import GetRecommendations
72
75
  from deezer_python_gql.generated.get_similar_tracks import GetSimilarTracks
@@ -209,6 +212,7 @@ def test_client_has_generated_methods() -> None:
209
212
  "remove_tracks_from_playlist",
210
213
  "get_podcast",
211
214
  "get_podcast_episode",
215
+ "get_podcast_episodes_by_ids",
212
216
  "get_favorite_podcasts",
213
217
  "get_podcast_episode_bookmarks",
214
218
  "add_podcast_to_favorite",
@@ -241,7 +245,55 @@ def test_client_has_generated_methods() -> None:
241
245
 
242
246
 
243
247
  # ---------------------------------------------------------------------------
244
- # 2. Auth flow (mocked HTTP)
248
+ # 2. Lifecycle (connection pool management)
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ @pytest.mark.asyncio
253
+ async def test_close_shuts_down_internal_client() -> None:
254
+ """Verify close() calls aclose() on the internally-created httpx client."""
255
+ client = DeezerBaseClient(arl="test")
256
+
257
+ with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_cls:
258
+ mock_instance = AsyncMock()
259
+ mock_cls.return_value = mock_instance
260
+
261
+ # Trigger lazy creation
262
+ client._get_http_client() # noqa: SLF001
263
+ assert client._http_client is mock_instance # noqa: SLF001
264
+
265
+ await client.close()
266
+
267
+ mock_instance.aclose.assert_awaited_once()
268
+ assert client._http_client is None # noqa: SLF001
269
+
270
+
271
+ @pytest.mark.asyncio
272
+ async def test_close_skips_external_client() -> None:
273
+ """Verify close() does NOT close an externally-provided httpx client."""
274
+ external = AsyncMock(spec=httpx.AsyncClient)
275
+ client = DeezerBaseClient(arl="test", http_client=external)
276
+
277
+ await client.close()
278
+
279
+ external.aclose.assert_not_awaited()
280
+
281
+
282
+ @pytest.mark.asyncio
283
+ async def test_context_manager_calls_close() -> None:
284
+ """Verify the async context manager closes the client on exit."""
285
+ with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_cls:
286
+ mock_instance = AsyncMock()
287
+ mock_cls.return_value = mock_instance
288
+
289
+ async with DeezerBaseClient(arl="test") as client:
290
+ client._get_http_client() # noqa: SLF001
291
+
292
+ mock_instance.aclose.assert_awaited_once()
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # 3. Auth flow (mocked HTTP)
245
297
  # ---------------------------------------------------------------------------
246
298
 
247
299
 
@@ -251,27 +303,19 @@ async def test_auth_acquires_jwt_on_first_request() -> None:
251
303
  jwt = _make_jwt()
252
304
  client = DeezerBaseClient(arl="test_arl")
253
305
 
254
- mock_auth = AsyncMock(return_value=_mock_auth_response(jwt))
255
- mock_gql = AsyncMock(
256
- return_value=httpx.Response(
257
- 200,
258
- json={"data": {"me": {"id": "1"}}},
259
- request=httpx.Request("POST", DeezerBaseClient.PIPE_URL),
260
- ),
306
+ gql_response = httpx.Response(
307
+ 200,
308
+ json={"data": {"me": {"id": "1"}}},
309
+ request=httpx.Request("POST", DeezerBaseClient.PIPE_URL),
261
310
  )
262
311
 
263
312
  with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_client_cls:
264
313
  mock_instance = AsyncMock()
265
- mock_instance.post = mock_auth
266
- mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
267
- mock_instance.__aexit__ = AsyncMock(return_value=False)
268
- mock_client_cls.return_value = mock_instance
269
-
270
- # First call: should trigger auth, then make GQL request
271
- # Override post to return auth first, then GQL response
272
314
  mock_instance.post = AsyncMock(
273
- side_effect=[_mock_auth_response(jwt), mock_gql.return_value],
315
+ side_effect=[_mock_auth_response(jwt), gql_response],
274
316
  )
317
+ mock_client_cls.return_value = mock_instance
318
+
275
319
  resp = await client.execute(query="{ me { id } }")
276
320
 
277
321
  assert resp.status_code == 200
@@ -295,8 +339,6 @@ async def test_auth_reuses_valid_jwt() -> None:
295
339
  with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_client_cls:
296
340
  mock_instance = AsyncMock()
297
341
  mock_instance.post = AsyncMock(return_value=gql_response)
298
- mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
299
- mock_instance.__aexit__ = AsyncMock(return_value=False)
300
342
  mock_client_cls.return_value = mock_instance
301
343
 
302
344
  await client.execute(query="{ me { id } }")
@@ -317,9 +359,6 @@ async def test_auth_refreshes_expiring_jwt() -> None:
317
359
 
318
360
  with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_client_cls:
319
361
  mock_instance = AsyncMock()
320
- mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
321
- mock_instance.__aexit__ = AsyncMock(return_value=False)
322
- # Auth response, then GQL response
323
362
  mock_instance.post = AsyncMock(
324
363
  side_effect=[
325
364
  _mock_auth_response(new_jwt),
@@ -344,8 +383,6 @@ async def test_auth_sends_arl_cookie_to_correct_domain() -> None:
344
383
 
345
384
  with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_client_cls:
346
385
  mock_instance = AsyncMock()
347
- mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
348
- mock_instance.__aexit__ = AsyncMock(return_value=False)
349
386
  mock_instance.post = AsyncMock(
350
387
  side_effect=[
351
388
  _mock_auth_response(),
@@ -375,8 +412,6 @@ async def test_auth_parses_text_plain_response() -> None:
375
412
  # Verify _ensure_jwt correctly parses the text/plain body
376
413
  with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_client_cls:
377
414
  mock_instance = AsyncMock()
378
- mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
379
- mock_instance.__aexit__ = AsyncMock(return_value=False)
380
415
  mock_instance.post = AsyncMock(return_value=_mock_auth_response(jwt))
381
416
  mock_client_cls.return_value = mock_instance
382
417
 
@@ -387,7 +422,7 @@ async def test_auth_parses_text_plain_response() -> None:
387
422
 
388
423
 
389
424
  # ---------------------------------------------------------------------------
390
- # 3. Error handling (mocked HTTP)
425
+ # 4. Error handling (mocked HTTP)
391
426
  # ---------------------------------------------------------------------------
392
427
 
393
428
 
@@ -467,7 +502,49 @@ def test_get_data_returns_data_on_success() -> None:
467
502
 
468
503
 
469
504
  # ---------------------------------------------------------------------------
470
- # 4. Model smoke tests (one per query — fixture-based)
505
+ # 5. check_audiobook_ids tests
506
+ # ---------------------------------------------------------------------------
507
+
508
+
509
+ @pytest.mark.asyncio
510
+ async def test_check_audiobook_ids_returns_matching() -> None:
511
+ """Verify check_audiobook_ids returns IDs that are valid audiobooks."""
512
+ client = DeezerBaseClient(arl="test_arl")
513
+ client._jwt = _make_jwt(exp=time.time() + 600) # noqa: SLF001
514
+ client._jwt_expires_at = time.time() + 600 # noqa: SLF001
515
+
516
+ # a0 is an audiobook (has displayTitle), a1 is not (displayTitle is null)
517
+ gql_response = httpx.Response(
518
+ 200,
519
+ json={
520
+ "data": {
521
+ "a0": {"id": "111", "displayTitle": "Test Book"},
522
+ "a1": {"id": "222", "displayTitle": None},
523
+ }
524
+ },
525
+ request=httpx.Request("POST", DeezerBaseClient.PIPE_URL),
526
+ )
527
+
528
+ with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_client_cls:
529
+ mock_instance = AsyncMock()
530
+ mock_instance.post = AsyncMock(return_value=gql_response)
531
+ mock_client_cls.return_value = mock_instance
532
+
533
+ result = await client.check_audiobook_ids(["111", "222"])
534
+
535
+ assert result == {"111"}
536
+
537
+
538
+ @pytest.mark.asyncio
539
+ async def test_check_audiobook_ids_empty_input() -> None:
540
+ """Verify check_audiobook_ids returns empty set for empty input."""
541
+ client = DeezerBaseClient(arl="test_arl")
542
+ result = await client.check_audiobook_ids([])
543
+ assert result == set()
544
+
545
+
546
+ # ---------------------------------------------------------------------------
547
+ # 6. Model smoke tests (one per query — fixture-based)
471
548
  # ---------------------------------------------------------------------------
472
549
 
473
550
 
@@ -548,7 +625,7 @@ def test_smoke_search() -> None:
548
625
 
549
626
 
550
627
  # ---------------------------------------------------------------------------
551
- # 5. Browse-related model smoke tests (new queries)
628
+ # 6. Browse-related model smoke tests (new queries)
552
629
  # ---------------------------------------------------------------------------
553
630
 
554
631
 
@@ -995,6 +1072,20 @@ def test_smoke_get_podcast_episode() -> None:
995
1072
  assert ep.podcast.display_title == "Tech Weekly"
996
1073
 
997
1074
 
1075
+ def test_smoke_get_podcast_episodes_by_ids() -> None:
1076
+ """Verify GetPodcastEpisodesByIds fixture parses with nullable entries."""
1077
+ data = _load_fixture("get_podcast_episodes_by_ids.json")
1078
+ episodes = GetPodcastEpisodesByIds.model_validate(data).podcast_episodes_by_ids
1079
+ assert len(episodes) == 3
1080
+ assert episodes[0] is not None
1081
+ assert episodes[0].id == "ep_100"
1082
+ assert episodes[0].title == "Episode 100: AI Revolution"
1083
+ assert episodes[0].duration == 2400
1084
+ assert episodes[1] is not None
1085
+ assert episodes[1].id == "ep_099"
1086
+ assert episodes[2] is None
1087
+
1088
+
998
1089
  def test_smoke_get_favorite_podcasts() -> None:
999
1090
  """Verify GetFavoritePodcasts fixture parses with pagination."""
1000
1091
  data = _load_fixture("get_favorite_podcasts.json")