simulo 0.26.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. simulo/__init__.py +433 -0
  2. simulo/_client/__init__.py +6 -0
  3. simulo/_client/_entrypoint.py +313 -0
  4. simulo/_client/_mounts.py +25 -0
  5. simulo/_client/_runner.py +186 -0
  6. simulo/_client/_secure_downloads.py +1181 -0
  7. simulo/_client/app.py +1308 -0
  8. simulo/_client/asset.py +331 -0
  9. simulo/_client/asset_api.py +517 -0
  10. simulo/_client/asset_package.py +1103 -0
  11. simulo/_client/asset_pins.py +187 -0
  12. simulo/_client/builtin_aliases.py +107 -0
  13. simulo/_client/bundle.py +254 -0
  14. simulo/_client/cancel_api.py +104 -0
  15. simulo/_client/cli.py +9063 -0
  16. simulo/_client/config.py +186 -0
  17. simulo/_client/credentials.py +210 -0
  18. simulo/_client/discovery.py +214 -0
  19. simulo/_client/export_api.py +212 -0
  20. simulo/_client/export_bundle.py +296 -0
  21. simulo/_client/facades.py +581 -0
  22. simulo/_client/http.py +414 -0
  23. simulo/_client/identity_api.py +117 -0
  24. simulo/_client/install_samples.py +267 -0
  25. simulo/_client/jobs_api.py +224 -0
  26. simulo/_client/learning.py +393 -0
  27. simulo/_client/login.py +319 -0
  28. simulo/_client/mode.py +29 -0
  29. simulo/_client/outputs.py +116 -0
  30. simulo/_client/packaging.py +445 -0
  31. simulo/_client/preflight_api.py +186 -0
  32. simulo/_client/preflight_render.py +200 -0
  33. simulo/_client/registry.py +98 -0
  34. simulo/_client/runtime.py +185 -0
  35. simulo/_client/runtime_display.py +90 -0
  36. simulo/_client/seed_ref.py +76 -0
  37. simulo/_client/stub.py +41 -0
  38. simulo/_client/submit_api.py +1057 -0
  39. simulo/_client/templates/__init__.py +21 -0
  40. simulo/_client/templates/inference/app.py.tmpl +316 -0
  41. simulo/_client/templates/inference/simuloignore.tmpl +30 -0
  42. simulo/_client/templates/scenario/app.py.tmpl +93 -0
  43. simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
  44. simulo/_client/templates/training/app.py.tmpl +235 -0
  45. simulo/_client/templates/training/simuloignore.tmpl +29 -0
  46. simulo/_client/view_fragment.py +21 -0
  47. simulo/_client/view_session_api.py +122 -0
  48. simulo/_client/volume.py +71 -0
  49. simulo/callbacks.py +274 -0
  50. simulo/py.typed +0 -0
  51. simulo-0.26.0.dist-info/METADATA +130 -0
  52. simulo-0.26.0.dist-info/RECORD +55 -0
  53. simulo-0.26.0.dist-info/WHEEL +5 -0
  54. simulo-0.26.0.dist-info/entry_points.txt +2 -0
  55. simulo-0.26.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,517 @@
