capturekitdev 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.
- capturekit/__init__.py +61 -0
- capturekit/_endpoints.py +62 -0
- capturekit/_operations.py +791 -0
- capturekit/_response.py +82 -0
- capturekit/_serialization.py +109 -0
- capturekit/_version.py +1 -0
- capturekit/client.py +533 -0
- capturekit/errors.py +85 -0
- capturekit/models.py +81 -0
- capturekit/py.typed +1 -0
- capturekitdev-0.1.0.dist-info/METADATA +215 -0
- capturekitdev-0.1.0.dist-info/RECORD +14 -0
- capturekitdev-0.1.0.dist-info/WHEEL +4 -0
- capturekitdev-0.1.0.dist-info/licenses/LICENSE +21 -0
capturekit/client.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""Blocking and async-I/O CaptureKit clients backed by HTTPX."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
9
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, Literal, Self, TypeVar
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._endpoints import ENDPOINTS
|
|
18
|
+
from ._operations import _AsyncOperations, _SyncOperations
|
|
19
|
+
from ._response import create_api_error, create_raw_response, decode_success_response
|
|
20
|
+
from ._serialization import encode_json_body, encode_query, serialize_request
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
from .errors import (
|
|
23
|
+
CaptureKitConfigurationError,
|
|
24
|
+
CaptureKitJobTimeoutError,
|
|
25
|
+
CaptureKitSerializationError,
|
|
26
|
+
CaptureKitTransportError,
|
|
27
|
+
)
|
|
28
|
+
from .models import APIResponse, BatchResult, HTTPMethod, JsonResponse, RawResponse, RequestOptions
|
|
29
|
+
|
|
30
|
+
_I = TypeVar("_I")
|
|
31
|
+
_T = TypeVar("_T")
|
|
32
|
+
|
|
33
|
+
DEFAULT_BASE_URL = "https://api.capturekit.dev"
|
|
34
|
+
DEFAULT_TIMEOUT = 120.0
|
|
35
|
+
DEFAULT_CONCURRENCY = 10
|
|
36
|
+
"""Tasks run at the same time by ``batch()``."""
|
|
37
|
+
MAX_RETRIES = 3
|
|
38
|
+
"""Retries after a network error or a 408, 429, 500, 502, 503 or 504 response."""
|
|
39
|
+
RETRY_BACKOFF_FACTOR = 1.5
|
|
40
|
+
"""Each retry waits this many times longer than the previous one (a Retry-After header wins)."""
|
|
41
|
+
DEFAULT_RETRY_DELAY = 1.0
|
|
42
|
+
"""Seconds before the first retry."""
|
|
43
|
+
MAX_RETRY_DELAY = 30.0
|
|
44
|
+
RETRYABLE_STATUSES = frozenset({408, 500, 502, 503, 504})
|
|
45
|
+
API_KEY_ENVIRONMENT_VARIABLE = "CAPTUREKIT_API_KEY"
|
|
46
|
+
JOB_TERMINAL_STATUSES = frozenset({"CompletedJob", "Error", "NotFound", "BadRequest"})
|
|
47
|
+
MAX_POLL_INTERVAL = 60.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class _Configuration:
|
|
52
|
+
api_key: str
|
|
53
|
+
base_url: str
|
|
54
|
+
timeout: float | None
|
|
55
|
+
headers: Mapping[str, str]
|
|
56
|
+
max_retries: int
|
|
57
|
+
retry_delay: float
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class _PreparedRequest:
|
|
62
|
+
method: HTTPMethod
|
|
63
|
+
url: str
|
|
64
|
+
headers: dict[str, str]
|
|
65
|
+
content: bytes | None
|
|
66
|
+
timeout: float | None
|
|
67
|
+
expected: Literal["json", "any"]
|
|
68
|
+
max_retries: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CaptureKitClient(_SyncOperations):
|
|
72
|
+
"""Reusable blocking CaptureKit client. Use it as a context manager or call ``close()``."""
|
|
73
|
+
|
|
74
|
+
__slots__ = ("_closed", "_configuration", "_http_client", "_owns_http_client")
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
api_key: str | None = None,
|
|
79
|
+
*,
|
|
80
|
+
base_url: str | None = None,
|
|
81
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
82
|
+
headers: Mapping[str, str] | None = None,
|
|
83
|
+
max_retries: int = MAX_RETRIES,
|
|
84
|
+
retry_delay: float = DEFAULT_RETRY_DELAY,
|
|
85
|
+
http_client: httpx.Client | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self._configuration = _build_configuration(
|
|
88
|
+
api_key, base_url, timeout, headers, max_retries, retry_delay
|
|
89
|
+
)
|
|
90
|
+
if http_client is not None and not isinstance(http_client, httpx.Client):
|
|
91
|
+
raise CaptureKitConfigurationError("http_client must be an httpx.Client when provided.")
|
|
92
|
+
self._owns_http_client = http_client is None
|
|
93
|
+
self._http_client = http_client or httpx.Client(follow_redirects=False)
|
|
94
|
+
self._closed = False
|
|
95
|
+
|
|
96
|
+
def request(
|
|
97
|
+
self,
|
|
98
|
+
method: HTTPMethod,
|
|
99
|
+
path: str,
|
|
100
|
+
params: Mapping[str, Any] | None = None,
|
|
101
|
+
*,
|
|
102
|
+
timeout: float | None = None,
|
|
103
|
+
headers: Mapping[str, str] | None = None,
|
|
104
|
+
max_retries: int | None = None,
|
|
105
|
+
) -> APIResponse:
|
|
106
|
+
"""Send any request to the CaptureKit API, including endpoints newer than this SDK."""
|
|
107
|
+
|
|
108
|
+
options = RequestOptions(
|
|
109
|
+
method=method, timeout=timeout, headers=headers, max_retries=max_retries
|
|
110
|
+
)
|
|
111
|
+
return self._send(_prepare(self._configuration, method, path, params, options, "any"))
|
|
112
|
+
|
|
113
|
+
def batch(
|
|
114
|
+
self,
|
|
115
|
+
items: Iterable[_I],
|
|
116
|
+
task: Callable[[_I], _T],
|
|
117
|
+
*,
|
|
118
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
119
|
+
) -> list[BatchResult[_T]]:
|
|
120
|
+
"""Run ``task`` for every item on ``concurrency`` threads (default 10).
|
|
121
|
+
|
|
122
|
+
Returns one :class:`BatchResult` per item, in input order; a failing item never stops the others.
|
|
123
|
+
The client is thread-safe, so ``task`` can call its methods directly.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
_check_concurrency(concurrency)
|
|
127
|
+
values = list(items)
|
|
128
|
+
if not values:
|
|
129
|
+
return []
|
|
130
|
+
with ThreadPoolExecutor(max_workers=min(concurrency, len(values))) as pool:
|
|
131
|
+
futures = [pool.submit(task, value) for value in values]
|
|
132
|
+
return [_settle(index, future) for index, future in enumerate(futures)]
|
|
133
|
+
|
|
134
|
+
def wait_for_job(
|
|
135
|
+
self,
|
|
136
|
+
job_id: str,
|
|
137
|
+
*,
|
|
138
|
+
interval: float = 3.0,
|
|
139
|
+
timeout: float = 43_200.0,
|
|
140
|
+
on_progress: Callable[[Any], None] | None = None,
|
|
141
|
+
) -> JsonResponse:
|
|
142
|
+
"""Poll ``GET /v1/jobs/{id}`` until the job reaches a terminal status.
|
|
143
|
+
|
|
144
|
+
Polls after ``interval`` seconds, then 1.5x longer each time up to 60 s. Raises
|
|
145
|
+
``CaptureKitJobTimeoutError`` after ``timeout`` seconds (default 12 hours).
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
_check_job_id(job_id)
|
|
149
|
+
started = time.monotonic()
|
|
150
|
+
while True:
|
|
151
|
+
last = self.request("GET", "/v1/jobs/{id}", {"id": job_id})
|
|
152
|
+
if not isinstance(last, JsonResponse):
|
|
153
|
+
raise CaptureKitTransportError("The job endpoint did not return JSON.", TypeError())
|
|
154
|
+
if on_progress is not None:
|
|
155
|
+
on_progress(last.data)
|
|
156
|
+
if _is_terminal(last.data):
|
|
157
|
+
return last
|
|
158
|
+
if time.monotonic() - started + interval > timeout:
|
|
159
|
+
raise CaptureKitJobTimeoutError(job_id, last)
|
|
160
|
+
time.sleep(interval)
|
|
161
|
+
interval = _next_interval(interval)
|
|
162
|
+
|
|
163
|
+
def close(self) -> None:
|
|
164
|
+
"""Close transport resources owned by this client."""
|
|
165
|
+
|
|
166
|
+
if self._closed:
|
|
167
|
+
return
|
|
168
|
+
try:
|
|
169
|
+
if self._owns_http_client:
|
|
170
|
+
self._http_client.close()
|
|
171
|
+
finally:
|
|
172
|
+
self._closed = True
|
|
173
|
+
|
|
174
|
+
def __enter__(self) -> Self:
|
|
175
|
+
self._ensure_open()
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
def __exit__(
|
|
179
|
+
self,
|
|
180
|
+
exc_type: type[BaseException] | None,
|
|
181
|
+
exc_value: BaseException | None,
|
|
182
|
+
traceback: TracebackType | None,
|
|
183
|
+
) -> None:
|
|
184
|
+
self.close()
|
|
185
|
+
|
|
186
|
+
def _call(
|
|
187
|
+
self, name: str, params: dict[str, Any], options: RequestOptions | None
|
|
188
|
+
) -> APIResponse:
|
|
189
|
+
return self._send(_prepare_operation(self._configuration, name, params, options))
|
|
190
|
+
|
|
191
|
+
def _send(self, prepared: _PreparedRequest) -> APIResponse:
|
|
192
|
+
self._ensure_open()
|
|
193
|
+
attempt = 0
|
|
194
|
+
while True:
|
|
195
|
+
try:
|
|
196
|
+
response = self._http_client.request(
|
|
197
|
+
prepared.method,
|
|
198
|
+
prepared.url,
|
|
199
|
+
headers=prepared.headers,
|
|
200
|
+
content=prepared.content,
|
|
201
|
+
timeout=prepared.timeout,
|
|
202
|
+
follow_redirects=False,
|
|
203
|
+
)
|
|
204
|
+
body = response.read()
|
|
205
|
+
except httpx.TimeoutException as cause:
|
|
206
|
+
raise CaptureKitTransportError("CaptureKit request timed out.", cause) from cause
|
|
207
|
+
except Exception as cause:
|
|
208
|
+
if attempt < prepared.max_retries:
|
|
209
|
+
time.sleep(_retry_delay(self._configuration.retry_delay, attempt, None))
|
|
210
|
+
attempt += 1
|
|
211
|
+
continue
|
|
212
|
+
raise CaptureKitTransportError(
|
|
213
|
+
"CaptureKit request failed before an HTTP response body was received.", cause
|
|
214
|
+
) from cause
|
|
215
|
+
raw_response = create_raw_response(response, body)
|
|
216
|
+
if _is_retryable(raw_response) and attempt < prepared.max_retries:
|
|
217
|
+
retry_after = (raw_response.headers.get("retry-after") or (None,))[0]
|
|
218
|
+
time.sleep(_retry_delay(self._configuration.retry_delay, attempt, retry_after))
|
|
219
|
+
attempt += 1
|
|
220
|
+
continue
|
|
221
|
+
return _decode(raw_response, prepared)
|
|
222
|
+
|
|
223
|
+
def _ensure_open(self) -> None:
|
|
224
|
+
if self._closed:
|
|
225
|
+
raise CaptureKitConfigurationError("This CaptureKitClient is closed.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class AsyncCaptureKitClient(_AsyncOperations):
|
|
229
|
+
"""Reusable async-I/O CaptureKit client. Use it as an async context manager or await ``aclose()``."""
|
|
230
|
+
|
|
231
|
+
__slots__ = ("_closed", "_configuration", "_http_client", "_owns_http_client")
|
|
232
|
+
|
|
233
|
+
def __init__(
|
|
234
|
+
self,
|
|
235
|
+
api_key: str | None = None,
|
|
236
|
+
*,
|
|
237
|
+
base_url: str | None = None,
|
|
238
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
239
|
+
headers: Mapping[str, str] | None = None,
|
|
240
|
+
max_retries: int = MAX_RETRIES,
|
|
241
|
+
retry_delay: float = DEFAULT_RETRY_DELAY,
|
|
242
|
+
http_client: httpx.AsyncClient | None = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
self._configuration = _build_configuration(
|
|
245
|
+
api_key, base_url, timeout, headers, max_retries, retry_delay
|
|
246
|
+
)
|
|
247
|
+
if http_client is not None and not isinstance(http_client, httpx.AsyncClient):
|
|
248
|
+
raise CaptureKitConfigurationError(
|
|
249
|
+
"http_client must be an httpx.AsyncClient when provided."
|
|
250
|
+
)
|
|
251
|
+
self._owns_http_client = http_client is None
|
|
252
|
+
self._http_client = http_client or httpx.AsyncClient(follow_redirects=False)
|
|
253
|
+
self._closed = False
|
|
254
|
+
|
|
255
|
+
async def request(
|
|
256
|
+
self,
|
|
257
|
+
method: HTTPMethod,
|
|
258
|
+
path: str,
|
|
259
|
+
params: Mapping[str, Any] | None = None,
|
|
260
|
+
*,
|
|
261
|
+
timeout: float | None = None,
|
|
262
|
+
headers: Mapping[str, str] | None = None,
|
|
263
|
+
max_retries: int | None = None,
|
|
264
|
+
) -> APIResponse:
|
|
265
|
+
"""Send any request to the CaptureKit API, including endpoints newer than this SDK."""
|
|
266
|
+
|
|
267
|
+
options = RequestOptions(
|
|
268
|
+
method=method, timeout=timeout, headers=headers, max_retries=max_retries
|
|
269
|
+
)
|
|
270
|
+
return await self._send(_prepare(self._configuration, method, path, params, options, "any"))
|
|
271
|
+
|
|
272
|
+
async def batch(
|
|
273
|
+
self,
|
|
274
|
+
items: Iterable[_I],
|
|
275
|
+
task: Callable[[_I], Awaitable[_T]],
|
|
276
|
+
*,
|
|
277
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
278
|
+
) -> list[BatchResult[_T]]:
|
|
279
|
+
"""Await ``task`` for every item with at most ``concurrency`` in flight (default 10).
|
|
280
|
+
|
|
281
|
+
Returns one :class:`BatchResult` per item, in input order; a failing item never stops the others.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
_check_concurrency(concurrency)
|
|
285
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
286
|
+
|
|
287
|
+
async def run(index: int, value: _I) -> BatchResult[_T]:
|
|
288
|
+
async with semaphore:
|
|
289
|
+
try:
|
|
290
|
+
return BatchResult(index=index, value=await task(value))
|
|
291
|
+
except Exception as error:
|
|
292
|
+
return BatchResult(index=index, error=error)
|
|
293
|
+
|
|
294
|
+
return list(await asyncio.gather(*(run(i, v) for i, v in enumerate(items))))
|
|
295
|
+
|
|
296
|
+
async def wait_for_job(
|
|
297
|
+
self,
|
|
298
|
+
job_id: str,
|
|
299
|
+
*,
|
|
300
|
+
interval: float = 3.0,
|
|
301
|
+
timeout: float = 43_200.0,
|
|
302
|
+
on_progress: Callable[[Any], None] | None = None,
|
|
303
|
+
) -> JsonResponse:
|
|
304
|
+
"""Poll ``GET /v1/jobs/{id}`` until the job reaches a terminal status."""
|
|
305
|
+
|
|
306
|
+
_check_job_id(job_id)
|
|
307
|
+
started = time.monotonic()
|
|
308
|
+
while True:
|
|
309
|
+
last = await self.request("GET", "/v1/jobs/{id}", {"id": job_id})
|
|
310
|
+
if not isinstance(last, JsonResponse):
|
|
311
|
+
raise CaptureKitTransportError("The job endpoint did not return JSON.", TypeError())
|
|
312
|
+
if on_progress is not None:
|
|
313
|
+
on_progress(last.data)
|
|
314
|
+
if _is_terminal(last.data):
|
|
315
|
+
return last
|
|
316
|
+
if time.monotonic() - started + interval > timeout:
|
|
317
|
+
raise CaptureKitJobTimeoutError(job_id, last)
|
|
318
|
+
await asyncio.sleep(interval)
|
|
319
|
+
interval = _next_interval(interval)
|
|
320
|
+
|
|
321
|
+
async def aclose(self) -> None:
|
|
322
|
+
"""Close transport resources owned by this client."""
|
|
323
|
+
|
|
324
|
+
if self._closed:
|
|
325
|
+
return
|
|
326
|
+
try:
|
|
327
|
+
if self._owns_http_client:
|
|
328
|
+
await self._http_client.aclose()
|
|
329
|
+
finally:
|
|
330
|
+
self._closed = True
|
|
331
|
+
|
|
332
|
+
async def __aenter__(self) -> Self:
|
|
333
|
+
self._ensure_open()
|
|
334
|
+
return self
|
|
335
|
+
|
|
336
|
+
async def __aexit__(
|
|
337
|
+
self,
|
|
338
|
+
exc_type: type[BaseException] | None,
|
|
339
|
+
exc_value: BaseException | None,
|
|
340
|
+
traceback: TracebackType | None,
|
|
341
|
+
) -> None:
|
|
342
|
+
await self.aclose()
|
|
343
|
+
|
|
344
|
+
async def _call(
|
|
345
|
+
self, name: str, params: dict[str, Any], options: RequestOptions | None
|
|
346
|
+
) -> APIResponse:
|
|
347
|
+
return await self._send(_prepare_operation(self._configuration, name, params, options))
|
|
348
|
+
|
|
349
|
+
async def _send(self, prepared: _PreparedRequest) -> APIResponse:
|
|
350
|
+
self._ensure_open()
|
|
351
|
+
attempt = 0
|
|
352
|
+
while True:
|
|
353
|
+
try:
|
|
354
|
+
response = await self._http_client.request(
|
|
355
|
+
prepared.method,
|
|
356
|
+
prepared.url,
|
|
357
|
+
headers=prepared.headers,
|
|
358
|
+
content=prepared.content,
|
|
359
|
+
timeout=prepared.timeout,
|
|
360
|
+
follow_redirects=False,
|
|
361
|
+
)
|
|
362
|
+
body = await response.aread()
|
|
363
|
+
except httpx.TimeoutException as cause:
|
|
364
|
+
raise CaptureKitTransportError("CaptureKit request timed out.", cause) from cause
|
|
365
|
+
except Exception as cause:
|
|
366
|
+
if attempt < prepared.max_retries:
|
|
367
|
+
await asyncio.sleep(
|
|
368
|
+
_retry_delay(self._configuration.retry_delay, attempt, None)
|
|
369
|
+
)
|
|
370
|
+
attempt += 1
|
|
371
|
+
continue
|
|
372
|
+
raise CaptureKitTransportError(
|
|
373
|
+
"CaptureKit request failed before an HTTP response body was received.", cause
|
|
374
|
+
) from cause
|
|
375
|
+
raw_response = create_raw_response(response, body)
|
|
376
|
+
if _is_retryable(raw_response) and attempt < prepared.max_retries:
|
|
377
|
+
retry_after = (raw_response.headers.get("retry-after") or (None,))[0]
|
|
378
|
+
await asyncio.sleep(
|
|
379
|
+
_retry_delay(self._configuration.retry_delay, attempt, retry_after)
|
|
380
|
+
)
|
|
381
|
+
attempt += 1
|
|
382
|
+
continue
|
|
383
|
+
return _decode(raw_response, prepared)
|
|
384
|
+
|
|
385
|
+
def _ensure_open(self) -> None:
|
|
386
|
+
if self._closed:
|
|
387
|
+
raise CaptureKitConfigurationError("This AsyncCaptureKitClient is closed.")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _build_configuration(
|
|
391
|
+
api_key: str | None,
|
|
392
|
+
base_url: str | None,
|
|
393
|
+
timeout: float | None,
|
|
394
|
+
headers: Mapping[str, str] | None,
|
|
395
|
+
max_retries: int = MAX_RETRIES,
|
|
396
|
+
retry_delay: float = DEFAULT_RETRY_DELAY,
|
|
397
|
+
) -> _Configuration:
|
|
398
|
+
key = api_key if api_key is not None else os.environ.get(API_KEY_ENVIRONMENT_VARIABLE)
|
|
399
|
+
if not isinstance(key, str) or not key.strip():
|
|
400
|
+
raise CaptureKitConfigurationError(
|
|
401
|
+
f"A non-blank CaptureKit API key is required. Pass api_key or set {API_KEY_ENVIRONMENT_VARIABLE}."
|
|
402
|
+
)
|
|
403
|
+
if any(ch in key for ch in "\r\n"):
|
|
404
|
+
raise CaptureKitConfigurationError(
|
|
405
|
+
"The CaptureKit API key cannot be used in an HTTP header."
|
|
406
|
+
)
|
|
407
|
+
url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
408
|
+
parts = urlsplit(url)
|
|
409
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
410
|
+
raise CaptureKitConfigurationError("base_url must be an absolute http or https URL.")
|
|
411
|
+
if timeout is not None and (not isinstance(timeout, (int, float)) or timeout <= 0):
|
|
412
|
+
raise CaptureKitConfigurationError("timeout must be a positive number of seconds or None.")
|
|
413
|
+
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
|
|
414
|
+
raise CaptureKitConfigurationError("max_retries must be a non-negative integer.")
|
|
415
|
+
if not isinstance(retry_delay, (int, float)) or retry_delay < 0:
|
|
416
|
+
raise CaptureKitConfigurationError("retry_delay must be a non-negative number of seconds.")
|
|
417
|
+
return _Configuration(
|
|
418
|
+
api_key=key,
|
|
419
|
+
base_url=url,
|
|
420
|
+
timeout=timeout,
|
|
421
|
+
headers=dict(headers or {}),
|
|
422
|
+
max_retries=max_retries,
|
|
423
|
+
retry_delay=float(retry_delay),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _prepare_operation(
|
|
428
|
+
configuration: _Configuration,
|
|
429
|
+
name: str,
|
|
430
|
+
params: Mapping[str, Any],
|
|
431
|
+
options: RequestOptions | None,
|
|
432
|
+
) -> _PreparedRequest:
|
|
433
|
+
endpoint = ENDPOINTS[name]
|
|
434
|
+
method = (options.method if options else None) or endpoint.methods[0]
|
|
435
|
+
if method not in endpoint.methods:
|
|
436
|
+
raise CaptureKitConfigurationError(
|
|
437
|
+
f"{endpoint.path} supports {', '.join(endpoint.methods)}, not {method}."
|
|
438
|
+
)
|
|
439
|
+
for required in endpoint.required:
|
|
440
|
+
if params.get(required) is None:
|
|
441
|
+
raise CaptureKitSerializationError(f'The "{required}" parameter is required.', required)
|
|
442
|
+
return _prepare(configuration, method, endpoint.path, params, options, endpoint.response)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _prepare(
|
|
446
|
+
configuration: _Configuration,
|
|
447
|
+
method: HTTPMethod,
|
|
448
|
+
path: str,
|
|
449
|
+
params: Mapping[str, Any] | None,
|
|
450
|
+
options: RequestOptions | None,
|
|
451
|
+
expected: Literal["json", "any"],
|
|
452
|
+
) -> _PreparedRequest:
|
|
453
|
+
if method not in ("GET", "POST"):
|
|
454
|
+
raise CaptureKitConfigurationError(f"Unsupported HTTP method: {method!r}.")
|
|
455
|
+
if not isinstance(path, str) or not path.startswith("/"):
|
|
456
|
+
raise CaptureKitConfigurationError('The request path must start with "/".')
|
|
457
|
+
serialized = serialize_request(method, path, params)
|
|
458
|
+
url = configuration.base_url + serialized.path
|
|
459
|
+
if serialized.query:
|
|
460
|
+
url += "?" + encode_query(serialized.query)
|
|
461
|
+
headers = {
|
|
462
|
+
"Accept": "application/json, */*;q=0.8",
|
|
463
|
+
"User-Agent": f"capturekit-python/{__version__}",
|
|
464
|
+
**configuration.headers,
|
|
465
|
+
**dict((options.headers if options else None) or {}),
|
|
466
|
+
"x-api-key": configuration.api_key,
|
|
467
|
+
}
|
|
468
|
+
content = None
|
|
469
|
+
if serialized.body is not None:
|
|
470
|
+
headers["Content-Type"] = "application/json"
|
|
471
|
+
content = encode_json_body(serialized.body)
|
|
472
|
+
timeout = options.timeout if options and options.timeout is not None else configuration.timeout
|
|
473
|
+
max_retries = (
|
|
474
|
+
options.max_retries
|
|
475
|
+
if options and options.max_retries is not None
|
|
476
|
+
else configuration.max_retries
|
|
477
|
+
)
|
|
478
|
+
return _PreparedRequest(method, url, headers, content, timeout, expected, max_retries)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _decode(raw_response: RawResponse, prepared: _PreparedRequest) -> APIResponse:
|
|
482
|
+
if not 200 <= raw_response.status <= 299:
|
|
483
|
+
raise create_api_error(raw_response)
|
|
484
|
+
return decode_success_response(raw_response, prepared.expected)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _check_concurrency(concurrency: int) -> None:
|
|
488
|
+
if isinstance(concurrency, bool) or not isinstance(concurrency, int) or concurrency < 1:
|
|
489
|
+
raise CaptureKitConfigurationError("concurrency must be a positive integer.")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _settle(index: int, future: Future[_T]) -> BatchResult[_T]:
|
|
493
|
+
error = future.exception()
|
|
494
|
+
if error is not None:
|
|
495
|
+
return BatchResult(index=index, error=error)
|
|
496
|
+
return BatchResult(index=index, value=future.result())
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _is_retryable(raw_response: RawResponse) -> bool:
|
|
500
|
+
"""Server errors and plan rate limits (401/429 "Rate limit exceeded") are retried; per-key quotas are not."""
|
|
501
|
+
|
|
502
|
+
if raw_response.status in RETRYABLE_STATUSES:
|
|
503
|
+
return True
|
|
504
|
+
if raw_response.status not in (401, 429):
|
|
505
|
+
return False
|
|
506
|
+
message = (create_api_error(raw_response).error_message or "").lower()
|
|
507
|
+
if "quota" in message:
|
|
508
|
+
return False
|
|
509
|
+
return "rate limit" in message or (raw_response.status == 429 and not message)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _retry_delay(base: float, attempt: int, retry_after: str | None) -> float:
|
|
513
|
+
if retry_after is not None:
|
|
514
|
+
try:
|
|
515
|
+
seconds = float(retry_after)
|
|
516
|
+
except ValueError:
|
|
517
|
+
seconds = -1.0
|
|
518
|
+
if seconds >= 0:
|
|
519
|
+
return min(seconds, 60.0)
|
|
520
|
+
return float(min(base * RETRY_BACKOFF_FACTOR**attempt, MAX_RETRY_DELAY))
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _check_job_id(job_id: str) -> None:
|
|
524
|
+
if not isinstance(job_id, str) or not job_id.strip():
|
|
525
|
+
raise CaptureKitSerializationError("A non-blank job id is required.", "id")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _is_terminal(job: Any) -> bool:
|
|
529
|
+
return isinstance(job, dict) and job.get("status") in JOB_TERMINAL_STATUSES
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _next_interval(interval: float) -> float:
|
|
533
|
+
return max(interval, min(interval * 1.5, MAX_POLL_INTERVAL))
|
capturekit/errors.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""CaptureKit SDK error categories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .models import JsonResponse, RawResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CaptureKitError(Exception):
|
|
11
|
+
"""Base class for all SDK-defined errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CaptureKitConfigurationError(CaptureKitError):
|
|
15
|
+
"""The client configuration is missing or unusable."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CaptureKitSerializationError(CaptureKitError):
|
|
19
|
+
"""A request value cannot be represented safely on the wire."""
|
|
20
|
+
|
|
21
|
+
param: str | None
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, param: str | None = None) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.param = param
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CaptureKitTransportError(CaptureKitError):
|
|
29
|
+
"""The request failed before a complete HTTP response was received."""
|
|
30
|
+
|
|
31
|
+
cause: Exception
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, cause: Exception) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.cause = cause
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CaptureKitAPIError(CaptureKitError):
|
|
39
|
+
"""CaptureKit returned a non-success HTTP status."""
|
|
40
|
+
|
|
41
|
+
status: int
|
|
42
|
+
error_message: str | None
|
|
43
|
+
response_body: Any | None
|
|
44
|
+
raw_response: RawResponse
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
raw_response: RawResponse,
|
|
50
|
+
response_body: Any | None = None,
|
|
51
|
+
error_message: str | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(
|
|
54
|
+
error_message or f"CaptureKit API request failed with status {raw_response.status}."
|
|
55
|
+
)
|
|
56
|
+
self.status = raw_response.status
|
|
57
|
+
self.error_message = error_message
|
|
58
|
+
self.response_body = response_body
|
|
59
|
+
self.raw_response = raw_response
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CaptureKitResponseDecodingError(CaptureKitError):
|
|
63
|
+
"""A successful response could not be decoded as the documented representation."""
|
|
64
|
+
|
|
65
|
+
raw_response: RawResponse
|
|
66
|
+
cause: Exception | None
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self, message: str, raw_response: RawResponse, cause: Exception | None = None
|
|
70
|
+
) -> None:
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
self.raw_response = raw_response
|
|
73
|
+
self.cause = cause
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CaptureKitJobTimeoutError(CaptureKitError):
|
|
77
|
+
"""An async job did not reach a terminal status before the deadline."""
|
|
78
|
+
|
|
79
|
+
job_id: str
|
|
80
|
+
last_response: JsonResponse | None
|
|
81
|
+
|
|
82
|
+
def __init__(self, job_id: str, last_response: JsonResponse | None = None) -> None:
|
|
83
|
+
super().__init__(f"Job {job_id} did not finish before the deadline.")
|
|
84
|
+
self.job_id = job_id
|
|
85
|
+
self.last_response = last_response
|
capturekit/models.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Public response and option types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Generic, Literal, TypeAlias, TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
HTTPMethod: TypeAlias = Literal["GET", "POST"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class RawResponse:
|
|
16
|
+
"""Buffered HTTP response: status, lower-cased multi-value headers, content type and body bytes."""
|
|
17
|
+
|
|
18
|
+
status: int
|
|
19
|
+
status_text: str
|
|
20
|
+
headers: Mapping[str, tuple[str, ...]]
|
|
21
|
+
content_type: str | None
|
|
22
|
+
body: bytes = field(repr=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class JsonResponse:
|
|
27
|
+
"""A successful JSON response; ``data`` is the parsed body."""
|
|
28
|
+
|
|
29
|
+
data: Any
|
|
30
|
+
raw_response: RawResponse = field(repr=False)
|
|
31
|
+
kind: Literal["json"] = "json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class BinaryResponse:
|
|
36
|
+
"""A successful non-JSON response (image, PDF, HTML…)."""
|
|
37
|
+
|
|
38
|
+
bytes: bytes = field(repr=False)
|
|
39
|
+
content_type: str | None = None
|
|
40
|
+
raw_response: RawResponse | None = field(default=None, repr=False)
|
|
41
|
+
kind: Literal["binary"] = "binary"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
APIResponse: TypeAlias = JsonResponse | BinaryResponse
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class RequestOptions:
|
|
49
|
+
"""Per-request options for generated endpoint methods."""
|
|
50
|
+
|
|
51
|
+
method: HTTPMethod | None = None
|
|
52
|
+
"""GET (default when supported) sends query parameters; POST sends a JSON body."""
|
|
53
|
+
timeout: float | None = None
|
|
54
|
+
"""Seconds; overrides the client timeout for this request."""
|
|
55
|
+
headers: Mapping[str, str] | None = None
|
|
56
|
+
"""Extra headers for this request."""
|
|
57
|
+
max_retries: int | None = None
|
|
58
|
+
"""Overrides the client retry count for this request (0 disables retries)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class BatchResult(Generic[T]):
|
|
63
|
+
"""Outcome of one ``batch()`` item: ``value`` when ``ok``, else ``error``."""
|
|
64
|
+
|
|
65
|
+
index: int
|
|
66
|
+
value: T | None = None
|
|
67
|
+
error: BaseException | None = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def ok(self) -> bool:
|
|
71
|
+
return self.error is None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class EndpointDefinition:
|
|
76
|
+
"""Path, supported methods and required parameters of one documented operation."""
|
|
77
|
+
|
|
78
|
+
path: str
|
|
79
|
+
methods: tuple[HTTPMethod, ...]
|
|
80
|
+
response: Literal["json", "any"]
|
|
81
|
+
required: tuple[str, ...]
|
capturekit/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|