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.
- simulo/__init__.py +433 -0
- simulo/_client/__init__.py +6 -0
- simulo/_client/_entrypoint.py +313 -0
- simulo/_client/_mounts.py +25 -0
- simulo/_client/_runner.py +186 -0
- simulo/_client/_secure_downloads.py +1181 -0
- simulo/_client/app.py +1308 -0
- simulo/_client/asset.py +331 -0
- simulo/_client/asset_api.py +517 -0
- simulo/_client/asset_package.py +1103 -0
- simulo/_client/asset_pins.py +187 -0
- simulo/_client/builtin_aliases.py +107 -0
- simulo/_client/bundle.py +254 -0
- simulo/_client/cancel_api.py +104 -0
- simulo/_client/cli.py +9063 -0
- simulo/_client/config.py +186 -0
- simulo/_client/credentials.py +210 -0
- simulo/_client/discovery.py +214 -0
- simulo/_client/export_api.py +212 -0
- simulo/_client/export_bundle.py +296 -0
- simulo/_client/facades.py +581 -0
- simulo/_client/http.py +414 -0
- simulo/_client/identity_api.py +117 -0
- simulo/_client/install_samples.py +267 -0
- simulo/_client/jobs_api.py +224 -0
- simulo/_client/learning.py +393 -0
- simulo/_client/login.py +319 -0
- simulo/_client/mode.py +29 -0
- simulo/_client/outputs.py +116 -0
- simulo/_client/packaging.py +445 -0
- simulo/_client/preflight_api.py +186 -0
- simulo/_client/preflight_render.py +200 -0
- simulo/_client/registry.py +98 -0
- simulo/_client/runtime.py +185 -0
- simulo/_client/runtime_display.py +90 -0
- simulo/_client/seed_ref.py +76 -0
- simulo/_client/stub.py +41 -0
- simulo/_client/submit_api.py +1057 -0
- simulo/_client/templates/__init__.py +21 -0
- simulo/_client/templates/inference/app.py.tmpl +316 -0
- simulo/_client/templates/inference/simuloignore.tmpl +30 -0
- simulo/_client/templates/scenario/app.py.tmpl +93 -0
- simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
- simulo/_client/templates/training/app.py.tmpl +235 -0
- simulo/_client/templates/training/simuloignore.tmpl +29 -0
- simulo/_client/view_fragment.py +21 -0
- simulo/_client/view_session_api.py +122 -0
- simulo/_client/volume.py +71 -0
- simulo/callbacks.py +274 -0
- simulo/py.typed +0 -0
- simulo-0.26.0.dist-info/METADATA +130 -0
- simulo-0.26.0.dist-info/RECORD +55 -0
- simulo-0.26.0.dist-info/WHEEL +5 -0
- simulo-0.26.0.dist-info/entry_points.txt +2 -0
- 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
|