deezer-python-gql 0.17.1__tar.gz → 0.17.2__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.
Files changed (89) hide show
  1. {deezer_python_gql-0.17.1/deezer_python_gql.egg-info → deezer_python_gql-0.17.2}/PKG-INFO +12 -5
  2. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/README.md +6 -0
  3. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/base_client.py +35 -3
  4. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/base_client.py +35 -3
  5. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2/deezer_python_gql.egg-info}/PKG-INFO +12 -5
  6. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql.egg-info/requires.txt +5 -4
  7. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/pyproject.toml +7 -5
  8. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/tests/test_client.py +124 -9
  9. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/LICENSE +0 -0
  10. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/MANIFEST.in +0 -0
  11. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/__init__.py +0 -0
  12. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/__init__.py +0 -0
  13. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_album_to_favorite.py +0 -0
  14. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_artist_to_favorite.py +0 -0
  15. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_audiobook_to_favorite.py +0 -0
  16. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_playlist_to_favorite.py +0 -0
  17. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_podcast_to_favorite.py +0 -0
  18. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_track_to_favorite.py +0 -0
  19. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/add_tracks_to_playlist.py +0 -0
  20. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/base_model.py +0 -0
  21. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/bookmark_podcast_episode.py +0 -0
  22. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/client.py +0 -0
  23. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/create_playlist.py +0 -0
  24. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/delete_playlist.py +0 -0
  25. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/enums.py +0 -0
  26. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/fragments.py +0 -0
  27. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_album.py +0 -0
  28. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_artist.py +0 -0
  29. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_artist_mix.py +0 -0
  30. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_audiobook.py +0 -0
  31. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_audiobook_chapter.py +0 -0
  32. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_charts.py +0 -0
  33. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_favorite_albums.py +0 -0
  34. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_favorite_artists.py +0 -0
  35. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_favorite_audiobooks.py +0 -0
  36. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_favorite_playlists.py +0 -0
  37. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_favorite_podcasts.py +0 -0
  38. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_favorite_tracks.py +0 -0
  39. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_flow.py +0 -0
  40. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_flow_batch.py +0 -0
  41. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_flow_config_tracks.py +0 -0
  42. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_flow_configs.py +0 -0
  43. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_livestream.py +0 -0
  44. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_made_for_me.py +0 -0
  45. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_me.py +0 -0
  46. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_music_together_affinity.py +0 -0
  47. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_music_together_group.py +0 -0
  48. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_music_together_groups.py +0 -0
  49. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_personal_tracks.py +0 -0
  50. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_playlist.py +0 -0
  51. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_podcast.py +0 -0
  52. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_podcast_episode.py +0 -0
  53. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +0 -0
  54. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +0 -0
  55. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_recently_played.py +0 -0
  56. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_recommendations.py +0 -0
  57. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_similar_artists.py +0 -0
  58. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_similar_tracks.py +0 -0
  59. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_smart_tracklist.py +0 -0
  60. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_track.py +0 -0
  61. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_track_mix.py +0 -0
  62. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_user_charts.py +0 -0
  63. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/get_user_playlists.py +0 -0
  64. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/input_types.py +0 -0
  65. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/mark_as_not_played_podcast_episode.py +0 -0
  66. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/mark_as_played_podcast_episode.py +0 -0
  67. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/music_together_create_group.py +0 -0
  68. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/music_together_generate_group_name.py +0 -0
  69. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/music_together_join_group.py +0 -0
  70. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/music_together_leave_group.py +0 -0
  71. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +0 -0
  72. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/music_together_update_group_settings.py +0 -0
  73. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_album_from_favorite.py +0 -0
  74. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_artist_from_favorite.py +0 -0
  75. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_audiobook_from_favorite.py +0 -0
  76. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_playlist_from_favorite.py +0 -0
  77. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_podcast_from_favorite.py +0 -0
  78. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_track_from_favorite.py +0 -0
  79. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/remove_tracks_from_playlist.py +0 -0
  80. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/search.py +0 -0
  81. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/search_flows.py +0 -0
  82. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/unbookmark_podcast_episode.py +0 -0
  83. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/generated/update_playlist.py +0 -0
  84. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql/py.typed +0 -0
  85. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql.egg-info/SOURCES.txt +0 -0
  86. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
  87. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql.egg-info/not-zip-safe +0 -0
  88. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/deezer_python_gql.egg-info/top_level.txt +0 -0
  89. {deezer_python_gql-0.17.1 → deezer_python_gql-0.17.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.17.1
3
+ Version: 0.17.2
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
@@ -13,15 +13,16 @@ Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: aiohttp>=3.13.5
15
15
  Requires-Dist: pydantic>=2.0.0
16
+ Requires-Dist: yarl>=1.22.0
16
17
  Provides-Extra: test
17
- Requires-Dist: codespell==2.4.2; extra == "test"
18
- Requires-Dist: mypy==2.1.0; extra == "test"
19
- Requires-Dist: pre-commit==4.6.0; extra == "test"
18
+ Requires-Dist: codespell==2.4.3; extra == "test"
19
+ Requires-Dist: mypy==2.3.1; extra == "test"
20
+ Requires-Dist: pre-commit==4.6.2; extra == "test"
20
21
  Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
21
22
  Requires-Dist: pytest==9.1.1; extra == "test"
22
23
  Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
23
24
  Requires-Dist: pytest-cov==7.1.0; extra == "test"
24
- Requires-Dist: ruff==0.15.20; extra == "test"
25
+ Requires-Dist: ruff==0.16.5; extra == "test"
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
27
28
  Dynamic: license-file
@@ -47,6 +48,7 @@ import asyncio
47
48
 
48
49
  from deezer_python_gql import DeezerGQLClient
49
50
 
51
+
50
52
  async def main():
51
53
  client = DeezerGQLClient(arl="YOUR_ARL_COOKIE")
52
54
 
@@ -74,6 +76,7 @@ async def main():
74
76
  results = await client.search(query="Daft Punk")
75
77
  print(len(results.tracks.edges), "tracks found")
76
78
 
79
+
77
80
  asyncio.run(main())
78
81
  ```
79
82
 
@@ -252,6 +255,10 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
252
255
  handles token acquisition and refresh automatically — you only need to supply a
253
256
  valid ARL value.
254
257
 
258
+ An external `aiohttp.ClientSession` may be shared by clients for different Deezer
259
+ accounts. Each client keeps Deezer's response cookies locally and masks cookies from
260
+ the shared session jar, so authentication state cannot cross between those accounts.
261
+
255
262
  If the ARL is rejected (bad or expired), the client raises
256
263
  `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
257
264
  Transient server errors raise `GraphQLClientHttpError` instead.
@@ -19,6 +19,7 @@ import asyncio
19
19
 
20
20
  from deezer_python_gql import DeezerGQLClient
21
21
 
22
+
22
23
  async def main():
23
24
  client = DeezerGQLClient(arl="YOUR_ARL_COOKIE")
24
25
 
@@ -46,6 +47,7 @@ async def main():
46
47
  results = await client.search(query="Daft Punk")
47
48
  print(len(results.tracks.edges), "tracks found")
48
49
 
50
+
49
51
  asyncio.run(main())
50
52
  ```
51
53
 
@@ -224,6 +226,10 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
224
226
  handles token acquisition and refresh automatically — you only need to supply a
225
227
  valid ARL value.
226
228
 
229
+ An external `aiohttp.ClientSession` may be shared by clients for different Deezer
230
+ accounts. Each client keeps Deezer's response cookies locally and masks cookies from
231
+ the shared session jar, so authentication state cannot cross between those accounts.
232
+
227
233
  If the ARL is rejected (bad or expired), the client raises
228
234
  `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
229
235
  Transient server errors raise `GraphQLClientHttpError` instead.
@@ -8,9 +8,13 @@ import logging
8
8
  import time
9
9
  from base64 import urlsafe_b64decode
10
10
  from dataclasses import dataclass
11
- from typing import Any, ClassVar, Self, cast
11
+ from typing import TYPE_CHECKING, Any, ClassVar, Self, cast
12
12
 
13
13
  from aiohttp import ClientSession, ClientTimeout
14
+ from yarl import URL
15
+
16
+ if TYPE_CHECKING:
17
+ from aiohttp import ClientResponse
14
18
 
15
19
  logger = logging.getLogger(__name__)
16
20
 
@@ -137,6 +141,9 @@ class DeezerBaseClient:
137
141
  self._arl = arl
138
142
  self._session = session
139
143
  self._owns_session = session is None
144
+ # An external session may be shared by clients for different Deezer accounts.
145
+ # Keep Deezer's response cookies per client instead of trusting that shared jar.
146
+ self._cookies: dict[str, str] = {}
140
147
  self._jwt: str | None = None
141
148
  self._jwt_expires_at: float = 0
142
149
  self._jwt_lock = asyncio.Lock()
@@ -167,6 +174,21 @@ class DeezerBaseClient:
167
174
  """Exit the async context manager, closing internal resources."""
168
175
  await self.close()
169
176
 
177
+ def _request_cookies(self, url: str, *, include_arl: bool = False) -> dict[str, str]:
178
+ """Return account-local cookies while neutralizing a shared session's jar."""
179
+ session = self._get_session()
180
+ blanked = dict.fromkeys(session.cookie_jar.filter_cookies(URL(url)), "")
181
+ cookies = blanked | self._cookies
182
+ if include_arl:
183
+ cookies["arl"] = self._arl
184
+ return cookies
185
+
186
+ def _store_cookies(self, response: ClientResponse) -> None:
187
+ """Remember response cookies for this client instead of relying on the session jar."""
188
+ self._cookies.update(
189
+ {name: morsel.value for name, morsel in response.cookies.items() if name != "arl"}
190
+ )
191
+
170
192
  async def execute(
171
193
  self,
172
194
  query: str,
@@ -189,6 +211,8 @@ class DeezerBaseClient:
189
211
  headers["Authorization"] = f"Bearer {jwt}"
190
212
  headers["Content-Type"] = "application/json"
191
213
  kwargs.setdefault("timeout", ClientTimeout(total=self.REQUEST_TIMEOUT_SECONDS))
214
+ request_cookies = self._request_cookies(self.url)
215
+ request_cookies.update(kwargs.pop("cookies", None) or {})
192
216
 
193
217
  payload: dict[str, Any] = {"query": query}
194
218
  if operation_name:
@@ -203,7 +227,14 @@ class DeezerBaseClient:
203
227
  }
204
228
 
205
229
  session = self._get_session()
206
- async with session.post(self.url, json=payload, headers=headers, **kwargs) as resp:
230
+ async with session.post(
231
+ self.url,
232
+ json=payload,
233
+ headers=headers,
234
+ cookies=request_cookies,
235
+ **kwargs,
236
+ ) as resp:
237
+ self._store_cookies(resp)
207
238
  body = await resp.read()
208
239
  gql_response = GQLResponse(
209
240
  status=resp.status,
@@ -321,9 +352,10 @@ class DeezerBaseClient:
321
352
  async with session.post(
322
353
  self.AUTH_URL,
323
354
  params=params,
324
- cookies={"arl": self._arl},
355
+ cookies=self._request_cookies(self.AUTH_URL, include_arl=True),
325
356
  timeout=ClientTimeout(total=10),
326
357
  ) as resp:
358
+ self._store_cookies(resp)
327
359
  status = resp.status
328
360
  is_success = resp.ok
329
361
  # Response body is text/plain containing JSON
@@ -10,9 +10,13 @@ import logging
10
10
  import time
11
11
  from base64 import urlsafe_b64decode
12
12
  from dataclasses import dataclass
13
- from typing import Any, ClassVar, Self, cast
13
+ from typing import TYPE_CHECKING, Any, ClassVar, Self, cast
14
14
 
15
15
  from aiohttp import ClientSession, ClientTimeout
16
+ from yarl import URL
17
+
18
+ if TYPE_CHECKING:
19
+ from aiohttp import ClientResponse
16
20
 
17
21
  logger = logging.getLogger(__name__)
18
22
 
@@ -139,6 +143,9 @@ class DeezerBaseClient:
139
143
  self._arl = arl
140
144
  self._session = session
141
145
  self._owns_session = session is None
146
+ # An external session may be shared by clients for different Deezer accounts.
147
+ # Keep Deezer's response cookies per client instead of trusting that shared jar.
148
+ self._cookies: dict[str, str] = {}
142
149
  self._jwt: str | None = None
143
150
  self._jwt_expires_at: float = 0
144
151
  self._jwt_lock = asyncio.Lock()
@@ -169,6 +176,21 @@ class DeezerBaseClient:
169
176
  """Exit the async context manager, closing internal resources."""
170
177
  await self.close()
171
178
 
179
+ def _request_cookies(self, url: str, *, include_arl: bool = False) -> dict[str, str]:
180
+ """Return account-local cookies while neutralizing a shared session's jar."""
181
+ session = self._get_session()
182
+ blanked = dict.fromkeys(session.cookie_jar.filter_cookies(URL(url)), "")
183
+ cookies = blanked | self._cookies
184
+ if include_arl:
185
+ cookies["arl"] = self._arl
186
+ return cookies
187
+
188
+ def _store_cookies(self, response: ClientResponse) -> None:
189
+ """Remember response cookies for this client instead of relying on the session jar."""
190
+ self._cookies.update(
191
+ {name: morsel.value for name, morsel in response.cookies.items() if name != "arl"}
192
+ )
193
+
172
194
  async def execute(
173
195
  self,
174
196
  query: str,
@@ -191,6 +213,8 @@ class DeezerBaseClient:
191
213
  headers["Authorization"] = f"Bearer {jwt}"
192
214
  headers["Content-Type"] = "application/json"
193
215
  kwargs.setdefault("timeout", ClientTimeout(total=self.REQUEST_TIMEOUT_SECONDS))
216
+ request_cookies = self._request_cookies(self.url)
217
+ request_cookies.update(kwargs.pop("cookies", None) or {})
194
218
 
195
219
  payload: dict[str, Any] = {"query": query}
196
220
  if operation_name:
@@ -205,7 +229,14 @@ class DeezerBaseClient:
205
229
  }
206
230
 
207
231
  session = self._get_session()
208
- async with session.post(self.url, json=payload, headers=headers, **kwargs) as resp:
232
+ async with session.post(
233
+ self.url,
234
+ json=payload,
235
+ headers=headers,
236
+ cookies=request_cookies,
237
+ **kwargs,
238
+ ) as resp:
239
+ self._store_cookies(resp)
209
240
  body = await resp.read()
210
241
  gql_response = GQLResponse(
211
242
  status=resp.status,
@@ -323,9 +354,10 @@ class DeezerBaseClient:
323
354
  async with session.post(
324
355
  self.AUTH_URL,
325
356
  params=params,
326
- cookies={"arl": self._arl},
357
+ cookies=self._request_cookies(self.AUTH_URL, include_arl=True),
327
358
  timeout=ClientTimeout(total=10),
328
359
  ) as resp:
360
+ self._store_cookies(resp)
329
361
  status = resp.status
330
362
  is_success = resp.ok
331
363
  # Response body is text/plain containing JSON
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.17.1
3
+ Version: 0.17.2
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
@@ -13,15 +13,16 @@ Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: aiohttp>=3.13.5
15
15
  Requires-Dist: pydantic>=2.0.0
16
+ Requires-Dist: yarl>=1.22.0
16
17
  Provides-Extra: test
17
- Requires-Dist: codespell==2.4.2; extra == "test"
18
- Requires-Dist: mypy==2.1.0; extra == "test"
19
- Requires-Dist: pre-commit==4.6.0; extra == "test"
18
+ Requires-Dist: codespell==2.4.3; extra == "test"
19
+ Requires-Dist: mypy==2.3.1; extra == "test"
20
+ Requires-Dist: pre-commit==4.6.2; extra == "test"
20
21
  Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
21
22
  Requires-Dist: pytest==9.1.1; extra == "test"
22
23
  Requires-Dist: pytest-asyncio==1.4.0; extra == "test"
23
24
  Requires-Dist: pytest-cov==7.1.0; extra == "test"
24
- Requires-Dist: ruff==0.15.20; extra == "test"
25
+ Requires-Dist: ruff==0.16.5; extra == "test"
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
27
28
  Dynamic: license-file
@@ -47,6 +48,7 @@ import asyncio
47
48
 
48
49
  from deezer_python_gql import DeezerGQLClient
49
50
 
51
+
50
52
  async def main():
51
53
  client = DeezerGQLClient(arl="YOUR_ARL_COOKIE")
52
54
 
@@ -74,6 +76,7 @@ async def main():
74
76
  results = await client.search(query="Daft Punk")
75
77
  print(len(results.tracks.edges), "tracks found")
76
78
 
79
+
77
80
  asyncio.run(main())
78
81
  ```
79
82
 
@@ -252,6 +255,10 @@ The Pipe API uses short-lived JWTs obtained from an ARL cookie. The base client
252
255
  handles token acquisition and refresh automatically — you only need to supply a
253
256
  valid ARL value.
254
257
 
258
+ An external `aiohttp.ClientSession` may be shared by clients for different Deezer
259
+ accounts. Each client keeps Deezer's response cookies locally and masks cookies from
260
+ the shared session jar, so authentication state cannot cross between those accounts.
261
+
255
262
  If the ARL is rejected (bad or expired), the client raises
256
263
  `GraphQLClientAuthError` — catch it to prompt for a new ARL instead of retrying.
257
264
  Transient server errors raise `GraphQLClientHttpError` instead.
@@ -1,15 +1,16 @@
1
1
  aiohttp>=3.13.5
2
2
  pydantic>=2.0.0
3
+ yarl>=1.22.0
3
4
 
4
5
  [dev]
5
6
  ariadne-codegen[subscriptions]
6
7
 
7
8
  [test]
8
- codespell==2.4.2
9
- mypy==2.1.0
10
- pre-commit==4.6.0
9
+ codespell==2.4.3
10
+ mypy==2.3.1
11
+ pre-commit==4.6.2
11
12
  pre-commit-hooks==6.0.0
12
13
  pytest==9.1.1
13
14
  pytest-asyncio==1.4.0
14
15
  pytest-cov==7.1.0
15
- ruff==0.15.20
16
+ ruff==0.16.5
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deezer-python-gql"
3
- version = "0.17.1"
3
+ version = "0.17.2"
4
4
  description = "Async typed Python client for Deezer's Pipe GraphQL API."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -15,6 +15,7 @@ classifiers = [
15
15
  dependencies = [
16
16
  "aiohttp>=3.13.5",
17
17
  "pydantic>=2.0.0",
18
+ "yarl>=1.22.0",
18
19
  ]
19
20
 
20
21
  [project.license]
@@ -22,14 +23,14 @@ text = "Apache-2.0"
22
23
 
23
24
  [project.optional-dependencies]
24
25
  test = [
25
- "codespell==2.4.2",
26
- "mypy==2.1.0",
27
- "pre-commit==4.6.0",
26
+ "codespell==2.4.3",
27
+ "mypy==2.3.1",
28
+ "pre-commit==4.6.2",
28
29
  "pre-commit-hooks==6.0.0",
29
30
  "pytest==9.1.1",
30
31
  "pytest-asyncio==1.4.0",
31
32
  "pytest-cov==7.1.0",
32
- "ruff==0.15.20",
33
+ "ruff==0.16.5",
33
34
  ]
34
35
  dev = [
35
36
  "ariadne-codegen[subscriptions]",
@@ -99,6 +100,7 @@ ignore = [
99
100
  "TD004",
100
101
  "TRY003",
101
102
  "COM812",
103
+ "CPY001",
102
104
  "ISC001",
103
105
  ]
104
106
  select = [
@@ -13,11 +13,14 @@ import asyncio
13
13
  import base64
14
14
  import json
15
15
  import time
16
+ from http.cookies import SimpleCookie
16
17
  from pathlib import Path
17
- from typing import Any
18
+ from typing import Any, Self, cast
18
19
  from unittest.mock import AsyncMock, MagicMock, patch
19
20
 
20
21
  import pytest
22
+ from aiohttp import ClientSession, CookieJar
23
+ from yarl import URL
21
24
 
22
25
  from deezer_python_gql import DeezerGQLClient
23
26
  from deezer_python_gql.base_client import (
@@ -124,7 +127,7 @@ def _load_fixture(name: str) -> dict[str, Any]:
124
127
  return result
125
128
 
126
129
 
127
- def _make_jwt(exp: float | None = None) -> str:
130
+ def _make_jwt(exp: float | None = None, subject: str | None = None) -> str:
128
131
  """
129
132
  Build a fake JWT with a configurable expiration timestamp.
130
133
 
@@ -133,13 +136,10 @@ def _make_jwt(exp: float | None = None) -> str:
133
136
  if exp is None:
134
137
  exp = time.time() + 360 # 6 min, matching Deezer's real TTL
135
138
  header = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b"=").decode()
136
- payload = (
137
- base64.urlsafe_b64encode(
138
- json.dumps({"exp": exp}).encode(),
139
- )
140
- .rstrip(b"=")
141
- .decode()
142
- )
139
+ payload_data: dict[str, float | str] = {"exp": exp}
140
+ if subject is not None:
141
+ payload_data["sub"] = subject
142
+ payload = base64.urlsafe_b64encode(json.dumps(payload_data).encode()).rstrip(b"=").decode()
143
143
  return f"{header}.{payload}.fake_signature"
144
144
 
145
145
 
@@ -156,6 +156,7 @@ def _mock_post_context_manager(response_body: bytes, status: int = 200) -> Async
156
156
  mock_resp.read = AsyncMock(return_value=response_body)
157
157
  mock_resp.text = AsyncMock(return_value=response_body.decode())
158
158
  mock_resp.raise_for_status = MagicMock()
159
+ mock_resp.cookies = SimpleCookie()
159
160
 
160
161
  cm = AsyncMock()
161
162
  cm.__aenter__ = AsyncMock(return_value=mock_resp)
@@ -171,6 +172,86 @@ def _mock_auth_cm(jwt: str | None = None) -> AsyncMock:
171
172
  return _mock_post_context_manager(body)
172
173
 
173
174
 
175
+ class _FakeCookieResponse:
176
+ """Small aiohttp response stand-in for the shared-cookie regression test."""
177
+
178
+ def __init__(
179
+ self,
180
+ body: bytes,
181
+ response_cookies: SimpleCookie | None = None,
182
+ ) -> None:
183
+ self.status = 200
184
+ self.ok = True
185
+ self._body = body
186
+ self.cookies = response_cookies or SimpleCookie()
187
+
188
+ async def __aenter__(self) -> Self:
189
+ return self
190
+
191
+ async def __aexit__(self, *args: object) -> None:
192
+ return None
193
+
194
+ async def read(self) -> bytes:
195
+ return self._body
196
+
197
+ async def text(self) -> str:
198
+ return self._body.decode()
199
+
200
+
201
+ class _FakeSharedSession:
202
+ """Model Deezer preferring an existing refresh cookie over a new ARL."""
203
+
204
+ def __init__(self) -> None:
205
+ self.cookie_jar = CookieJar()
206
+ self._arl_accounts = {"arl-a": "1001", "arl-b": "2002"}
207
+ self._refresh_accounts = {"refresh-a": "1001", "refresh-b": "2002"}
208
+ self._account_refresh = {value: key for key, value in self._refresh_accounts.items()}
209
+ self._account_cookies = {
210
+ account: {
211
+ "refresh-token": refresh,
212
+ "sid": f"sid-{account}",
213
+ "dzr_uniq_id": f"unique-{account}",
214
+ "_abck": f"abck-{account}",
215
+ "bm_sz": f"bm-{account}",
216
+ }
217
+ for account, refresh in self._account_refresh.items()
218
+ }
219
+ self._account_jwt = {
220
+ account: _make_jwt(subject=account) for account in self._arl_accounts.values()
221
+ }
222
+ self._jwt_account = {jwt: account for account, jwt in self._account_jwt.items()}
223
+ self.requests: list[tuple[str, dict[str, str]]] = []
224
+
225
+ def post(self, url: str, **kwargs: Any) -> _FakeCookieResponse:
226
+ """Resolve the request identity and update the shared response-cookie jar."""
227
+ request_cookies = {
228
+ name: morsel.value for name, morsel in self.cookie_jar.filter_cookies(URL(url)).items()
229
+ }
230
+ request_cookies.update(kwargs.get("cookies") or {})
231
+ self.requests.append((url, request_cookies))
232
+
233
+ if url == DeezerBaseClient.AUTH_URL:
234
+ if refresh := request_cookies.get("refresh-token"):
235
+ account = self._refresh_accounts[refresh]
236
+ else:
237
+ account = self._arl_accounts[request_cookies["arl"]]
238
+ response_cookies = SimpleCookie()
239
+ for name, value in self._account_cookies[account].items():
240
+ response_cookies[name] = value
241
+ response_cookies[name]["domain"] = ".deezer.com"
242
+ response_cookies[name]["path"] = "/"
243
+ self.cookie_jar.update_cookies(response_cookies, response_url=URL(url))
244
+ return _FakeCookieResponse(
245
+ json.dumps({"jwt": self._account_jwt[account]}).encode(),
246
+ response_cookies,
247
+ )
248
+
249
+ authorization = cast("dict[str, str]", kwargs["headers"])["Authorization"]
250
+ jwt = authorization.removeprefix("Bearer ")
251
+ account = self._jwt_account[jwt]
252
+ return _FakeCookieResponse(json.dumps({"data": {"me": {"id": account}}}).encode())
253
+
254
+
174
255
  # ---------------------------------------------------------------------------
175
256
  # 1. Client setup
176
257
  # ---------------------------------------------------------------------------
@@ -413,6 +494,40 @@ async def test_auth_sends_arl_cookie_to_correct_domain() -> None:
413
494
  assert auth_call.kwargs["cookies"] == {"arl": "my_secret_arl"}
414
495
 
415
496
 
497
+ @pytest.mark.parametrize(("first_arl", "second_arl"), [("arl-a", "arl-b"), ("arl-b", "arl-a")])
498
+ @pytest.mark.asyncio
499
+ async def test_shared_session_keeps_account_cookies_isolated(
500
+ first_arl: str, second_arl: str
501
+ ) -> None:
502
+ """Verify a shared jar cannot make the first authenticated account win."""
503
+ session = _FakeSharedSession()
504
+ expected = session._arl_accounts # noqa: SLF001
505
+ first = DeezerGQLClient(arl=first_arl, session=cast("ClientSession", session))
506
+ second = DeezerGQLClient(arl=second_arl, session=cast("ClientSession", session))
507
+
508
+ first_me = await first.get_me()
509
+ second_me = await second.get_me()
510
+
511
+ assert first_me is not None
512
+ assert second_me is not None
513
+ assert first_me.id == expected[first_arl]
514
+ assert second_me.id == expected[second_arl]
515
+
516
+ auth_requests = [cookies for url, cookies in session.requests if url == first.AUTH_URL]
517
+ pipe_requests = [cookies for url, cookies in session.requests if url == first.url]
518
+ assert auth_requests == [
519
+ {"arl": first_arl},
520
+ {
521
+ **dict.fromkeys(session._account_cookies[first_me.id], ""), # noqa: SLF001
522
+ "arl": second_arl,
523
+ },
524
+ ]
525
+ assert pipe_requests == [
526
+ session._account_cookies[first_me.id], # noqa: SLF001
527
+ session._account_cookies[second_me.id], # noqa: SLF001
528
+ ]
529
+
530
+
416
531
  @pytest.mark.asyncio
417
532
  async def test_auth_parses_text_plain_response() -> None:
418
533
  """Verify the client handles auth.deezer.com's text/plain JSON response."""