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/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
+