rephonic 1.0.0__py3-none-any.whl
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.
- rephonic/__init__.py +47 -0
- rephonic/_base_client.py +266 -0
- rephonic/_client.py +148 -0
- rephonic/_exceptions.py +93 -0
- rephonic/_version.py +1 -0
- rephonic/pagination.py +84 -0
- rephonic/py.typed +0 -0
- rephonic/resources/__init__.py +21 -0
- rephonic/resources/account.py +28 -0
- rephonic/resources/charts.py +61 -0
- rephonic/resources/common.py +68 -0
- rephonic/resources/episodes.py +107 -0
- rephonic/resources/podcasts.py +130 -0
- rephonic/resources/search.py +221 -0
- rephonic-1.0.0.dist-info/METADATA +279 -0
- rephonic-1.0.0.dist-info/RECORD +18 -0
- rephonic-1.0.0.dist-info/WHEEL +4 -0
- rephonic-1.0.0.dist-info/licenses/LICENSE +21 -0
rephonic/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Official Python client for the Rephonic podcast API.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from rephonic import Rephonic
|
|
6
|
+
|
|
7
|
+
client = Rephonic(api_key="...")
|
|
8
|
+
podcast = client.podcasts.get("huberman-lab")
|
|
9
|
+
|
|
10
|
+
Or async::
|
|
11
|
+
|
|
12
|
+
from rephonic import AsyncRephonic
|
|
13
|
+
|
|
14
|
+
async with AsyncRephonic(api_key="...") as client:
|
|
15
|
+
podcast = await client.podcasts.get("huberman-lab")
|
|
16
|
+
|
|
17
|
+
See https://rephonic.com/developers for the full API reference.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from ._client import AsyncRephonic, Rephonic
|
|
21
|
+
from ._exceptions import (
|
|
22
|
+
APIConnectionError,
|
|
23
|
+
APIStatusError,
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
BadRequestError,
|
|
26
|
+
InternalServerError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
PermissionDeniedError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
RephonicError,
|
|
31
|
+
)
|
|
32
|
+
from ._version import __version__
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Rephonic",
|
|
36
|
+
"AsyncRephonic",
|
|
37
|
+
"RephonicError",
|
|
38
|
+
"APIConnectionError",
|
|
39
|
+
"APIStatusError",
|
|
40
|
+
"AuthenticationError",
|
|
41
|
+
"BadRequestError",
|
|
42
|
+
"PermissionDeniedError",
|
|
43
|
+
"NotFoundError",
|
|
44
|
+
"RateLimitError",
|
|
45
|
+
"InternalServerError",
|
|
46
|
+
"__version__",
|
|
47
|
+
]
|
rephonic/_base_client.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Low-level HTTP transport shared by all resources.
|
|
2
|
+
|
|
3
|
+
Two flavours live here: :class:`BaseClient` (sync, uses ``httpx.Client``) and
|
|
4
|
+
:class:`AsyncBaseClient` (async, uses ``httpx.AsyncClient``). Resource classes
|
|
5
|
+
hold a reference to one of these and dispatch HTTP requests through it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import random
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Mapping, Union
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ._exceptions import APIConnectionError, error_for_status
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def quote_path(segment: str) -> str:
|
|
24
|
+
"""URL-encode a path segment so callers can't break out of the path.
|
|
25
|
+
|
|
26
|
+
``safe=""`` means slashes, dots, and other reserved characters are all
|
|
27
|
+
percent-encoded. Use for every user-supplied value interpolated into a
|
|
28
|
+
path (podcast_id, episode_id, platform, country, category).
|
|
29
|
+
"""
|
|
30
|
+
return quote(str(segment), safe="")
|
|
31
|
+
|
|
32
|
+
DEFAULT_BASE_URL = "https://api.rephonic.com"
|
|
33
|
+
DEFAULT_TIMEOUT = 30.0
|
|
34
|
+
DEFAULT_MAX_RETRIES = 2
|
|
35
|
+
|
|
36
|
+
QueryValue = Union[str, int, float, bool, None]
|
|
37
|
+
Query = Mapping[str, QueryValue]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _validate_api_key(api_key: str) -> None:
|
|
41
|
+
if not api_key:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"No API key provided. Pass api_key=... or set the REPHONIC_API_KEY "
|
|
44
|
+
"environment variable. Get one at https://rephonic.com/developers."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate_max_retries(max_retries: int) -> None:
|
|
49
|
+
if max_retries < 0:
|
|
50
|
+
raise ValueError(f"max_retries must be >= 0, got {max_retries}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _default_headers() -> dict[str, str]:
|
|
54
|
+
return {"User-Agent": f"rephonic-python/{__version__}"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _auth_headers(api_key: str) -> dict[str, str]:
|
|
58
|
+
return {"X-Rephonic-Api-Key": api_key}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BaseClient:
|
|
62
|
+
"""Synchronous HTTP transport: auth, retries, error mapping.
|
|
63
|
+
|
|
64
|
+
Resource classes receive a ``BaseClient`` and call :meth:`request` with a
|
|
65
|
+
relative path and optional query params.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
api_key: str,
|
|
72
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
73
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
74
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
75
|
+
http_client: httpx.Client | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
_validate_api_key(api_key)
|
|
78
|
+
_validate_max_retries(max_retries)
|
|
79
|
+
self._api_key = api_key
|
|
80
|
+
self._base_url = base_url.rstrip("/")
|
|
81
|
+
self._max_retries = max_retries
|
|
82
|
+
self._owns_http_client = http_client is None
|
|
83
|
+
self._http_client = http_client or httpx.Client(timeout=timeout, headers=_default_headers())
|
|
84
|
+
|
|
85
|
+
def close(self) -> None:
|
|
86
|
+
"""Close the underlying HTTP client if it was created by this instance."""
|
|
87
|
+
if self._owns_http_client:
|
|
88
|
+
self._http_client.close()
|
|
89
|
+
|
|
90
|
+
def __enter__(self) -> BaseClient:
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
94
|
+
self.close()
|
|
95
|
+
|
|
96
|
+
def request(
|
|
97
|
+
self,
|
|
98
|
+
method: str,
|
|
99
|
+
path: str,
|
|
100
|
+
*,
|
|
101
|
+
params: Query | None = None,
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
"""Make an authenticated request and return the parsed JSON body.
|
|
104
|
+
|
|
105
|
+
Retries on connection errors and on 429/5xx responses, with exponential
|
|
106
|
+
backoff plus jitter (or ``Retry-After`` when the server sends it).
|
|
107
|
+
"""
|
|
108
|
+
url = f"{self._base_url}{path}"
|
|
109
|
+
cleaned = _clean_params(params) if params else None
|
|
110
|
+
headers = _auth_headers(self._api_key)
|
|
111
|
+
|
|
112
|
+
last_error: BaseException | None = None
|
|
113
|
+
for attempt in range(self._max_retries + 1):
|
|
114
|
+
try:
|
|
115
|
+
response = self._http_client.request(method, url, params=cleaned, headers=headers)
|
|
116
|
+
except httpx.HTTPError as exc:
|
|
117
|
+
last_error = exc
|
|
118
|
+
if attempt >= self._max_retries:
|
|
119
|
+
raise APIConnectionError(f"Unable to reach Rephonic API: {exc}") from exc
|
|
120
|
+
time.sleep(_backoff_delay(attempt))
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if response.status_code < 400:
|
|
124
|
+
return _parse_json(response)
|
|
125
|
+
|
|
126
|
+
if _should_retry_status(response.status_code) and attempt < self._max_retries:
|
|
127
|
+
time.sleep(_sleep_for_response(attempt, response))
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
raise _build_status_error(response)
|
|
131
|
+
|
|
132
|
+
assert last_error is not None
|
|
133
|
+
raise APIConnectionError(str(last_error)) from last_error
|
|
134
|
+
|
|
135
|
+
def get(self, path: str, *, params: Query | None = None) -> dict[str, Any]:
|
|
136
|
+
return self.request("GET", path, params=params)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AsyncBaseClient:
|
|
140
|
+
"""Asynchronous HTTP transport. Mirrors :class:`BaseClient`."""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
api_key: str,
|
|
146
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
147
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
148
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
149
|
+
http_client: httpx.AsyncClient | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
_validate_api_key(api_key)
|
|
152
|
+
_validate_max_retries(max_retries)
|
|
153
|
+
self._api_key = api_key
|
|
154
|
+
self._base_url = base_url.rstrip("/")
|
|
155
|
+
self._max_retries = max_retries
|
|
156
|
+
self._owns_http_client = http_client is None
|
|
157
|
+
self._http_client = http_client or httpx.AsyncClient(
|
|
158
|
+
timeout=timeout, headers=_default_headers()
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def aclose(self) -> None:
|
|
162
|
+
"""Close the underlying HTTP client if it was created by this instance."""
|
|
163
|
+
if self._owns_http_client:
|
|
164
|
+
await self._http_client.aclose()
|
|
165
|
+
|
|
166
|
+
async def __aenter__(self) -> AsyncBaseClient:
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
170
|
+
await self.aclose()
|
|
171
|
+
|
|
172
|
+
async def request(
|
|
173
|
+
self,
|
|
174
|
+
method: str,
|
|
175
|
+
path: str,
|
|
176
|
+
*,
|
|
177
|
+
params: Query | None = None,
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
url = f"{self._base_url}{path}"
|
|
180
|
+
cleaned = _clean_params(params) if params else None
|
|
181
|
+
headers = _auth_headers(self._api_key)
|
|
182
|
+
|
|
183
|
+
last_error: BaseException | None = None
|
|
184
|
+
for attempt in range(self._max_retries + 1):
|
|
185
|
+
try:
|
|
186
|
+
response = await self._http_client.request(
|
|
187
|
+
method, url, params=cleaned, headers=headers
|
|
188
|
+
)
|
|
189
|
+
except httpx.HTTPError as exc:
|
|
190
|
+
last_error = exc
|
|
191
|
+
if attempt >= self._max_retries:
|
|
192
|
+
raise APIConnectionError(f"Unable to reach Rephonic API: {exc}") from exc
|
|
193
|
+
await asyncio.sleep(_backoff_delay(attempt))
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
if response.status_code < 400:
|
|
197
|
+
return _parse_json(response)
|
|
198
|
+
|
|
199
|
+
if _should_retry_status(response.status_code) and attempt < self._max_retries:
|
|
200
|
+
await asyncio.sleep(_sleep_for_response(attempt, response))
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
raise _build_status_error(response)
|
|
204
|
+
|
|
205
|
+
assert last_error is not None
|
|
206
|
+
raise APIConnectionError(str(last_error)) from last_error
|
|
207
|
+
|
|
208
|
+
async def get(self, path: str, *, params: Query | None = None) -> dict[str, Any]:
|
|
209
|
+
return await self.request("GET", path, params=params)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _clean_params(params: Query) -> dict[str, str]:
|
|
213
|
+
"""Drop ``None`` values and coerce booleans to lowercase strings."""
|
|
214
|
+
out: dict[str, str] = {}
|
|
215
|
+
for key, value in params.items():
|
|
216
|
+
if value is None:
|
|
217
|
+
continue
|
|
218
|
+
if isinstance(value, bool):
|
|
219
|
+
out[key] = "true" if value else "false"
|
|
220
|
+
else:
|
|
221
|
+
out[key] = str(value)
|
|
222
|
+
return out
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_json(response: httpx.Response) -> dict[str, Any]:
|
|
226
|
+
try:
|
|
227
|
+
data = response.json()
|
|
228
|
+
except json.JSONDecodeError as exc:
|
|
229
|
+
raise APIConnectionError(
|
|
230
|
+
f"Rephonic API returned non-JSON response (status {response.status_code})"
|
|
231
|
+
) from exc
|
|
232
|
+
if not isinstance(data, dict):
|
|
233
|
+
raise APIConnectionError(
|
|
234
|
+
f"Rephonic API returned unexpected JSON shape: {type(data).__name__}"
|
|
235
|
+
)
|
|
236
|
+
return data
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _build_status_error(response: httpx.Response) -> Exception:
|
|
240
|
+
body: dict[str, Any] | None = None
|
|
241
|
+
try:
|
|
242
|
+
parsed = response.json()
|
|
243
|
+
if isinstance(parsed, dict):
|
|
244
|
+
body = parsed
|
|
245
|
+
except json.JSONDecodeError:
|
|
246
|
+
pass
|
|
247
|
+
message = (body or {}).get("error") or response.text or response.reason_phrase
|
|
248
|
+
return error_for_status(response.status_code, message, body=body, response=response)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _should_retry_status(status: int) -> bool:
|
|
252
|
+
return status == 429 or status >= 500
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _backoff_delay(attempt: int) -> float:
|
|
256
|
+
return float(min(2**attempt, 10)) + random.uniform(0, 0.25)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _sleep_for_response(attempt: int, response: httpx.Response) -> float:
|
|
260
|
+
retry_after = response.headers.get("Retry-After")
|
|
261
|
+
if retry_after:
|
|
262
|
+
try:
|
|
263
|
+
return float(retry_after)
|
|
264
|
+
except ValueError:
|
|
265
|
+
pass
|
|
266
|
+
return _backoff_delay(attempt)
|
rephonic/_client.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""The top-level :class:`Rephonic` and :class:`AsyncRephonic` clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._base_client import (
|
|
11
|
+
DEFAULT_BASE_URL,
|
|
12
|
+
DEFAULT_MAX_RETRIES,
|
|
13
|
+
DEFAULT_TIMEOUT,
|
|
14
|
+
AsyncBaseClient,
|
|
15
|
+
BaseClient,
|
|
16
|
+
)
|
|
17
|
+
from .resources import (
|
|
18
|
+
Account,
|
|
19
|
+
AsyncAccount,
|
|
20
|
+
AsyncCharts,
|
|
21
|
+
AsyncCommon,
|
|
22
|
+
AsyncEpisodes,
|
|
23
|
+
AsyncPodcasts,
|
|
24
|
+
AsyncSearch,
|
|
25
|
+
Charts,
|
|
26
|
+
Common,
|
|
27
|
+
Episodes,
|
|
28
|
+
Podcasts,
|
|
29
|
+
Search,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
ENV_API_KEY = "REPHONIC_API_KEY"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_api_key(api_key: str | None) -> str:
|
|
36
|
+
resolved = api_key or os.environ.get(ENV_API_KEY)
|
|
37
|
+
if not resolved:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
"No API key provided. Pass api_key=... or set the REPHONIC_API_KEY "
|
|
40
|
+
"environment variable. Get one at https://rephonic.com/developers."
|
|
41
|
+
)
|
|
42
|
+
return resolved
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Rephonic:
|
|
46
|
+
"""Official Python client for the Rephonic podcast API.
|
|
47
|
+
|
|
48
|
+
Get an API key at https://rephonic.com/developers.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
api_key: Your Rephonic API key. Falls back to ``REPHONIC_API_KEY`` env.
|
|
52
|
+
base_url: Override the API base URL.
|
|
53
|
+
timeout: Request timeout in seconds.
|
|
54
|
+
max_retries: Retries on connection errors, 429s, and 5xxs (default 2).
|
|
55
|
+
http_client: Bring your own ``httpx.Client`` (for proxies, custom TLS).
|
|
56
|
+
|
|
57
|
+
Example::
|
|
58
|
+
|
|
59
|
+
from rephonic import Rephonic
|
|
60
|
+
|
|
61
|
+
client = Rephonic(api_key="...")
|
|
62
|
+
podcast = client.podcasts.get("huberman-lab")
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
api_key: str | None = None,
|
|
68
|
+
*,
|
|
69
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
70
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
71
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
72
|
+
http_client: httpx.Client | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self._base_client = BaseClient(
|
|
75
|
+
api_key=_resolve_api_key(api_key),
|
|
76
|
+
base_url=base_url,
|
|
77
|
+
timeout=timeout,
|
|
78
|
+
max_retries=max_retries,
|
|
79
|
+
http_client=http_client,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
self.search = Search(self._base_client)
|
|
83
|
+
self.podcasts = Podcasts(self._base_client)
|
|
84
|
+
self.episodes = Episodes(self._base_client)
|
|
85
|
+
self.charts = Charts(self._base_client)
|
|
86
|
+
self.common = Common(self._base_client)
|
|
87
|
+
self.account = Account(self._base_client)
|
|
88
|
+
|
|
89
|
+
def close(self) -> None:
|
|
90
|
+
"""Close the underlying HTTP client (if owned by this instance)."""
|
|
91
|
+
self._base_client.close()
|
|
92
|
+
|
|
93
|
+
def __enter__(self) -> Rephonic:
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
97
|
+
self.close()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AsyncRephonic:
|
|
101
|
+
"""Asynchronous client for the Rephonic podcast API.
|
|
102
|
+
|
|
103
|
+
Same surface as :class:`Rephonic`, but every resource method is a
|
|
104
|
+
coroutine. ``iter_*`` methods return async iterators.
|
|
105
|
+
|
|
106
|
+
Example::
|
|
107
|
+
|
|
108
|
+
from rephonic import AsyncRephonic
|
|
109
|
+
|
|
110
|
+
async with AsyncRephonic(api_key="...") as client:
|
|
111
|
+
podcast = await client.podcasts.get("huberman-lab")
|
|
112
|
+
async for p in client.search.iter_podcasts(query="ai", limit=100):
|
|
113
|
+
print(p["name"])
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
api_key: str | None = None,
|
|
119
|
+
*,
|
|
120
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
121
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
122
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
123
|
+
http_client: httpx.AsyncClient | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
self._base_client = AsyncBaseClient(
|
|
126
|
+
api_key=_resolve_api_key(api_key),
|
|
127
|
+
base_url=base_url,
|
|
128
|
+
timeout=timeout,
|
|
129
|
+
max_retries=max_retries,
|
|
130
|
+
http_client=http_client,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
self.search = AsyncSearch(self._base_client)
|
|
134
|
+
self.podcasts = AsyncPodcasts(self._base_client)
|
|
135
|
+
self.episodes = AsyncEpisodes(self._base_client)
|
|
136
|
+
self.charts = AsyncCharts(self._base_client)
|
|
137
|
+
self.common = AsyncCommon(self._base_client)
|
|
138
|
+
self.account = AsyncAccount(self._base_client)
|
|
139
|
+
|
|
140
|
+
async def aclose(self) -> None:
|
|
141
|
+
"""Close the underlying async HTTP client (if owned by this instance)."""
|
|
142
|
+
await self._base_client.aclose()
|
|
143
|
+
|
|
144
|
+
async def __aenter__(self) -> AsyncRephonic:
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
148
|
+
await self.aclose()
|
rephonic/_exceptions.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Exception classes raised by the Rephonic client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RephonicError(Exception):
|
|
7
|
+
"""Base class for all Rephonic client errors.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
message: Human-readable error description.
|
|
11
|
+
status_code: HTTP status code, or ``None`` for non-HTTP errors.
|
|
12
|
+
body: Parsed JSON body of the error response, if available.
|
|
13
|
+
response: The underlying ``httpx.Response`` object, if available.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
message: str,
|
|
19
|
+
*,
|
|
20
|
+
status_code: Optional[int] = None,
|
|
21
|
+
body: Optional[Dict[str, Any]] = None,
|
|
22
|
+
response: Any = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.message = message
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.body = body
|
|
28
|
+
self.response = response
|
|
29
|
+
|
|
30
|
+
def __str__(self) -> str:
|
|
31
|
+
if self.status_code is not None:
|
|
32
|
+
return f"[{self.status_code}] {self.message}"
|
|
33
|
+
return self.message
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class APIConnectionError(RephonicError):
|
|
37
|
+
"""Raised when the client cannot reach the Rephonic API (network error, DNS, timeout)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class APIStatusError(RephonicError):
|
|
41
|
+
"""Base class for errors returned by the API with a non-2xx status code."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BadRequestError(APIStatusError):
|
|
45
|
+
"""Raised for HTTP 400 responses. Usually indicates a malformed request or invalid parameter."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AuthenticationError(APIStatusError):
|
|
49
|
+
"""Raised for HTTP 401 responses. Indicates a missing or invalid API key."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PermissionDeniedError(APIStatusError):
|
|
53
|
+
"""Raised for HTTP 403 responses. The API key is valid but lacks access to the resource."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NotFoundError(APIStatusError):
|
|
57
|
+
"""Raised for HTTP 404 responses.
|
|
58
|
+
|
|
59
|
+
Note: the Rephonic API returns ``400 Bad Request`` (``BadRequestError``)
|
|
60
|
+
for missing resources, not 404 — this class is reserved for future use
|
|
61
|
+
and for explicitly catching any endpoint that might adopt 404 semantics.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RateLimitError(APIStatusError):
|
|
66
|
+
"""Raised for HTTP 429 responses. You have exceeded your request quota or rate limit."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class InternalServerError(APIStatusError):
|
|
70
|
+
"""Raised for HTTP 5xx responses. The Rephonic API is experiencing an internal error."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_STATUS_TO_ERROR = {
|
|
74
|
+
400: BadRequestError,
|
|
75
|
+
401: AuthenticationError,
|
|
76
|
+
403: PermissionDeniedError,
|
|
77
|
+
404: NotFoundError,
|
|
78
|
+
429: RateLimitError,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def error_for_status(
|
|
83
|
+
status_code: int,
|
|
84
|
+
message: str,
|
|
85
|
+
*,
|
|
86
|
+
body: Optional[Dict[str, Any]] = None,
|
|
87
|
+
response: Any = None,
|
|
88
|
+
) -> APIStatusError:
|
|
89
|
+
"""Map an HTTP status code to the most specific error subclass."""
|
|
90
|
+
cls = _STATUS_TO_ERROR.get(status_code)
|
|
91
|
+
if cls is None:
|
|
92
|
+
cls = InternalServerError if status_code >= 500 else APIStatusError
|
|
93
|
+
return cls(message, status_code=status_code, body=body, response=response)
|
rephonic/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
rephonic/pagination.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Pagination helpers — sync and async.
|
|
2
|
+
|
|
3
|
+
The Rephonic API paginates via ``page`` / ``per_page`` query parameters. Each
|
|
4
|
+
paginated endpoint returns items under a fixed key (e.g. ``podcasts``,
|
|
5
|
+
``episodes``, ``reviews``) and the helpers here provide iterators that
|
|
6
|
+
transparently fetch subsequent pages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
AsyncIterator,
|
|
14
|
+
Awaitable,
|
|
15
|
+
Callable,
|
|
16
|
+
Iterator,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _should_continue(response: dict[str, Any], num_items: int, per_page: int) -> bool:
|
|
21
|
+
"""Decide whether to request another page.
|
|
22
|
+
|
|
23
|
+
``more`` is authoritative when the endpoint returns it (search endpoints).
|
|
24
|
+
Otherwise fall back to the "short page means last page" heuristic.
|
|
25
|
+
"""
|
|
26
|
+
more = response.get("more")
|
|
27
|
+
if more is True:
|
|
28
|
+
return True
|
|
29
|
+
if more is False:
|
|
30
|
+
return False
|
|
31
|
+
return num_items >= per_page
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def iter_pages(
|
|
35
|
+
fetch_page: Callable[[int, int], dict[str, Any]],
|
|
36
|
+
*,
|
|
37
|
+
items_key: str,
|
|
38
|
+
per_page: int,
|
|
39
|
+
start_page: int = 1,
|
|
40
|
+
limit: int | None = None,
|
|
41
|
+
) -> Iterator[dict[str, Any]]:
|
|
42
|
+
"""Yield items one at a time across paginated responses.
|
|
43
|
+
|
|
44
|
+
Stops when the response's ``more`` field is ``False`` (or, for endpoints
|
|
45
|
+
that don't send it, when the page is shorter than ``per_page``), or when
|
|
46
|
+
``limit`` is reached.
|
|
47
|
+
"""
|
|
48
|
+
page = start_page
|
|
49
|
+
yielded = 0
|
|
50
|
+
while True:
|
|
51
|
+
response = fetch_page(page, per_page)
|
|
52
|
+
items = response.get(items_key) or []
|
|
53
|
+
for item in items:
|
|
54
|
+
if limit is not None and yielded >= limit:
|
|
55
|
+
return
|
|
56
|
+
yield item
|
|
57
|
+
yielded += 1
|
|
58
|
+
if not _should_continue(response, len(items), per_page):
|
|
59
|
+
return
|
|
60
|
+
page += 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def aiter_pages(
|
|
64
|
+
fetch_page: Callable[[int, int], Awaitable[dict[str, Any]]],
|
|
65
|
+
*,
|
|
66
|
+
items_key: str,
|
|
67
|
+
per_page: int,
|
|
68
|
+
start_page: int = 1,
|
|
69
|
+
limit: int | None = None,
|
|
70
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
71
|
+
"""Async counterpart to :func:`iter_pages`."""
|
|
72
|
+
page = start_page
|
|
73
|
+
yielded = 0
|
|
74
|
+
while True:
|
|
75
|
+
response = await fetch_page(page, per_page)
|
|
76
|
+
items = response.get(items_key) or []
|
|
77
|
+
for item in items:
|
|
78
|
+
if limit is not None and yielded >= limit:
|
|
79
|
+
return
|
|
80
|
+
yield item
|
|
81
|
+
yielded += 1
|
|
82
|
+
if not _should_continue(response, len(items), per_page):
|
|
83
|
+
return
|
|
84
|
+
page += 1
|
rephonic/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .account import Account, AsyncAccount
|
|
2
|
+
from .charts import AsyncCharts, Charts
|
|
3
|
+
from .common import AsyncCommon, Common
|
|
4
|
+
from .episodes import AsyncEpisodes, Episodes
|
|
5
|
+
from .podcasts import AsyncPodcasts, Podcasts
|
|
6
|
+
from .search import AsyncSearch, Search
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Account",
|
|
10
|
+
"AsyncAccount",
|
|
11
|
+
"Charts",
|
|
12
|
+
"AsyncCharts",
|
|
13
|
+
"Common",
|
|
14
|
+
"AsyncCommon",
|
|
15
|
+
"Episodes",
|
|
16
|
+
"AsyncEpisodes",
|
|
17
|
+
"Podcasts",
|
|
18
|
+
"AsyncPodcasts",
|
|
19
|
+
"Search",
|
|
20
|
+
"AsyncSearch",
|
|
21
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Account endpoints: quota and usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .._base_client import AsyncBaseClient, BaseClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Account:
|
|
11
|
+
"""Account-level endpoints."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, client: BaseClient) -> None:
|
|
14
|
+
self._client = client
|
|
15
|
+
|
|
16
|
+
def quota(self) -> dict[str, Any]:
|
|
17
|
+
"""API request quota and usage for the current month."""
|
|
18
|
+
return self._client.get("/api/accounts/quota/")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsyncAccount:
|
|
22
|
+
"""Async counterpart to :class:`Account`."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, client: AsyncBaseClient) -> None:
|
|
25
|
+
self._client = client
|
|
26
|
+
|
|
27
|
+
async def quota(self) -> dict[str, Any]:
|
|
28
|
+
return await self._client.get("/api/accounts/quota/")
|