deezer-python-gql 0.14.0__tar.gz → 0.15.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.
Files changed (90) hide show
  1. {deezer_python_gql-0.14.0/deezer_python_gql.egg-info → deezer_python_gql-0.15.0}/PKG-INFO +3 -3
  2. deezer_python_gql-0.15.0/deezer_python_gql/__init__.py +21 -0
  3. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/base_client.py +71 -64
  4. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/base_client.py +71 -64
  5. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0/deezer_python_gql.egg-info}/PKG-INFO +3 -3
  6. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql.egg-info/requires.txt +2 -2
  7. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/pyproject.toml +3 -3
  8. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/tests/test_client.py +124 -115
  9. deezer_python_gql-0.14.0/deezer_python_gql/__init__.py +0 -5
  10. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/LICENSE +0 -0
  11. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/MANIFEST.in +0 -0
  12. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/README.md +0 -0
  13. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/__init__.py +0 -0
  14. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_album_to_favorite.py +0 -0
  15. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_artist_to_favorite.py +0 -0
  16. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_audiobook_to_favorite.py +0 -0
  17. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_playlist_to_favorite.py +0 -0
  18. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_podcast_to_favorite.py +0 -0
  19. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_track_to_favorite.py +0 -0
  20. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_tracks_to_playlist.py +0 -0
  21. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/base_model.py +0 -0
  22. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/bookmark_podcast_episode.py +0 -0
  23. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/client.py +0 -0
  24. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/create_playlist.py +0 -0
  25. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/delete_playlist.py +0 -0
  26. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/enums.py +0 -0
  27. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/fragments.py +0 -0
  28. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_album.py +0 -0
  29. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_artist.py +0 -0
  30. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_artist_mix.py +0 -0
  31. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_audiobook.py +0 -0
  32. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_audiobook_chapter.py +0 -0
  33. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_charts.py +0 -0
  34. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_favorite_albums.py +0 -0
  35. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_favorite_artists.py +0 -0
  36. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_favorite_audiobooks.py +0 -0
  37. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_favorite_playlists.py +0 -0
  38. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_favorite_podcasts.py +0 -0
  39. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_favorite_tracks.py +0 -0
  40. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_flow.py +0 -0
  41. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_flow_batch.py +0 -0
  42. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_flow_config_tracks.py +0 -0
  43. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_flow_configs.py +0 -0
  44. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_livestream.py +0 -0
  45. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_made_for_me.py +0 -0
  46. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_me.py +0 -0
  47. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_music_together_affinity.py +0 -0
  48. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_music_together_group.py +0 -0
  49. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_music_together_groups.py +0 -0
  50. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_personal_tracks.py +0 -0
  51. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_playlist.py +0 -0
  52. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_podcast.py +0 -0
  53. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_podcast_episode.py +0 -0
  54. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +0 -0
  55. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +0 -0
  56. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_recently_played.py +0 -0
  57. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_recommendations.py +0 -0
  58. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_similar_artists.py +0 -0
  59. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_similar_tracks.py +0 -0
  60. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_smart_tracklist.py +0 -0
  61. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_track.py +0 -0
  62. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_track_mix.py +0 -0
  63. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_user_charts.py +0 -0
  64. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_user_playlists.py +0 -0
  65. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/input_types.py +0 -0
  66. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/mark_as_not_played_podcast_episode.py +0 -0
  67. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/mark_as_played_podcast_episode.py +0 -0
  68. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/music_together_create_group.py +0 -0
  69. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/music_together_generate_group_name.py +0 -0
  70. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/music_together_join_group.py +0 -0
  71. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/music_together_leave_group.py +0 -0
  72. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +0 -0
  73. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/music_together_update_group_settings.py +0 -0
  74. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_album_from_favorite.py +0 -0
  75. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_artist_from_favorite.py +0 -0
  76. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_audiobook_from_favorite.py +0 -0
  77. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_playlist_from_favorite.py +0 -0
  78. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_podcast_from_favorite.py +0 -0
  79. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_track_from_favorite.py +0 -0
  80. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_tracks_from_playlist.py +0 -0
  81. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/search.py +0 -0
  82. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/search_flows.py +0 -0
  83. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/unbookmark_podcast_episode.py +0 -0
  84. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/update_playlist.py +0 -0
  85. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/py.typed +0 -0
  86. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql.egg-info/SOURCES.txt +0 -0
  87. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
  88. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql.egg-info/not-zip-safe +0 -0
  89. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql.egg-info/top_level.txt +0 -0
  90. {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.14.0
3
+ Version: 0.15.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
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3.13
11
11
  Requires-Python: >=3.12
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: httpx>=0.27.0
14
+ Requires-Dist: aiohttp>=3.13.5
15
15
  Requires-Dist: pydantic>=2.0.0
16
16
  Provides-Extra: test
17
17
  Requires-Dist: codespell==2.4.2; extra == "test"
@@ -21,7 +21,7 @@ Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
21
21
  Requires-Dist: pytest==9.0.3; extra == "test"
22
22
  Requires-Dist: pytest-asyncio==1.3.0; extra == "test"
23
23
  Requires-Dist: pytest-cov==7.1.0; extra == "test"
24
- Requires-Dist: ruff==0.15.12; extra == "test"
24
+ Requires-Dist: ruff==0.15.13; extra == "test"
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
27
27
  Dynamic: license-file
@@ -0,0 +1,21 @@
1
+ """Async typed Python client for Deezer's Pipe GraphQL API."""
2
+
3
+ from deezer_python_gql.generated.base_client import (
4
+ GQLResponse,
5
+ GraphQLClientError,
6
+ GraphQLClientGraphQLError,
7
+ GraphQLClientGraphQLMultiError,
8
+ GraphQLClientHttpError,
9
+ GraphQLClientInvalidResponseError,
10
+ )
11
+ from deezer_python_gql.generated.client import DeezerGQLClient
12
+
13
+ __all__ = [
14
+ "DeezerGQLClient",
15
+ "GQLResponse",
16
+ "GraphQLClientError",
17
+ "GraphQLClientGraphQLError",
18
+ "GraphQLClientGraphQLMultiError",
19
+ "GraphQLClientHttpError",
20
+ "GraphQLClientInvalidResponseError",
21
+ ]
@@ -6,13 +6,28 @@ import json
6
6
  import logging
7
7
  import time
8
8
  from base64 import urlsafe_b64decode
9
+ from dataclasses import dataclass
9
10
  from typing import Any, ClassVar, Self, cast
10
11
 
11
- import httpx
12
+ from aiohttp import ClientSession, ClientTimeout
12
13
 
13
14
  logger = logging.getLogger(__name__)
14
15
 
15
16
 
17
+ @dataclass(frozen=True, slots=True)
18
+ class GQLResponse:
19
+ """
20
+ Lightweight response container for GraphQL HTTP responses.
21
+
22
+ aiohttp responses cannot escape their context manager, so we capture
23
+ the essential fields inside the ``async with`` block and return this.
24
+ """
25
+
26
+ status: int
27
+ data: bytes
28
+ is_success: bool
29
+
30
+
16
31
  class GraphQLClientError(Exception):
17
32
  """Base exception for GraphQL client errors."""
18
33
 
@@ -20,7 +35,7 @@ class GraphQLClientError(Exception):
20
35
  class GraphQLClientHttpError(GraphQLClientError):
21
36
  """Raised when the HTTP response indicates an error."""
22
37
 
23
- def __init__(self, status_code: int, response: httpx.Response) -> None:
38
+ def __init__(self, status_code: int, response: GQLResponse) -> None:
24
39
  self.status_code = status_code
25
40
  self.response = response
26
41
  super().__init__(f"HTTP status code: {status_code}")
@@ -29,7 +44,7 @@ class GraphQLClientHttpError(GraphQLClientError):
29
44
  class GraphQLClientInvalidResponseError(GraphQLClientError):
30
45
  """Raised when the response cannot be parsed as valid GraphQL."""
31
46
 
32
- def __init__(self, response: httpx.Response) -> None:
47
+ def __init__(self, response: GQLResponse) -> None:
33
48
  self.response = response
34
49
  super().__init__("Invalid response format")
35
50
 
@@ -79,17 +94,15 @@ class GraphQLClientGraphQLMultiError(GraphQLClientError):
79
94
 
80
95
 
81
96
  class DeezerBaseClient:
82
- """Async HTTP client for Deezer's Pipe GraphQL API with ARL-based auth.
83
-
84
- Handles the ARL cookie → JWT token exchange and automatic refresh.
85
- This class is used as the base client for ariadne-codegen's generated client.
97
+ """
98
+ Async HTTP client for Deezer's Pipe GraphQL API with ARL-based auth.
86
99
 
87
- Manages its own httpx connection pool by default. Pass an external
88
- ``http_client`` only if you need to share a pool across multiple clients.
100
+ Manages its own aiohttp session by default. Pass an external
101
+ ``session`` to share a connection pool across multiple clients.
89
102
 
90
103
  :param arl: Deezer ARL cookie value for authentication.
91
104
  :param url: GraphQL endpoint URL (defaults to Pipe API).
92
- :param http_client: Optional pre-configured httpx.AsyncClient.
105
+ :param session: Optional pre-configured aiohttp.ClientSession.
93
106
  If provided, the caller is responsible for closing it.
94
107
  """
95
108
 
@@ -101,33 +114,34 @@ class DeezerBaseClient:
101
114
  self,
102
115
  arl: str,
103
116
  url: str = PIPE_URL,
104
- http_client: httpx.AsyncClient | None = None,
117
+ session: ClientSession | None = None,
105
118
  ) -> None:
106
119
  self.url = url
107
120
  self._arl = arl
108
- self._http_client = http_client
109
- self._owns_http_client = http_client is None
121
+ self._session = session
122
+ self._owns_session = session is None
110
123
  self._jwt: str | None = None
111
124
  self._jwt_expires_at: float = 0
112
125
  self._last_operation_name: str | None = None
113
126
  self._last_variables: dict[str, Any] | None = None
114
127
 
115
- def _get_http_client(self) -> httpx.AsyncClient:
116
- """Return the HTTP client, creating an internal one if needed."""
117
- if self._http_client is None:
118
- self._http_client = httpx.AsyncClient()
119
- self._owns_http_client = True
120
- return self._http_client
128
+ def _get_session(self) -> ClientSession:
129
+ """Return the HTTP session, creating an internal one if needed."""
130
+ if self._session is None:
131
+ self._session = ClientSession()
132
+ self._owns_session = True
133
+ return self._session
121
134
 
122
135
  async def close(self) -> None:
123
- """Close the internal HTTP client if we own it.
136
+ """
137
+ Close the internal HTTP session if we own it.
124
138
 
125
139
  Safe to call multiple times. Does nothing if an external
126
- ``http_client`` was provided at construction time.
140
+ ``session`` was provided at construction time.
127
141
  """
128
- if self._owns_http_client and self._http_client is not None:
129
- await self._http_client.aclose()
130
- self._http_client = None
142
+ if self._owns_session and self._session is not None:
143
+ await self._session.close()
144
+ self._session = None
131
145
 
132
146
  async def __aenter__(self) -> Self:
133
147
  """Enter the async context manager."""
@@ -143,15 +157,14 @@ class DeezerBaseClient:
143
157
  operation_name: str | None = None,
144
158
  variables: dict[str, Any] | None = None,
145
159
  **kwargs: Any,
146
- ) -> httpx.Response:
147
- """Execute a GraphQL query against the Pipe API.
148
-
149
- Automatically handles JWT acquisition and refresh from the ARL cookie.
160
+ ) -> GQLResponse:
161
+ """
162
+ Execute a GraphQL query against the Pipe API.
150
163
 
