simulo 0.9.0__tar.gz → 0.11.0__tar.gz
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-0.9.0/src/simulo.egg-info → simulo-0.11.0}/PKG-INFO +7 -2
- {simulo-0.9.0 → simulo-0.11.0}/PYPI.md +5 -0
- {simulo-0.9.0 → simulo-0.11.0}/pyproject.toml +2 -2
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/_runner.py +1 -1
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/app.py +110 -4
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/bundle.py +47 -10
- simulo-0.11.0/src/simulo/_client/cancel_api.py +105 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/cli.py +743 -29
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/credentials.py +36 -7
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/http.py +19 -3
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/jobs_api.py +21 -4
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/packaging.py +176 -15
- simulo-0.11.0/src/simulo/_client/seed_ref.py +76 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/submit_api.py +118 -2
- {simulo-0.9.0 → simulo-0.11.0/src/simulo.egg-info}/PKG-INFO +7 -2
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo.egg-info/SOURCES.txt +2 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo.egg-info/requires.txt +1 -1
- {simulo-0.9.0 → simulo-0.11.0}/MANIFEST.in +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/setup.cfg +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/__init__.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/__init__.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/_entrypoint.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/_mounts.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/asset.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/config.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/discovery.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/learning.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/login.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/manifest.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/mode.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/registry.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/runtime.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/stub.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/view_session_api.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/volume.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/callbacks.py +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo/py.typed +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo.egg-info/dependency_links.txt +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo.egg-info/entry_points.txt +0 -0
- {simulo-0.9.0 → simulo-0.11.0}/src/simulo.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simulo
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Simulo SDK and CLI — define robotics simulation and training apps in Python and run them on the Simulo cloud.
|
|
5
5
|
Author-email: Simulo Team <team@simulo.ai>
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
15
15
|
Classifier: Typing :: Typed
|
|
16
16
|
Requires-Python: >=3.11
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
|
-
Requires-Dist: simulo-interfaces<0.
|
|
18
|
+
Requires-Dist: simulo-interfaces<0.5,>=0.4
|
|
19
19
|
Requires-Dist: mcap<2,>=1.3
|
|
20
20
|
Provides-Extra: dev
|
|
21
21
|
Requires-Dist: black>=24.0; extra == "dev"
|
|
@@ -85,4 +85,9 @@ simulo run app.py --name robot --repeat 5
|
|
|
85
85
|
|
|
86
86
|
Without logging in, `simulo run` packages the app locally without submitting it.
|
|
87
87
|
|
|
88
|
+
Every cloud submit is fresh — running the same command twice trains twice, never a silent
|
|
89
|
+
no-op. To continue training from an earlier job's checkpoint instead, pass
|
|
90
|
+
`--from <job-ref>[:best|:latest]`. Job ids, and their unambiguous prefixes (`>=4` characters),
|
|
91
|
+
work anywhere the CLI takes a job id.
|
|
92
|
+
|
|
88
93
|
Learn more at [simulo.ai](https://simulo.ai).
|
|
@@ -53,4 +53,9 @@ simulo run app.py --name robot --repeat 5
|
|
|
53
53
|
|
|
54
54
|
Without logging in, `simulo run` packages the app locally without submitting it.
|
|
55
55
|
|
|
56
|
+
Every cloud submit is fresh — running the same command twice trains twice, never a silent
|
|
57
|
+
no-op. To continue training from an earlier job's checkpoint instead, pass
|
|
58
|
+
`--from <job-ref>[:best|:latest]`. Job ids, and their unambiguous prefixes (`>=4` characters),
|
|
59
|
+
work anywhere the CLI takes a job id.
|
|
60
|
+
|
|
56
61
|
Learn more at [simulo.ai](https://simulo.ai).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "simulo"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.0"
|
|
8
8
|
description = "Simulo SDK and CLI — define robotics simulation and training apps in Python and run them on the Simulo cloud."
|
|
9
9
|
readme = "PYPI.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -25,7 +25,7 @@ classifiers = [
|
|
|
25
25
|
# guard, resolved only at job-execution time — never in this distribution.
|
|
26
26
|
# mcap: used by `simulo recordings` to read back a downloaded recording and
|
|
27
27
|
# verify it (message count) — MIT-licensed, deps only lz4+zstandard.
|
|
28
|
-
dependencies = ["simulo-interfaces>=0.
|
|
28
|
+
dependencies = ["simulo-interfaces>=0.4,<0.5", "mcap>=1.3,<2"]
|
|
29
29
|
|
|
30
30
|
[project.optional-dependencies]
|
|
31
31
|
dev = ["black>=24.0", "isort>=5.13", "ruff>=0.5", "mypy>=1.10", "pytest>=8.2", "build>=1.2", "twine>=5.1"]
|
|
@@ -92,7 +92,7 @@ class JobHandle:
|
|
|
92
92
|
|
|
93
93
|
@property
|
|
94
94
|
def package_path(self) -> Path:
|
|
95
|
-
"""Directory the package was written to (``<out>/<package_id>/``)."""
|
|
95
|
+
"""Directory the package was written to (``<out>/<bundle_dir_name(package_id)>/``)."""
|
|
96
96
|
return self._package_path
|
|
97
97
|
|
|
98
98
|
@property
|
|
@@ -97,6 +97,57 @@ def viewstream_requested(enabled: bool) -> Iterator[None]:
|
|
|
97
97
|
_VIEWSTREAM_REQUESTED = previous
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Seed ambient signal — `simulo run --from <job-ref>[:best|:latest]` scopes the
|
|
102
|
+
# seed reference for one submit (explicit-run-intent plan), exactly like the
|
|
103
|
+
# viewstream signal above and for the same two reasons: the actual `spawn()`
|
|
104
|
+
# happens deep inside the user's @app.local_entrypoint, and the seed is a
|
|
105
|
+
# SUBMIT-time job property that must never enter `args` (whose canonical form
|
|
106
|
+
# feeds the `package_id` sha256) or the package manifest. It rides the
|
|
107
|
+
# `POST /v1/jobs` body as a top-level sibling of `args`, passed through
|
|
108
|
+
# verbatim — the control plane resolves the ref (org-scoped, prefixes and
|
|
109
|
+
# `:best`/`:latest` included) in the same request.
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
_SEED_FROM_REF: Optional[str] = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class SeedRequiresCloudError(RuntimeError):
|
|
116
|
+
"""``--from`` was passed but the submit resolved to the local-disk path.
|
|
117
|
+
|
|
118
|
+
A local-disk submit only writes a package — no control plane exists to
|
|
119
|
+
resolve the seed reference or hand the checkpoint to a worker, so the
|
|
120
|
+
resulting run would silently be FRESH instead of seeded (the same
|
|
121
|
+
wrong-result hazard the loud-degradation check guards on the cloud
|
|
122
|
+
path). Refused loudly instead of ignored.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@contextlib.contextmanager
|
|
127
|
+
def seed_from_requested(seed_ref: Optional[str]) -> Iterator[None]:
|
|
128
|
+
"""Scope ``--from``'s seed reference for one ``simulo run`` submit call."""
|
|
129
|
+
global _SEED_FROM_REF
|
|
130
|
+
previous = _SEED_FROM_REF
|
|
131
|
+
_SEED_FROM_REF = seed_ref
|
|
132
|
+
try:
|
|
133
|
+
yield
|
|
134
|
+
finally:
|
|
135
|
+
_SEED_FROM_REF = previous
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _require_no_seed_for_local_submit(reason: str) -> None:
|
|
139
|
+
"""Refuse a local-disk submit that carries an ambient ``--from`` seed."""
|
|
140
|
+
if _SEED_FROM_REF is None:
|
|
141
|
+
return
|
|
142
|
+
raise SeedRequiresCloudError(
|
|
143
|
+
f"--from {_SEED_FROM_REF!r} needs a cloud control plane to resolve the checkpoint, but "
|
|
144
|
+
f"{reason} — a local-disk submit only writes the package and cannot continue from another "
|
|
145
|
+
"job's checkpoint, so the run would silently start fresh instead. Log in first "
|
|
146
|
+
"(`simulo login`) or point SIMULO_API_URL/SIMULO_ENV at a control plane (and unset "
|
|
147
|
+
"SIMULO_SUBMIT=local), or drop --from for a fresh local run."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
100
151
|
@dataclass
|
|
101
152
|
class JobSpec:
|
|
102
153
|
"""Recorded metadata for one ``@app.job``-decorated function."""
|
|
@@ -153,7 +204,12 @@ def _spawn_local(job_name: str, package_path: Path) -> JobHandle:
|
|
|
153
204
|
|
|
154
205
|
|
|
155
206
|
def _spawn_cloud(
|
|
156
|
-
job_name: str,
|
|
207
|
+
job_name: str,
|
|
208
|
+
package_path: Path,
|
|
209
|
+
creds: Optional[dict[str, Any]],
|
|
210
|
+
*,
|
|
211
|
+
viewstream: bool = False,
|
|
212
|
+
seed_from: Optional[str] = None,
|
|
157
213
|
) -> JobHandle:
|
|
158
214
|
"""Tar the bundle, upload it, create the job, and return a cloud JobHandle.
|
|
159
215
|
|
|
@@ -162,12 +218,38 @@ def _spawn_cloud(
|
|
|
162
218
|
— see the live-visualization plan's wire-contract rule): a package's
|
|
163
219
|
content-addressed identity must never change based on whether this
|
|
164
220
|
particular execution asked for a live stream.
|
|
221
|
+
|
|
222
|
+
``seed_from`` (``simulo run --from``, explicit-run-intent plan) follows
|
|
223
|
+
the identical rule: the seed reference is a SUBMIT-time job property,
|
|
224
|
+
never a package property — it rides the job-create call only, verbatim
|
|
225
|
+
(the control plane resolves prefixes and ``:best``/``:latest``). When the
|
|
226
|
+
server honors it, the RESOLVED provenance is announced on stderr (the
|
|
227
|
+
full source-job UUID — the confirmation that a typed prefix resolved to
|
|
228
|
+
the job the user meant); when an older server ignores it, ``create_job``
|
|
229
|
+
raises rather than let a fresh run masquerade as a seeded one.
|
|
165
230
|
"""
|
|
166
231
|
from simulo._client.packaging import MANIFEST_FILENAME, ensure_tar
|
|
167
232
|
from simulo._client.submit_api import SubmitApiClient
|
|
168
233
|
|
|
169
234
|
manifest = json.loads((package_path / MANIFEST_FILENAME).read_text(encoding="utf-8"))
|
|
170
|
-
package_id
|
|
235
|
+
# The manifest's package_id is the wire identity (``sha256:<hex>``) and is
|
|
236
|
+
# the ONLY source of truth for it — every manifest ``package()`` writes
|
|
237
|
+
# carries one (see packaging.py). The local directory name is NOT a valid
|
|
238
|
+
# fallback: it is a short, non-invertible, filesystem-safe form of the id
|
|
239
|
+
# (see ``packaging.bundle_dir_name`` — truncated for Windows MAX_PATH),
|
|
240
|
+
# so guessing an id back from it would either be impossible (can't invert
|
|
241
|
+
# a truncated hash) or, worse, silently put a WRONG/incomplete id on the
|
|
242
|
+
# wire. An id-less manifest means a bundle written by a client older than
|
|
243
|
+
# this fix, or a corrupted/foreign directory — fail loudly and tell the
|
|
244
|
+
# caller how to recover, never guess.
|
|
245
|
+
package_id = manifest.get("package_id")
|
|
246
|
+
if not package_id:
|
|
247
|
+
raise RuntimeError(
|
|
248
|
+
f"Package at {package_path} has no package_id in its manifest (it predates this client "
|
|
249
|
+
"version, or its manifest is missing/corrupted). Re-run `simulo run` (or call .spawn() "
|
|
250
|
+
"again) to rebuild it before submitting to the cloud."
|
|
251
|
+
)
|
|
252
|
+
package_id = str(package_id)
|
|
171
253
|
source_digest = str(manifest.get("source_digest", ""))
|
|
172
254
|
args = dict(manifest.get("args") or {})
|
|
173
255
|
|
|
@@ -187,7 +269,23 @@ def _spawn_cloud(
|
|
|
187
269
|
archive_size=len(archive_bytes),
|
|
188
270
|
)
|
|
189
271
|
client.upload_archive(package_id, archive_bytes)
|
|
190
|
-
record = client.create_job(
|
|
272
|
+
record = client.create_job(
|
|
273
|
+
package_id=package_id,
|
|
274
|
+
job_name=job_name,
|
|
275
|
+
args=args,
|
|
276
|
+
viewstream=viewstream,
|
|
277
|
+
seed_from_job_id=seed_from,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if seed_from is not None:
|
|
281
|
+
# create_job has already guaranteed the seed dict is present (the
|
|
282
|
+
# loud-degradation guard) — this line is the human confirmation that
|
|
283
|
+
# the ref (possibly a prefix) resolved to the intended source job.
|
|
284
|
+
seed = record.get("seed") or {}
|
|
285
|
+
print(
|
|
286
|
+
f"Continuing from job {seed.get('job_id')} ({seed.get('name')}, {seed.get('kind')} checkpoint)",
|
|
287
|
+
file=sys.stderr,
|
|
288
|
+
)
|
|
191
289
|
|
|
192
290
|
return JobHandle(JobId(str(record["job_id"])), package_path, job_name, cloud=True, base_url=base_url, token=token)
|
|
193
291
|
|
|
@@ -242,12 +340,20 @@ class JobFunction:
|
|
|
242
340
|
# fail outright if the refresh token itself is no longer valid). A
|
|
243
341
|
# local-disk submit must never depend on the network succeeding.
|
|
244
342
|
if config.is_submit_forced_local():
|
|
343
|
+
_require_no_seed_for_local_submit("SIMULO_SUBMIT=local forces a local-disk submit")
|
|
245
344
|
handle: JobHandle = _spawn_local(self._spec.name, package_path)
|
|
246
345
|
else:
|
|
247
346
|
creds = credentials.try_get_valid_credentials()
|
|
248
347
|
if _cloud_mode_active(creds):
|
|
249
|
-
handle = _spawn_cloud(
|
|
348
|
+
handle = _spawn_cloud(
|
|
349
|
+
self._spec.name,
|
|
350
|
+
package_path,
|
|
351
|
+
creds,
|
|
352
|
+
viewstream=_VIEWSTREAM_REQUESTED,
|
|
353
|
+
seed_from=_SEED_FROM_REF,
|
|
354
|
+
)
|
|
250
355
|
else:
|
|
356
|
+
_require_no_seed_for_local_submit("no cloud target is configured")
|
|
251
357
|
handle = _spawn_local(self._spec.name, package_path)
|
|
252
358
|
_record_spawn(handle)
|
|
253
359
|
return handle
|
|
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Any, Optional
|
|
|
21
21
|
|
|
22
22
|
from simulo._client.asset import Asset
|
|
23
23
|
from simulo._client.manifest import Manifest
|
|
24
|
-
from simulo._client.packaging import canonical_args, package
|
|
24
|
+
from simulo._client.packaging import canonical_args, package, read_bundle_package_id
|
|
25
25
|
from simulo._client.volume import Volume
|
|
26
26
|
|
|
27
27
|
if TYPE_CHECKING: # typing-only; avoids any import cycle with app.py at runtime
|
|
@@ -36,7 +36,18 @@ DEFAULT_OUT_SUBPATH = (".simulo", "packages")
|
|
|
36
36
|
# so identity reuse is not a concern, and keying on the job + args means multiple
|
|
37
37
|
# spawns in one entrypoint (different job or different args) each write their own
|
|
38
38
|
# package instead of the first one being reused for all.
|
|
39
|
-
|
|
39
|
+
#
|
|
40
|
+
# Each entry stores ``(bundle_dir, package_id)`` — the package_id the bundle
|
|
41
|
+
# was written for when THIS process cached it — not just the path. The local
|
|
42
|
+
# bundle directory name is a short, non-injective digest prefix (see
|
|
43
|
+
# ``packaging.bundle_dir_name``), so a directory existing at the cached path is
|
|
44
|
+
# NOT proof it still holds this submission's content: another submission whose
|
|
45
|
+
# id happens to share the same short prefix could have called ``package()``
|
|
46
|
+
# again for a DIFFERENT id and overwritten it (``package()`` unconditionally
|
|
47
|
+
# rmtree+rewrites whatever it finds there). ``ensure_bundle`` re-verifies the
|
|
48
|
+
# manifest on every cache hit before trusting it (see below) — turning a
|
|
49
|
+
# prefix collision into a redundant rebuild, never a silently wrong package.
|
|
50
|
+
_BUNDLE_CACHE: dict[tuple[int, str, str], tuple[Path, str]] = {}
|
|
40
51
|
|
|
41
52
|
|
|
42
53
|
def _mount_ref(mount: object) -> dict[str, Any]:
|
|
@@ -134,21 +145,47 @@ def ensure_bundle(
|
|
|
134
145
|
|
|
135
146
|
This is the *submit* primitive: the manifest is built from the live app object
|
|
136
147
|
(no second discovery import) with the submitted job's ``args`` recorded, and
|
|
137
|
-
the source tree is written to ``<out>/<package_id>/`` — the
|
|
138
|
-
directory keyed on source + ``job_name`` + ``args
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
148
|
+
the source tree is written to ``<out>/<bundle_dir_name(package_id)>/`` — the
|
|
149
|
+
per-submission directory keyed on source + ``job_name`` + ``args`` (named by
|
|
150
|
+
the id's short, filesystem-safe form; see ``packaging.bundle_dir_name``).
|
|
151
|
+
Memoised per submission (``(id(app), job_name, canonical_json(args))``) so
|
|
152
|
+
re-submitting the same job with the same args reuses the package, while a
|
|
153
|
+
different job or different args writes a distinct package rather than
|
|
154
|
+
clobbering the first.
|
|
155
|
+
|
|
156
|
+
A cache HIT is re-verified, not trusted blindly: the short bundle directory
|
|
157
|
+
name is not injective (Windows MAX_PATH trade-off — see
|
|
158
|
+
``packaging.bundle_dir_name``), so before returning a cached path this reads
|
|
159
|
+
the manifest actually on disk there and confirms its ``package_id`` still
|
|
160
|
+
matches what THIS cache entry was written for. A mismatch — some other
|
|
161
|
+
submission's id collided on the same short prefix and ``package()``
|
|
162
|
+
overwrote the directory — is treated as a MISS: the package is rebuilt
|
|
163
|
+
(deterministically restoring the correct content for this exact
|
|
164
|
+
submission), never served as a silently wrong package.
|
|
142
165
|
"""
|
|
143
166
|
cache_key = (id(app), job_name, canonical_args(args))
|
|
144
167
|
cached = _BUNDLE_CACHE.get(cache_key)
|
|
145
|
-
if cached is not None
|
|
146
|
-
|
|
168
|
+
if cached is not None:
|
|
169
|
+
cached_dir, cached_package_id = cached
|
|
170
|
+
if cached_dir.is_dir() and read_bundle_package_id(cached_dir) == cached_package_id:
|
|
171
|
+
return cached_dir
|
|
147
172
|
if out_dir is None:
|
|
148
173
|
out_dir = project_root.joinpath(*DEFAULT_OUT_SUBPATH)
|
|
149
174
|
manifest = build_manifest(app, app_file, project_root, args=args)
|
|
150
175
|
bundle = package(app_file, out_dir=out_dir, project_root=project_root, manifest=manifest, job_name=job_name)
|
|
151
|
-
|
|
176
|
+
bundle_package_id = read_bundle_package_id(bundle)
|
|
177
|
+
if bundle_package_id is None:
|
|
178
|
+
# Internal invariant, not a user-facing input error: package() always
|
|
179
|
+
# stamps package_id into the manifest it writes (see packaging.py). A
|
|
180
|
+
# bare `assert` here would be stripped under `python -O`, silently
|
|
181
|
+
# caching a bundle keyed on a package_id we never actually confirmed
|
|
182
|
+
# (critic finding, PR #363 round-2 re-review, 2026-07-12) — use a
|
|
183
|
+
# real, always-live exception instead.
|
|
184
|
+
raise RuntimeError(
|
|
185
|
+
f"Internal error: package() wrote a bundle at {bundle} with no package_id in its manifest. "
|
|
186
|
+
"This should never happen — please report it."
|
|
187
|
+
)
|
|
188
|
+
_BUNDLE_CACHE[cache_key] = (bundle, bundle_package_id)
|
|
152
189
|
return bundle
|
|
153
190
|
|
|
154
191
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""HTTP client for the job cancel endpoint (stdlib only, torch-free).
|
|
2
|
+
|
|
3
|
+
Wire contract (job-cancel-and-demo-apps plan — see
|
|
4
|
+
``context/implementation-plans/job-cancel-and-demo-apps-plan.md``; the control
|
|
5
|
+
plane's half of this route is built by a parallel PR, so this client codes
|
|
6
|
+
directly against the plan's PINNED contract rather than waiting on it):
|
|
7
|
+
``POST /v1/jobs/{job_id}/cancel`` (Cognito bearer, org-scoped).
|
|
8
|
+
|
|
9
|
+
* 200 ``{"job_id": ..., "status": "cancelled"}`` — a QUEUED job is cancelled
|
|
10
|
+
immediately (it can never be claimed by a worker after this).
|
|
11
|
+
* 202 ``{"job_id": ..., "status": "running", "cancel_requested": true}`` — a
|
|
12
|
+
RUNNING job has cancellation requested; the worker confirms termination
|
|
13
|
+
within one heartbeat interval. Idempotent — a repeat cancel of an
|
|
14
|
+
already-cancel-requested running job returns the same 202 shape again.
|
|
15
|
+
* 409 ``job_not_running`` — the job is already in a terminal state
|
|
16
|
+
(completed/failed/cancelled); the response message carries the actual
|
|
17
|
+
status, but the caller (``simulo._client.cli``) does not surface it
|
|
18
|
+
verbatim — it reuses the same friendly "has ended" copy ``simulo view``
|
|
19
|
+
uses for the identical situation.
|
|
20
|
+
* 404 ``job_not_found`` — unknown or cross-org job id (anti-enumeration;
|
|
21
|
+
never 403). This is the cancel route's ONLY pinned 404 code — mirrors
|
|
22
|
+
``view_session_api.py``'s contract, and the caller relies on that the same
|
|
23
|
+
way: any OTHER 404 code means the ``/cancel`` route itself isn't deployed
|
|
24
|
+
in this environment (e.g. prod before this feature ships there), not that
|
|
25
|
+
the job is missing — see ``cli._CANCEL_UNAVAILABLE_MESSAGE``.
|
|
26
|
+
|
|
27
|
+
Error responses use the standard ``{"error": {"code", "message", "request_id"}}``
|
|
28
|
+
envelope, parsed by ``http.py`` exactly like every other client in this
|
|
29
|
+
package.
|
|
30
|
+
|
|
31
|
+
The route path is a LOCAL constant here rather than imported from
|
|
32
|
+
``simulo.interfaces.platform.runs`` — deliberately, the same reconciliation
|
|
33
|
+
call ``view_session_api.py`` made: the control-plane cancel route is being
|
|
34
|
+
built by a parallel PR and may land independently of this one, so this client
|
|
35
|
+
codes directly against the plan's pinned path/response shape rather than
|
|
36
|
+
sharing a file with that PR (avoiding a merge conflict). Reconcile into a
|
|
37
|
+
shared ``simulo.interfaces.platform`` constant in a follow-up once that PR has
|
|
38
|
+
merged, if a shared module materializes for it.
|
|
39
|
+
|
|
40
|
+
Shares request plumbing (structured-error parsing, the https-when-token
|
|
41
|
+
guard, the foreign-host bearer rule) with ``jobs_api.py``/``view_session_api.py``
|
|
42
|
+
via ``http.py`` — one request path, not reimplemented a fourth time.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from typing import Any, Optional
|
|
48
|
+
|
|
49
|
+
from simulo._client import http
|
|
50
|
+
|
|
51
|
+
#: ``POST`` — request cancellation of a queued or running job. ``.format(job_id=...)``.
|
|
52
|
+
CANCEL_ROUTE_TEMPLATE = "/v1/jobs/{job_id}/cancel"
|
|
53
|
+
|
|
54
|
+
_REQUEST_TIMEOUT_S = 10.0 # every outbound call has an explicit timeout (NFR)
|
|
55
|
+
_UNAVAILABLE_HINT = "Check SIMULO_API_URL / SIMULO_ENV, and that you are logged in (`simulo login`)."
|
|
56
|
+
|
|
57
|
+
#: Required fields of a well-formed cancel response (see module docstring).
|
|
58
|
+
_REQUIRED_FIELDS = ("job_id", "status")
|
|
59
|
+
|
|
60
|
+
# Backward-compatible-style aliases — same convention as jobs_api.py /
|
|
61
|
+
# view_session_api.py: these are literally ``http.HttpError``/``http.HttpHTTPError``
|
|
62
|
+
# (not new subclasses), so callers that already catch ``JobsApiError`` (also
|
|
63
|
+
# ``http.HttpError``) catch these too without any extra wiring.
|
|
64
|
+
CancelApiError = http.HttpError
|
|
65
|
+
CancelApiHTTPError = http.HttpHTTPError
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CancelApiClient:
|
|
69
|
+
"""Client for ``POST /v1/jobs/{job_id}/cancel``."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, base_url: str, *, token: Optional[str] = None) -> None:
|
|
72
|
+
if not base_url.startswith(("http://", "https://")):
|
|
73
|
+
raise CancelApiError(f"API base URL must be an http(s) URL, got {base_url!r}.")
|
|
74
|
+
self._base_url = base_url.rstrip("/")
|
|
75
|
+
self._token = token
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def base_url(self) -> str:
|
|
79
|
+
return self._base_url
|
|
80
|
+
|
|
81
|
+
def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
82
|
+
"""Request cancellation of *job_id*; returns the parsed 200/202 body.
|
|
83
|
+
|
|
84
|
+
Raises :class:`CancelApiHTTPError` on any non-2xx response — the
|
|
85
|
+
caller (``simulo._client.cli``) classifies by ``exc.code``, never by
|
|
86
|
+
``exc.status`` alone (both ``job_not_running`` and the route-missing
|
|
87
|
+
degradation share statuses with other codes, exactly like
|
|
88
|
+
``ViewSessionApiClient.create_view_session``'s callers already do).
|
|
89
|
+
"""
|
|
90
|
+
path = CANCEL_ROUTE_TEMPLATE.format(job_id=http.quote_path_segment(job_id))
|
|
91
|
+
payload = http.request_json(
|
|
92
|
+
"POST",
|
|
93
|
+
self._base_url + path,
|
|
94
|
+
token=self._token,
|
|
95
|
+
api_base_url=self._base_url,
|
|
96
|
+
json_body={},
|
|
97
|
+
timeout=_REQUEST_TIMEOUT_S,
|
|
98
|
+
unavailable_hint=_UNAVAILABLE_HINT,
|
|
99
|
+
)
|
|
100
|
+
if not isinstance(payload, dict):
|
|
101
|
+
raise CancelApiError(f"Malformed cancel response for job {job_id!r}: expected a JSON object.")
|
|
102
|
+
missing = [field for field in _REQUIRED_FIELDS if field not in payload]
|
|
103
|
+
if missing:
|
|
104
|
+
raise CancelApiError(f"Malformed cancel response for job {job_id!r}: missing field(s) {missing}.")
|
|
105
|
+
return payload
|