deezer-python-gql 0.7.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.7.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
@@ -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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.7.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deezer-python-gql"
3
- version = "0.7.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"
@@ -245,7 +245,55 @@ def test_client_has_generated_methods() -> None:
245
245
 
246
246
 
247
247
  # ---------------------------------------------------------------------------
248
- # 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)
249
297
  # ---------------------------------------------------------------------------
250
298
 
251
299
 
@@ -255,27 +303,19 @@ async def test_auth_acquires_jwt_on_first_request() -> None:
255
303
  jwt = _make_jwt()
256
304
  client = DeezerBaseClient(arl="test_arl")
257
305
 
258
- mock_auth = AsyncMock(return_value=_mock_auth_response(jwt))
259
- mock_gql = AsyncMock(
260
- return_value=httpx.Response(
261
- 200,
262
- json={"data": {"me": {"id": "1"}}},
263
- request=httpx.Request("POST", DeezerBaseClient.PIPE_URL),
264
- ),
306
+ gql_response = httpx.Response(
307
+ 200,
308
+ json={"data": {"me": {"id": "1"}}},
309
+ request=httpx.Request("POST", DeezerBaseClient.PIPE_URL),
265
310
  )
266
311
 
267
312
  with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_client_cls:
268
313
  mock_instance = AsyncMock()
269
- mock_instance.post = mock_auth
270
- mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
271
- mock_instance.__aexit__ = AsyncMock(return_value=False)
272
- mock_client_cls.return_value = mock_instance
273
-
274
- # First call: should trigger auth, then make GQL request
275
- # Override post to return auth first, then GQL response
276
314
  mock_instance.post = AsyncMock(
277
- side_effect=[_mock_auth_response(jwt), mock_gql.return_value],
315
+ side_effect=[_mock_auth_response(jwt), gql_response],
278
316
  )
317
+ mock_client_cls.return_value = mock_instance
318
+
279
319
  resp = await client.execute(query="{ me { id } }")
280
320
 
281
321
  assert resp.status_code == 200
@@ -299,8 +339,6 @@ async def test_auth_reuses_valid_jwt() -> None:
299
339
  with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_client_cls:
300
340
  mock_instance = AsyncMock()
301
341
  mock_instance.post = AsyncMock(return_value=gql_response)
302
- mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
303
- mock_instance.__aexit__ = AsyncMock(return_value=False)
304
342
  mock_client_cls.return_value = mock_instance
305
343
 
306
344
  await client.execute(query="{ me { id } }")
@@ -321,9 +359,6 @@ async def test_auth_refreshes_expiring_jwt() -> None:
321
359
 
322
360
  with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_client_cls:
323
361
  mock_instance = AsyncMock()
324
- mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
325
- mock_instance.__aexit__ = AsyncMock(return_value=False)
326
- # Auth response, then GQL response
327
362
  mock_instance.post = AsyncMock(
328
363
  side_effect=[
329
364
  _mock_auth_response(new_jwt),
@@ -348,8 +383,6 @@ async def test_auth_sends_arl_cookie_to_correct_domain() -> None:
348
383
 
349
384
  with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_client_cls:
350
385
  mock_instance = AsyncMock()
351
- mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
352
- mock_instance.__aexit__ = AsyncMock(return_value=False)
353
386
  mock_instance.post = AsyncMock(
354
387
  side_effect=[
355
388
  _mock_auth_response(),
@@ -379,8 +412,6 @@ async def test_auth_parses_text_plain_response() -> None:
379
412
  # Verify _ensure_jwt correctly parses the text/plain body
380
413
  with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_client_cls:
381
414
  mock_instance = AsyncMock()
382
- mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
383
- mock_instance.__aexit__ = AsyncMock(return_value=False)
384
415
  mock_instance.post = AsyncMock(return_value=_mock_auth_response(jwt))
385
416
  mock_client_cls.return_value = mock_instance
386
417
 
@@ -391,7 +422,7 @@ async def test_auth_parses_text_plain_response() -> None:
391
422
 
392
423
 
393
424
  # ---------------------------------------------------------------------------
394
- # 3. Error handling (mocked HTTP)
425
+ # 4. Error handling (mocked HTTP)
395
426
  # ---------------------------------------------------------------------------
396
427
 
397
428
 
@@ -471,7 +502,7 @@ def test_get_data_returns_data_on_success() -> None:
471
502
 
472
503
 
473
504
  # ---------------------------------------------------------------------------
474
- # 4a. check_audiobook_ids tests
505
+ # 5. check_audiobook_ids tests
475
506
  # ---------------------------------------------------------------------------
476
507
 
477
508
 
@@ -497,8 +528,6 @@ async def test_check_audiobook_ids_returns_matching() -> None:
497
528
  with patch("deezer_python_gql.base_client.httpx.AsyncClient") as mock_client_cls:
498
529
  mock_instance = AsyncMock()
499
530
  mock_instance.post = AsyncMock(return_value=gql_response)
500
- mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
501
- mock_instance.__aexit__ = AsyncMock(return_value=False)
502
531
  mock_client_cls.return_value = mock_instance
503
532
 
504
533
  result = await client.check_audiobook_ids(["111", "222"])
@@ -515,7 +544,7 @@ async def test_check_audiobook_ids_empty_input() -> None:
515
544
 
516
545
 
517
546
  # ---------------------------------------------------------------------------
518
- # 5. Model smoke tests (one per query — fixture-based)
547
+ # 6. Model smoke tests (one per query — fixture-based)
519
548
  # ---------------------------------------------------------------------------
520
549
 
521
550
 
@@ -596,7 +625,7 @@ def test_smoke_search() -> None:
596
625
 
597
626
 
598
627
  # ---------------------------------------------------------------------------
599
- # 5. Browse-related model smoke tests (new queries)
628
+ # 6. Browse-related model smoke tests (new queries)
600
629
  # ---------------------------------------------------------------------------
601
630
 
602
631