151
164
  :param query: The GraphQL query string.
152
165
  :param operation_name: Optional operation name for multi-operation documents.
153
166
  :param variables: Optional query variables.
154
- :param kwargs: Additional keyword arguments passed to httpx.
167
+ :param kwargs: Additional keyword arguments passed to the session request.
155
168
  """
156
169
  logger.debug("GQL execute: %s (variables=%s)", operation_name or "<unnamed>", variables)
157
170
  self._last_operation_name = operation_name
@@ -174,34 +187,34 @@ class DeezerBaseClient:
174
187
  k: v for k, v in variables.items() if not isinstance(v, UnsetType)
175
188
  }
176
189
 
177
- client = self._get_http_client()
178
- resp = await client.post(
179
- self.url,
180
- json=payload,
181
- headers=headers,
182
- **kwargs,
183
- )
190
+ session = self._get_session()
191
+ async with session.post(self.url, json=payload, headers=headers, **kwargs) as resp:
192
+ body = await resp.read()
193
+ gql_response = GQLResponse(
194
+ status=resp.status,
195
+ data=body,
196
+ is_success=resp.ok,
197
+ )
198
+
184
199
  logger.debug(
185
200
  "GQL response: %s status=%s length=%s",
186
201
  operation_name or "<unnamed>",
187
- resp.status_code,
188
- len(resp.content),
202
+ gql_response.status,
203
+ len(gql_response.data),
189
204
  )
190
- return resp
205
+ return gql_response
191
206
 
192
- def get_data(self, response: httpx.Response) -> dict[str, Any]:
193
- """Parse a GraphQL response and return the data dict.
194
-
195
- Handles the Pipe API's text/plain content type and standard
196
- GraphQL error responses.
207
+ def get_data(self, response: GQLResponse) -> dict[str, Any]:
208
+ """
209
+ Parse a GraphQL response and return the data dict.
197
210
 
198
- :param response: The HTTP response from execute().
211
+ :param response: The GQLResponse from execute().
199
212
  """
