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.
- {deezer_python_gql-0.14.0/deezer_python_gql.egg-info → deezer_python_gql-0.15.0}/PKG-INFO +3 -3
- deezer_python_gql-0.15.0/deezer_python_gql/__init__.py +21 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/base_client.py +71 -64
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/base_client.py +71 -64
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0/deezer_python_gql.egg-info}/PKG-INFO +3 -3
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql.egg-info/requires.txt +2 -2
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/pyproject.toml +3 -3
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/tests/test_client.py +124 -115
- deezer_python_gql-0.14.0/deezer_python_gql/__init__.py +0 -5
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/LICENSE +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/MANIFEST.in +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/README.md +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/__init__.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_album_to_favorite.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_artist_to_favorite.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_audiobook_to_favorite.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_playlist_to_favorite.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_podcast_to_favorite.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_track_to_favorite.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/add_tracks_to_playlist.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/base_model.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/bookmark_podcast_episode.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/client.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/create_playlist.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/delete_playlist.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/enums.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/fragments.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_album.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_artist.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_artist_mix.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_audiobook.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_audiobook_chapter.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_charts.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_favorite_albums.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_favorite_artists.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_favorite_audiobooks.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_favorite_playlists.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_favorite_podcasts.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_favorite_tracks.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_flow.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_flow_batch.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_flow_config_tracks.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_flow_configs.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_livestream.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_made_for_me.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_me.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_music_together_affinity.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_music_together_group.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_music_together_groups.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_personal_tracks.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_playlist.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_podcast.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_podcast_episode.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_podcast_episode_bookmarks.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_podcast_episodes_by_ids.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_recently_played.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_recommendations.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_similar_artists.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_similar_tracks.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_smart_tracklist.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_track.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_track_mix.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_user_charts.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/get_user_playlists.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/input_types.py +0 -0
- {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
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/mark_as_played_podcast_episode.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/music_together_create_group.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/music_together_generate_group_name.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/music_together_join_group.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/music_together_leave_group.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/music_together_refresh_suggested_tracklist.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/music_together_update_group_settings.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_album_from_favorite.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_artist_from_favorite.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_audiobook_from_favorite.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_playlist_from_favorite.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_podcast_from_favorite.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_track_from_favorite.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/remove_tracks_from_playlist.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/search.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/search_flows.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/unbookmark_podcast_episode.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/update_playlist.py +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/py.typed +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql.egg-info/SOURCES.txt +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql.egg-info/dependency_links.txt +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql.egg-info/not-zip-safe +0 -0
- {deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql.egg-info/top_level.txt +0 -0
- {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.
|
|
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:
|
|
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.
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
"""
|
|
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
|
|
88
|
-
``
|
|
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
|
|
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
|
-
|
|
117
|
+
session: ClientSession | None = None,
|
|
105
118
|
) -> None:
|
|
106
119
|
self.url = url
|
|
107
120
|
self._arl = arl
|
|
108
|
-
self.
|
|
109
|
-
self.
|
|
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
|
|
116
|
-
"""Return the HTTP
|
|
117
|
-
if self.
|
|
118
|
-
self.
|
|
119
|
-
self.
|
|
120
|
-
return self.
|
|
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
|
-
"""
|
|
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
|
-
``
|
|
140
|
+
``session`` was provided at construction time.
|
|
127
141
|
"""
|
|
128
|
-
if self.
|
|
129
|
-
await self.
|
|
130
|
-
self.
|
|
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
|
-
) ->
|
|
147
|
-
"""
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
188
|
-
len(
|
|
202
|
+
gql_response.status,
|
|
203
|
+
len(gql_response.data),
|
|
189
204
|
)
|
|
190
|
-
return
|
|
205
|
+
return gql_response
|
|
191
206
|
|
|
192
|
-
def get_data(self, response:
|
|
193
|
-
"""
|
|
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
|
|
211
|
+
:param response: The GQLResponse from execute().
|
|
199
212
|
"""
|
|
200
213
|
if not response.is_success:
|
|
201
|
-
raise GraphQLClientHttpError(status_code=response.
|
|
214
|
+
raise GraphQLClientHttpError(status_code=response.status, response=response)
|
|
202
215
|
|
|
203
216
|
try:
|
|
204
|
-
response_json = response.
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
"""
|
{deezer_python_gql-0.14.0 → deezer_python_gql-0.15.0}/deezer_python_gql/generated/base_client.py
RENAMED
|
@@ -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
|
|
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:
|
|
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:
|
|
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
|
-
"""
|
|
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
|
|
90
|
-
``
|
|
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
|
|
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
|
-
|
|
119
|
+
session: ClientSession | None = None,
|
|
107
120
|
) -> None:
|
|
108
121
|
self.url = url
|
|
109
122
|
self._arl = arl
|
|
110
|
-
self.
|
|
111
|
-
self.
|
|
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
|
|
118
|
-
"""Return the HTTP
|
|
119
|
-
if self.
|
|
120
|
-
self.
|
|
121
|
-
self.
|
|
122
|
-
return self.
|
|
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
|
-
"""
|
|
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
|
-
``
|
|
142
|
+
``session`` was provided at construction time.
|
|
129
143
|
"""
|
|
130
|
-
if self.
|
|
131
|
-
await self.
|
|
132
|
-
self.
|
|
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
|
-
) ->
|
|
149
|
-
"""
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
190
|
-
len(
|
|
204
|
+
gql_response.status,
|
|
205
|
+
len(gql_response.data),
|
|
191
206
|
)
|
|
192
|
-
return
|
|
207
|
+
return gql_response
|
|
193
208
|
|
|
194
|
-
def get_data(self, response:
|
|
195
|
-
"""
|
|
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
|
|
213
|
+
:param response: The GQLResponse from execute().
|
|
201
214
|
"""
|
|
202
215
|
if not response.is_success:
|
|
203
|
-
raise GraphQLClientHttpError(status_code=response.
|
|
216
|
+
raise GraphQLClientHttpError(status_code=response.status, response=response)
|
|
204
217
|
|
|
205
218
|
try:
|
|
206
|
-
response_json = response.
|
|
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
|
-
|
|
287
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"""
|
|
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.
|
|
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:
|
|
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.
|
|
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,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "deezer-python-gql"
|
|
3
|
-
version = "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
|
-
"
|
|
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.
|
|
32
|
+
"ruff==0.15.13",
|
|
33
33
|
]
|
|
34
34
|
dev = [
|
|
35
35
|
"ariadne-codegen[subscriptions]",
|