1
+ """HTTP client for the asset-catalog publish/validate surface (stdlib only).
2
+
3
+ Implements the client half of ``simulo.interfaces.platform.asset_catalog``'s
4
+ publish flow — initiate → (multipart|single-PUT) upload → complete →
5
+ poll/validate — against the FROZEN v1 customer contract. Shares the request
6
+ plumbing (structured errors, the https-when-token guard, the foreign-host
7
+ bearer rule) with every other client via ``http.py``.
8
+
9
+ **Upload orchestration** (:func:`upload_archive`) is resume-first: it reads the
10
+ server's upload-state (S3 ListParts passthrough) BEFORE sending bytes, skips
11
+ parts that already landed with the right size and a matching etag, uploads the
12
+ rest against batch presigns (the ``{"parts": [...]}`` envelope), and completes
13
+ with the etags a FINAL upload-state read reports — the one source both storage
14
+ backends (S3 and LocalStorage) agree on, so the client never has to parse a
15
+ PUT response header. A single-PUT upload (``upload_id == null`` in the initiate
16
+ envelope — the frozen null-not-empty-string shape) PUTs the whole archive to
17
+ the one presigned URL and completes with its own sha256 as the (unchecked on
18
+ this path) etag.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import urllib.parse
26
+ from pathlib import Path
27
+ from typing import Any, Callable, Optional
28
+
29
+ from simulo._client import http
30
+ from simulo.interfaces.platform.asset_catalog import (
31
+ ASSET_ROUTE_TEMPLATE,
32
+ ASSET_VERSION_BY_NUMBER_ROUTE_TEMPLATE,
33
+ ASSET_VERSION_DEPRECATE_ROUTE_TEMPLATE,
34
+ ASSET_VERSION_DOWNLOAD_ROUTE_TEMPLATE,
35
+ ASSET_VERSION_REACTIVATE_ROUTE_TEMPLATE,
36
+ ASSET_VERSION_REPORT_ROUTE_TEMPLATE,
37
+ ASSET_VERSION_ROUTE_TEMPLATE,
38
+ ASSET_VERSION_UPLOAD_COMPLETE_ROUTE_TEMPLATE,
39
+ ASSET_VERSION_UPLOAD_PARTS_ROUTE_TEMPLATE,
40
+ ASSET_VERSION_UPLOAD_ROUTE_TEMPLATE,
41
+ ASSET_VERSION_VALIDATE_ROUTE_TEMPLATE,
42
+ ASSET_VERSIONS_ROUTE_TEMPLATE,
43
+ ASSETS_ROUTE,
44
+ )
45
+
46
+ _REQUEST_TIMEOUT_S = 10.0 # every outbound call has an explicit timeout (NFR)
47
+ #: Per-part PUT timeout — a part is up to 64 MiB (ASSET_UPLOAD_PART_BYTES).
48
+ _PART_UPLOAD_TIMEOUT_S = 300.0
49
+ #: Presign-batch ceiling per request (mirrors the CP's request-envelope cap).
50
+ _MAX_PART_NUMBERS_PER_BATCH = 1000
51
+ #: Whole-archive presigned GET timeout (``simulo asset get``) — a SINGLE
52
+ #: request regardless of archive size (a presigned S3 GET has no multipart-
53
+ #: download equivalent the way the upload side does), so this must
54
+ #: comfortably cover the :data:`~simulo.interfaces.platform.asset_catalog.
55
+ #: MAX_ASSET_VERSION_BYTES` (10 GiB) ceiling on a slow connection. Matches
56
+ #: the magnitude of ``cli.py``'s own big-cloud-operation windows
57
+ #: (``_ASSET_VALIDATION_WINDOW_S``), not the much smaller
58
+ #: ``submit_api._DOWNLOAD_TIMEOUT_S`` (60s) sized for job models/recordings,
59
+ #: which are typically orders of magnitude smaller than an asset package.
60
+ _ARCHIVE_DOWNLOAD_TIMEOUT_S = 1800.0
61
+ #: Page size for ``list_assets``'s internal pagination loop — mirrors
62
+ #: ``submit_api.py``'s ``_LIST_PAGE_LIMIT`` (also the server's
63
+ #: ``MAX_PAGE_LIMIT``, ``jobs/constants.py``), so a full catalog listing
64
+ #: completes in the fewest round trips the server allows.
65
+ _LIST_PAGE_LIMIT = 100
66
+ #: Defense-in-depth cap on how many pages ``list_assets`` will follow —
67
+ #: mirrors ``submit_api.py``'s ``_LIST_MAX_PAGES`` (an org's catalog is
68
+ #: bounded by its storage quota in practice, never truly unbounded, but a
69
+ #: cap keeps a pathological response from looping forever).
70
+ _LIST_MAX_PAGES = 1000
71
+
72
+ _UNAVAILABLE_HINT = "Check SIMULO_API_URL / SIMULO_ENV, and that you are logged in (`simulo login`)."
73
+
74
+
75
+ class AssetApiError(http.HttpError):
76
+ """Base error for the asset-catalog API surface."""
77
+
78
+
79
+ class AssetApiUnavailable(AssetApiError):
80
+ """The control plane cannot be reached at all."""
81
+
82
+
83
+ class AssetApiHTTPError(AssetApiError):
84
+ """A structured non-2xx from the asset surface (code/message/status kept)."""
85
+
86
+ def __init__(self, status: int, code: str, message: str, *, retry_after: Optional[int] = None) -> None:
87
+ super().__init__(f"{code}: {message}")
88
+ self.status = status
89
+ self.code = code
90
+ self.message = message
91
+ self.retry_after = retry_after
92
+
93
+
94
+ def _translate(exc: http.HttpError) -> AssetApiError:
95
+ if isinstance(exc, http.HttpHTTPError):
96
+ return AssetApiHTTPError(exc.status, exc.code, exc.message, retry_after=exc.retry_after)
97
+ if isinstance(exc, http.HttpUnavailable):
98
+ return AssetApiUnavailable(str(exc))
99
+ return AssetApiError(str(exc))
100
+
101
+
102
+ class AssetApiClient:
103
+ """Bearer-authenticated client for organization catalog operations and reads."""
104
+
105
+ def __init__(
106
+ self,
107
+ base_url: str,
108
+ *,
109
+ token: Optional[str] = None,
110
+ ) -> None:
111
+ if not base_url.startswith(("http://", "https://")):
112
+ raise AssetApiError(f"API base URL must be an http(s) URL, got {base_url!r}.")
113
+ self._base_url = base_url.rstrip("/")
114
+ self._token = token
115
+
116
+ @property
117
+ def base_url(self) -> str:
118
+ return self._base_url
119
+
120
+ # ------------------------------------------------------------------
121
+ # Publish flow
122
+ # ------------------------------------------------------------------
123
+
124
+ def initiate(self, kind: str, name: str, body: dict[str, Any]) -> dict[str, Any]:
125
+ path = ASSET_VERSIONS_ROUTE_TEMPLATE.format(
126
+ kind=http.quote_path_segment(kind), name=http.quote_path_segment(name)
127
+ )
128
+ payload = self._request("POST", path, json_body=body)
129
+ if not isinstance(payload, dict) or not payload.get("version_id"):
130
+ raise AssetApiError(f"Malformed initiate response: {payload!r}")
131
+ return payload
132
+
133
+ def presign_parts(self, version_id: str, part_numbers: list[int]) -> list[dict[str, Any]]:
134
+ path = ASSET_VERSION_UPLOAD_PARTS_ROUTE_TEMPLATE.format(version_id=http.quote_path_segment(version_id))
135
+ payload = self._request("POST", path, json_body={"part_numbers": part_numbers})
136
+ parts = payload.get("parts") if isinstance(payload, dict) else None
137
+ if not isinstance(parts, list):
138
+ raise AssetApiError(f"Malformed part-presign response (no 'parts' array): {payload!r}")
139
+ return [part for part in parts if isinstance(part, dict)]
140
+
141
+ def get_upload_state(self, version_id: str) -> dict[str, Any]:
142
+ """Resume state for an in-progress organization upload."""
143
+ path = ASSET_VERSION_UPLOAD_ROUTE_TEMPLATE.format(version_id=http.quote_path_segment(version_id))
144
+ payload = self._request("GET", path)
145
+ if not isinstance(payload, dict):
146
+ raise AssetApiError(f"Malformed upload-state response: {payload!r}")
147
+ return payload
148
+
149
+ def complete(self, version_id: str, parts: list[dict[str, Any]]) -> dict[str, Any]:
150
+ path = ASSET_VERSION_UPLOAD_COMPLETE_ROUTE_TEMPLATE.format(version_id=http.quote_path_segment(version_id))
151
+ payload = self._request("POST", path, json_body={"parts": parts})
152
+ if not isinstance(payload, dict) or not payload.get("validation_job_id"):
153
+ raise AssetApiError(f"Malformed upload-complete response: {payload!r}")
154
+ return payload
155
+
156
+ def revalidate(self, version_id: str, runtime: Optional[str]) -> dict[str, Any]:
157
+ path = ASSET_VERSION_VALIDATE_ROUTE_TEMPLATE.format(version_id=http.quote_path_segment(version_id))
158
+ body: dict[str, Any] = {}
159
+ if runtime is not None:
160
+ body["runtime"] = runtime
161
+ payload = self._request("POST", path, json_body=body)
162
+ if not isinstance(payload, dict) or not payload.get("validation_job_id"):
163
+ raise AssetApiError(f"Malformed revalidate response: {payload!r}")
164
+ return payload
165
+
166
+ # ------------------------------------------------------------------
167
+ # Reads (polling + ref → version resolution)
168
+ # ------------------------------------------------------------------
169
+
170
+ def get_asset(self, kind: str, name: str, *, publisher: Optional[str] = None) -> dict[str, Any]:
171
+ path = ASSET_ROUTE_TEMPLATE.format(kind=http.quote_path_segment(kind), name=http.quote_path_segment(name))
172
+ query = {"publisher": publisher} if publisher else None
173
+ payload = self._request("GET", path, query=query)
174
+ if not isinstance(payload, dict) or not payload.get("asset_id"):
175
+ raise AssetApiError(f"Malformed asset response: {payload!r}")
176
+ return payload
177
+
178
+ def get_version(self, version_id: str) -> dict[str, Any]:
179
+ path = ASSET_VERSION_ROUTE_TEMPLATE.format(version_id=http.quote_path_segment(version_id))
180
+ payload = self._request("GET", path)
181
+ if not isinstance(payload, dict) or not payload.get("version_id"):
182
+ raise AssetApiError(f"Malformed asset-version response: {payload!r}")
183
+ return payload
184
+
185
+ def get_report(self, version_id: str) -> dict[str, Any]:
186
+ """The stored validation/conversion report (customer route only)."""
187
+ path = ASSET_VERSION_REPORT_ROUTE_TEMPLATE.format(version_id=http.quote_path_segment(version_id))
188
+ payload = self._request("GET", path)
189
+ if not isinstance(payload, dict):
190
+ raise AssetApiError(f"Malformed report response: {payload!r}")
191
+ return payload
192
+
193
+ # ------------------------------------------------------------------
194
+ # list / search (PR-8) — ``scope=global``/``all``
195
+ # is still a customer read available to any authenticated user for global
196
+ # rows use the same read route as organization rows.
197
+ # ------------------------------------------------------------------
198
+
199
+ def list_assets(
200
+ self,
201
+ *,
202
+ scope: str,
203
+ kind: Optional[str] = None,
204
+ q: Optional[str] = None,
205
+ tag: Optional[str] = None,
206
+ include_deprecated: bool = False,
207
+ ) -> list[dict[str, Any]]:
208
+ """Every :class:`~simulo.interfaces.platform.asset_catalog.AssetRecord`
209
+ row matching the filters — pages internally (the
210
+ ``submit_api.list_recordings``/``list_models`` pattern) so a caller
211
+ never has to drive ``?page=``/``?limit=`` itself. ``include_deprecated``
212
+ (``simulo asset list --deprecated``) additionally surfaces assets whose
213
+ latest live version is deprecated (``latest_version_status='deprecated'``)."""
214
+ query_base: dict[str, str] = {"scope": scope, "limit": str(_LIST_PAGE_LIMIT)}
215
+ if kind:
216
+ query_base["kind"] = kind
217
+ if q:
218
+ query_base["q"] = q
219
+ if tag:
220
+ query_base["tag"] = tag
221
+ if include_deprecated:
222
+ query_base["include_deprecated"] = "true"
223
+ items: list[dict[str, Any]] = []
224
+ page = 1
225
+ while page <= _LIST_MAX_PAGES:
226
+ payload = self._request("GET", ASSETS_ROUTE, query={**query_base, "page": str(page)})
227
+ page_items = payload.get("items") if isinstance(payload, dict) else None
228
+ if not isinstance(page_items, list):
229
+ raise AssetApiError(f"Malformed asset list response: {payload!r}")
230
+ items.extend(item for item in page_items if isinstance(item, dict))
231
+ if len(page_items) < _LIST_PAGE_LIMIT:
232
+ return items
233
+ page += 1
234
+ return items
235
+
236
+ def list_assets_page(
237
+ self,
238
+ *,
239
+ scope: str,
240
+ kind: Optional[str] = None,
241
+ q: Optional[str] = None,
242
+ tag: Optional[str] = None,
243
+ include_deprecated: bool = False,
244
+ limit: int,
245
+ ) -> tuple[list[dict[str, Any]], int]:
246
+ """Page 1 (up to *limit* rows) of :meth:`list_assets`'s same query,
247
+ plus the total matching-row count.
248
+
249
+ The single-request counterpart to :meth:`list_assets` (which loops
250
+ every page) — backs the CLI's default recent-N ``asset list`` view /
251
+ ``--limit N``, so a bounded view never pays for pages it will not
252
+ display. ``search`` keeps using :meth:`list_assets` unchanged."""
253
+ query: dict[str, str] = {"scope": scope, "limit": str(limit), "page": "1"}
254
+ if kind:
255
+ query["kind"] = kind
256
+ if q:
257
+ query["q"] = q
258
+ if tag:
259
+ query["tag"] = tag
260
+ if include_deprecated:
261
+ query["include_deprecated"] = "true"
262
+ payload = self._request("GET", ASSETS_ROUTE, query=query)
263
+ items = payload.get("items") if isinstance(payload, dict) else None
264
+ total = payload.get("total") if isinstance(payload, dict) else None
265
+ if not isinstance(items, list) or not isinstance(total, int):
266
+ raise AssetApiError(f"Malformed asset list response: {payload!r}")
267
+ return [item for item in items if isinstance(item, dict)], total
268
+
269
+ # ------------------------------------------------------------------
270
+ # get (PR-8) — the frozen contract exposes no administrative download
271
+ # mirror. A global asset's refusal (403
272
+ # ``not_exportable``) comes from the SAME customer route every
273
+ # org download uses, not a separate privileged path.
274
+ # ------------------------------------------------------------------
275
+
276
+ def get_download_url(self, version_id: str) -> dict[str, Any]:
277
+ """``GET .../download`` — a presigned archive GET, or a structured
278
+ 403 ``not_exportable`` (global asset) / 409 ``asset_version_not_downloadable``
279
+ (not yet published) raised as :class:`AssetApiHTTPError`."""
280
+ path = ASSET_VERSION_DOWNLOAD_ROUTE_TEMPLATE.format(version_id=http.quote_path_segment(version_id))
281
+ payload = self._request("GET", path)
282
+ if not isinstance(payload, dict) or not payload.get("url"):
283
+ raise AssetApiError(f"Malformed asset download-link response: {payload!r}")
284
+ return payload
285
+
286
+ def download_bytes(self, url: str) -> bytes:
287
+ """GET the presigned archive bytes. The foreign-host rule applies
288
+ exactly like :meth:`upload_part_bytes`: the bearer rides only when
289
+ *url*'s host equals the API host (a presigned S3 GET carries its own
290
+ auth in the query string)."""
291
+ try:
292
+ return http.request_bytes(
293
+ "GET",
294
+ url,
295
+ token=self._token,
296
+ api_base_url=self._base_url,
297
+ timeout=_ARCHIVE_DOWNLOAD_TIMEOUT_S,
298
+ unavailable_hint=_UNAVAILABLE_HINT,
299
+ )
300
+ except http.HttpError as exc:
301
+ raise _translate(exc) from exc
302
+
303
+ # ------------------------------------------------------------------
304
+ # deprecate / delete (PR-8) — keyed by (kind, name, vN), never the
305
+ # opaque version_id (the "{vN} is a typed placeholder" PR-1 resolution:
306
+ # ``.format(version=<int>)``, never a pre-formatted "v3" string).
307
+ # ------------------------------------------------------------------
308
+
309
+ def deprecate_version_by_number(self, kind: str, name: str, version: int) -> dict[str, Any]:
310
+ path = ASSET_VERSION_DEPRECATE_ROUTE_TEMPLATE.format(
311
+ kind=http.quote_path_segment(kind), name=http.quote_path_segment(name), version=version
312
+ )
313
+ payload = self._request("POST", path)
314
+ if not isinstance(payload, dict):
315
+ raise AssetApiError(f"Malformed deprecate response: {payload!r}")
316
+ return payload
317
+
318
+ def reactivate_version_by_number(self, kind: str, name: str, version: int) -> dict[str, Any]:
319
+ """``POST .../reactivate`` — the inverse of :meth:`deprecate_version_by_number`:
320
+ un-deprecate a version (``deprecated`` → ``published``, back in
321
+ list/search). Idempotent on an already-published version; the server
322
+ refuses a ``failed``/``uploading``/``validating`` version (409). A
323
+ ``deleted`` version is never reachable — it 404s (filtered out of the
324
+ by-number lookup), not this 409."""
325
+ path = ASSET_VERSION_REACTIVATE_ROUTE_TEMPLATE.format(
326
+ kind=http.quote_path_segment(kind), name=http.quote_path_segment(name), version=version
327
+ )
328
+ payload = self._request("POST", path)
329
+ if not isinstance(payload, dict):
330
+ raise AssetApiError(f"Malformed reactivate response: {payload!r}")
331
+ return payload
332
+
333
+ def delete_version_by_number(self, kind: str, name: str, version: int, *, force: bool = False) -> dict[str, Any]:
334
+ """``DELETE`` a version. ``force=True`` sends ``?force=true`` — the
335
+ override that detaches referencing job pins (tombstoning them) and
336
+ hard-deletes an ORG version even when a past job used it. The server
337
+ refuses ``force`` on a global/passthrough version (403)."""
338
+ path = ASSET_VERSION_BY_NUMBER_ROUTE_TEMPLATE.format(
339
+ kind=http.quote_path_segment(kind), name=http.quote_path_segment(name), version=version
340
+ )
341
+ query = {"force": "true"} if force else None
342
+ payload = self._request("DELETE", path, query=query)
343
+ if not isinstance(payload, dict):
344
+ raise AssetApiError(f"Malformed delete response: {payload!r}")
345
+ return payload
346
+
347
+ # ------------------------------------------------------------------
348
+ # Raw part upload
349
+ # ------------------------------------------------------------------
350
+
351
+ def upload_part_bytes(self, url: str, data: bytes) -> Optional[str]:
352
+ """PUT one part's bytes to its presigned URL; return the part's etag.
353
+
354
+ The bearer rides ONLY when the URL's host is the API host (LocalStorage
355
+ presigns point back at the control plane; S3 presigns carry their own
356
+ SigV4 auth in the query string — the foreign-host rule). The presigned
357
+ URL is the credential for a part PUT.
358
+
359
+ The etag comes from the ``ETag`` response header (S3's convention) or,
360
+ failing that, the JSON response body's ``etag`` field (the LocalStorage
361
+ part route's shape). ``None`` when neither is present.
362
+ """
363
+ captured: dict[str, str] = {}
364
+
365
+ def _capture(headers: Any) -> None:
366
+ etag = headers.get("ETag") if headers is not None else None
367
+ if etag:
368
+ captured["etag"] = str(etag).strip('"')
369
+
370
+ try:
371
+ raw = http.request_bytes(
372
+ "PUT",
373
+ url,
374
+ token=self._token,
375
+ api_base_url=self._base_url,
376
+ body=data,
377
+ content_type="application/octet-stream",
378
+ timeout=_PART_UPLOAD_TIMEOUT_S,
379
+ unavailable_hint=_UNAVAILABLE_HINT,
380
+ on_response_headers=_capture,
381
+ )
382
+ except http.HttpError as exc:
383
+ raise _translate(exc) from exc
384
+ if "etag" in captured:
385
+ return captured["etag"]
386
+ try:
387
+ body = json.loads(raw.decode("utf-8")) if raw else None
388
+ except ValueError:
389
+ body = None
390
+ if isinstance(body, dict) and body.get("etag"):
391
+ return str(body["etag"])
392
+ return None
393
+
394
+ # ------------------------------------------------------------------
395
+ # Internals
396
+ # ------------------------------------------------------------------
397
+
398
+ def _request(
399
+ self,
400
+ method: str,
401
+ path: str,
402
+ *,
403
+ json_body: Optional[dict[str, Any]] = None,
404
+ query: Optional[dict[str, str]] = None,
405
+ ) -> Any:
406
+ url = self._base_url + path
407
+ if query:
408
+ url += "?" + urllib.parse.urlencode(query)
409
+ try:
410
+ return http.request_json(
411
+ method,
412
+ url,
413
+ token=self._token,
414
+ api_base_url=self._base_url,
415
+ json_body=json_body,
416
+ timeout=_REQUEST_TIMEOUT_S,
417
+ unavailable_hint=_UNAVAILABLE_HINT,
418
+ )
419
+ except http.HttpError as exc:
420
+ raise _translate(exc) from exc
421
+
422
+
423
+ # ---------------------------------------------------------------------------
424
+ # Upload orchestration — resume-aware multipart / single-PUT
425
+ # ---------------------------------------------------------------------------
426
+
427
+
428
+ def _read_chunk(tar_path: Path, part_number: int, part_size: int) -> bytes:
429
+ with tar_path.open("rb") as fh:
430
+ fh.seek((part_number - 1) * part_size)
431
+ return fh.read(part_size)
432
+
433
+
434
+ def _chunk_matches_landed(chunk: bytes, landed: dict[str, Any]) -> bool:
435
+ """Trust a landed part only when its size matches AND its etag matches the
436
+ chunk under one of the two digest conventions in play (LocalStorage stages
437
+ parts with a sha256 etag; S3's single-part etag is the md5 hex). Anything
438
+ else re-uploads — an idempotent overwrite, never a wrong keep."""
439
+ if landed.get("size_bytes") != len(chunk):
440
+ return False
441
+ etag = str(landed.get("etag") or "").strip('"').lower()
442
+ if not etag:
443
+ return False
444
+ return etag in (
445
+ hashlib.sha256(chunk).hexdigest(),
446
+ hashlib.md5(chunk, usedforsecurity=False).hexdigest(),
447
+ )
448
+
449
+
450
+ def upload_archive(
451
+ client: AssetApiClient,
452
+ *,
453
+ version_id: str,
454
+ upload: dict[str, Any],
455
+ tar_path: Path,
456
+ archive_sha256: str,
457
+ echo: Callable[[str], None],
458
+ ) -> tuple[dict[str, Any], int]:
459
+ """Drive the whole upload for one initiate ``upload`` envelope; complete it.
460
+
461
+ Returns ``(complete_response, skipped_parts)`` — ``skipped_parts`` is how
462
+ many parts had already landed (the resumable-upload disclosure). Raises
463
+ :class:`AssetApiError` on any failure — nothing is swallowed.
464
+ """
465
+ part_size = int(upload["part_size"])
466
+ part_count = int(upload["part_count"])
467
+ upload_id = upload.get("upload_id")
468
+
469
+ if upload_id is None:
470
+ # Single-PUT fast path: one presigned URL, the whole archive, and a
471
+ # complete whose etag is unchecked on this path (sent honestly anyway).
472
+ specs = client.presign_parts(version_id, [1])
473
+ if not specs:
474
+ raise AssetApiError("The server returned no presigned URL for the upload.")
475
+ etag = client.upload_part_bytes(str(specs[0]["url"]), tar_path.read_bytes())
476
+ completed = [{"part_number": 1, "etag": etag or archive_sha256}]
477
+ return client.complete(version_id, completed), 0
478
+
479
+ landed_by_number: dict[int, dict[str, Any]] = {}
480
+ state = client.get_upload_state(version_id)
481
+ for part in state.get("parts") or []:
482
+ if isinstance(part, dict) and isinstance(part.get("part_number"), int):
483
+ landed_by_number[part["part_number"]] = part
484
+
485
+ #: part_number -> etag for the complete call. Pre-seeded from the resume
486
+ #: state for skipped parts; captured from each PUT response otherwise.
487
+ etags: dict[int, str] = {}
488
+ needed: list[int] = []
489
+ skipped = 0
490
+ for part_number in range(1, part_count + 1):
491
+ landed = landed_by_number.get(part_number)
492
+ if landed is not None and _chunk_matches_landed(_read_chunk(tar_path, part_number, part_size), landed):
493
+ skipped += 1
494
+ etags[part_number] = str(landed.get("etag") or "")
495
+ else:
496
+ needed.append(part_number)
497
+ if skipped:
498
+ echo(f" resuming upload — {skipped} of {part_count} part(s) already uploaded")
499
+
500
+ uploaded = 0
501
+ for start in range(0, len(needed), _MAX_PART_NUMBERS_PER_BATCH):
502
+ batch = needed[start : start + _MAX_PART_NUMBERS_PER_BATCH]
503
+ specs_by_number = {int(spec["part_number"]): spec for spec in client.presign_parts(version_id, batch)}
504
+ for part_number in batch:
505
+ spec = specs_by_number.get(part_number)
506
+ if spec is None:
507
+ raise AssetApiError(f"The server did not presign part {part_number}.")
508
+ chunk = _read_chunk(tar_path, part_number, part_size)
509
+ etag = client.upload_part_bytes(str(spec["url"]), chunk)
510
+ # LocalStorage's staged-part etag is the part's sha256; fall back to
511
+ # it when neither the header nor the body carried one.
512
+ etags[part_number] = etag or hashlib.sha256(chunk).hexdigest()
513
+ uploaded += 1
514
+ echo(f" uploaded part {skipped + uploaded}/{part_count}")
515
+
516
+ completed = [{"part_number": n, "etag": etags[n]} for n in range(1, part_count + 1)]
517
+ return client.complete(version_id, completed), skipped