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.
Files changed (40) hide show
  1. {simulo-0.9.0/src/simulo.egg-info → simulo-0.11.0}/PKG-INFO +7 -2
  2. {simulo-0.9.0 → simulo-0.11.0}/PYPI.md +5 -0
  3. {simulo-0.9.0 → simulo-0.11.0}/pyproject.toml +2 -2
  4. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/_runner.py +1 -1
  5. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/app.py +110 -4
  6. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/bundle.py +47 -10
  7. simulo-0.11.0/src/simulo/_client/cancel_api.py +105 -0
  8. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/cli.py +743 -29
  9. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/credentials.py +36 -7
  10. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/http.py +19 -3
  11. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/jobs_api.py +21 -4
  12. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/packaging.py +176 -15
  13. simulo-0.11.0/src/simulo/_client/seed_ref.py +76 -0
  14. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/submit_api.py +118 -2
  15. {simulo-0.9.0 → simulo-0.11.0/src/simulo.egg-info}/PKG-INFO +7 -2
  16. {simulo-0.9.0 → simulo-0.11.0}/src/simulo.egg-info/SOURCES.txt +2 -0
  17. {simulo-0.9.0 → simulo-0.11.0}/src/simulo.egg-info/requires.txt +1 -1
  18. {simulo-0.9.0 → simulo-0.11.0}/MANIFEST.in +0 -0
  19. {simulo-0.9.0 → simulo-0.11.0}/setup.cfg +0 -0
  20. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/__init__.py +0 -0
  21. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/__init__.py +0 -0
  22. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/_entrypoint.py +0 -0
  23. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/_mounts.py +0 -0
  24. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/asset.py +0 -0
  25. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/config.py +0 -0
  26. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/discovery.py +0 -0
  27. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/learning.py +0 -0
  28. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/login.py +0 -0
  29. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/manifest.py +0 -0
  30. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/mode.py +0 -0
  31. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/registry.py +0 -0
  32. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/runtime.py +0 -0
  33. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/stub.py +0 -0
  34. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/view_session_api.py +0 -0
  35. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/_client/volume.py +0 -0
  36. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/callbacks.py +0 -0
  37. {simulo-0.9.0 → simulo-0.11.0}/src/simulo/py.typed +0 -0
  38. {simulo-0.9.0 → simulo-0.11.0}/src/simulo.egg-info/dependency_links.txt +0 -0
  39. {simulo-0.9.0 → simulo-0.11.0}/src/simulo.egg-info/entry_points.txt +0 -0
  40. {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.9.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.3,>=0.2
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.9.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.2,<0.3", "mcap>=1.3,<2"]
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, package_path: Path, creds: Optional[dict[str, Any]], *, viewstream: bool = False
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 = str(manifest.get("package_id") or package_path.name)
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(package_id=package_id, job_name=job_name, args=args, viewstream=viewstream)
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(self._spec.name, package_path, creds, viewstream=_VIEWSTREAM_REQUESTED)
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
- _BUNDLE_CACHE: dict[tuple[int, str, str], Path] = {}
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 per-submission
138
- directory keyed on source + ``job_name`` + ``args``. Memoised per submission
139
- (``(id(app), job_name, canonical_json(args))``) so re-submitting the same job
140
- with the same args reuses the package, while a different job or different args
141
- writes a distinct package rather than clobbering the first.
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 and cached.is_dir():
146
- return cached
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
- _BUNDLE_CACHE[cache_key] = bundle
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