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.
- {deezer_python_gql-0.7.0/deezer_python_gql.egg-info → deezer_python_gql-0.8.0}/PKG-INFO +1 -1
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/deezer_python_gql/base_client.py +52 -26
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0/deezer_python_gql.egg-info}/PKG-INFO +1 -1
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/pyproject.toml +1 -1
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/tests/test_client.py +60 -31
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/LICENSE +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/MANIFEST.in +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/README.md +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/deezer_python_gql/__init__.py +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/deezer_python_gql/py.typed +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/deezer_python_gql.egg-info/SOURCES.txt +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/deezer_python_gql.egg-info/not-zip-safe +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/deezer_python_gql.egg-info/requires.txt +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/deezer_python_gql.egg-info/top_level.txt +0 -0
- {deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/setup.cfg +0 -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.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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)
|
|
@@ -245,7 +245,55 @@ def test_client_has_generated_methods() -> None:
|
|
|
245
245
|
|
|
246
246
|
|
|
247
247
|
# ---------------------------------------------------------------------------
|
|
248
|
-
# 2.
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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),
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
628
|
+
# 6. Browse-related model smoke tests (new queries)
|
|
600
629
|
# ---------------------------------------------------------------------------
|
|
601
630
|
|
|
602
631
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/deezer_python_gql.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deezer_python_gql-0.7.0 → deezer_python_gql-0.8.0}/deezer_python_gql.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|