200
213
  if not response.is_success:
201
- raise GraphQLClientHttpError(status_code=response.status_code, response=response)
214
+ raise GraphQLClientHttpError(status_code=response.status, response=response)
202
215
 
203
216
  try:
204
- response_json = response.json()
217
+ response_json = json.loads(response.data)
205
218
  except ValueError as exc:
206
219
  raise GraphQLClientInvalidResponseError(response=response) from exc
207
220
 
@@ -268,12 +281,7 @@ class DeezerBaseClient:
268
281
  cls._inject_missing_typenames(item)
269
282
 
270
283
  async def _ensure_jwt(self) -> str:
271
- """Acquire or refresh the JWT token from ARL cookie.
272
-
273
- The Pipe API uses short-lived JWTs (~6 min TTL) obtained by
274
- POSTing the ARL cookie to auth.deezer.com. The response is
275
- Content-Type: text/plain containing JSON.
276
- """
284
+ """Acquire or refresh the JWT token from ARL cookie."""
277
285
  now = time.time()
278
286
  if self._jwt and now < (self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS):
279
287
  return self._jwt
@@ -281,16 +289,18 @@ class DeezerBaseClient:
281
289
  logger.debug("JWT expired or missing, refreshing from ARL")
282
290
  params = {"jo": "p", "rto": "c", "i": "c"}
