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 +36 -0
- podengine/_core/__init__.py +5 -0
- podengine/_core/_client.py +349 -0
- podengine/_core/errors.py +62 -0
- podengine/_core/transform.py +71 -0
- podengine/_generated/__init__.py +1 -0
- podengine/_generated/models.py +2461 -0
- podengine/_generated/resources.py +4040 -0
- podengine/py.typed +0 -0
- podengine-0.1.0.dist-info/METADATA +128 -0
- podengine-0.1.0.dist-info/RECORD +13 -0
- podengine-0.1.0.dist-info/WHEEL +4 -0
- podengine-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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."""
|