launchhelm 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.
- launchhelm/__init__.py +35 -0
- launchhelm/_version.py +5 -0
- launchhelm/client.py +1660 -0
- launchhelm/commands.py +231 -0
- launchhelm/env.py +70 -0
- launchhelm/errors.py +62 -0
- launchhelm/models.py +6546 -0
- launchhelm/py.typed +0 -0
- launchhelm/rich.py +807 -0
- launchhelm/runtime.py +1348 -0
- launchhelm/session.py +2824 -0
- launchhelm/store.py +893 -0
- launchhelm/system.py +213 -0
- launchhelm/torch/__init__.py +41 -0
- launchhelm/torch/_checkpoint.py +181 -0
- launchhelm/torch/_controls.py +306 -0
- launchhelm/torch/_dist.py +210 -0
- launchhelm/torch/_trainer.py +1167 -0
- launchhelm/torch/hf.py +172 -0
- launchhelm/torch/lightning.py +179 -0
- launchhelm/transport.py +197 -0
- launchhelm/values.py +325 -0
- launchhelm-0.1.0.dist-info/METADATA +193 -0
- launchhelm-0.1.0.dist-info/RECORD +26 -0
- launchhelm-0.1.0.dist-info/WHEEL +4 -0
- launchhelm-0.1.0.dist-info/licenses/LICENSE +201 -0
launchhelm/client.py
ADDED
|
@@ -0,0 +1,1660 @@
|
|
|
1
|
+
"""Synchronous HTTP client for the LaunchHelm v1 API.
|
|
2
|
+
|
|
3
|
+
Contract: HTTPS is required outside loopback. Request bodies that are already
|
|
4
|
+
encoded bytes are sent unchanged so telemetry fingerprints stay stable.
|
|
5
|
+
Responses larger than 8 MiB are rejected. Redirects are not followed.
|
|
6
|
+
|
|
7
|
+
Failure: ``LaunchHelmError`` for transport uncertainty (retryable), HTTP
|
|
8
|
+
problem documents, and invalid JSON. ``ValueError`` for a malformed base URL
|
|
9
|
+
or organization id.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from datetime import UTC, datetime, timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Protocol
|
|
21
|
+
from urllib.parse import urlparse
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from ._version import __version__
|
|
26
|
+
from .errors import LaunchHelmError
|
|
27
|
+
from .models import (
|
|
28
|
+
AckInput,
|
|
29
|
+
AckResult,
|
|
30
|
+
AnalysisClaimInput,
|
|
31
|
+
AnalysisLeaseInput,
|
|
32
|
+
AnalysisReport,
|
|
33
|
+
AnalysisRunInput,
|
|
34
|
+
DeliveryClaimInput,
|
|
35
|
+
DeliveryPage,
|
|
36
|
+
Document,
|
|
37
|
+
ExportInput,
|
|
38
|
+
InvitationInput,
|
|
39
|
+
InvitationResendInput,
|
|
40
|
+
NotificationDispatchResult,
|
|
41
|
+
NotificationInput,
|
|
42
|
+
NotificationReadInput,
|
|
43
|
+
PublicDocument,
|
|
44
|
+
PublicDocumentPage,
|
|
45
|
+
PublicViewInput,
|
|
46
|
+
PublicViewPublication,
|
|
47
|
+
PublicViewToken,
|
|
48
|
+
PublicViewUpdate,
|
|
49
|
+
Query,
|
|
50
|
+
QueryResult,
|
|
51
|
+
RetentionHoldInput,
|
|
52
|
+
RetentionPlan,
|
|
53
|
+
RetentionRestoreInput,
|
|
54
|
+
Scope,
|
|
55
|
+
UsageInput,
|
|
56
|
+
UsageReport,
|
|
57
|
+
)
|
|
58
|
+
from .store import TelemetryBatch
|
|
59
|
+
from .transport import AbortableTransport
|
|
60
|
+
from .values import (
|
|
61
|
+
JSONObject,
|
|
62
|
+
JSONValue,
|
|
63
|
+
as_json_value,
|
|
64
|
+
encode,
|
|
65
|
+
json_object,
|
|
66
|
+
string_object,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
MAX_RESPONSE_BYTES = 8 << 20
|
|
70
|
+
UPLOAD_TIMEOUT_SECONDS = 120
|
|
71
|
+
MIN_PART_SIZE = 5 << 20
|
|
72
|
+
MAX_PART_SIZE = 64 << 20
|
|
73
|
+
MAX_PARTS = 10000
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RuntimeClient(Protocol):
|
|
77
|
+
"""Subset of ``Client`` required by ``Runtime``.
|
|
78
|
+
|
|
79
|
+
Contract: implementations must allow concurrent calls from the runtime's
|
|
80
|
+
control thread, uploader thread, and caller thread. ``httpx.Client``
|
|
81
|
+
satisfies this; test doubles must not share mutable response buffers
|
|
82
|
+
without their own locking.
|
|
83
|
+
|
|
84
|
+
Failure: methods raise ``LaunchHelmError`` using the same codes as
|
|
85
|
+
``Client``.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
base_url: str
|
|
89
|
+
organization_id: str
|
|
90
|
+
|
|
91
|
+
def get_document(self, document_id: str, *, revision: str = "") -> Document:
|
|
92
|
+
"""See ``Client.get_document``."""
|
|
93
|
+
|
|
94
|
+
def connect(
|
|
95
|
+
self,
|
|
96
|
+
participant_id: str,
|
|
97
|
+
instance_id: str,
|
|
98
|
+
*,
|
|
99
|
+
safe_points: tuple[str, ...] = (),
|
|
100
|
+
attempt_id: str = "",
|
|
101
|
+
) -> JSONObject:
|
|
102
|
+
"""See ``Client.connect``."""
|
|
103
|
+
|
|
104
|
+
def heartbeat(
|
|
105
|
+
self, participant_id: str, epoch: str, *, safe_points: tuple[str, ...] = ()
|
|
106
|
+
) -> JSONObject:
|
|
107
|
+
"""See ``Client.heartbeat``."""
|
|
108
|
+
|
|
109
|
+
def claim(self, participant_id: str, epoch: str, *, wait_ms: int = 0) -> JSONObject | None:
|
|
110
|
+
"""See ``Client.claim``."""
|
|
111
|
+
|
|
112
|
+
def create_document(self, kind: str, scope: Scope, spec: JSONObject) -> Document:
|
|
113
|
+
"""See ``Client.create_document``."""
|
|
114
|
+
|
|
115
|
+
def list_documents(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
kind: str = "",
|
|
119
|
+
project_id: str = "",
|
|
120
|
+
run_id: str = "",
|
|
121
|
+
cursor: str = "",
|
|
122
|
+
limit: int = 100,
|
|
123
|
+
) -> JSONObject:
|
|
124
|
+
"""See ``Client.list_documents``."""
|
|
125
|
+
|
|
126
|
+
def query(
|
|
127
|
+
self,
|
|
128
|
+
stream_id: str,
|
|
129
|
+
*,
|
|
130
|
+
start: str,
|
|
131
|
+
end: str,
|
|
132
|
+
limit: int = 1000,
|
|
133
|
+
order: str = "",
|
|
134
|
+
after_epoch: str = "",
|
|
135
|
+
after_sequence: str = "",
|
|
136
|
+
) -> JSONObject:
|
|
137
|
+
"""See ``Client.query``."""
|
|
138
|
+
|
|
139
|
+
def get_command(self, command_id: str) -> JSONObject:
|
|
140
|
+
"""See ``Client.get_command``."""
|
|
141
|
+
|
|
142
|
+
def begin(self, command: JSONObject) -> JSONObject:
|
|
143
|
+
"""See ``Client.begin``."""
|
|
144
|
+
|
|
145
|
+
def report(self, command: JSONObject, result: JSONObject) -> JSONObject:
|
|
146
|
+
"""See ``Client.report``."""
|
|
147
|
+
|
|
148
|
+
def renew(self, command: JSONObject) -> None:
|
|
149
|
+
"""See ``Client.renew``."""
|
|
150
|
+
|
|
151
|
+
def ingest(self, batch: TelemetryBatch) -> JSONObject:
|
|
152
|
+
"""See ``Client.ingest``."""
|
|
153
|
+
|
|
154
|
+
def abort(self) -> None:
|
|
155
|
+
"""See ``Client.abort``."""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class SessionClient(RuntimeClient, Protocol):
|
|
159
|
+
"""Subset of ``Client`` required by ``init`` and ``Run``.
|
|
160
|
+
|
|
161
|
+
Contract: adds the document administration calls a session uses beyond
|
|
162
|
+
the runtime surface. Capability-dependent calls (``create_key``,
|
|
163
|
+
``upload_file``, ``download_artifact``) stay optional and are probed
|
|
164
|
+
with ``getattr``.
|
|
165
|
+
|
|
166
|
+
Failure: methods raise ``LaunchHelmError`` using the same codes as
|
|
167
|
+
``Client``.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def update_document(self, document: Document, spec: JSONObject) -> Document:
|
|
171
|
+
"""See ``Client.update_document``."""
|
|
172
|
+
|
|
173
|
+
def close(self) -> None:
|
|
174
|
+
"""See ``Client.close``."""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Client:
|
|
178
|
+
"""Authenticated LaunchHelm HTTP client.
|
|
179
|
+
|
|
180
|
+
Contract: every JSON request body is ``encode``d unless the caller passes
|
|
181
|
+
already-encoded bytes (telemetry ingest). The process must not put
|
|
182
|
+
credentials in ``base_url``.
|
|
183
|
+
|
|
184
|
+
Failure: see module docstring.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
base_url: str,
|
|
190
|
+
api_key: str,
|
|
191
|
+
organization_id: str,
|
|
192
|
+
*,
|
|
193
|
+
transport: httpx.BaseTransport | None = None,
|
|
194
|
+
timeout: float = 30,
|
|
195
|
+
) -> None:
|
|
196
|
+
parsed = urlparse(base_url)
|
|
197
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
198
|
+
raise ValueError("base URL must not contain credentials, query, or fragment")
|
|
199
|
+
if parsed.scheme != "https" and not (
|
|
200
|
+
parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}
|
|
201
|
+
):
|
|
202
|
+
raise ValueError("HTTPS is required outside loopback")
|
|
203
|
+
uuid.UUID(organization_id)
|
|
204
|
+
self.base_url = base_url.rstrip("/")
|
|
205
|
+
self.organization_id = organization_id
|
|
206
|
+
self._api_key = api_key
|
|
207
|
+
self._timeout = timeout
|
|
208
|
+
self._transport: httpx.BaseTransport = (
|
|
209
|
+
transport if transport is not None else AbortableTransport()
|
|
210
|
+
)
|
|
211
|
+
self.http = httpx.Client(
|
|
212
|
+
base_url=self.base_url,
|
|
213
|
+
timeout=timeout,
|
|
214
|
+
follow_redirects=False,
|
|
215
|
+
transport=self._transport,
|
|
216
|
+
headers={
|
|
217
|
+
"Authorization": f"Bearer {api_key}",
|
|
218
|
+
"X-LaunchHelm-Protocol": "1.0",
|
|
219
|
+
"User-Agent": f"launchhelm-python/{__version__}",
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
self._prefix = f"/v1/organizations/{organization_id}"
|
|
223
|
+
|
|
224
|
+
def close(self) -> None:
|
|
225
|
+
"""Close the underlying HTTP pool.
|
|
226
|
+
|
|
227
|
+
Contract: idle connections are released; further requests raise from
|
|
228
|
+
httpx. A request blocked in another thread is not interrupted; use
|
|
229
|
+
``abort`` for that.
|
|
230
|
+
|
|
231
|
+
Failure: none.
|
|
232
|
+
"""
|
|
233
|
+
self.http.close()
|
|
234
|
+
|
|
235
|
+
def abort(self) -> None:
|
|
236
|
+
"""Fail in-flight requests immediately, then close the pool.
|
|
237
|
+
|
|
238
|
+
Contract: with the default transport, a parked long-poll claim in
|
|
239
|
+
another thread raises ``LaunchHelmError("transport")`` within
|
|
240
|
+
milliseconds; ``Runtime.close`` relies on this so shutdown does not
|
|
241
|
+
wait out the claim. A caller-supplied transport that lacks ``abort``
|
|
242
|
+
is only closed.
|
|
243
|
+
|
|
244
|
+
Failure: none.
|
|
245
|
+
"""
|
|
246
|
+
abort = getattr(self._transport, "abort", None)
|
|
247
|
+
if callable(abort):
|
|
248
|
+
abort()
|
|
249
|
+
self.close()
|
|
250
|
+
|
|
251
|
+
def __enter__(self) -> Client:
|
|
252
|
+
"""Return ``self`` for ``with Client(...)`` usage.
|
|
253
|
+
|
|
254
|
+
Contract: ``__exit__`` closes the pool.
|
|
255
|
+
|
|
256
|
+
Failure: none.
|
|
257
|
+
"""
|
|
258
|
+
return self
|
|
259
|
+
|
|
260
|
+
def __exit__(self, *args: object) -> None:
|
|
261
|
+
"""Close the client, ignoring the block exception.
|
|
262
|
+
|
|
263
|
+
Contract: does not suppress exceptions from the ``with`` body.
|
|
264
|
+
|
|
265
|
+
Failure: none.
|
|
266
|
+
"""
|
|
267
|
+
self.close()
|
|
268
|
+
|
|
269
|
+
def request(
|
|
270
|
+
self,
|
|
271
|
+
method: str,
|
|
272
|
+
path: str,
|
|
273
|
+
data: JSONValue | bytes | None = None,
|
|
274
|
+
*,
|
|
275
|
+
idempotency_key: str | None = None,
|
|
276
|
+
params: dict[str, JSONValue] | None = None,
|
|
277
|
+
timeout: float | None = None,
|
|
278
|
+
bearer: str | None = None,
|
|
279
|
+
) -> JSONValue:
|
|
280
|
+
"""Send one HTTP request and parse a JSON response.
|
|
281
|
+
|
|
282
|
+
Contract: ``data`` bytes are sent unchanged; other values are passed
|
|
283
|
+
through ``encode``. Problem-document errors become ``LaunchHelmError``.
|
|
284
|
+
``timeout`` overrides the client default for this request only.
|
|
285
|
+
``bearer`` replaces the client API key on this request.
|
|
286
|
+
|
|
287
|
+
Failure: ``ValueError`` for a non-relative path. ``LaunchHelmError``
|
|
288
|
+
for transport, oversize responses, invalid JSON, or HTTP errors.
|
|
289
|
+
"""
|
|
290
|
+
if not path.startswith("/") or path.startswith("//"):
|
|
291
|
+
raise ValueError("API path must be relative to the configured server")
|
|
292
|
+
headers = {"Content-Type": "application/json"}
|
|
293
|
+
if bearer is not None:
|
|
294
|
+
headers["Authorization"] = f"Bearer {bearer}"
|
|
295
|
+
if idempotency_key:
|
|
296
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
297
|
+
content = data if isinstance(data, bytes) else (encode(data) if data is not None else None)
|
|
298
|
+
query = _query_params(params)
|
|
299
|
+
stream_timeout = timeout if timeout is not None else self.http.timeout
|
|
300
|
+
try:
|
|
301
|
+
with self.http.stream(
|
|
302
|
+
method,
|
|
303
|
+
path,
|
|
304
|
+
content=content,
|
|
305
|
+
headers=headers,
|
|
306
|
+
params=query,
|
|
307
|
+
timeout=stream_timeout,
|
|
308
|
+
) as response:
|
|
309
|
+
chunks = bytearray()
|
|
310
|
+
for chunk in response.iter_bytes():
|
|
311
|
+
chunks.extend(chunk)
|
|
312
|
+
if len(chunks) > MAX_RESPONSE_BYTES:
|
|
313
|
+
raise LaunchHelmError("response_too_large", "Use a smaller page")
|
|
314
|
+
try:
|
|
315
|
+
parsed: object = json.loads(chunks)
|
|
316
|
+
except (ValueError, UnicodeError):
|
|
317
|
+
if response.status_code >= 300:
|
|
318
|
+
# A proxy or load balancer answered (for example a 502
|
|
319
|
+
# page while the API restarts). Classify by status so
|
|
320
|
+
# a deploy is retried like any other 5xx.
|
|
321
|
+
raise LaunchHelmError(
|
|
322
|
+
"http_error",
|
|
323
|
+
f"HTTP {response.status_code} without a problem document",
|
|
324
|
+
status=response.status_code,
|
|
325
|
+
retryable=response.status_code >= 500 or response.status_code == 429,
|
|
326
|
+
) from None
|
|
327
|
+
raise LaunchHelmError(
|
|
328
|
+
"invalid_response",
|
|
329
|
+
"Server returned invalid JSON",
|
|
330
|
+
status=response.status_code,
|
|
331
|
+
) from None
|
|
332
|
+
result = _json_value(parsed)
|
|
333
|
+
if response.status_code >= 300:
|
|
334
|
+
problem = result.get("error") if isinstance(result, dict) else None
|
|
335
|
+
details = problem if isinstance(problem, dict) else {}
|
|
336
|
+
code = details.get("code")
|
|
337
|
+
message = details.get("message")
|
|
338
|
+
retryable_flag = details.get("retryable", False)
|
|
339
|
+
raise LaunchHelmError(
|
|
340
|
+
code if isinstance(code, str) else "http_error",
|
|
341
|
+
message if isinstance(message, str) else "Request failed",
|
|
342
|
+
status=response.status_code,
|
|
343
|
+
retryable=(retryable_flag if isinstance(retryable_flag, bool) else False)
|
|
344
|
+
or response.status_code >= 500,
|
|
345
|
+
)
|
|
346
|
+
return result
|
|
347
|
+
except httpx.HTTPError:
|
|
348
|
+
raise LaunchHelmError(
|
|
349
|
+
"transport", "Request delivery or outcome is uncertain", retryable=True
|
|
350
|
+
) from None
|
|
351
|
+
|
|
352
|
+
def create_document(self, kind: str, scope: Scope, spec: JSONObject) -> Document:
|
|
353
|
+
"""Create a resource in ``scope``.
|
|
354
|
+
|
|
355
|
+
Contract: ``scope.organization_id`` must match this client.
|
|
356
|
+
|
|
357
|
+
Failure: ``ValueError`` on organization mismatch. ``LaunchHelmError``
|
|
358
|
+
from the server.
|
|
359
|
+
"""
|
|
360
|
+
if scope.organization_id != self.organization_id:
|
|
361
|
+
raise ValueError("organization scope mismatch")
|
|
362
|
+
return Document.parse(
|
|
363
|
+
json_object(
|
|
364
|
+
self.request(
|
|
365
|
+
"POST",
|
|
366
|
+
self._prefix + "/documents",
|
|
367
|
+
{
|
|
368
|
+
"kind": kind,
|
|
369
|
+
"scope": string_object(scope.wire()),
|
|
370
|
+
"spec": spec,
|
|
371
|
+
},
|
|
372
|
+
),
|
|
373
|
+
what="document",
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def create_documents(self, documents: list[tuple[str, Scope, JSONObject]]) -> list[Document]:
|
|
378
|
+
"""Create every document in one request.
|
|
379
|
+
|
|
380
|
+
Contract: the server commits the batch together. ``documents`` holds
|
|
381
|
+
one to 1024 ``(kind, scope, spec)`` triples. Every scope uses this
|
|
382
|
+
client's organization.
|
|
383
|
+
|
|
384
|
+
Failure: ``ValueError`` on an empty batch, a batch over 1024, or an
|
|
385
|
+
organization mismatch. ``LaunchHelmError`` from the server. A rejected
|
|
386
|
+
batch creates nothing.
|
|
387
|
+
"""
|
|
388
|
+
if not documents or len(documents) > 1024:
|
|
389
|
+
raise ValueError("document batch must contain 1 to 1024 documents")
|
|
390
|
+
body: list[JSONValue] = []
|
|
391
|
+
for kind, scope, spec in documents:
|
|
392
|
+
if scope.organization_id != self.organization_id:
|
|
393
|
+
raise ValueError("organization scope mismatch")
|
|
394
|
+
body.append(
|
|
395
|
+
{
|
|
396
|
+
"kind": kind,
|
|
397
|
+
"scope": string_object(scope.wire()),
|
|
398
|
+
"spec": spec,
|
|
399
|
+
}
|
|
400
|
+
)
|
|
401
|
+
result = json_object(
|
|
402
|
+
self.request("POST", self._prefix + "/document-batches", {"documents": body}),
|
|
403
|
+
what="document batch",
|
|
404
|
+
)
|
|
405
|
+
opened = result.get("documents")
|
|
406
|
+
if not isinstance(opened, list) or len(opened) != len(documents):
|
|
407
|
+
raise LaunchHelmError("protocol", "document batch length does not match")
|
|
408
|
+
return [Document.parse(json_object(item, what="document")) for item in opened]
|
|
409
|
+
|
|
410
|
+
def create_key(self, participant: Document) -> JSONObject:
|
|
411
|
+
"""Mint a worker key bound to ``participant`` and its run.
|
|
412
|
+
|
|
413
|
+
Contract: the key can connect, write telemetry, and read or create
|
|
414
|
+
documents in that run. It expires in seven days. The token is returned
|
|
415
|
+
once.
|
|
416
|
+
|
|
417
|
+
Failure: ``ValueError`` on organization mismatch. ``LaunchHelmError``
|
|
418
|
+
from the server.
|
|
419
|
+
"""
|
|
420
|
+
scope = participant.scope
|
|
421
|
+
if scope.organization_id != self.organization_id:
|
|
422
|
+
raise ValueError("organization scope mismatch")
|
|
423
|
+
expires = (datetime.now(UTC) + timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
424
|
+
return json_object(
|
|
425
|
+
self.request(
|
|
426
|
+
"POST",
|
|
427
|
+
self._prefix + "/keys",
|
|
428
|
+
{
|
|
429
|
+
"name": "logger",
|
|
430
|
+
"participant_id": participant.id,
|
|
431
|
+
"expires_at": expires,
|
|
432
|
+
"grants": [
|
|
433
|
+
{
|
|
434
|
+
"effect": "allow",
|
|
435
|
+
"scope": string_object(scope.wire()),
|
|
436
|
+
"actions": [
|
|
437
|
+
"participant.connect",
|
|
438
|
+
"telemetry.write",
|
|
439
|
+
"document.create",
|
|
440
|
+
"document.read",
|
|
441
|
+
"document.list",
|
|
442
|
+
"document.update",
|
|
443
|
+
"artifact.write",
|
|
444
|
+
"artifact.download",
|
|
445
|
+
"command.claim",
|
|
446
|
+
"command.report",
|
|
447
|
+
],
|
|
448
|
+
"kinds": ["*"],
|
|
449
|
+
"resources": ["*"],
|
|
450
|
+
"bounds": {},
|
|
451
|
+
}
|
|
452
|
+
],
|
|
453
|
+
},
|
|
454
|
+
),
|
|
455
|
+
what="key",
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def adopt(self, token: str) -> None:
|
|
459
|
+
"""Use ``token`` for later requests from this client."""
|
|
460
|
+
self.http.headers["Authorization"] = f"Bearer {token}"
|
|
461
|
+
|
|
462
|
+
def with_key(self) -> Client:
|
|
463
|
+
"""Return a client on this origin using the construction credential.
|
|
464
|
+
|
|
465
|
+
Contract: the credential this client was built with is reused — not a
|
|
466
|
+
token later installed by ``adopt``. The derived client owns its own
|
|
467
|
+
connection pool; the caller closes it. The credential stays in
|
|
468
|
+
memory only.
|
|
469
|
+
|
|
470
|
+
Failure: same as construction.
|
|
471
|
+
"""
|
|
472
|
+
return Client(
|
|
473
|
+
self.base_url,
|
|
474
|
+
self._api_key,
|
|
475
|
+
self.organization_id,
|
|
476
|
+
timeout=self._timeout,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
def end_session(
|
|
480
|
+
self, participant_id: str, epoch: str, *, close_run: str = "never"
|
|
481
|
+
) -> JSONObject:
|
|
482
|
+
"""End the participant's live session epoch.
|
|
483
|
+
|
|
484
|
+
Contract: ``close_run`` is ``never`` or ``when_idle``. With
|
|
485
|
+
``when_idle`` the run document closes when no other participant still
|
|
486
|
+
holds a live session epoch. Re-ending an already finished epoch
|
|
487
|
+
succeeds. The response carries the finished ``session`` epoch record
|
|
488
|
+
and ``run_closed``.
|
|
489
|
+
|
|
490
|
+
Failure: ``ValueError`` for a malformed id or unknown policy.
|
|
491
|
+
``LaunchHelmError`` ``conflict`` when ``epoch`` is not the live
|
|
492
|
+
epoch, ``forbidden`` for a credential not bound to the participant.
|
|
493
|
+
"""
|
|
494
|
+
uuid.UUID(participant_id)
|
|
495
|
+
if close_run not in {"never", "when_idle"}:
|
|
496
|
+
raise ValueError("close_run must be never or when_idle")
|
|
497
|
+
return json_object(
|
|
498
|
+
self.request(
|
|
499
|
+
"POST",
|
|
500
|
+
self._prefix + f"/participants/{participant_id}/session/end",
|
|
501
|
+
{"epoch": epoch, "close_run": close_run},
|
|
502
|
+
),
|
|
503
|
+
what="session end",
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
def get_document(self, document_id: str, *, revision: str = "") -> Document:
|
|
507
|
+
"""Fetch a resource by id, optionally at a historical revision.
|
|
508
|
+
|
|
509
|
+
Contract: ``document_id`` must be a UUID.
|
|
510
|
+
|
|
511
|
+
Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` from
|
|
512
|
+
the server.
|
|
513
|
+
"""
|
|
514
|
+
uuid.UUID(document_id)
|
|
515
|
+
return Document.parse(
|
|
516
|
+
json_object(
|
|
517
|
+
self.request(
|
|
518
|
+
"GET",
|
|
519
|
+
self._prefix + f"/documents/{document_id}",
|
|
520
|
+
params={"revision": revision} if revision else None,
|
|
521
|
+
),
|
|
522
|
+
what="document",
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def update_document(self, document: Document, spec: JSONObject) -> Document:
|
|
527
|
+
"""Patch ``document`` expecting its current revision.
|
|
528
|
+
|
|
529
|
+
Contract: uses ``expected_revision`` for optimistic concurrency.
|
|
530
|
+
|
|
531
|
+
Failure: ``LaunchHelmError`` with ``conflict`` if the revision moved.
|
|
532
|
+
"""
|
|
533
|
+
return Document.parse(
|
|
534
|
+
json_object(
|
|
535
|
+
self.request(
|
|
536
|
+
"PATCH",
|
|
537
|
+
self._prefix + f"/documents/{document.id}",
|
|
538
|
+
{"expected_revision": document.revision, "spec": spec},
|
|
539
|
+
),
|
|
540
|
+
what="document",
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
def list_documents(
|
|
545
|
+
self,
|
|
546
|
+
*,
|
|
547
|
+
kind: str = "",
|
|
548
|
+
project_id: str = "",
|
|
549
|
+
run_id: str = "",
|
|
550
|
+
cursor: str = "",
|
|
551
|
+
limit: int = 100,
|
|
552
|
+
) -> JSONObject:
|
|
553
|
+
"""List resources with the given filters.
|
|
554
|
+
|
|
555
|
+
Contract: empty filters are sent as empty strings; the server treats
|
|
556
|
+
them as unconstrained.
|
|
557
|
+
|
|
558
|
+
Failure: ``LaunchHelmError`` from the server.
|
|
559
|
+
"""
|
|
560
|
+
return json_object(
|
|
561
|
+
self.request(
|
|
562
|
+
"GET",
|
|
563
|
+
self._prefix + "/documents",
|
|
564
|
+
params={
|
|
565
|
+
"kind": kind,
|
|
566
|
+
"project_id": project_id,
|
|
567
|
+
"run_id": run_id,
|
|
568
|
+
"cursor": cursor,
|
|
569
|
+
"limit": limit,
|
|
570
|
+
},
|
|
571
|
+
),
|
|
572
|
+
what="document list",
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def submit_command(self, request: JSONObject, *, idempotency_key: str) -> JSONObject:
|
|
576
|
+
"""Submit a command with a caller-chosen idempotency key.
|
|
577
|
+
|
|
578
|
+
Contract: repeating the same key and fingerprint returns the original
|
|
579
|
+
command; a different fingerprint with the same key is a conflict.
|
|
580
|
+
|
|
581
|
+
Failure: ``LaunchHelmError`` from the server.
|
|
582
|
+
"""
|
|
583
|
+
return json_object(
|
|
584
|
+
self.request(
|
|
585
|
+
"POST",
|
|
586
|
+
self._prefix + "/commands",
|
|
587
|
+
request,
|
|
588
|
+
idempotency_key=idempotency_key,
|
|
589
|
+
),
|
|
590
|
+
what="command",
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
def get_command(self, command_id: str) -> JSONObject:
|
|
594
|
+
"""Fetch a command by id.
|
|
595
|
+
|
|
596
|
+
Contract: ``command_id`` must be a UUID.
|
|
597
|
+
|
|
598
|
+
Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` from
|
|
599
|
+
the server.
|
|
600
|
+
"""
|
|
601
|
+
uuid.UUID(command_id)
|
|
602
|
+
return json_object(
|
|
603
|
+
self.request("GET", self._prefix + f"/commands/{command_id}"),
|
|
604
|
+
what="command",
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
def decide_command(self, command: JSONObject, action: str) -> JSONObject:
|
|
608
|
+
"""Approve or cancel ``command`` using its fingerprint.
|
|
609
|
+
|
|
610
|
+
Contract: ``action`` is ``approve`` or ``cancel``.
|
|
611
|
+
|
|
612
|
+
Failure: ``ValueError`` for an unknown action. ``LaunchHelmError``
|
|
613
|
+
from the server.
|
|
614
|
+
"""
|
|
615
|
+
if action not in {"approve", "cancel"}:
|
|
616
|
+
raise ValueError("invalid command decision")
|
|
617
|
+
return json_object(
|
|
618
|
+
self.request(
|
|
619
|
+
"POST",
|
|
620
|
+
self._prefix + f"/commands/{command['id']}/{action}",
|
|
621
|
+
{"fingerprint": command["fingerprint"]},
|
|
622
|
+
),
|
|
623
|
+
what="command",
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
def events(self, *, cursor: str = "0", limit: int = 100) -> JSONObject:
|
|
627
|
+
"""Read a page of organization events.
|
|
628
|
+
|
|
629
|
+
Contract: ``cursor`` ``0`` starts at the beginning of the log.
|
|
630
|
+
|
|
631
|
+
Failure: ``LaunchHelmError`` from the server.
|
|
632
|
+
"""
|
|
633
|
+
return json_object(
|
|
634
|
+
self.request(
|
|
635
|
+
"GET",
|
|
636
|
+
self._prefix + "/events",
|
|
637
|
+
params={"cursor": cursor, "limit": limit},
|
|
638
|
+
),
|
|
639
|
+
what="events",
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
def claim_deliveries(
|
|
643
|
+
self,
|
|
644
|
+
subscription_id: str,
|
|
645
|
+
input_: DeliveryClaimInput | None = None,
|
|
646
|
+
) -> DeliveryPage:
|
|
647
|
+
"""Lease one ordered delivery batch from an event subscription.
|
|
648
|
+
|
|
649
|
+
Contract: unacknowledged deliveries are at-least-once. The returned
|
|
650
|
+
lease id and cursor must be passed to ``ack_deliveries`` after durable
|
|
651
|
+
consumer handling.
|
|
652
|
+
|
|
653
|
+
Failure: ``ValueError`` for a malformed id. ``LaunchHelmError`` from
|
|
654
|
+
the server.
|
|
655
|
+
"""
|
|
656
|
+
uuid.UUID(subscription_id)
|
|
657
|
+
return DeliveryPage.parse(
|
|
658
|
+
json_object(
|
|
659
|
+
self.request(
|
|
660
|
+
"POST",
|
|
661
|
+
self._prefix + f"/event-subscriptions/{subscription_id}/claim",
|
|
662
|
+
(input_ or DeliveryClaimInput()).wire(),
|
|
663
|
+
),
|
|
664
|
+
what="delivery_page",
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
def ack_deliveries(
|
|
669
|
+
self,
|
|
670
|
+
subscription_id: str,
|
|
671
|
+
input_: AckInput,
|
|
672
|
+
) -> AckResult:
|
|
673
|
+
"""Cumulatively acknowledge deliveries held by one lease."""
|
|
674
|
+
uuid.UUID(subscription_id)
|
|
675
|
+
return AckResult.parse(
|
|
676
|
+
json_object(
|
|
677
|
+
self.request(
|
|
678
|
+
"POST",
|
|
679
|
+
self._prefix + f"/event-subscriptions/{subscription_id}/ack",
|
|
680
|
+
input_.wire(),
|
|
681
|
+
),
|
|
682
|
+
what="ack_result",
|
|
683
|
+
)
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
def dispatch_notification(self, input_: NotificationInput) -> NotificationDispatchResult:
|
|
687
|
+
"""Queue, deduplicate, or preference-suppress one notification."""
|
|
688
|
+
return NotificationDispatchResult.parse(
|
|
689
|
+
json_object(
|
|
690
|
+
self.request("POST", self._prefix + "/notifications", input_.wire()),
|
|
691
|
+
what="notification_dispatch_result",
|
|
692
|
+
)
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
def list_notifications(
|
|
696
|
+
self,
|
|
697
|
+
*,
|
|
698
|
+
account_id: str = "",
|
|
699
|
+
state: str = "",
|
|
700
|
+
cursor: str = "",
|
|
701
|
+
limit: int = 50,
|
|
702
|
+
) -> JSONObject:
|
|
703
|
+
"""Return one inbox page, optionally filtered by account and state."""
|
|
704
|
+
return json_object(
|
|
705
|
+
self.request(
|
|
706
|
+
"GET",
|
|
707
|
+
self._prefix + "/notifications",
|
|
708
|
+
params={
|
|
709
|
+
"account_id": account_id,
|
|
710
|
+
"state": state,
|
|
711
|
+
"cursor": cursor,
|
|
712
|
+
"limit": limit,
|
|
713
|
+
},
|
|
714
|
+
),
|
|
715
|
+
what="notification_page",
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
def read_notification(
|
|
719
|
+
self,
|
|
720
|
+
notification_id: str,
|
|
721
|
+
input_: NotificationReadInput | None = None,
|
|
722
|
+
) -> Document:
|
|
723
|
+
"""Mark a notification read, optionally guarded by revision."""
|
|
724
|
+
uuid.UUID(notification_id)
|
|
725
|
+
return Document.parse(
|
|
726
|
+
json_object(
|
|
727
|
+
self.request(
|
|
728
|
+
"POST",
|
|
729
|
+
self._prefix + f"/notifications/{notification_id}/read",
|
|
730
|
+
(input_ or NotificationReadInput()).wire(),
|
|
731
|
+
),
|
|
732
|
+
what="document",
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
def publish_public_view(self, input_: PublicViewInput) -> PublicViewPublication:
|
|
737
|
+
"""Preview or store a public view.
|
|
738
|
+
|
|
739
|
+
Contract: ``confirm`` false returns the projection and writes nothing.
|
|
740
|
+
``input_.scope.organization_id`` must match this client.
|
|
741
|
+
|
|
742
|
+
Failure: ``ValueError`` on organization mismatch. ``LaunchHelmError``
|
|
743
|
+
from the server.
|
|
744
|
+
"""
|
|
745
|
+
if input_.scope.organization_id != self.organization_id:
|
|
746
|
+
raise ValueError("organization scope mismatch")
|
|
747
|
+
return PublicViewPublication.parse(
|
|
748
|
+
json_object(
|
|
749
|
+
self.request("POST", self._prefix + "/public-views", input_.wire()),
|
|
750
|
+
what="public_view_publication",
|
|
751
|
+
)
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
def update_public_view(
|
|
755
|
+
self, document_id: str, input_: PublicViewUpdate
|
|
756
|
+
) -> PublicViewPublication:
|
|
757
|
+
"""Revise a public view.
|
|
758
|
+
|
|
759
|
+
Contract: ``document_id`` is a UUID. Snapshot views cannot gain
|
|
760
|
+
resources, paths, or surfaces.
|
|
761
|
+
|
|
762
|
+
Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` from
|
|
763
|
+
the server.
|
|
764
|
+
"""
|
|
765
|
+
uuid.UUID(document_id)
|
|
766
|
+
return PublicViewPublication.parse(
|
|
767
|
+
json_object(
|
|
768
|
+
self.request(
|
|
769
|
+
"PATCH",
|
|
770
|
+
self._prefix + f"/public-views/{document_id}",
|
|
771
|
+
input_.wire(),
|
|
772
|
+
),
|
|
773
|
+
what="public_view_publication",
|
|
774
|
+
)
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
def mint_public_view_token(self, document_id: str) -> PublicViewToken:
|
|
778
|
+
"""Mint a one-time public-view bearer token.
|
|
779
|
+
|
|
780
|
+
Contract: the returned token is the only copy. ``document_id`` is a UUID.
|
|
781
|
+
|
|
782
|
+
Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` from
|
|
783
|
+
the server.
|
|
784
|
+
"""
|
|
785
|
+
uuid.UUID(document_id)
|
|
786
|
+
return PublicViewToken.parse(
|
|
787
|
+
json_object(
|
|
788
|
+
self.request("POST", self._prefix + f"/public-views/{document_id}/tokens"),
|
|
789
|
+
what="public_view_token",
|
|
790
|
+
)
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
def revoke_public_view(self, document_id: str) -> Document:
|
|
794
|
+
"""Disable a public view and revoke its live tokens.
|
|
795
|
+
|
|
796
|
+
Contract: ``document_id`` is a UUID. A second call is idempotent.
|
|
797
|
+
|
|
798
|
+
Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` from
|
|
799
|
+
the server.
|
|
800
|
+
"""
|
|
801
|
+
uuid.UUID(document_id)
|
|
802
|
+
return Document.parse(
|
|
803
|
+
json_object(
|
|
804
|
+
self.request("POST", self._prefix + f"/public-views/{document_id}/revoke"),
|
|
805
|
+
what="document",
|
|
806
|
+
)
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
def list_public_documents(self, token: str) -> PublicDocumentPage:
|
|
810
|
+
"""List the documents a public token may read.
|
|
811
|
+
|
|
812
|
+
Contract: ``token`` is sent as the bearer credential for this call
|
|
813
|
+
only. The client's API key is not used.
|
|
814
|
+
|
|
815
|
+
Failure: ``ValueError`` when ``token`` is empty. ``LaunchHelmError``
|
|
816
|
+
from the server.
|
|
817
|
+
"""
|
|
818
|
+
self._require_public_token(token)
|
|
819
|
+
return PublicDocumentPage.parse(
|
|
820
|
+
json_object(
|
|
821
|
+
self.request("GET", "/v1/public/documents", bearer=token),
|
|
822
|
+
what="public_document_page",
|
|
823
|
+
)
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
def read_public_document(self, token: str, document_id: str) -> PublicDocument:
|
|
827
|
+
"""Read one published document.
|
|
828
|
+
|
|
829
|
+
Contract: ``token`` authenticates this call. ``document_id`` is a UUID.
|
|
830
|
+
|
|
831
|
+
Failure: ``ValueError`` for an empty token or a non-UUID id.
|
|
832
|
+
``LaunchHelmError`` from the server.
|
|
833
|
+
"""
|
|
834
|
+
self._require_public_token(token)
|
|
835
|
+
uuid.UUID(document_id)
|
|
836
|
+
return PublicDocument.parse(
|
|
837
|
+
json_object(
|
|
838
|
+
self.request("GET", f"/v1/public/documents/{document_id}", bearer=token),
|
|
839
|
+
what="public_document",
|
|
840
|
+
)
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
def run_public_query(self, token: str, query: Query) -> QueryResult:
|
|
844
|
+
"""Evaluate a query inside the public view's published series.
|
|
845
|
+
|
|
846
|
+
Contract: ``token`` authenticates this call. Select sources are bound
|
|
847
|
+
to the view's organization by the server.
|
|
848
|
+
|
|
849
|
+
Failure: ``ValueError`` when ``token`` is empty. ``LaunchHelmError``
|
|
850
|
+
from the server.
|
|
851
|
+
"""
|
|
852
|
+
self._require_public_token(token)
|
|
853
|
+
return QueryResult.parse(
|
|
854
|
+
json_object(
|
|
855
|
+
self.request("POST", "/v1/public/queries", query.wire(), bearer=token),
|
|
856
|
+
what="query_result",
|
|
857
|
+
)
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
def read_public_artifact(self, token: str, document_id: str) -> PublicDocument:
|
|
861
|
+
"""Read one published artifact document.
|
|
862
|
+
|
|
863
|
+
Contract: ``token`` authenticates this call. ``document_id`` is a UUID.
|
|
864
|
+
|
|
865
|
+
Failure: ``ValueError`` for an empty token or a non-UUID id.
|
|
866
|
+
``LaunchHelmError`` from the server.
|
|
867
|
+
"""
|
|
868
|
+
self._require_public_token(token)
|
|
869
|
+
uuid.UUID(document_id)
|
|
870
|
+
return PublicDocument.parse(
|
|
871
|
+
json_object(
|
|
872
|
+
self.request("GET", f"/v1/public/artifacts/{document_id}", bearer=token),
|
|
873
|
+
what="public_document",
|
|
874
|
+
)
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
def download_artifact(self, document_id: str) -> bytes:
|
|
878
|
+
"""Download an artifact's bytes.
|
|
879
|
+
|
|
880
|
+
Contract: ``GET .../download`` returns a URL. This method follows it
|
|
881
|
+
and returns the body.
|
|
882
|
+
|
|
883
|
+
Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` when
|
|
884
|
+
the download document has no URL, the signed target is insecure, or
|
|
885
|
+
the fetch fails.
|
|
886
|
+
"""
|
|
887
|
+
uuid.UUID(document_id)
|
|
888
|
+
body = json_object(
|
|
889
|
+
self.request("GET", self._prefix + f"/artifacts/{document_id}/download"),
|
|
890
|
+
what="download",
|
|
891
|
+
)
|
|
892
|
+
url = body.get("url")
|
|
893
|
+
if not isinstance(url, str) or not url:
|
|
894
|
+
raise LaunchHelmError("protocol", "artifact download has no url")
|
|
895
|
+
_check_download_url(url)
|
|
896
|
+
try:
|
|
897
|
+
# The signed object request must not inherit API auth or cookies.
|
|
898
|
+
response = self.http.send(httpx.Request("GET", url), auth=None, follow_redirects=False)
|
|
899
|
+
response.raise_for_status()
|
|
900
|
+
except httpx.HTTPError as error:
|
|
901
|
+
raise LaunchHelmError("transport", "artifact download failed") from error
|
|
902
|
+
return response.content
|
|
903
|
+
|
|
904
|
+
def download_public_artifact(self, token: str, document_id: str) -> str:
|
|
905
|
+
"""Return a short-lived download URL for a published ready artifact.
|
|
906
|
+
|
|
907
|
+
Contract: ``token`` authenticates this call. ``document_id`` is a UUID.
|
|
908
|
+
|
|
909
|
+
Failure: ``ValueError`` for an empty token or a non-UUID id.
|
|
910
|
+
``LaunchHelmError`` when the server omits a URL or rejects the call.
|
|
911
|
+
"""
|
|
912
|
+
self._require_public_token(token)
|
|
913
|
+
uuid.UUID(document_id)
|
|
914
|
+
signed = json_object(
|
|
915
|
+
self.request(
|
|
916
|
+
"GET",
|
|
917
|
+
f"/v1/public/artifacts/{document_id}/download",
|
|
918
|
+
bearer=token,
|
|
919
|
+
),
|
|
920
|
+
what="url",
|
|
921
|
+
)
|
|
922
|
+
url = signed.get("url")
|
|
923
|
+
if not isinstance(url, str) or url == "":
|
|
924
|
+
raise LaunchHelmError("invalid_response", "Download response is missing a URL")
|
|
925
|
+
return url
|
|
926
|
+
|
|
927
|
+
def create_invitation(self, input_: InvitationInput) -> Document:
|
|
928
|
+
"""Create a pending invitation for one verified email address."""
|
|
929
|
+
return Document.parse(
|
|
930
|
+
json_object(
|
|
931
|
+
self.request("POST", self._prefix + "/invitations", input_.wire()),
|
|
932
|
+
what="document",
|
|
933
|
+
)
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
def list_invitations(self, *, cursor: str = "", limit: int = 50) -> JSONObject:
|
|
937
|
+
"""Return one page of organization invitations."""
|
|
938
|
+
return json_object(
|
|
939
|
+
self.request(
|
|
940
|
+
"GET",
|
|
941
|
+
self._prefix + "/invitations",
|
|
942
|
+
params={"cursor": cursor, "limit": limit},
|
|
943
|
+
),
|
|
944
|
+
what="invitation_page",
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
def resend_invitation(self, document_id: str, input_: InvitationResendInput) -> Document:
|
|
948
|
+
"""Record another send and extend a pending invitation."""
|
|
949
|
+
uuid.UUID(document_id)
|
|
950
|
+
return Document.parse(
|
|
951
|
+
json_object(
|
|
952
|
+
self.request(
|
|
953
|
+
"POST",
|
|
954
|
+
self._prefix + f"/invitations/{document_id}/resend",
|
|
955
|
+
input_.wire(),
|
|
956
|
+
),
|
|
957
|
+
what="document",
|
|
958
|
+
)
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
def revoke_invitation(self, document_id: str) -> Document:
|
|
962
|
+
"""Revoke a pending invitation."""
|
|
963
|
+
uuid.UUID(document_id)
|
|
964
|
+
return Document.parse(
|
|
965
|
+
json_object(
|
|
966
|
+
self.request("POST", self._prefix + f"/invitations/{document_id}/revoke", {}),
|
|
967
|
+
what="document",
|
|
968
|
+
)
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
def accept_invitation(self, document_id: str) -> Document:
|
|
972
|
+
"""Accept an invitation addressed to this account's verified email."""
|
|
973
|
+
uuid.UUID(document_id)
|
|
974
|
+
return Document.parse(
|
|
975
|
+
json_object(
|
|
976
|
+
self.request("POST", self._prefix + f"/invitations/{document_id}/accept", {}),
|
|
977
|
+
what="document",
|
|
978
|
+
)
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
def decline_invitation(self, document_id: str) -> Document:
|
|
982
|
+
"""Decline an invitation addressed to this account's verified email."""
|
|
983
|
+
uuid.UUID(document_id)
|
|
984
|
+
return Document.parse(
|
|
985
|
+
json_object(
|
|
986
|
+
self.request("POST", self._prefix + f"/invitations/{document_id}/decline", {}),
|
|
987
|
+
what="document",
|
|
988
|
+
)
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
def list_my_invitations(self) -> JSONObject:
|
|
992
|
+
"""Return pending invitations addressed to the caller's verified email."""
|
|
993
|
+
return json_object(self.request("GET", "/v1/invitations"), what="invitation_inbox")
|
|
994
|
+
|
|
995
|
+
def dispatch_analysis(self, input_: AnalysisRunInput, *, idempotency_key: str) -> Document:
|
|
996
|
+
"""Queue one analysis run. The same key and input return the original run."""
|
|
997
|
+
return Document.parse(
|
|
998
|
+
json_object(
|
|
999
|
+
self.request(
|
|
1000
|
+
"POST",
|
|
1001
|
+
self._prefix + "/analysis-runs",
|
|
1002
|
+
input_.wire(),
|
|
1003
|
+
idempotency_key=idempotency_key,
|
|
1004
|
+
),
|
|
1005
|
+
what="document",
|
|
1006
|
+
)
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
def claim_analysis(self, document_id: str, input_: AnalysisClaimInput) -> Document:
|
|
1010
|
+
"""Lease a queued analysis run for this connected worker."""
|
|
1011
|
+
uuid.UUID(document_id)
|
|
1012
|
+
return Document.parse(
|
|
1013
|
+
json_object(
|
|
1014
|
+
self.request(
|
|
1015
|
+
"POST",
|
|
1016
|
+
self._prefix + f"/analysis-runs/{document_id}/claim",
|
|
1017
|
+
input_.wire(),
|
|
1018
|
+
),
|
|
1019
|
+
what="document",
|
|
1020
|
+
)
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
def start_analysis(self, document_id: str, input_: AnalysisLeaseInput) -> Document:
|
|
1024
|
+
"""Move a leased analysis run from dispatched to running."""
|
|
1025
|
+
uuid.UUID(document_id)
|
|
1026
|
+
return Document.parse(
|
|
1027
|
+
json_object(
|
|
1028
|
+
self.request(
|
|
1029
|
+
"POST",
|
|
1030
|
+
self._prefix + f"/analysis-runs/{document_id}/start",
|
|
1031
|
+
input_.wire(),
|
|
1032
|
+
),
|
|
1033
|
+
what="document",
|
|
1034
|
+
)
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
def report_analysis(self, document_id: str, input_: AnalysisReport) -> Document:
|
|
1038
|
+
"""Record a terminal result for the analysis run this worker holds."""
|
|
1039
|
+
uuid.UUID(document_id)
|
|
1040
|
+
return Document.parse(
|
|
1041
|
+
json_object(
|
|
1042
|
+
self.request(
|
|
1043
|
+
"POST",
|
|
1044
|
+
self._prefix + f"/analysis-runs/{document_id}/report",
|
|
1045
|
+
input_.wire(),
|
|
1046
|
+
),
|
|
1047
|
+
what="document",
|
|
1048
|
+
)
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
def cancel_analysis(self, document_id: str) -> Document:
|
|
1052
|
+
"""Cancel a live analysis run and release its unacked work item."""
|
|
1053
|
+
uuid.UUID(document_id)
|
|
1054
|
+
return Document.parse(
|
|
1055
|
+
json_object(
|
|
1056
|
+
self.request("POST", self._prefix + f"/analysis-runs/{document_id}/cancel", {}),
|
|
1057
|
+
what="document",
|
|
1058
|
+
)
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
def record_usage(self, input_: UsageInput) -> Document:
|
|
1062
|
+
"""Record one usage measurement. The idempotency key is a body field."""
|
|
1063
|
+
return Document.parse(
|
|
1064
|
+
json_object(
|
|
1065
|
+
self.request("POST", self._prefix + "/usage", input_.wire()),
|
|
1066
|
+
what="document",
|
|
1067
|
+
)
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
def report_usage(self, *, start: str = "", end: str = "") -> UsageReport:
|
|
1071
|
+
"""Sum the usage ledger over an optional half-open window."""
|
|
1072
|
+
params: dict[str, JSONValue] = {}
|
|
1073
|
+
if start:
|
|
1074
|
+
params["from"] = start
|
|
1075
|
+
if end:
|
|
1076
|
+
params["to"] = end
|
|
1077
|
+
return UsageReport.parse(
|
|
1078
|
+
json_object(
|
|
1079
|
+
self.request("GET", self._prefix + "/usage", params=params or None),
|
|
1080
|
+
what="usage_report",
|
|
1081
|
+
)
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
def create_export(self, input_: ExportInput, *, idempotency_key: str) -> Document:
|
|
1085
|
+
"""Queue one pinned export. The same key and input return the original export."""
|
|
1086
|
+
return Document.parse(
|
|
1087
|
+
json_object(
|
|
1088
|
+
self.request(
|
|
1089
|
+
"POST",
|
|
1090
|
+
self._prefix + "/exports",
|
|
1091
|
+
input_.wire(),
|
|
1092
|
+
idempotency_key=idempotency_key,
|
|
1093
|
+
),
|
|
1094
|
+
what="document",
|
|
1095
|
+
)
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
def cancel_export(self, document_id: str) -> Document:
|
|
1099
|
+
"""Stop a queued or running export before its artifact is published."""
|
|
1100
|
+
uuid.UUID(document_id)
|
|
1101
|
+
return Document.parse(
|
|
1102
|
+
json_object(
|
|
1103
|
+
self.request("POST", self._prefix + f"/exports/{document_id}/cancel"),
|
|
1104
|
+
what="document",
|
|
1105
|
+
)
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
def plan_retention(self, policy_id: str) -> RetentionPlan:
|
|
1109
|
+
"""Dry-run one retention policy without hiding documents."""
|
|
1110
|
+
uuid.UUID(policy_id)
|
|
1111
|
+
return RetentionPlan.parse(
|
|
1112
|
+
json_object(
|
|
1113
|
+
self.request(
|
|
1114
|
+
"POST",
|
|
1115
|
+
self._prefix + f"/retention-policies/{policy_id}/plan",
|
|
1116
|
+
),
|
|
1117
|
+
what="retention_plan",
|
|
1118
|
+
)
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
def create_retention_hold(self, input_: RetentionHoldInput) -> Document:
|
|
1122
|
+
"""Pin one document so retention will not hide it."""
|
|
1123
|
+
return Document.parse(
|
|
1124
|
+
json_object(
|
|
1125
|
+
self.request("POST", self._prefix + "/retention-holds", input_.wire()),
|
|
1126
|
+
what="document",
|
|
1127
|
+
)
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
def release_retention_hold(self, document_id: str) -> Document:
|
|
1131
|
+
"""Release a hold so the pinned document can be deleted again."""
|
|
1132
|
+
uuid.UUID(document_id)
|
|
1133
|
+
return Document.parse(
|
|
1134
|
+
json_object(
|
|
1135
|
+
self.request(
|
|
1136
|
+
"POST",
|
|
1137
|
+
self._prefix + f"/retention-holds/{document_id}/release",
|
|
1138
|
+
),
|
|
1139
|
+
what="document",
|
|
1140
|
+
)
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
def restore_retention(self, input_: RetentionRestoreInput) -> Document:
|
|
1144
|
+
"""Return a hidden document to the catalog before its bytes are purged."""
|
|
1145
|
+
return Document.parse(
|
|
1146
|
+
json_object(
|
|
1147
|
+
self.request("POST", self._prefix + "/retention-restores", input_.wire()),
|
|
1148
|
+
what="document",
|
|
1149
|
+
)
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
def _require_public_token(self, token: str) -> None:
|
|
1153
|
+
if token == "":
|
|
1154
|
+
raise ValueError("public view token is required")
|
|
1155
|
+
|
|
1156
|
+
def query(
|
|
1157
|
+
self,
|
|
1158
|
+
stream_id: str,
|
|
1159
|
+
*,
|
|
1160
|
+
start: str,
|
|
1161
|
+
end: str,
|
|
1162
|
+
limit: int = 1000,
|
|
1163
|
+
order: str = "",
|
|
1164
|
+
after_epoch: str = "",
|
|
1165
|
+
after_sequence: str = "",
|
|
1166
|
+
) -> JSONObject:
|
|
1167
|
+
"""Query telemetry points in ``[start, end]``.
|
|
1168
|
+
|
|
1169
|
+
Contract: ``stream_id`` must be a UUID. ``order="desc"`` returns the
|
|
1170
|
+
newest points first. ``after_epoch`` and ``after_sequence`` together
|
|
1171
|
+
skip rows at ``start`` that are not strictly after that point, so a
|
|
1172
|
+
page whose points share one timestamp can continue. This is not an
|
|
1173
|
+
analytical query engine; the server enforces bounded pages.
|
|
1174
|
+
|
|
1175
|
+
Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError`` from
|
|
1176
|
+
the server.
|
|
1177
|
+
"""
|
|
1178
|
+
uuid.UUID(stream_id)
|
|
1179
|
+
params: dict[str, JSONValue] = {"from": start, "to": end, "limit": limit}
|
|
1180
|
+
if order:
|
|
1181
|
+
params["order"] = order
|
|
1182
|
+
if after_epoch and after_sequence:
|
|
1183
|
+
params["after_epoch"] = after_epoch
|
|
1184
|
+
params["after_sequence"] = after_sequence
|
|
1185
|
+
return json_object(
|
|
1186
|
+
self.request(
|
|
1187
|
+
"GET",
|
|
1188
|
+
self._prefix + f"/streams/{stream_id}/points",
|
|
1189
|
+
params=params,
|
|
1190
|
+
),
|
|
1191
|
+
what="points",
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1194
|
+
def read_samples(
|
|
1195
|
+
self,
|
|
1196
|
+
stream_id: str,
|
|
1197
|
+
*,
|
|
1198
|
+
axis: JSONObject | None = None,
|
|
1199
|
+
select: JSONObject,
|
|
1200
|
+
) -> JSONObject:
|
|
1201
|
+
"""Read bounded samples of one stream for a rich renderer.
|
|
1202
|
+
|
|
1203
|
+
Contract: mirrors ``POST /telemetry/samples`` — ``axis`` is an
|
|
1204
|
+
``axis_spec`` object (defaults to the ``step`` coordinate axis)
|
|
1205
|
+
and ``select`` a ``samples_select`` object such as
|
|
1206
|
+
``{"mode": "latest", "limit": 16}``, ``{"mode": "at",
|
|
1207
|
+
"values": ["10"]}``, ``{"mode": "spread", "limit": 64,
|
|
1208
|
+
"window": {...}}``, or ``{"mode": "index", "limit": 256}``.
|
|
1209
|
+
Returns the ``samples_result`` object: ``points`` carry ``x`` and
|
|
1210
|
+
the stored ``value`` (omitted for ``index``), ``complete`` is
|
|
1211
|
+
false when the response was truncated, and ``media`` maps media
|
|
1212
|
+
artifact ids to authorized download entries.
|
|
1213
|
+
|
|
1214
|
+
Failure: ``ValueError`` from ``uuid.UUID``. ``LaunchHelmError``
|
|
1215
|
+
from the server.
|
|
1216
|
+
"""
|
|
1217
|
+
uuid.UUID(stream_id)
|
|
1218
|
+
payload: JSONObject = {
|
|
1219
|
+
"stream_id": stream_id,
|
|
1220
|
+
"axis": axis or {"kind": "coordinate", "name": "step"},
|
|
1221
|
+
"select": select,
|
|
1222
|
+
}
|
|
1223
|
+
return json_object(
|
|
1224
|
+
self.request(
|
|
1225
|
+
"POST",
|
|
1226
|
+
self._prefix + "/telemetry/samples",
|
|
1227
|
+
payload,
|
|
1228
|
+
),
|
|
1229
|
+
what="samples",
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
def run_query(self, organization_id: str, query: Query) -> QueryResult:
|
|
1233
|
+
"""Evaluate an ad-hoc telemetry query.
|
|
1234
|
+
|
|
1235
|
+
Contract: ``organization_id`` must match this client. ``query`` is
|
|
1236
|
+
sent as its wire document.
|
|
1237
|
+
|
|
1238
|
+
Failure: ``ValueError`` from ``uuid.UUID`` or an organization
|
|
1239
|
+
mismatch. ``LaunchHelmError`` from the server.
|
|
1240
|
+
"""
|
|
1241
|
+
self._require_organization(organization_id)
|
|
1242
|
+
return QueryResult.parse(
|
|
1243
|
+
json_object(
|
|
1244
|
+
self.request(
|
|
1245
|
+
"POST",
|
|
1246
|
+
f"/v1/organizations/{organization_id}/telemetry/queries",
|
|
1247
|
+
query.wire(),
|
|
1248
|
+
),
|
|
1249
|
+
what="query_result",
|
|
1250
|
+
)
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
def run_saved_query(
|
|
1254
|
+
self, organization_id: str, query_id: str, *, revision: str | None = None
|
|
1255
|
+
) -> QueryResult:
|
|
1256
|
+
"""Evaluate a stored query document, optionally at ``revision``.
|
|
1257
|
+
|
|
1258
|
+
Contract: ``organization_id`` must match this client. ``query_id``
|
|
1259
|
+
must be a UUID. Empty ``revision`` selects the current document.
|
|
1260
|
+
|
|
1261
|
+
Failure: ``ValueError`` from ``uuid.UUID`` or an organization
|
|
1262
|
+
mismatch. ``LaunchHelmError`` from the server.
|
|
1263
|
+
"""
|
|
1264
|
+
self._require_organization(organization_id)
|
|
1265
|
+
uuid.UUID(query_id)
|
|
1266
|
+
body: JSONObject = {}
|
|
1267
|
+
if revision:
|
|
1268
|
+
body["revision"] = revision
|
|
1269
|
+
return QueryResult.parse(
|
|
1270
|
+
json_object(
|
|
1271
|
+
self.request(
|
|
1272
|
+
"POST",
|
|
1273
|
+
f"/v1/organizations/{organization_id}/queries/{query_id}/run",
|
|
1274
|
+
body,
|
|
1275
|
+
),
|
|
1276
|
+
what="query_result",
|
|
1277
|
+
)
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
def list_my_organizations(self) -> list[tuple[str, str]]:
|
|
1281
|
+
"""Return ``(id, name)`` for every organization this key can see.
|
|
1282
|
+
|
|
1283
|
+
Contract: ``GET /v1/organizations`` is account-scoped. The client's
|
|
1284
|
+
organization id is not part of the request. Pages follow
|
|
1285
|
+
``next_cursor`` until the server ends the list.
|
|
1286
|
+
|
|
1287
|
+
Failure: ``LaunchHelmError`` from the server or a malformed page.
|
|
1288
|
+
"""
|
|
1289
|
+
cursor = ""
|
|
1290
|
+
found: list[tuple[str, str]] = []
|
|
1291
|
+
for _ in range(100):
|
|
1292
|
+
page = json_object(
|
|
1293
|
+
self.request(
|
|
1294
|
+
"GET",
|
|
1295
|
+
"/v1/organizations",
|
|
1296
|
+
params={"cursor": cursor, "limit": 100},
|
|
1297
|
+
),
|
|
1298
|
+
what="organization list",
|
|
1299
|
+
)
|
|
1300
|
+
items = page.get("items") or []
|
|
1301
|
+
if not isinstance(items, list):
|
|
1302
|
+
raise LaunchHelmError("protocol", "organization list is not a page")
|
|
1303
|
+
for item in items:
|
|
1304
|
+
if not isinstance(item, dict):
|
|
1305
|
+
raise LaunchHelmError("protocol", "organization list entry is invalid")
|
|
1306
|
+
identity = item.get("id")
|
|
1307
|
+
if not isinstance(identity, str):
|
|
1308
|
+
raise LaunchHelmError("protocol", "organization list entry is invalid")
|
|
1309
|
+
name = item.get("name")
|
|
1310
|
+
found.append((identity, name if isinstance(name, str) else ""))
|
|
1311
|
+
next_cursor = page.get("next_cursor") or ""
|
|
1312
|
+
if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor:
|
|
1313
|
+
break
|
|
1314
|
+
cursor = next_cursor
|
|
1315
|
+
return found
|
|
1316
|
+
|
|
1317
|
+
def _require_organization(self, organization_id: str) -> None:
|
|
1318
|
+
uuid.UUID(organization_id)
|
|
1319
|
+
if organization_id != self.organization_id:
|
|
1320
|
+
raise ValueError("organization scope mismatch")
|
|
1321
|
+
|
|
1322
|
+
def connect(
|
|
1323
|
+
self,
|
|
1324
|
+
participant_id: str,
|
|
1325
|
+
instance_id: str,
|
|
1326
|
+
*,
|
|
1327
|
+
safe_points: tuple[str, ...] = (),
|
|
1328
|
+
attempt_id: str = "",
|
|
1329
|
+
) -> JSONObject:
|
|
1330
|
+
"""Open an execution session for ``participant_id``.
|
|
1331
|
+
|
|
1332
|
+
Contract: ``instance_id`` identifies this process. The returned
|
|
1333
|
+
``epoch`` is the session fence token. ``safe_points`` is the full set
|
|
1334
|
+
of cooperative names this runtime services; Claim returns only commands
|
|
1335
|
+
whose ``safe_point`` is empty or in this set. ``attempt_id`` binds
|
|
1336
|
+
emitted provenance to the active attempt. Repeating Connect with the
|
|
1337
|
+
same instance and attempt does not fence in-flight commands and grows
|
|
1338
|
+
the serviced safe-point set.
|
|
1339
|
+
|
|
1340
|
+
Failure: ``LaunchHelmError`` from the server.
|
|
1341
|
+
"""
|
|
1342
|
+
body: JSONObject = {
|
|
1343
|
+
"instance_id": instance_id,
|
|
1344
|
+
"safe_points": list(safe_points),
|
|
1345
|
+
}
|
|
1346
|
+
if attempt_id:
|
|
1347
|
+
body["attempt_id"] = attempt_id
|
|
1348
|
+
return json_object(
|
|
1349
|
+
self.request(
|
|
1350
|
+
"POST",
|
|
1351
|
+
self._prefix + f"/participants/{participant_id}/sessions",
|
|
1352
|
+
body,
|
|
1353
|
+
),
|
|
1354
|
+
what="session",
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
def heartbeat(
|
|
1358
|
+
self,
|
|
1359
|
+
participant_id: str,
|
|
1360
|
+
epoch: str,
|
|
1361
|
+
*,
|
|
1362
|
+
safe_points: tuple[str, ...] = (),
|
|
1363
|
+
) -> JSONObject:
|
|
1364
|
+
"""Renew the execution session identified by ``epoch``.
|
|
1365
|
+
|
|
1366
|
+
Contract: a conflict means this runtime is fenced. Optional
|
|
1367
|
+
``safe_points`` are unioned into the session (same validation and cap
|
|
1368
|
+
as Connect) without fencing. Connect still declares the initial set.
|
|
1369
|
+
|
|
1370
|
+
Failure: ``LaunchHelmError``; ``conflict`` is the fence signal.
|
|
1371
|
+
"""
|
|
1372
|
+
body: JSONObject = {"epoch": epoch}
|
|
1373
|
+
if safe_points:
|
|
1374
|
+
body["safe_points"] = list(safe_points)
|
|
1375
|
+
return json_object(
|
|
1376
|
+
self.request(
|
|
1377
|
+
"POST",
|
|
1378
|
+
self._prefix + f"/participants/{participant_id}/heartbeat",
|
|
1379
|
+
body,
|
|
1380
|
+
),
|
|
1381
|
+
what="session",
|
|
1382
|
+
)
|
|
1383
|
+
|
|
1384
|
+
def claim(self, participant_id: str, epoch: str, *, wait_ms: int = 0) -> JSONObject | None:
|
|
1385
|
+
"""Claim the next command for this participant and epoch.
|
|
1386
|
+
|
|
1387
|
+
Contract: returns ``None`` when the queue is empty. ``wait_ms`` is a
|
|
1388
|
+
bounded server-side long poll (0 is immediate). The HTTP timeout is
|
|
1389
|
+
set above ``wait_ms`` so a parked claim is not aborted by the client.
|
|
1390
|
+
|
|
1391
|
+
Failure: ``LaunchHelmError`` from the server.
|
|
1392
|
+
"""
|
|
1393
|
+
body: JSONObject = {"epoch": epoch}
|
|
1394
|
+
timeout: float | None = None
|
|
1395
|
+
if wait_ms > 0:
|
|
1396
|
+
body["wait_ms"] = wait_ms
|
|
1397
|
+
timeout = wait_ms / 1000 + 6
|
|
1398
|
+
payload = json_object(
|
|
1399
|
+
self.request(
|
|
1400
|
+
"POST",
|
|
1401
|
+
self._prefix + f"/participants/{participant_id}/commands",
|
|
1402
|
+
body,
|
|
1403
|
+
timeout=timeout,
|
|
1404
|
+
),
|
|
1405
|
+
what="claim",
|
|
1406
|
+
)
|
|
1407
|
+
command = payload.get("command")
|
|
1408
|
+
if command is None:
|
|
1409
|
+
return None
|
|
1410
|
+
return json_object(command, what="command")
|
|
1411
|
+
|
|
1412
|
+
def begin(self, command: JSONObject) -> JSONObject:
|
|
1413
|
+
"""Mark ``command`` as applying under its lease.
|
|
1414
|
+
|
|
1415
|
+
Contract: must be called after local journal phase ``starting`` and
|
|
1416
|
+
before the handler. A definitive rejection is not retried as success.
|
|
1417
|
+
|
|
1418
|
+
Failure: ``LaunchHelmError`` from the server.
|
|
1419
|
+
"""
|
|
1420
|
+
request = json_object(command.get("request") or {}, what="command request")
|
|
1421
|
+
return json_object(
|
|
1422
|
+
self.request(
|
|
1423
|
+
"POST",
|
|
1424
|
+
self._prefix + f"/commands/{command['id']}/begin",
|
|
1425
|
+
{"lease_id": command["lease_id"], "epoch": request["epoch"]},
|
|
1426
|
+
),
|
|
1427
|
+
what="command",
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
def report(self, command: JSONObject, result: JSONObject) -> JSONObject:
|
|
1431
|
+
"""Publish a command result (applied, rejected, or outcome_unknown).
|
|
1432
|
+
|
|
1433
|
+
Contract: unknown effects stay unknown; this method does not re-run
|
|
1434
|
+
the handler.
|
|
1435
|
+
|
|
1436
|
+
Failure: ``LaunchHelmError`` from the server. A conflict on a
|
|
1437
|
+
rejected result is reconciled by the runtime.
|
|
1438
|
+
"""
|
|
1439
|
+
return json_object(
|
|
1440
|
+
self.request("POST", self._prefix + f"/commands/{command['id']}/result", result),
|
|
1441
|
+
what="command",
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
def renew(self, command: JSONObject) -> None:
|
|
1445
|
+
"""Renew the lease of an in-flight command.
|
|
1446
|
+
|
|
1447
|
+
Contract: a conflict means the lease is already terminal.
|
|
1448
|
+
|
|
1449
|
+
Failure: ``LaunchHelmError`` from the server.
|
|
1450
|
+
"""
|
|
1451
|
+
request = json_object(command.get("request") or {}, what="command request")
|
|
1452
|
+
self.request(
|
|
1453
|
+
"POST",
|
|
1454
|
+
self._prefix + f"/commands/{command['id']}/heartbeat",
|
|
1455
|
+
{"lease_id": command["lease_id"], "epoch": request["epoch"]},
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
def ingest(self, batch: TelemetryBatch) -> JSONObject:
|
|
1459
|
+
"""Submit a durable telemetry batch.
|
|
1460
|
+
|
|
1461
|
+
Contract: sends ``batch.payload`` unchanged so the wire document is
|
|
1462
|
+
byte-identical to the spooled batch.
|
|
1463
|
+
|
|
1464
|
+
Failure: ``LaunchHelmError`` from the server. Durability checking is
|
|
1465
|
+
the runtime's responsibility.
|
|
1466
|
+
"""
|
|
1467
|
+
return json_object(
|
|
1468
|
+
self.request("POST", self._prefix + "/telemetry", batch.payload),
|
|
1469
|
+
what="receipt",
|
|
1470
|
+
)
|
|
1471
|
+
|
|
1472
|
+
def ingest_many(self, batches: Sequence[TelemetryBatch]) -> JSONObject:
|
|
1473
|
+
"""Submit grouped telemetry batches in one request.
|
|
1474
|
+
|
|
1475
|
+
Contract: each ``batch.payload`` is embedded unchanged so every wire
|
|
1476
|
+
batch document stays byte-identical to its spooled record. The
|
|
1477
|
+
response carries one receipt per submitted batch, keyed by
|
|
1478
|
+
``batch_id``.
|
|
1479
|
+
|
|
1480
|
+
Failure: ``ValueError`` when ``batches`` is empty.
|
|
1481
|
+
``LaunchHelmError`` from the server — including ``not_found`` when
|
|
1482
|
+
the server predates grouped ingest. Per-batch durability checking is
|
|
1483
|
+
the runtime's responsibility.
|
|
1484
|
+
"""
|
|
1485
|
+
if not batches:
|
|
1486
|
+
raise ValueError("ingest_many requires at least one batch")
|
|
1487
|
+
payload = (
|
|
1488
|
+
b'{"protocol":"1.0","batches":[' + b",".join(batch.payload for batch in batches) + b"]}"
|
|
1489
|
+
)
|
|
1490
|
+
return json_object(
|
|
1491
|
+
self.request("POST", self._prefix + "/telemetry/batches", payload),
|
|
1492
|
+
what="ingestion envelope",
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
def upload_file(
|
|
1496
|
+
self,
|
|
1497
|
+
path: str | Path,
|
|
1498
|
+
scope: Scope,
|
|
1499
|
+
*,
|
|
1500
|
+
idempotency_key: str,
|
|
1501
|
+
media_type: str = "application/octet-stream",
|
|
1502
|
+
part_size: int = 16 << 20,
|
|
1503
|
+
provenance: JSONObject | None = None,
|
|
1504
|
+
purpose: str = "general",
|
|
1505
|
+
) -> Document:
|
|
1506
|
+
"""Upload a local file as an artifact via signed multipart PUT.
|
|
1507
|
+
|
|
1508
|
+
Contract: the source file must not change during the upload (size,
|
|
1509
|
+
mtime, inode). Object-store URLs must be HTTPS outside loopback.
|
|
1510
|
+
Repeating ``idempotency_key`` resumes or returns the existing
|
|
1511
|
+
artifact.
|
|
1512
|
+
|
|
1513
|
+
Failure: ``ValueError`` for an impossible part budget. ``LaunchHelmError``
|
|
1514
|
+
for insecure URLs, transport uncertainty, a missing ETag, or a
|
|
1515
|
+
mutated source file.
|
|
1516
|
+
"""
|
|
1517
|
+
source = Path(path)
|
|
1518
|
+
if part_size < MIN_PART_SIZE or part_size > MAX_PART_SIZE:
|
|
1519
|
+
raise ValueError("part_size must be between 5 and 64 MiB")
|
|
1520
|
+
with source.open("rb") as stream:
|
|
1521
|
+
initial = source.stat()
|
|
1522
|
+
size = initial.st_size
|
|
1523
|
+
if size < 1 or (size + part_size - 1) // part_size > MAX_PARTS:
|
|
1524
|
+
raise ValueError("file does not fit the configured multipart budget")
|
|
1525
|
+
digest = hashlib.file_digest(stream, "sha256").hexdigest()
|
|
1526
|
+
stream.seek(0)
|
|
1527
|
+
payload: JSONObject = {
|
|
1528
|
+
"scope": string_object(scope.wire()),
|
|
1529
|
+
"name": source.name,
|
|
1530
|
+
"media_type": media_type,
|
|
1531
|
+
"size": str(size),
|
|
1532
|
+
"sha256": digest,
|
|
1533
|
+
"provenance": provenance or {"inputs": [], "coordinates": [], "labels": {}},
|
|
1534
|
+
"purpose": purpose,
|
|
1535
|
+
}
|
|
1536
|
+
artifact = Document.parse(
|
|
1537
|
+
json_object(
|
|
1538
|
+
self.request(
|
|
1539
|
+
"POST",
|
|
1540
|
+
self._prefix + "/artifacts",
|
|
1541
|
+
payload,
|
|
1542
|
+
idempotency_key=idempotency_key,
|
|
1543
|
+
),
|
|
1544
|
+
what="artifact",
|
|
1545
|
+
)
|
|
1546
|
+
)
|
|
1547
|
+
state = artifact.spec.get("state")
|
|
1548
|
+
if state in {"ready", "verifying"}:
|
|
1549
|
+
return artifact
|
|
1550
|
+
parts: list[JSONValue] = []
|
|
1551
|
+
with httpx.Client(timeout=UPLOAD_TIMEOUT_SECONDS, follow_redirects=False) as uploads:
|
|
1552
|
+
number = 0
|
|
1553
|
+
while chunk := stream.read(part_size):
|
|
1554
|
+
number += 1
|
|
1555
|
+
signed = json_object(
|
|
1556
|
+
self.request(
|
|
1557
|
+
"POST",
|
|
1558
|
+
self._prefix + f"/artifacts/{artifact.id}/parts",
|
|
1559
|
+
{
|
|
1560
|
+
"number": number,
|
|
1561
|
+
"size": str(len(chunk)),
|
|
1562
|
+
"sha256": hashlib.sha256(chunk).hexdigest(),
|
|
1563
|
+
},
|
|
1564
|
+
),
|
|
1565
|
+
what="signed part",
|
|
1566
|
+
)
|
|
1567
|
+
url = signed.get("url")
|
|
1568
|
+
if not isinstance(url, str):
|
|
1569
|
+
raise LaunchHelmError("invalid_response", "Signed upload is missing a URL")
|
|
1570
|
+
target = urlparse(url)
|
|
1571
|
+
if target.scheme != "https" and target.hostname not in {
|
|
1572
|
+
"localhost",
|
|
1573
|
+
"127.0.0.1",
|
|
1574
|
+
"::1",
|
|
1575
|
+
}:
|
|
1576
|
+
raise LaunchHelmError("insecure_upload", "Object upload requires HTTPS")
|
|
1577
|
+
headers_field = signed.get("headers")
|
|
1578
|
+
upload_headers = (
|
|
1579
|
+
{str(k): str(v) for k, v in headers_field.items()}
|
|
1580
|
+
if isinstance(headers_field, dict)
|
|
1581
|
+
else {}
|
|
1582
|
+
)
|
|
1583
|
+
try:
|
|
1584
|
+
response = uploads.put(url, content=chunk, headers=upload_headers)
|
|
1585
|
+
except httpx.HTTPError:
|
|
1586
|
+
raise LaunchHelmError(
|
|
1587
|
+
"upload_uncertain",
|
|
1588
|
+
"Retry with the same artifact idempotency key",
|
|
1589
|
+
retryable=True,
|
|
1590
|
+
) from None
|
|
1591
|
+
if response.status_code >= 300 or "etag" not in response.headers:
|
|
1592
|
+
raise LaunchHelmError(
|
|
1593
|
+
"upload_failed",
|
|
1594
|
+
"Object store did not acknowledge the part",
|
|
1595
|
+
retryable=True,
|
|
1596
|
+
)
|
|
1597
|
+
part: JSONObject = {
|
|
1598
|
+
"number": number,
|
|
1599
|
+
"etag": response.headers["etag"],
|
|
1600
|
+
}
|
|
1601
|
+
parts.append(part)
|
|
1602
|
+
final = source.stat()
|
|
1603
|
+
if (initial.st_size, initial.st_mtime_ns, initial.st_ino) != (
|
|
1604
|
+
final.st_size,
|
|
1605
|
+
final.st_mtime_ns,
|
|
1606
|
+
final.st_ino,
|
|
1607
|
+
):
|
|
1608
|
+
raise LaunchHelmError("source_changed", "Artifact source changed during upload")
|
|
1609
|
+
complete: JSONObject = {"parts": parts}
|
|
1610
|
+
return Document.parse(
|
|
1611
|
+
json_object(
|
|
1612
|
+
self.request(
|
|
1613
|
+
"POST",
|
|
1614
|
+
self._prefix + f"/artifacts/{artifact.id}/complete",
|
|
1615
|
+
complete,
|
|
1616
|
+
),
|
|
1617
|
+
what="artifact",
|
|
1618
|
+
)
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _query_params(params: dict[str, JSONValue] | None) -> dict[str, str | int] | None:
|
|
1623
|
+
if params is None:
|
|
1624
|
+
return None
|
|
1625
|
+
query: dict[str, str | int] = {}
|
|
1626
|
+
for key, value in params.items():
|
|
1627
|
+
if isinstance(value, bool) or value is None:
|
|
1628
|
+
query[key] = json.dumps(value)
|
|
1629
|
+
elif isinstance(value, str | int):
|
|
1630
|
+
query[key] = value
|
|
1631
|
+
else:
|
|
1632
|
+
query[key] = json.dumps(value)
|
|
1633
|
+
return query
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
def _check_download_url(url: str) -> None:
|
|
1637
|
+
try:
|
|
1638
|
+
parsed = urlparse(url)
|
|
1639
|
+
host = parsed.hostname
|
|
1640
|
+
# Touching port validates its range and syntax.
|
|
1641
|
+
_ = parsed.port
|
|
1642
|
+
userinfo = parsed.username is not None or parsed.password is not None
|
|
1643
|
+
except ValueError:
|
|
1644
|
+
raise LaunchHelmError(
|
|
1645
|
+
"insecure_download", "Object download requires HTTPS without credentials"
|
|
1646
|
+
) from None
|
|
1647
|
+
secure = parsed.scheme == "https" or (
|
|
1648
|
+
parsed.scheme == "http" and host in {"localhost", "127.0.0.1", "::1"}
|
|
1649
|
+
)
|
|
1650
|
+
if not secure or not host or userinfo:
|
|
1651
|
+
raise LaunchHelmError(
|
|
1652
|
+
"insecure_download", "Object download requires HTTPS without credentials"
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def _json_value(value: object) -> JSONValue:
|
|
1657
|
+
try:
|
|
1658
|
+
return as_json_value(value)
|
|
1659
|
+
except TypeError:
|
|
1660
|
+
raise LaunchHelmError("invalid_response", "Server returned a non-JSON value") from None
|