283
291
 
284
- client = self._get_http_client()
285
- resp = await client.post(
292
+ session = self._get_session()
293
+ async with session.post(
286
294
  self.AUTH_URL,
287
295
  params=params,
288
296
  cookies={"arl": self._arl},
289
- )
290
- resp.raise_for_status()
297
+ timeout=ClientTimeout(total=10),
298
+ ) as resp:
299
+ resp.raise_for_status()
300
+ # Response body is text/plain containing JSON
301
+ text = await resp.text()
291
302
 
292
- # Response body is text/plain containing JSON
293
- data = json.loads(resp.text)
303
+ data = json.loads(text)
294
304
  self._jwt = data["jwt"]
295
305
 
296
306
  # Decode expiration from JWT payload (second segment, base64url-encoded)
@@ -304,11 +314,8 @@ class DeezerBaseClient:
304
314
  return self._jwt
305
315
 
306
316
  async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
307
- """Check which album IDs are also valid audiobooks on Deezer.
308
-
309
- Uses GraphQL aliases to batch-check many IDs in a single request.
310
- Returns the subset of IDs that are audiobooks (i.e., the audiobook
311
- query returns non-null for them).
317
+ """
318
+ Check which album IDs are also valid audiobooks on Deezer.
312
319
 
313
320
  :param album_ids: List of Deezer album/audiobook IDs to check.
