podengine 0.1.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.
podengine/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Pod Engine — official Python SDK.
2
+
3
+ Example:
4
+ from podengine import PodEngine
5
+
6
+ pe = PodEngine(api_key="...")
7
+ chart = pe.charts.get_latest_chart(chart_type="apple", country="us", category="top podcasts")
8
+
9
+ # Async:
10
+ from podengine import AsyncPodEngine
11
+
12
+ pe = AsyncPodEngine(api_key="...")
13
+ chart = await pe.charts.get_latest_chart(chart_type="apple", country="us", category="top podcasts")
14
+ """
15
+
16
+ from podengine._core._client import RequestOptions
17
+ from podengine._core.errors import (
18
+ PodEngineAPIError,
19
+ PodEngineConnectionError,
20
+ PodEngineError,
21
+ )
22
+ from podengine._generated import models
23
+ from podengine._generated.resources import AsyncPodEngine, PodEngine
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "PodEngine",
29
+ "AsyncPodEngine",
30
+ "RequestOptions",
31
+ "PodEngineError",
32
+ "PodEngineAPIError",
33
+ "PodEngineConnectionError",
34
+ "models",
35
+ "__version__",
36
+ ]
@@ -0,0 +1,5 @@
1
+ """Hand-written transport core. Stable across regenerations of the typed client."""
2
+
3
+ from .errors import PodEngineAPIError, PodEngineConnectionError, PodEngineError
4
+
5
+ __all__ = ["PodEngineError", "PodEngineAPIError", "PodEngineConnectionError"]
@@ -0,0 +1,349 @@
1
+ """Transport core for the Pod Engine SDK (sync + async).
2
+
3
+ The generated resource methods are thin wrappers that hand a static
4
+ :class:`EndpointDescriptor` plus the caller's params to ``request``. Every HTTP concern —
5
+ auth, URL building, query/body serialization, retries, error normalization and envelope
6
+ unwrapping — lives here. The generated layer above turns the unwrapped payload into typed
7
+ pydantic models. This module has no internal/monorepo imports so the package publishes and
8
+ mirrors standalone.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import os
16
+ import random
17
+ import time
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+ from urllib.parse import quote
21
+
22
+ import httpx
23
+
24
+ from .errors import PodEngineAPIError, PodEngineConnectionError
25
+ from .transform import build_query_params, extract_error_message, to_jsonable
26
+
27
+ DEFAULT_BASE_URL = "https://api.podengine.ai"
28
+ DEFAULT_SOURCE = "api"
29
+ DEFAULT_MAX_RETRIES = 2
30
+ DEFAULT_TIMEOUT = 60.0
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class EndpointDescriptor:
35
+ """Static metadata the generator emits for each endpoint."""
36
+
37
+ method: str
38
+ #: Path template with ``{param}`` placeholders, e.g. ``/api/v1/episodes/{episodeId}/details``.
39
+ path: str
40
+ #: Names of params that fill ``{...}`` placeholders in the path (wire names).
41
+ path_params: tuple[str, ...]
42
+ #: Names of params serialized into the query string (wire names).
43
+ query_params: tuple[str, ...]
44
+ #: How the JSON body is assembled: ``"none"`` | ``"merge"`` (object body) | ``"field"`` (array/primitive body).
45
+ body: str
46
+ #: Whether the success response is a binary download (returned as ``bytes``).
47
+ binary: bool
48
+
49
+
50
+ @dataclass
51
+ class RequestOptions:
52
+ """Per-call overrides, merged over the client defaults for a single request."""
53
+
54
+ timeout: float | None = None
55
+ max_retries: int | None = None
56
+ headers: dict[str, str] | None = None
57
+
58
+
59
+ @dataclass
60
+ class _Prepared:
61
+ method: str
62
+ url: str
63
+ params: list[tuple[str, str]]
64
+ json_body: Any
65
+ has_body: bool
66
+ headers: dict[str, str]
67
+ binary: bool
68
+
69
+
70
+ def _is_retriable_status(status: int) -> bool:
71
+ return status == 429 or status == 408 or status >= 500
72
+
73
+
74
+ def _backoff_seconds(attempt: int) -> float:
75
+ # Exponential backoff with jitter, capped at 8s (mirrors the TS SDK).
76
+ base = min(8.0, 0.25 * (2 ** (attempt - 1)))
77
+ return base + base * 0.25 * random.random()
78
+
79
+
80
+ def _retry_after_seconds(response: httpx.Response) -> float | None:
81
+ header = response.headers.get("retry-after")
82
+ if not header:
83
+ return None
84
+ try:
85
+ return float(header)
86
+ except ValueError:
87
+ from email.utils import parsedate_to_datetime
88
+
89
+ try:
90
+ when = parsedate_to_datetime(header)
91
+ except (TypeError, ValueError):
92
+ return None
93
+ if when is None:
94
+ return None
95
+ import datetime as _dt
96
+
97
+ now = _dt.datetime.now(tz=when.tzinfo)
98
+ return max(0.0, (when - now).total_seconds())
99
+
100
+
101
+ class _BaseCore:
102
+ """Configuration + the pure request/response plumbing shared by both transports."""
103
+
104
+ def __init__(
105
+ self,
106
+ *,
107
+ api_key: str,
108
+ base_url: str | None = None,
109
+ source: str = DEFAULT_SOURCE,
110
+ headers: dict[str, str] | None = None,
111
+ max_retries: int = DEFAULT_MAX_RETRIES,
112
+ timeout: float | None = DEFAULT_TIMEOUT,
113
+ ) -> None:
114
+ if not api_key:
115
+ raise PodEngineAPIError(
116
+ message="A Pod Engine `api_key` is required. Get one at https://www.podengine.ai/get-started.",
117
+ status=0,
118
+ method="CONFIG",
119
+ url="",
120
+ )
121
+ self._api_key = api_key
122
+ resolved_base = base_url or os.environ.get("PODENGINE_API_URL") or DEFAULT_BASE_URL
123
+ self._base_url = resolved_base.rstrip("/")
124
+ self._source = source
125
+ self._headers = dict(headers or {})
126
+ self._max_retries = max_retries
127
+ self._timeout = timeout
128
+
129
+ def _prepare(
130
+ self,
131
+ descriptor: EndpointDescriptor,
132
+ params: dict[str, Any] | None,
133
+ options: RequestOptions | None,
134
+ ) -> _Prepared:
135
+ all_params = params or {}
136
+ path_param_set = set(descriptor.path_params)
137
+ query_param_set = set(descriptor.query_params)
138
+
139
+ path = descriptor.path
140
+ for name in descriptor.path_params:
141
+ value = all_params.get(name)
142
+ if value is None:
143
+ raise PodEngineAPIError(
144
+ message=(f'Missing required path parameter "{name}" for {descriptor.method} {descriptor.path}.'),
145
+ status=0,
146
+ method=descriptor.method,
147
+ url=self._base_url + path,
148
+ )
149
+ path = path.replace("{" + name + "}", quote(str(value), safe=""))
150
+
151
+ query: dict[str, Any] = {}
152
+ for name in descriptor.query_params:
153
+ if all_params.get(name) is not None:
154
+ query[name] = all_params[name]
155
+ query_items = build_query_params(query)
156
+
157
+ has_body = descriptor.body != "none"
158
+ json_body: Any = None
159
+ if descriptor.body == "merge":
160
+ json_body = to_jsonable(
161
+ {
162
+ key: value
163
+ for key, value in all_params.items()
164
+ if key not in path_param_set and key not in query_param_set
165
+ }
166
+ )
167
+ elif descriptor.body == "field":
168
+ json_body = to_jsonable(all_params.get("body"))
169
+
170
+ headers = {
171
+ "Authorization": self._api_key,
172
+ "x-source": self._source,
173
+ **self._headers,
174
+ **((options.headers if options else None) or {}),
175
+ }
176
+ if has_body:
177
+ headers["Content-Type"] = "application/json"
178
+
179
+ return _Prepared(
180
+ method=descriptor.method,
181
+ url=f"{self._base_url}{path}",
182
+ params=query_items,
183
+ json_body=json_body,
184
+ has_body=has_body,
185
+ headers=headers,
186
+ binary=descriptor.binary,
187
+ )
188
+
189
+ def _decode_success(self, response: httpx.Response, prepared: _Prepared) -> Any:
190
+ if prepared.binary:
191
+ return response.content
192
+ if response.status_code == 204 or not response.content:
193
+ return None
194
+ text = response.text
195
+ try:
196
+ payload = json.loads(text)
197
+ except json.JSONDecodeError as err:
198
+ raise PodEngineAPIError(
199
+ message=(
200
+ f"Expected a JSON response but the body could not be parsed "
201
+ f"(status {response.status_code}): {text[:200]}"
202
+ ),
203
+ status=response.status_code,
204
+ method=prepared.method,
205
+ url=prepared.url,
206
+ body=text,
207
+ request_id=response.headers.get("x-request-id"),
208
+ ) from err
209
+ # Unwrap the ``{ status, data }`` envelope the API uses for JSON responses.
210
+ if isinstance(payload, dict) and "data" in payload:
211
+ return payload["data"]
212
+ return payload
213
+
214
+ def _api_error(self, response: httpx.Response, prepared: _Prepared) -> PodEngineAPIError:
215
+ raw = response.text
216
+ body: Any = None
217
+ if raw:
218
+ try:
219
+ body = json.loads(raw)
220
+ except json.JSONDecodeError:
221
+ body = raw
222
+ return PodEngineAPIError(
223
+ message=extract_error_message(body, f"Request failed with status {response.status_code}"),
224
+ status=response.status_code,
225
+ method=prepared.method,
226
+ url=prepared.url,
227
+ body=body,
228
+ request_id=response.headers.get("x-request-id"),
229
+ )
230
+
231
+ def _request_kwargs(self, prepared: _Prepared, timeout: float | None) -> dict[str, Any]:
232
+ kwargs: dict[str, Any] = {
233
+ "method": prepared.method,
234
+ "url": prepared.url,
235
+ "params": prepared.params,
236
+ "headers": prepared.headers,
237
+ "timeout": timeout,
238
+ }
239
+ if prepared.has_body:
240
+ kwargs["json"] = prepared.json_body
241
+ return kwargs
242
+
243
+
244
+ class PodEngineCore(_BaseCore):
245
+ """Synchronous transport backed by ``httpx.Client``."""
246
+
247
+ def __init__(self, *, http_client: httpx.Client | None = None, **kwargs: Any) -> None:
248
+ super().__init__(**kwargs)
249
+ self._client = http_client or httpx.Client()
250
+ self._owns_client = http_client is None
251
+
252
+ def request(
253
+ self,
254
+ descriptor: EndpointDescriptor,
255
+ params: dict[str, Any] | None = None,
256
+ options: RequestOptions | None = None,
257
+ ) -> Any:
258
+ prepared = self._prepare(descriptor, params, options)
259
+ max_retries = options.max_retries if options and options.max_retries is not None else self._max_retries
260
+ timeout = options.timeout if options and options.timeout is not None else self._timeout
261
+
262
+ attempt = 0
263
+ while True:
264
+ try:
265
+ response = self._client.request(**self._request_kwargs(prepared, timeout))
266
+ except httpx.TransportError as err:
267
+ if attempt < max_retries:
268
+ attempt += 1
269
+ time.sleep(_backoff_seconds(attempt))
270
+ continue
271
+ raise PodEngineConnectionError(
272
+ message=f"Unable to reach the Pod Engine API at {prepared.url}. {err}",
273
+ method=prepared.method,
274
+ url=prepared.url,
275
+ cause=err,
276
+ ) from err
277
+
278
+ if not response.is_success:
279
+ if _is_retriable_status(response.status_code) and attempt < max_retries:
280
+ attempt += 1
281
+ time.sleep(_retry_after_seconds(response) or _backoff_seconds(attempt))
282
+ continue
283
+ raise self._api_error(response, prepared)
284
+
285
+ return self._decode_success(response, prepared)
286
+
287
+ def close(self) -> None:
288
+ if self._owns_client:
289
+ self._client.close()
290
+
291
+ def __enter__(self) -> PodEngineCore:
292
+ return self
293
+
294
+ def __exit__(self, *exc: object) -> None:
295
+ self.close()
296
+
297
+
298
+ class AsyncPodEngineCore(_BaseCore):
299
+ """Asynchronous transport backed by ``httpx.AsyncClient``."""
300
+
301
+ def __init__(self, *, http_client: httpx.AsyncClient | None = None, **kwargs: Any) -> None:
302
+ super().__init__(**kwargs)
303
+ self._client = http_client or httpx.AsyncClient()
304
+ self._owns_client = http_client is None
305
+
306
+ async def request(
307
+ self,
308
+ descriptor: EndpointDescriptor,
309
+ params: dict[str, Any] | None = None,
310
+ options: RequestOptions | None = None,
311
+ ) -> Any:
312
+ prepared = self._prepare(descriptor, params, options)
313
+ max_retries = options.max_retries if options and options.max_retries is not None else self._max_retries
314
+ timeout = options.timeout if options and options.timeout is not None else self._timeout
315
+
316
+ attempt = 0
317
+ while True:
318
+ try:
319
+ response = await self._client.request(**self._request_kwargs(prepared, timeout))
320
+ except httpx.TransportError as err:
321
+ if attempt < max_retries:
322
+ attempt += 1
323
+ await asyncio.sleep(_backoff_seconds(attempt))
324
+ continue
325
+ raise PodEngineConnectionError(
326
+ message=f"Unable to reach the Pod Engine API at {prepared.url}. {err}",
327
+ method=prepared.method,
328
+ url=prepared.url,
329
+ cause=err,
330
+ ) from err
331
+
332
+ if not response.is_success:
333
+ if _is_retriable_status(response.status_code) and attempt < max_retries:
334
+ attempt += 1
335
+ await asyncio.sleep(_retry_after_seconds(response) or _backoff_seconds(attempt))
336
+ continue
337
+ raise self._api_error(response, prepared)
338
+
339
+ return self._decode_success(response, prepared)
340
+
341
+ async def aclose(self) -> None:
342
+ if self._owns_client:
343
+ await self._client.aclose()
344
+
345
+ async def __aenter__(self) -> AsyncPodEngineCore:
346
+ return self
347
+
348
+ async def __aexit__(self, *exc: object) -> None:
349
+ await self.aclose()
@@ -0,0 +1,62 @@
1
+ """Error types raised by the Pod Engine SDK.
2
+
3
+ Every failure surfaces as a :class:`PodEngineError` subclass so consumers can ``except``
4
+ a single base type and narrow with ``isinstance`` when they care about the specifics.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ class PodEngineError(Exception):
13
+ """Base class for every error raised by the SDK."""
14
+
15
+
16
+ class PodEngineAPIError(PodEngineError):
17
+ """The API returned a non-2xx response.
18
+
19
+ Carries the HTTP status, the request URL/method, and a best-effort parsed error
20
+ message / raw body for debugging.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ *,
26
+ message: str,
27
+ status: int,
28
+ method: str,
29
+ url: str,
30
+ body: Any = None,
31
+ request_id: str | None = None,
32
+ ) -> None:
33
+ super().__init__(message)
34
+ self.message = message
35
+ self.status = status
36
+ self.method = method
37
+ self.url = url
38
+ #: Raw parsed response body, when available (decoded JSON or text).
39
+ self.body = body
40
+ #: Value of the ``x-request-id`` response header, when present.
41
+ self.request_id = request_id
42
+
43
+
44
+ class PodEngineConnectionError(PodEngineError):
45
+ """The request never produced an HTTP response.
46
+
47
+ DNS failure, connection refused, or a timeout — there is no status code.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ *,
53
+ message: str,
54
+ method: str,
55
+ url: str,
56
+ cause: BaseException | None = None,
57
+ ) -> None:
58
+ super().__init__(message)
59
+ self.message = message
60
+ self.method = method
61
+ self.url = url
62
+ self.cause = cause
@@ -0,0 +1,71 @@
1
+ """Pure request/response helpers shared by the sync and async transports.
2
+
3
+ No I/O, no network — just turning Python call values into the JSON/query shapes the API
4
+ expects. Date/UUID/model handling is delegated to ``pydantic_core.to_jsonable_python`` so a
5
+ caller can pass ``datetime`` objects, generated pydantic models, or plain dicts/lists
6
+ interchangeably.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from pydantic_core import to_jsonable_python
14
+
15
+
16
+ def to_jsonable(value: Any) -> Any:
17
+ """Convert an arbitrary call value (pydantic model, datetime, dict, ...) to a
18
+ JSON-serializable structure, emitting model fields under their wire aliases."""
19
+ return to_jsonable_python(value, by_alias=True, exclude_none=False)
20
+
21
+
22
+ def _stringify_query_value(value: Any) -> str:
23
+ if isinstance(value, bool):
24
+ # Match the wire convention used by the TypeScript SDK (lowercase booleans).
25
+ return "true" if value else "false"
26
+ return str(value)
27
+
28
+
29
+ def build_query_params(query: dict[str, Any]) -> list[tuple[str, str]]:
30
+ """Serialize a flat mapping into repeated query items (``?id=a&id=b``).
31
+
32
+ ``None`` entries are skipped, arrays are repeated, and ``datetime``/``UUID`` values are
33
+ rendered via their JSON form (ISO-8601 / canonical string).
34
+ """
35
+ items: list[tuple[str, str]] = []
36
+ for key, raw in query.items():
37
+ if raw is None:
38
+ continue
39
+ value = to_jsonable(raw)
40
+ if isinstance(value, (list, tuple)):
41
+ for item in value:
42
+ if item is None:
43
+ continue
44
+ items.append((key, _stringify_query_value(item)))
45
+ continue
46
+ items.append((key, _stringify_query_value(value)))
47
+ return items
48
+
49
+
50
+ def extract_error_message(body: Any, fallback: str) -> str:
51
+ """Pull the most useful human-readable message out of the various error envelope
52
+ shapes the API may return, falling back to the raw text / status."""
53
+ if isinstance(body, str):
54
+ return body or fallback
55
+ if isinstance(body, dict):
56
+ data = body.get("data") if isinstance(body.get("data"), dict) else None
57
+
58
+ def pick(key: str) -> Any:
59
+ if data is not None and data.get(key) is not None:
60
+ return data.get(key)
61
+ return body.get(key)
62
+
63
+ for key in ("message", "error", "errorMessage", "errorDetails"):
64
+ candidate = pick(key)
65
+ if isinstance(candidate, str) and candidate:
66
+ return candidate
67
+ if candidate is not None:
68
+ import json
69
+
70
+ return json.dumps(candidate)
71
+ return fallback
@@ -0,0 +1 @@
1
+ """Auto-generated, spec-derived layer. Regenerate with scripts/generate-python-client.ts."""