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.
- capture_runtime_client/__init__.py +41 -0
- capture_runtime_client/client.py +852 -0
- capture_runtime_client/codec.py +124 -0
- capture_runtime_client/contracts.py +272 -0
- capture_runtime_client/errors.py +170 -0
- capture_runtime_client/private/__init__.py +1 -0
- capture_runtime_client/private/assets/__init__.py +1 -0
- capture_runtime_client/private/assets/contract-set.json +1 -0
- capture_runtime_client/private/assets/contract-set.sha256 +1 -0
- capture_runtime_client/private/generated_models.py +627 -0
- capture_runtime_client/private/schemas/__init__.py +1 -0
- capture_runtime_client/private/schemas/capture-block.schema.json +151 -0
- capture_runtime_client/private/schemas/capture-document.schema.json +356 -0
- capture_runtime_client/private/schemas/capture-engine.schema.json +40 -0
- capture_runtime_client/private/schemas/capture-event-v2.schema.json +301 -0
- capture_runtime_client/private/schemas/capture-failure-v2.schema.json +40 -0
- capture_runtime_client/private/schemas/capture-operation-v2.schema.json +207 -0
- capture_runtime_client/private/schemas/capture-source.schema.json +34 -0
- capture_runtime_client/private/schemas/error-body-v2.schema.json +34 -0
- capture_runtime_client/private/schemas/error-envelope-v2.schema.json +49 -0
- capture_runtime_client/private/schemas/finalize-ingestion-v2.schema.json +27 -0
- capture_runtime_client/private/schemas/ingestion-v2.schema.json +127 -0
- capture_runtime_client/private/schemas/open-ingestion-v2.schema.json +79 -0
- capture_runtime_client/private/schemas/page-locator.schema.json +49 -0
- capture_runtime_client/private/schemas/partial-capture-v2.schema.json +267 -0
- capture_runtime_client/private/schemas/raw-capture-segment.schema.json +125 -0
- capture_runtime_client/private/schemas/raw-capture.schema.json +260 -0
- capture_runtime_client/private/schemas/report-structuring-failure-v2.schema.json +28 -0
- capture_runtime_client/private/schemas/runtime-artifact-descriptor-v2.schema.json +34 -0
- capture_runtime_client/private/schemas/runtime-installation-v2.schema.json +126 -0
- capture_runtime_client/private/schemas/runtime-installations-v2.schema.json +143 -0
- capture_runtime_client/private/schemas/runtime-model-installation-v2.schema.json +121 -0
- capture_runtime_client/private/schemas/runtime-model-installations-v2.schema.json +138 -0
- capture_runtime_client/private/schemas/runtime-model-option-v2.schema.json +80 -0
- capture_runtime_client/private/schemas/runtime-model-options-v2.schema.json +103 -0
- capture_runtime_client/private/schemas/runtime-ready.schema.json +88 -0
- capture_runtime_client/private/schemas/runtime-requirement-v2.schema.json +120 -0
- capture_runtime_client/private/schemas/runtime-requirements-v2.schema.json +137 -0
- capture_runtime_client/private/schemas/runtime-streaming-capabilities-v2.schema.json +64 -0
- capture_runtime_client/private/schemas/start-capture-v2.schema.json +59 -0
- capture_runtime_client/private/schemas/start-runtime-installation-v2.schema.json +26 -0
- capture_runtime_client/private/schemas/start-runtime-model-installation-v2.schema.json +21 -0
- capture_runtime_client/private/schemas/time-locator.schema.json +27 -0
- capture_runtime_client/py.typed +1 -0
- capture_runtime_client/transport.py +158 -0
- capture_runtime_client-0.4.0.dist-info/METADATA +18 -0
- capture_runtime_client-0.4.0.dist-info/RECORD +48 -0
- 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"]
|