314
321
  """
@@ -8,13 +8,28 @@ import json
8
8
  import logging
9
9
  import time
10
10
  from base64 import urlsafe_b64decode
11
+ from dataclasses import dataclass
11
12
  from typing import Any, ClassVar, Self, cast
12
13
 
13
- import httpx
14
+ from aiohttp import ClientSession, ClientTimeout
14
15
 
15
16
  logger = logging.getLogger(__name__)
16
17
 
17
18
 
19
+ @dataclass(frozen=True, slots=True)
20
+ class GQLResponse:
21
+ """
22
+ Lightweight response container for GraphQL HTTP responses.
23
+
24
+ aiohttp responses cannot escape their context manager, so we capture
25
+ the essential fields inside the ``async with`` block and return this.
26
+ """
27
+
28
+ status: int
29
+ data: bytes
30
+ is_success: bool
31
+
32
+
18
33
  class GraphQLClientError(Exception):
19
34
  """Base exception for GraphQL client errors."""
20
35
 
@@ -22,7 +37,7 @@ class GraphQLClientError(Exception):
22
37
  class GraphQLClientHttpError(GraphQLClientError):
23
38
  """Raised when the HTTP response indicates an error."""
24
39
 
25
- def __init__(self, status_code: int, response: httpx.Response) -> None:
40
+ def __init__(self, status_code: int, response: GQLResponse) -> None:
26
41
  self.status_code = status_code
27
42
  self.response = response
28
43
  super().__init__(f"HTTP status code: {status_code}")
@@ -31,7 +46,7 @@ class GraphQLClientHttpError(GraphQLClientError):
31
46
  class GraphQLClientInvalidResponseError(GraphQLClientError):
32
47
  """Raised when the response cannot be parsed as valid GraphQL."""
33
48
 
34
- def __init__(self, response: httpx.Response) -> None:
49
+ def __init__(self, response: GQLResponse) -> None:
35
50
  self.response = response
36
51
  super().__init__("Invalid response format")
37
52
 
@@ -81,17 +96,15 @@ class GraphQLClientGraphQLMultiError(GraphQLClientError):
81
96
 
82
97
 
83
98
  class DeezerBaseClient:
84
- """Async HTTP client for Deezer's Pipe GraphQL API with ARL-based auth.
85
-
86
- Handles the ARL cookie → JWT token exchange and automatic refresh.
87
- This class is used as the base client for ariadne-codegen's generated client.
99
+ """
100
+ Async HTTP client for Deezer's Pipe GraphQL API with ARL-based auth.
88
101
 
89
- Manages its own httpx connection pool by default. Pass an external
90
- ``http_client`` only if you need to share a pool across multiple clients.
102
+ Manages its own aiohttp session by default. Pass an external
103
+ ``session`` to share a connection pool across multiple clients.
91
104
 
92
105
  :param arl: Deezer ARL cookie value for authentication.
93
106
  :param url: GraphQL endpoint URL (defaults to Pipe API).
94
- :param http_client: Optional pre-configured httpx.AsyncClient.
107
+ :param session: Optional pre-configured aiohttp.ClientSession.
95
108
  If provided, the caller is responsible for closing it.
96
109
  """
97
110
 
@@ -103,33 +116,34 @@ class DeezerBaseClient:
103
116
  self,
104
117
  arl: str,
105
118
  url: str = PIPE_URL,
106
- http_client: httpx.AsyncClient | None = None,
119
+ session: ClientSession | None = None,
107
120
  ) -> None:
108
121
  self.url = url
109
122
  self._arl = arl
110
- self._http_client = http_client
111
- self._owns_http_client = http_client is None
123
+ self._session = session
124
+ self._owns_session = session is None
112
125
  self._jwt: str | None = None
113
126
  self._jwt_expires_at: float = 0
114
127
  self._last_operation_name: str | None = None
115
128
  self._last_variables: dict[str, Any] | None = None
116
129
 
117
- def _get_http_client(self) -> httpx.AsyncClient:
118
- """Return the HTTP client, creating an internal one if needed."""
119
- if self._http_client is None:
120
- self._http_client = httpx.AsyncClient()
121
- self._owns_http_client = True
122
- return self._http_client
130
+ def _get_session(self) -> ClientSession:
131
+ """Return the HTTP session, creating an internal one if needed."""
132
+ if self._session is None:
133
+ self._session = ClientSession()
134
+ self._owns_session = True
135
+ return self._session
123
136
 
