capture-runtime-client 0.4.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.
Files changed (48) hide show
  1. capture_runtime_client/__init__.py +41 -0
  2. capture_runtime_client/client.py +852 -0
  3. capture_runtime_client/codec.py +124 -0
  4. capture_runtime_client/contracts.py +272 -0
  5. capture_runtime_client/errors.py +170 -0
  6. capture_runtime_client/private/__init__.py +1 -0
  7. capture_runtime_client/private/assets/__init__.py +1 -0
  8. capture_runtime_client/private/assets/contract-set.json +1 -0
  9. capture_runtime_client/private/assets/contract-set.sha256 +1 -0
  10. capture_runtime_client/private/generated_models.py +627 -0
  11. capture_runtime_client/private/schemas/__init__.py +1 -0
  12. capture_runtime_client/private/schemas/capture-block.schema.json +151 -0
  13. capture_runtime_client/private/schemas/capture-document.schema.json +356 -0
  14. capture_runtime_client/private/schemas/capture-engine.schema.json +40 -0
  15. capture_runtime_client/private/schemas/capture-event-v2.schema.json +301 -0
  16. capture_runtime_client/private/schemas/capture-failure-v2.schema.json +40 -0
  17. capture_runtime_client/private/schemas/capture-operation-v2.schema.json +207 -0
  18. capture_runtime_client/private/schemas/capture-source.schema.json +34 -0
  19. capture_runtime_client/private/schemas/error-body-v2.schema.json +34 -0
  20. capture_runtime_client/private/schemas/error-envelope-v2.schema.json +49 -0
  21. capture_runtime_client/private/schemas/finalize-ingestion-v2.schema.json +27 -0
  22. capture_runtime_client/private/schemas/ingestion-v2.schema.json +127 -0
  23. capture_runtime_client/private/schemas/open-ingestion-v2.schema.json +79 -0
  24. capture_runtime_client/private/schemas/page-locator.schema.json +49 -0
  25. capture_runtime_client/private/schemas/partial-capture-v2.schema.json +267 -0
  26. capture_runtime_client/private/schemas/raw-capture-segment.schema.json +125 -0
  27. capture_runtime_client/private/schemas/raw-capture.schema.json +260 -0
  28. capture_runtime_client/private/schemas/report-structuring-failure-v2.schema.json +28 -0
  29. capture_runtime_client/private/schemas/runtime-artifact-descriptor-v2.schema.json +34 -0
  30. capture_runtime_client/private/schemas/runtime-installation-v2.schema.json +126 -0
  31. capture_runtime_client/private/schemas/runtime-installations-v2.schema.json +143 -0
  32. capture_runtime_client/private/schemas/runtime-model-installation-v2.schema.json +121 -0
  33. capture_runtime_client/private/schemas/runtime-model-installations-v2.schema.json +138 -0
  34. capture_runtime_client/private/schemas/runtime-model-option-v2.schema.json +80 -0
  35. capture_runtime_client/private/schemas/runtime-model-options-v2.schema.json +103 -0
  36. capture_runtime_client/private/schemas/runtime-ready.schema.json +88 -0
  37. capture_runtime_client/private/schemas/runtime-requirement-v2.schema.json +120 -0
  38. capture_runtime_client/private/schemas/runtime-requirements-v2.schema.json +137 -0
  39. capture_runtime_client/private/schemas/runtime-streaming-capabilities-v2.schema.json +64 -0
  40. capture_runtime_client/private/schemas/start-capture-v2.schema.json +59 -0
  41. capture_runtime_client/private/schemas/start-runtime-installation-v2.schema.json +26 -0
  42. capture_runtime_client/private/schemas/start-runtime-model-installation-v2.schema.json +21 -0
  43. capture_runtime_client/private/schemas/time-locator.schema.json +27 -0
  44. capture_runtime_client/py.typed +1 -0
  45. capture_runtime_client/transport.py +158 -0
  46. capture_runtime_client-0.4.0.dist-info/METADATA +18 -0
  47. capture_runtime_client-0.4.0.dist-info/RECORD +48 -0
  48. capture_runtime_client-0.4.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,852 @@
