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 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
+ ]
@@ -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()
@@ -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/")