124
137
  async def close(self) -> None:
125
- """Close the internal HTTP client if we own it.
138
+ """
139
+ Close the internal HTTP session if we own it.
126
140
 
127
141
  Safe to call multiple times. Does nothing if an external
128
- ``http_client`` was provided at construction time.
142
+ ``session`` was provided at construction time.
129
143
  """
130
- if self._owns_http_client and self._http_client is not None:
131
- await self._http_client.aclose()
132
- self._http_client = None
144
+ if self._owns_session and self._session is not None:
145
+ await self._session.close()
146
+ self._session = None
133
147
 
134
148
  async def __aenter__(self) -> Self:
135
149
  """Enter the async context manager."""
@@ -145,15 +159,14 @@ class DeezerBaseClient:
145
159
  operation_name: str | None = None,
146
160
  variables: dict[str, Any] | None = None,
147
161
  **kwargs: Any,
148
- ) -> httpx.Response:
149
- """Execute a GraphQL query against the Pipe API.
150
-
151
- Automatically handles JWT acquisition and refresh from the ARL cookie.
162
+ ) -> GQLResponse:
163
+ """
164
+ Execute a GraphQL query against the Pipe API.
152
165
 
153
166
  :param query: The GraphQL query string.
154
167
  :param operation_name: Optional operation name for multi-operation documents.
155
168
  :param variables: Optional query variables.
156
- :param kwargs: Additional keyword arguments passed to httpx.
169
+ :param kwargs: Additional keyword arguments passed to the session request.
157
170
  """
158
171
  logger.debug("GQL execute: %s (variables=%s)", operation_name or "<unnamed>", variables)
159
172
  self._last_operation_name = operation_name
@@ -176,34 +189,34 @@ class DeezerBaseClient:
176
189
  k: v for k, v in variables.items() if not isinstance(v, UnsetType)
177
190
  }
178
191
 
179
- client = self._get_http_client()
180
- resp = await client.post(
181
- self.url,
182
- json=payload,
183
- headers=headers,
184
- **kwargs,
185
- )
192
+ session = self._get_session()
193
+ async with session.post(self.url, json=payload, headers=headers, **kwargs) as resp:
194
+ body = await resp.read()
195
+ gql_response = GQLResponse(
196
+ status=resp.status,
197
+ data=body,
198
+ is_success=resp.ok,
199
+ )
200
+
186
201
  logger.debug(
187
202
  "GQL response: %s status=%s length=%s",
188
203
  operation_name or "<unnamed>",
189
- resp.status_code,
190
- len(resp.content),
204
+ gql_response.status,
205
+ len(gql_response.data),
191
206
  )
192
- return resp
207
+ return gql_response
193
208
 
194
- def get_data(self, response: httpx.Response) -> dict[str, Any]:
195
- """Parse a GraphQL response and return the data dict.
196
-
197
- Handles the Pipe API's text/plain content type and standard
198
- GraphQL error responses.
209
+ def get_data(self, response: GQLResponse) -> dict[str, Any]:
210
+ """
211
+ Parse a GraphQL response and return the data dict.
199
212
 