1
+ """Synchronous Capture Runtime v2 client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import time
9
+ from collections.abc import Callable, Collection, Iterator, Mapping
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from uuid import UUID
14
+
15
+ import httpx
16
+ from pydantic import ValidationError
17
+
18
+ from .codec import decode_json, decode_model, iter_sse
19
+ from .contracts import (
20
+ CAPTURE_API_VERSION,
21
+ CAPTURE_CONTRACT_SET_SHA256,
22
+ CAPTURE_DOCUMENT_SCHEMA_SHA256,
23
+ CAPTURE_DOCUMENT_SCHEMA_VERSION,
24
+ CAPTURE_RUNTIME_VERSION,
25
+ CaptureDocument,
26
+ CaptureEvent,
27
+ CaptureOperation,
28
+ CaptureSourceKind,
29
+ Ingestion,
30
+ PartialCapture,
31
+ RawCapture,
32
+ RuntimeInstallation,
33
+ RuntimeModelInstallation,
34
+ RuntimeModelOptions,
35
+ RuntimeReady,
36
+ RuntimeRequirements,
37
+ RuntimeStreamingCapabilities,
38
+ StructuringMode,
39
+ )
40
+ from .errors import (
41
+ CaptureRuntimeCompatibilityError,
42
+ CaptureRuntimeProtocolError,
43
+ CaptureTransportError,
44
+ )
45
+ from .transport import HttpRuntimeTransport, RuntimeTransport
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class RuntimeDiscovery:
50
+ ready: RuntimeReady
51
+ streaming: RuntimeStreamingCapabilities | None
52
+ schema_sha256: str
53
+ contract_index: Mapping[str, Any]
54
+ contract_bundle: Mapping[str, Any]
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class CaptureUpload:
59
+ file_name: str
60
+ content: bytes
61
+ source_kind: CaptureSourceKind | str
62
+ media_type: str = "application/octet-stream"
63
+ structuring_mode: StructuringMode | str = StructuringMode.RUNTIME
64
+ target_language: str | None = None
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class CaptureStreamingResult:
69
+ operation: CaptureOperation
70
+ raw: RawCapture
71
+ result: CaptureDocument
72
+
73
+
74
+ class CaptureRuntimeClient:
75
+ def __init__(
76
+ self,
77
+ *,
78
+ transport: RuntimeTransport | None = None,
79
+ base_url: str | int | None = None,
80
+ bearer_token: str | None = None,
81
+ timeout_seconds: float = 30,
82
+ expected_contract_set_sha256: str | None = None,
83
+ allowed_contract_set_sha256: Collection[str] | None = None,
84
+ max_retries: int = 2,
85
+ retry_backoff_seconds: float = 0.0,
86
+ ) -> None:
87
+ if max_retries < 0:
88
+ raise ValueError("max_retries must be non-negative")
89
+ if retry_backoff_seconds < 0:
90
+ raise ValueError("retry_backoff_seconds must be non-negative")
91
+ if transport is None:
92
+ if base_url is None or bearer_token is None:
93
+ raise ValueError("base_url and bearer_token are required without a transport")
94
+ transport = HttpRuntimeTransport(
95
+ base_url=base_url, bearer_token=bearer_token, timeout_seconds=timeout_seconds
96
+ )
97
+ self._transport = transport
98
+ self._allowed_contract_set_sha256 = frozenset(
99
+ allowed_contract_set_sha256
100
+ or (expected_contract_set_sha256 or CAPTURE_CONTRACT_SET_SHA256,)
101
+ )
102
+ self._max_retries = max_retries
103
+ self._retry_backoff_seconds = retry_backoff_seconds
104
+ self._discovery: RuntimeDiscovery | None = None
105
+ self._discovering = False
106
+
107
+ def handshake(self) -> RuntimeReady:
108
+ response = self._request("GET", "/v2/health/ready")
109
+ payload = decode_json(response)
110
+ if not isinstance(payload, Mapping):
111
+ raise CaptureRuntimeProtocolError(
112
+ "Capture Runtime readiness response is not an object."
113
+ )
114
+ self._assert_compatible_payload(payload)
115
+ ready = decode_model(httpx.Response(200, json=payload), RuntimeReady)
116
+ self._assert_compatible(ready)
117
+ return ready
118
+
119
+ def discover(self) -> RuntimeDiscovery:
120
+ if self._discovery is not None:
121
+ return self._discovery
122
+ if self._discovering:
123
+ raise CaptureRuntimeProtocolError("Capture Runtime contract discovery re-entered.")
124
+ self._discovering = True
125
+ try:
126
+ ready = self.handshake()
127
+ index_response = self._request("GET", "/meta/v2/contracts")
128
+ index = decode_json(index_response)
129
+ if not isinstance(index, Mapping):
130
+ raise CaptureRuntimeProtocolError(
131
+ "Capture Runtime contract index is not an object."
132
+ )
133
+ self._validate_contract_index(index, ready)
134
+ href = index.get("href")
135
+ if not isinstance(href, str) or not href.startswith(
136
+ "/meta/v2/contracts/sha256/"
137
+ ):
138
+ raise CaptureRuntimeProtocolError("Capture Runtime contract index href is invalid.")
139
+ href_digest = href.removeprefix("/meta/v2/contracts/sha256/")
140
+ if not re.fullmatch(r"[0-9a-f]{64}", href_digest) or href_digest != index.get(
141
+ "sha256"
142
+ ):
143
+ raise CaptureRuntimeCompatibilityError(
144
+ "Capture Runtime contract index href digest does not match "
145
+ "its advertised bundle hash."
146
+ )
147
+ bundle_response = self._request("GET", href)
148
+ bundle_bytes = bundle_response.content
149
+ digest = hashlib.sha256(bundle_bytes).hexdigest()
150
+ if digest not in self._allowed_contract_set_sha256:
151
+ raise CaptureRuntimeCompatibilityError(
152
+ "Capture Runtime contract bundle identity is not allowlisted."
153
+ )
154
+ etag = bundle_response.headers.get("etag")
155
+ if (
156
+ digest != index.get("sha256")
157
+ or bundle_response.headers.get("x-contract-sha256") not in {None, digest}
158
+ or (etag is not None and etag not in {digest, f'"{digest}"'})
159
+ ):
160
+ raise CaptureRuntimeCompatibilityError(
161
+ "Capture Runtime contract bundle hash is incompatible."
162
+ )
163
+ try:
164
+ bundle = json.loads(bundle_bytes)
165
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
166
+ raise CaptureRuntimeProtocolError(
167
+ "Capture Runtime contract bundle is not valid JSON."
168
+ ) from error
169
+ schema_sha256 = self._validate_contract_bundle(bundle, digest)
170
+ streaming = decode_model(
171
+ self._request("GET", "/v2/streaming/health/ready"),
172
+ RuntimeStreamingCapabilities,
173
+ )
174
+ self._discovery = RuntimeDiscovery(
175
+ ready=ready,
176
+ streaming=streaming,
177
+ schema_sha256=schema_sha256,
178
+ contract_index=index,
179
+ contract_bundle=bundle,
180
+ )
181
+ return self._discovery
182
+ finally:
183
+ self._discovering = False
184
+
185
+ def get_requirements(self) -> RuntimeRequirements:
186
+ return decode_model(self._request("GET", "/v2/runtime/requirements"), RuntimeRequirements)
187
+
188
+ def start_installation(
189
+ self, requirement_id: str, *, idempotency_key: UUID | str
190
+ ) -> RuntimeInstallation:
191
+ return decode_model(
192
+ self._request(
193
+ "POST",
194
+ "/v2/runtime/installations",
195
+ headers={"X-Idempotency-Key": str(idempotency_key)},
196
+ json={"requirementId": requirement_id, "consent": True},
197
+ ),
198
+ RuntimeInstallation,
199
+ )
200
+
201
+ def list_installations(self) -> list[RuntimeInstallation]:
202
+ payload = decode_json(self._request("GET", "/v2/runtime/installations"))
203
+ if not isinstance(payload, Mapping) or not isinstance(payload.get("items"), list):
204
+ raise CaptureRuntimeProtocolError("Capture Runtime installations response is invalid.")
205
+ try:
206
+ return [RuntimeInstallation.model_validate(item) for item in payload["items"]]
207
+ except ValidationError as error:
208
+ raise CaptureRuntimeProtocolError(
209
+ "Capture Runtime installations response is invalid."
210
+ ) from error
211
+
212
+ def get_installation(self, installation_id: str) -> RuntimeInstallation:
213
+ return decode_model(
214
+ self._request("GET", f"/v2/runtime/installations/{_safe_id(installation_id)}"),
215
+ RuntimeInstallation,
216
+ )
217
+
218
+ def cancel_installation(self, installation_id: str) -> RuntimeInstallation:
219
+ return decode_model(
220
+ self._request("POST", f"/v2/runtime/installations/{_safe_id(installation_id)}/cancel"),
221
+ RuntimeInstallation,
222
+ )
223
+
224
+ def get_model_options(self) -> RuntimeModelOptions:
225
+ return decode_model(
226
+ self._request("GET", "/v2/runtime/model-options"), RuntimeModelOptions
227
+ )
228
+
229
+ def get_model_installation(self, installation_id: str) -> RuntimeModelInstallation:
230
+ return decode_model(
231
+ self._request("GET", f"/v2/runtime/model-installations/{_safe_id(installation_id)}"),
232
+ RuntimeModelInstallation,
233
+ )
234
+
235
+ def get_model_installation_status(self, installation_id: str) -> RuntimeModelInstallation:
236
+ """Status-oriented alias for :meth:`get_model_installation`."""
237
+ return self.get_model_installation(installation_id)
238
+
239
+ def cancel_model_installation(self, installation_id: str) -> RuntimeModelInstallation:
240
+ return decode_model(
241
+ self._request(
242
+ "POST", f"/v2/runtime/model-installations/{_safe_id(installation_id)}/cancel"
243
+ ),
244
+ RuntimeModelInstallation,
245
+ )
246
+
247
+ def start_capture(self, upload: CaptureUpload, *, client_request_id: str) -> CaptureOperation:
248
+ """Open, upload, finalize, and start one v2 capture operation."""
249
+ return self.start_streaming_capture(upload, client_request_id=client_request_id)
250
+
251
+ def upload_path(
252
+ self,
253
+ path: Path,
254
+ *,
255
+ source_kind: CaptureSourceKind | str,
256
+ client_request_id: str,
257
+ media_type: str = "application/octet-stream",
258
+ target_language: str | None = None,
259
+ ) -> CaptureOperation:
260
+ return self.start_capture(
261
+ CaptureUpload(
262
+ path.name,
263
+ path.read_bytes(),
264
+ source_kind,
265
+ media_type,
266
+ target_language=target_language,
267
+ ),
268
+ client_request_id=client_request_id,
269
+ )
270
+
271
+ def get_capture(self, capture_id: str) -> CaptureOperation:
272
+ return self.get_streaming_capture(capture_id)
273
+
274
+ def cancel_capture(self, capture_id: str) -> CaptureOperation:
275
+ return self.cancel_streaming_capture(capture_id)
276
+
277
+ def get_raw(self, capture_id: str) -> RawCapture:
278
+ return decode_model(
279
+ self._request("GET", f"/v2/captures/{_safe_id(capture_id)}/raw"), RawCapture
280
+ )
281
+
282
+ def get_result(self, capture_id: str) -> CaptureStreamingResult:
283
+ return self.get_streaming_result(capture_id)
284
+
285
+ def commit_structure(
286
+ self,
287
+ capture_id: str,
288
+ candidate: str | bytes | Mapping[str, object] | CaptureDocument,
289
+ *,
290
+ idempotency_key: UUID | str,
291
+ ) -> CaptureOperation:
292
+ return self.commit_streaming_structure(
293
+ capture_id,
294
+ candidate,
295
+ idempotency_key=idempotency_key,
296
+ )
297
+
298
+ def report_structuring_failure(
299
+ self,
300
+ capture_id: str,
301
+ *,
302
+ code: str,
303
+ message: str,
304
+ idempotency_key: UUID | str,
305
+ ) -> CaptureOperation:
306
+ return self.report_streaming_failure(
307
+ capture_id,
308
+ code=code,
309
+ message=message,
310
+ idempotency_key=idempotency_key,
311
+ )
312
+
313
+ def delete_capture(self, capture_id: str) -> None:
314
+ self.delete_streaming_capture(capture_id)
315
+
316
+ def get_streaming_capabilities(self) -> RuntimeStreamingCapabilities:
317
+ return decode_model(
318
+ self._request("GET", "/v2/streaming/health/ready"), RuntimeStreamingCapabilities
319
+ )
320
+
321
+ def start_streaming_capture(
322
+ self, upload: CaptureUpload, *, client_request_id: str
323
+ ) -> CaptureOperation:
324
+ content = upload.content
325
+ digest = hashlib.sha256(content).hexdigest()
326
+ kind = CaptureSourceKind(upload.source_kind)
327
+ ingestion = decode_model(
328
+ self._request(
329
+ "POST",
330
+ "/v2/ingestions",
331
+ headers={"X-Idempotency-Key": f"{client_request_id}-ingestion"},
332
+ json={
333
+ "protocolVersion": "2",
334
+ "kind": kind.value,
335
+ "mode": "file",
336
+ "clientRequestId": f"{client_request_id}-ingestion",
337
+ "fileName": upload.file_name,
338
+ "mediaType": upload.media_type,
339
+ "totalBytes": len(content),
340
+ "sourceSha256": digest,
341
+ },
342
+ ),
343
+ Ingestion,
344
+ )
345
+ try:
346
+ chunk_size = min(1024 * 1024, self.get_streaming_capabilities().max_chunk_bytes)
347
+ for offset in range(ingestion.next_offset, len(content), chunk_size):
348
+ chunk = content[offset : offset + chunk_size]
349
+ ingestion = decode_model(
350
+ self._request(
351
+ "PUT",
352
+ f"/v2/ingestions/{ingestion.ingestion_id}/chunks/{ingestion.next_chunk_index}",
353
+ headers={
354
+ "Content-Range": (
355
+ f"bytes {offset}-{offset + len(chunk) - 1}/{len(content)}"
356
+ ),
357
+ "Digest": f"sha-256={hashlib.sha256(chunk).hexdigest()}",
358
+ "X-Idempotency-Key": (
359
+ f"{ingestion.ingestion_id}-{ingestion.next_chunk_index}"
360
+ ),
361
+ },
362
+ content=chunk,
363
+ ),
364
+ Ingestion,
365
+ )
366
+ decode_model(
367
+ self._request(
368
+ "POST",
369
+ f"/v2/ingestions/{ingestion.ingestion_id}/finalize",
370
+ json={"protocolVersion": "2", "totalBytes": len(content), "sha256": digest},
371
+ ),
372
+ Ingestion,
373
+ )
374
+ return decode_model(
375
+ self._request(
376
+ "POST",
377
+ "/v2/captures",
378
+ headers={"X-Idempotency-Key": client_request_id},
379
+ json={
380
+ "protocolVersion": "2",
381
+ "clientRequestId": client_request_id,
382
+ "ingestionId": ingestion.ingestion_id,
383
+ "structuringMode": StructuringMode(upload.structuring_mode).value,
384
+ "targetLanguage": upload.target_language,
385
+ "startPolicy": "eager",
386
+ },
387
+ ),
388
+ CaptureOperation,
389
+ )
390
+ except Exception:
391
+ self._transport.request("DELETE", f"/v2/ingestions/{ingestion.ingestion_id}")
392
+ raise
393
+
394
+ def get_streaming_capture(self, capture_id: str) -> CaptureOperation:
395
+ return self._decode_capture_operation(
396
+ self._request("GET", f"/v2/captures/{_safe_id(capture_id)}"), capture_id
397
+ )
398
+
399
+ def get_partial(self, capture_id: str) -> PartialCapture:
400
+ partial = decode_model(
401
+ self._request("GET", f"/v2/captures/{_safe_id(capture_id)}/partial"), PartialCapture
402
+ )
403
+ if partial.capture_id != capture_id:
404
+ raise CaptureRuntimeProtocolError(
405
+ "Capture Runtime returned an invalid capture identity."
406
+ )
407
+ return partial
408
+
409
+ def get_streaming_result(self, capture_id: str) -> CaptureStreamingResult:
410
+ payload = decode_json(self._request("GET", f"/v2/captures/{_safe_id(capture_id)}/result"))
411
+ try:
412
+ operation = CaptureOperation.model_validate(payload["operation"])
413
+ raw = RawCapture.model_validate(payload["raw"])
414
+ result = CaptureDocument.model_validate(payload["result"])
415
+ if operation.capture_id != capture_id:
416
+ raise ValueError("capture identity mismatch")
417
+ if raw.source != result.source or (
418
+ operation.source is not None and operation.source != raw.source
419
+ ):
420
+ raise ValueError("source identity mismatch")
421
+ return CaptureStreamingResult(
422
+ operation=operation,
423
+ raw=raw,
424
+ result=result,
425
+ )
426
+ except (KeyError, TypeError, ValueError, ValidationError) as error:
427
+ raise CaptureRuntimeProtocolError(
428
+ "Capture Runtime returned an invalid streaming result."
429
+ ) from error
430
+
431
+ def capture_events(
432
+ self,
433
+ capture_id: str,
434
+ *,
435
+ last_event_id: int | str | None = None,
436
+ max_reconnects: int = 2,
437
+ on_activity: Callable[[], None] | None = None,
438
+ ) -> Iterator[CaptureEvent]:
439
+ if max_reconnects < 0:
440
+ raise ValueError("max_reconnects must be non-negative")
441
+ self._ensure_discovered()
442
+ previous = int(last_event_id) if last_event_id is not None else -1
443
+ reconnects = 0
444
+ while True:
445
+ headers = {"Accept": "text/event-stream"}
446
+ if previous >= 0:
447
+ headers["Last-Event-ID"] = str(previous)
448
+ try:
449
+ with self._transport.stream(
450
+ "GET", f"/v2/captures/{_safe_id(capture_id)}/events", headers=headers
451
+ ) as response:
452
+ if response.status_code >= 400:
453
+ decode_json(response)
454
+ content_type = response.headers.get("content-type", "")
455
+ if not content_type.lower().startswith("text/event-stream"):
456
+ raise CaptureRuntimeProtocolError(
457
+ "Capture Runtime event stream has an invalid content type."
458
+ )
459
+
460
+ def chunks() -> Iterator[bytes]:
461
+ for chunk in response.iter_bytes():
462
+ if on_activity is not None:
463
+ on_activity()
464
+ yield chunk
465
+
466
+ for frame in iter_sse(chunks()):
467
+ try:
468
+ event = CaptureEvent.model_validate(json.loads(frame["data"]))
469
+ except (KeyError, json.JSONDecodeError, ValidationError) as error:
470
+ raise CaptureRuntimeProtocolError(
471
+ "Capture Runtime returned an invalid event frame."
472
+ ) from error
473
+ if event.capture_id != capture_id or event.sequence <= previous:
474
+ raise CaptureRuntimeProtocolError(
475
+ "Capture Runtime returned an invalid event identity or sequence."
476
+ )
477
+ previous = event.sequence
478
+ yield event
479
+ if event.event_type.value in {"completed", "failed", "cancelled"}:
480
+ return
481
+ except CaptureRuntimeProtocolError:
482
+ raise
483
+ except (UnicodeDecodeError, ValueError) as error:
484
+ raise CaptureRuntimeProtocolError(
485
+ "Capture Runtime returned an invalid event stream."
486
+ ) from error
487
+ except (CaptureTransportError, httpx.HTTPError):
488
+ if reconnects >= max_reconnects:
489
+ raise
490
+ reconnects += 1
491
+ continue
492
+ if reconnects >= max_reconnects:
493
+ return
494
+ reconnects += 1
495
+
496
+ def cancel_streaming_capture(self, capture_id: str) -> CaptureOperation:
497
+ return self._decode_capture_operation(
498
+ self._request("POST", f"/v2/captures/{_safe_id(capture_id)}/cancel"), capture_id
499
+ )
500
+
501
+ def commit_streaming_structure(
502
+ self,
503
+ capture_id: str,
504
+ candidate: str | bytes | Mapping[str, object] | CaptureDocument,
505
+ *,
506
+ idempotency_key: UUID | str,
507
+ ) -> CaptureOperation:
508
+ headers = {
509
+ "Content-Type": "application/json",
510
+ "X-Idempotency-Key": str(idempotency_key),
511
+ }
512
+ if isinstance(candidate, CaptureDocument):
513
+ kwargs: dict[str, Any] = {
514
+ "json": candidate.model_dump(mode="json", by_alias=True)
515
+ }
516
+ elif isinstance(candidate, Mapping):
517
+ kwargs = {"json": dict(candidate)}
518
+ else:
519
+ kwargs = {
520
+ "content": candidate.encode("utf-8")
521
+ if isinstance(candidate, str)
522
+ else candidate
523
+ }
524
+ return self._decode_capture_operation(
525
+ self._request(
526
+ "POST",
527
+ f"/v2/captures/{_safe_id(capture_id)}/structure/commit",
528
+ headers=headers,
529
+ **kwargs,
530
+ ),
531
+ capture_id,
532
+ )
533
+
534
+ def report_streaming_failure(
535
+ self, capture_id: str, *, code: str, message: str, idempotency_key: UUID | str
536
+ ) -> CaptureOperation:
537
+ return self._decode_capture_operation(
538
+ self._request(
539
+ "POST",
540
+ f"/v2/captures/{_safe_id(capture_id)}/structure/failure",
541
+ headers={"X-Idempotency-Key": str(idempotency_key)},
542
+ json={"protocolVersion": "2", "code": code, "message": message},
543
+ ),
544
+ capture_id,
545
+ )
546
+
547
+ def delete_streaming_capture(self, capture_id: str) -> None:
548
+ decode_json(self._request("DELETE", f"/v2/captures/{_safe_id(capture_id)}"))
549
+
550
+ def close(self) -> None:
551
+ close = getattr(self._transport, "close", None)
552
+ if callable(close):
553
+ close()
554
+
555
+ def __enter__(self) -> CaptureRuntimeClient:
556
+ return self
557
+
558
+ def __exit__(self, *_args: object) -> None:
559
+ self.close()
560
+
561
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
562
+ if (
563
+ path.startswith("/v2/")
564
+ and path != "/v2/health/ready"
565
+ and not self._discovering
566
+ ):
567
+ self._ensure_discovered()
568
+ normalized_method = method.upper()
569
+ headers = kwargs.get("headers") or {}
570
+ has_idempotency_key = any(
571
+ str(key).lower() == "x-idempotency-key" and bool(value)
572
+ for key, value in dict(headers).items()
573
+ )
574
+ retryable_request = normalized_method in {"GET", "HEAD", "DELETE"} or has_idempotency_key
575
+ retries = self._max_retries if retryable_request else 0
576
+ for attempt in range(retries + 1):
577
+ try:
578
+ response = self._transport.request(method, path, **kwargs)
579
+ except CaptureTransportError:
580
+ if attempt >= retries:
581
+ raise
582
+ self._sleep_before_retry(attempt)
583
+ continue
584
+ except httpx.HTTPError as error:
585
+ if attempt >= retries:
586
+ raise CaptureTransportError(
587
+ "Capture Runtime transport request failed.", str(error)
588
+ ) from error
589
+ self._sleep_before_retry(attempt)
590
+ continue
591
+ if response.status_code in {408, 425, 429, 500, 502, 503, 504} and attempt < retries:
592
+ retry_after = _retry_after_seconds(response)
593
+ response.close()
594
+ self._sleep_before_retry(attempt, retry_after)
595
+ continue
596
+ return response
597
+ raise AssertionError("retry loop must return or raise")
598
+
599
+ def _ensure_discovered(self) -> RuntimeDiscovery:
600
+ if self._discovery is not None:
601
+ return self._discovery
602
+ if self._discovering:
603
+ raise CaptureRuntimeProtocolError("Capture Runtime contract discovery re-entered.")
604
+ return self.discover()
605
+
606
+ @staticmethod
607
+ def _decode_capture_operation(
608
+ response: httpx.Response, requested_capture_id: str
609
+ ) -> CaptureOperation:
610
+ operation = decode_model(response, CaptureOperation)
611
+ if operation.capture_id != requested_capture_id:
612
+ raise CaptureRuntimeProtocolError(
613
+ "Capture Runtime returned an invalid capture identity."
614
+ )
615
+ return operation
616
+
617
+ def _sleep_before_retry(self, attempt: int, retry_after: float | None = None) -> None:
618
+ delay = (
619
+ retry_after
620
+ if retry_after is not None
621
+ else self._retry_backoff_seconds * (2**attempt)
622
+ )
623
+ if delay > 0:
624
+ time.sleep(delay)
625
+
626
+ @staticmethod
627
+ def _assert_compatible(ready: RuntimeReady) -> None:
628
+ failures: list[str] = []
629
+ if ready.api_version != CAPTURE_API_VERSION:
630
+ failures.append(f"API version {ready.api_version} is unsupported")
631
+ if ready.capture_document_schema_version != CAPTURE_DOCUMENT_SCHEMA_VERSION:
632
+ failures.append("CaptureDocument schema version is unsupported")
633
+ if ready.runtime_version != CAPTURE_RUNTIME_VERSION:
634
+ failures.append(
635
+ f"runtime version {ready.runtime_version} is incompatible with "
636
+ f"{CAPTURE_RUNTIME_VERSION}"
637
+ )
638
+ structuring_modes = ready.capabilities.get("structuringModes", [])
639
+ if (
640
+ StructuringMode.HOST.value not in structuring_modes
641
+ and StructuringMode.RUNTIME.value not in structuring_modes
642
+ ):
643
+ failures.append("runtime exposes no structuring mode")
644
+ if failures:
645
+ raise CaptureRuntimeCompatibilityError("; ".join(failures))
646
+
647
+ @staticmethod
648
+ def _assert_compatible_payload(payload: Mapping[str, Any]) -> None:
649
+ failures: list[str] = []
650
+ if payload.get("apiVersion") != CAPTURE_API_VERSION:
651
+ failures.append("API version is unsupported")
652
+ if payload.get("captureDocumentSchemaVersion") != CAPTURE_DOCUMENT_SCHEMA_VERSION:
653
+ failures.append("CaptureDocument schema version is unsupported")
654
+ if failures:
655
+ raise CaptureRuntimeCompatibilityError("; ".join(failures))
656
+
657
+ @staticmethod
658
+ def _validate_contract_index(index: Mapping[str, Any], ready: RuntimeReady) -> None:
659
+ required = {
660
+ "catalogVersion",
661
+ "runtimeVersion",
662
+ "contractSetVersion",
663
+ "surfaces",
664
+ "sha256",
665
+ "href",
666
+ }
667
+ if not required.issubset(index):
668
+ raise CaptureRuntimeProtocolError(
669
+ "Capture Runtime contract index is missing required fields."
670
+ )
671
+ if index.get("catalogVersion") != "2" or index.get("contractSetVersion") != "2":
672
+ raise CaptureRuntimeCompatibilityError(
673
+ "Capture Runtime contract catalog version is unsupported."
674
+ )
675
+ if index.get("runtimeVersion") != ready.runtime_version:
676
+ raise CaptureRuntimeCompatibilityError(
677
+ "Capture Runtime contract catalog runtimeVersion is incompatible."
678
+ )
679
+ if not isinstance(index.get("sha256"), str) or not re.fullmatch(
680
+ r"[0-9a-f]{64}", index["sha256"]
681
+ ):
682
+ raise CaptureRuntimeProtocolError("Capture Runtime contract index hash is invalid.")
683
+ surfaces = index.get("surfaces")
684
+ if not isinstance(surfaces, list) or {
685
+ surface.get("id") for surface in surfaces if isinstance(surface, Mapping)
686
+ } != {"v2"}:
687
+ raise CaptureRuntimeCompatibilityError(
688
+ "Capture Runtime contract catalog does not expose the v2 surface."
689
+ )
690
+
691
+ @staticmethod
692
+ def _validate_contract_bundle(bundle: Any, digest: str) -> str:
693
+ if (
694
+ not isinstance(bundle, Mapping)
695
+ or bundle.get("contractSetVersion") != "2"
696
+ or bundle.get("schemaDialect") != "https://json-schema.org/draft/2020-12/schema"
697
+ ):
698
+ raise CaptureRuntimeProtocolError("Capture Runtime contract bundle is invalid.")
699
+ for field in ("surfaces", "schemas", "operations", "problems", "invariants"):
700
+ if not isinstance(bundle.get(field), list):
701
+ raise CaptureRuntimeProtocolError(
702
+ f"Capture Runtime contract bundle {field} is invalid."
703
+ )
704
+ surfaces = bundle["surfaces"]
705
+ surface_ids = {surface.get("id") for surface in surfaces if isinstance(surface, Mapping)}
706
+ if surface_ids != {"v2"}:
707
+ raise CaptureRuntimeCompatibilityError(
708
+ "Capture Runtime contract bundle does not expose the v2 surface."
709
+ )
710
+ operations_value = bundle["operations"]
711
+ if not isinstance(operations_value, list):
712
+ raise CaptureRuntimeProtocolError(
713
+ "Capture Runtime contract bundle operations are invalid."
714
+ )
715
+ operations: list[Any] = operations_value
716
+ for operation in operations:
717
+ if not isinstance(operation, Mapping) or not all(
718
+ isinstance(operation.get(field), expected)
719
+ for field, expected in (
720
+ ("path", str),
721
+ ("method", str),
722
+ ("surface", str),
723
+ ("body", Mapping),
724
+ ("requiredHeaders", list),
725
+ ("idempotency", Mapping),
726
+ ("responseStatusCodes", list),
727
+ )
728
+ ):
729
+ raise CaptureRuntimeProtocolError(
730
+ "Capture Runtime contract operation metadata is invalid."
731
+ )
732
+ operation_paths = {
733
+ operation.get("path") for operation in operations if isinstance(operation, Mapping)
734
+ }
735
+ required_paths = {
736
+ "/v2/health/ready",
737
+ "/v2/streaming/health/ready",
738
+ "/v2/runtime/requirements",
739
+ "/v2/runtime/installations",
740
+ "/v2/runtime/model-options",
741
+ "/v2/runtime/model-installations",
742
+ "/v2/captures",
743
+ "/v2/captures/{capture_id}/events",
744
+ "/v2/captures/{capture_id}/raw",
745
+ "/v2/captures/{capture_id}/result",
746
+ }
747
+ if not required_paths.issubset(operation_paths):
748
+ raise CaptureRuntimeCompatibilityError(
749
+ "Capture Runtime contract bundle does not advertise the required client surface."
750
+ )
751
+
752
+ def find_operation(path: str) -> Mapping[str, Any] | None:
753
+ return next(
754
+ (
755
+ item
756
+ for item in operations
757
+ if isinstance(item, Mapping) and item.get("path") == path
758
+ ),
759
+ None,
760
+ )
761
+
762
+ upload = find_operation("/v2/captures")
763
+ chunk = find_operation("/v2/ingestions/{ingestion_id}/chunks/{chunk_index}")
764
+ events = find_operation("/v2/captures/{capture_id}/events")
765
+ if (
766
+ upload is None
767
+ or not isinstance(upload.get("body"), Mapping)
768
+ or upload["body"].get("kind") not in {"json", "none"}
769
+ or "X-Idempotency-Key" not in upload.get("requiredHeaders", [])
770
+ ):
771
+ raise CaptureRuntimeCompatibilityError(
772
+ "Capture Runtime v2 capture metadata is incompatible."
773
+ )
774
+ if (
775
+ chunk is None
776
+ or not isinstance(chunk.get("body"), Mapping)
777
+ or chunk["body"].get("kind") != "binary"
778
+ or not all(
779
+ header in chunk.get("requiredHeaders", [])
780
+ for header in ("Content-Range", "Digest", "X-Idempotency-Key")
781
+ )
782
+ ):
783
+ raise CaptureRuntimeCompatibilityError(
784
+ "Capture Runtime v2 chunk metadata is incompatible."
785
+ )
786
+ streaming = events.get("streaming") if events is not None else None
787
+ if (
788
+ events is None
789
+ or events.get("mediaType") != "text/event-stream"
790
+ or not isinstance(streaming, Mapping)
791
+ or streaming.get("kind") != "sse"
792
+ or streaming.get("lastEventIdHeader") != "Last-Event-ID"
793
+ ):
794
+ raise CaptureRuntimeCompatibilityError("Capture Runtime SSE metadata is incompatible.")
795
+ canonical_bundle = json.dumps(
796
+ bundle,
797
+ ensure_ascii=False,
798
+ sort_keys=True,
799
+ separators=(",", ":"),
800
+ allow_nan=False,
801
+ ).encode("utf-8")
802
+ if hashlib.sha256(canonical_bundle).hexdigest() != digest:
803
+ raise CaptureRuntimeCompatibilityError(
804
+ "Capture Runtime contract bundle bytes are not canonical."
805
+ )
806
+ schemas = bundle.get("schemas")
807
+ document = (
808
+ next(
809
+ (
810
+ item
811
+ for item in schemas
812
+ if isinstance(item, Mapping) and item.get("name") == "CaptureDocument"
813
+ ),
814
+ None,
815
+ )
816
+ if isinstance(schemas, list)
817
+ else None
818
+ )
819
+ if (
820
+ not isinstance(document, Mapping)
821
+ or document.get("schemaSha256") != CAPTURE_DOCUMENT_SCHEMA_SHA256
822
+ ):
823
+ raise CaptureRuntimeCompatibilityError(
824
+ "Capture Runtime document schema hash is incompatible."
825
+ )
826
+ return str(document["schemaSha256"])
827
+
828
+
829
+ def _safe_id(value: str) -> str:
830
+ if (
831
+ not value
832
+ or len(value) > 128
833
+ or any(
834
+ char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
835
+ for char in value
836
+ )
837
+ ):
838
+ raise ValueError("Capture Runtime identifier is invalid")
839
+ return value
840
+
841
+
842
+ def _retry_after_seconds(response: httpx.Response) -> float | None:
843
+ value = response.headers.get("retry-after")
844
+ if value is None:
845
+ return None
846
+ try:
847
+ return max(0.0, float(value))
848
+ except ValueError:
849
+ return None
850
+
851
+
852
+ __all__ = ["CaptureRuntimeClient", "CaptureUpload", "CaptureStreamingResult", "RuntimeDiscovery"]