200
- :param response: The HTTP response from execute().
213
+ :param response: The GQLResponse from execute().
201
214
  """
202
215
  if not response.is_success:
203
- raise GraphQLClientHttpError(status_code=response.status_code, response=response)
216
+ raise GraphQLClientHttpError(status_code=response.status, response=response)
204
217
 
205
218
  try:
206
- response_json = response.json()
219
+ response_json = json.loads(response.data)
207
220
  except ValueError as exc:
208
221
  raise GraphQLClientInvalidResponseError(response=response) from exc
209
222
 
@@ -270,12 +283,7 @@ class DeezerBaseClient:
270
283
  cls._inject_missing_typenames(item)
271
284
 
272
285
  async def _ensure_jwt(self) -> str:
273
- """Acquire or refresh the JWT token from ARL cookie.
274
-
275
- The Pipe API uses short-lived JWTs (~6 min TTL) obtained by
276
- POSTing the ARL cookie to auth.deezer.com. The response is
277
- Content-Type: text/plain containing JSON.
278
- """
286
+ """Acquire or refresh the JWT token from ARL cookie."""
279
287
  now = time.time()
280
288
  if self._jwt and now < (self._jwt_expires_at - self.JWT_REFRESH_MARGIN_SECONDS):
281
289
  return self._jwt
@@ -283,16 +291,18 @@ class DeezerBaseClient:
283
291
  logger.debug("JWT expired or missing, refreshing from ARL")
284
292
  params = {"jo": "p", "rto": "c", "i": "c"}
285
293
 
286
- client = self._get_http_client()
287
- resp = await client.post(
294
+ session = self._get_session()
295
+ async with session.post(
288
296
  self.AUTH_URL,
289
297
  params=params,
290
298
  cookies={"arl": self._arl},
291
- )
292
- resp.raise_for_status()
299
+ timeout=ClientTimeout(total=10),
300
+ ) as resp:
301
+ resp.raise_for_status()
302
+ # Response body is text/plain containing JSON
303
+ text = await resp.text()
293
304
 
294
- # Response body is text/plain containing JSON
295
- data = json.loads(resp.text)
305
+ data = json.loads(text)
296
306
  self._jwt = data["jwt"]
297
307
 
298
308
  # Decode expiration from JWT payload (second segment, base64url-encoded)
@@ -306,11 +316,8 @@ class DeezerBaseClient:
306
316
  return self._jwt
307
317
 
308
318
  async def check_audiobook_ids(self, album_ids: list[str]) -> set[str]:
309
- """Check which album IDs are also valid audiobooks on Deezer.
310
-
311
- Uses GraphQL aliases to batch-check many IDs in a single request.
312
- Returns the subset of IDs that are audiobooks (i.e., the audiobook
313
- query returns non-null for them).
319
+ """
320
+ Check which album IDs are also valid audiobooks on Deezer.
314
321
 
315
322
  :param album_ids: List of Deezer album/audiobook IDs to check.
316
323
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deezer-python-gql
3
- Version: 0.14.0
3
+ Version: 0.15.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
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3.13
11
11
  Requires-Python: >=3.12
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: httpx>=0.27.0
14
+ Requires-Dist: aiohttp>=3.13.5
15
15
  Requires-Dist: pydantic>=2.0.0
16
16
  Provides-Extra: test
17
17
  Requires-Dist: codespell==2.4.2; extra == "test"
@@ -21,7 +21,7 @@ Requires-Dist: pre-commit-hooks==6.0.0; extra == "test"
21
21
  Requires-Dist: pytest==9.0.3; extra == "test"
22
22
  Requires-Dist: pytest-asyncio==1.3.0; extra == "test"
23
23
  Requires-Dist: pytest-cov==7.1.0; extra == "test"
24
- Requires-Dist: ruff==0.15.12; extra == "test"
24
+ Requires-Dist: ruff==0.15.13; extra == "test"
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: ariadne-codegen[subscriptions]; extra == "dev"
27
27
  Dynamic: license-file
@@ -1,4 +1,4 @@
1
- httpx>=0.27.0
1
+ aiohttp>=3.13.5
2
2
  pydantic>=2.0.0
3
3
 
4
4
  [dev]
@@ -12,4 +12,4 @@ pre-commit-hooks==6.0.0
12
12
  pytest==9.0.3
13
13
  pytest-asyncio==1.3.0
14
14
  pytest-cov==7.1.0
15
- ruff==0.15.12
15
+ ruff==0.15.13
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deezer-python-gql"
3
- version = "0.14.0"
3
+ version = "0.15.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"
@@ -13,7 +13,7 @@ classifiers = [
13
13
  "Programming Language :: Python :: 3.13",
14
14
  ]
15
15
  dependencies = [
16
- "httpx>=0.27.0",
16
+ "aiohttp>=3.13.5",
17
17
  "pydantic>=2.0.0",
18
18
  ]
19
19
 
@@ -29,7 +29,7 @@ test = [
29
29
  "pytest==9.0.3",
30
30
  "pytest-asyncio==1.3.0",
31
31
  "pytest-cov==7.1.0",
32
- "ruff==0.15.12",
32
+ "ruff==0.15.13",
33
33
  ]
34
34
  dev = [
35
35
  "ariadne-codegen[subscriptions]",