simulo 0.20.1__tar.gz → 0.22.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 (58) hide show
  1. {simulo-0.20.1 → simulo-0.22.0}/MANIFEST.in +1 -1
  2. {simulo-0.20.1/src/simulo.egg-info → simulo-0.22.0}/PKG-INFO +2 -2
  3. {simulo-0.20.1 → simulo-0.22.0}/pyproject.toml +2 -2
  4. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/__init__.py +29 -14
  5. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/_runner.py +24 -9
  6. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/app.py +31 -8
  7. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/cancel_api.py +2 -2
  8. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/cli.py +1082 -395
  9. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/credentials.py +4 -2
  10. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/export_api.py +24 -17
  11. simulo-0.22.0/src/simulo/_client/identity_api.py +117 -0
  12. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/jobs_api.py +17 -12
  13. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/learning.py +11 -11
  14. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/login.py +18 -3
  15. simulo-0.20.1/src/simulo/_client/artifacts.py → simulo-0.22.0/src/simulo/_client/outputs.py +24 -22
  16. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/seed_ref.py +3 -3
  17. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/submit_api.py +210 -132
  18. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/view_session_api.py +6 -6
  19. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/callbacks.py +3 -3
  20. {simulo-0.20.1 → simulo-0.22.0/src/simulo.egg-info}/PKG-INFO +2 -2
  21. {simulo-0.20.1 → simulo-0.22.0}/src/simulo.egg-info/SOURCES.txt +2 -1
  22. {simulo-0.20.1 → simulo-0.22.0}/src/simulo.egg-info/requires.txt +1 -1
  23. {simulo-0.20.1 → simulo-0.22.0}/PYPI.md +0 -0
  24. {simulo-0.20.1 → simulo-0.22.0}/setup.cfg +0 -0
  25. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/__init__.py +0 -0
  26. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/_entrypoint.py +0 -0
  27. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/_mounts.py +0 -0
  28. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/asset.py +0 -0
  29. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/asset_api.py +0 -0
  30. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/asset_package.py +0 -0
  31. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/asset_pins.py +0 -0
  32. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/builtin_aliases.py +0 -0
  33. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/bundle.py +0 -0
  34. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/config.py +0 -0
  35. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/discovery.py +0 -0
  36. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/export_bundle.py +0 -0
  37. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/facades.py +0 -0
  38. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/http.py +0 -0
  39. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/mode.py +0 -0
  40. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/packaging.py +0 -0
  41. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/preflight_api.py +0 -0
  42. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/preflight_render.py +0 -0
  43. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/registry.py +0 -0
  44. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/runtime.py +0 -0
  45. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/runtime_display.py +0 -0
  46. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/stub.py +0 -0
  47. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/templates/__init__.py +0 -0
  48. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/templates/inference/app.py.tmpl +0 -0
  49. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/templates/inference/simuloignore.tmpl +0 -0
  50. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/templates/scenario/app.py.tmpl +0 -0
  51. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/templates/scenario/simuloignore.tmpl +0 -0
  52. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/templates/training/app.py.tmpl +0 -0
  53. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/templates/training/simuloignore.tmpl +0 -0
  54. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/_client/volume.py +0 -0
  55. {simulo-0.20.1 → simulo-0.22.0}/src/simulo/py.typed +0 -0
  56. {simulo-0.20.1 → simulo-0.22.0}/src/simulo.egg-info/dependency_links.txt +0 -0
  57. {simulo-0.20.1 → simulo-0.22.0}/src/simulo.egg-info/entry_points.txt +0 -0
  58. {simulo-0.20.1 → simulo-0.22.0}/src/simulo.egg-info/top_level.txt +0 -0
@@ -1,4 +1,4 @@
1
- # Public-metadata discretion boundary (see release.yml Guard 5): the sdist is
1
+ # Public-metadata discretion boundary (see release-pypi.yml Guard 5): the sdist is
2
2
  # PUBLIC on PyPI's "Download files" page just like the wheel, but setuptools'
3
3
  # sdist command auto-includes README.md and any top-level tests/ directory
4
4
  # regardless of the `readme = "PYPI.md"` pyproject setting. Exclude the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulo
3
- Version: 0.20.1
3
+ Version: 0.22.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.15,>=0.14.0
18
+ Requires-Dist: simulo-interfaces<0.17,>=0.16.0
19
19
  Requires-Dist: mcap<2,>=1.3
20
20
  Requires-Dist: defusedxml>=0.7.1
21
21
  Requires-Dist: usd-core<27,>=25.5
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulo"
7
- version = "0.20.1"
7
+ version = "0.22.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"
@@ -26,7 +26,7 @@ classifiers = [
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
28
  dependencies = [
29
- "simulo-interfaces>=0.14.0,<0.15",
29
+ "simulo-interfaces>=0.16.0,<0.17",
30
30
  "mcap>=1.3,<2",
31
31
  # Hardened XML parsing for URDF ingestion (USD Asset Catalogs, PR-12 fix
32
32
  # loop 2, security NIT): stdlib xml.etree.ElementTree relies on
@@ -28,10 +28,10 @@ missed that way before the rule replaced it.
28
28
 
29
29
  Three kinds of name live on ``simulo``:
30
30
 
31
- * **Platform** names — ``App / Runtime / Volume / Asset / Artifact /
32
- ArtifactKind / callbacks`` — are **real, eager** attributes bound below.
33
- They are always torch-free (``Artifact`` is the ``simulo-interfaces``
34
- contract dataclass, ``ArtifactKind`` its wire-stable ``StrEnum``, both
31
+ * **Platform** names — ``App / Runtime / Volume / Asset / Output /
32
+ OutputKind / callbacks`` — are **real, eager** attributes bound below.
33
+ They are always torch-free (``Output`` is the ``simulo-interfaces``
34
+ contract dataclass, ``OutputKind`` its wire-stable ``StrEnum``, both
35
35
  re-exported).
36
36
  * **Contract vocabulary** — the shared error hierarchy (``SimuloError`` and its
37
37
  subclasses), the ``NewType`` identifier aliases (``JobId``, ``ProjectId``, …),
@@ -43,7 +43,7 @@ Three kinds of name live on ``simulo``:
43
43
  so this adds no import and no dependency. Errors and IDs are likewise
44
44
  stdlib-only (``NewType`` is the underlying ``str`` at runtime).
45
45
  * **Lazy, mode-aware** names — the learning names ``Task / Scene / Robot /
46
- LearningEnv / RLTrainer / …`` plus the execution-time ``save_artifact`` and
46
+ LearningEnv / RLTrainer / …`` plus the execution-time ``save_output`` and
47
47
  ``run`` (the scenario execution loop) — are
48
48
  resolved through the PEP 562 ``__getattr__`` below, which delegates to the
49
49
  thin client's internal learning-name resolver. At submit (discovery mode) they resolve to
@@ -86,9 +86,13 @@ from simulo.interfaces import (
86
86
  ImportResolutionError,
87
87
  JobFailedError,
88
88
  JobId,
89
+ JobPublicId,
90
+ ModelPublicId,
89
91
  OrganizationId,
92
+ OutputPublicId,
90
93
  PackageId,
91
94
  ProjectId,
95
+ RecordingPublicId,
92
96
  ResourceId,
93
97
  ResourceNotFoundError,
94
98
  ResourceUri,
@@ -96,6 +100,10 @@ from simulo.interfaces import (
96
100
  SimuloError,
97
101
  TagId,
98
102
  TrainedModelId,
103
+ parse_job_public_id,
104
+ parse_model_public_id,
105
+ parse_output_public_id,
106
+ parse_recording_public_id,
99
107
  )
100
108
 
101
109
  # The authoring value types that the lazy resolver does NOT already carry.
@@ -144,7 +152,7 @@ from simulo.interfaces.authoring import (
144
152
  USDWorld,
145
153
  VisualMarkerBase,
146
154
  )
147
- from simulo.interfaces.platform import Artifact, ArtifactKind
155
+ from simulo.interfaces.platform import Output, OutputKind
148
156
 
149
157
  # Public surface = eager platform exports (real attributes; ``__getattr__`` never
150
158
  # fires for these) + the lazily-resolved learning names. Kept a static literal so
@@ -152,8 +160,8 @@ from simulo.interfaces.platform import Artifact, ArtifactKind
152
160
  __all__ = [
153
161
  # platform (eager, real)
154
162
  "App",
155
- "Artifact",
156
- "ArtifactKind",
163
+ "Output",
164
+ "OutputKind",
157
165
  "Runtime",
158
166
  "Volume",
159
167
  "Asset",
@@ -170,6 +178,14 @@ __all__ = [
170
178
  "ProjectId",
171
179
  "PackageId",
172
180
  "JobId",
181
+ "JobPublicId",
182
+ "ModelPublicId",
183
+ "RecordingPublicId",
184
+ "OutputPublicId",
185
+ "parse_job_public_id",
186
+ "parse_model_public_id",
187
+ "parse_recording_public_id",
188
+ "parse_output_public_id",
173
189
  "ResourceId",
174
190
  "CheckpointId",
175
191
  "TrainedModelId",
@@ -206,7 +222,7 @@ __all__ = [
206
222
  "SphereMarker",
207
223
  "ArrowMarker",
208
224
  "PointMarker",
209
- # lazy, mode-aware (the learning names + save_artifact)
225
+ # lazy, mode-aware (the learning names + save_output)
210
226
  "ActuatorGainsConfig",
211
227
  "Camera",
212
228
  "CameraSpawnConfig",
@@ -252,7 +268,7 @@ __all__ = [
252
268
  "Visual",
253
269
  "World",
254
270
  "run",
255
- "save_artifact",
271
+ "save_output",
256
272
  ]
257
273
 
258
274
 
@@ -381,11 +397,9 @@ if TYPE_CHECKING:
381
397
  TerminationCondition = TerminationConditionProtocol
382
398
  # Annotation-position mirror name (see the rule above).
383
399
  Scene = SceneProtocol
384
- # ``save_artifact`` aliases its real execution-mode implementation (a
385
- # torch-free thin-client function), so ``simulo.save_artifact("summary",
400
+ # ``save_output`` aliases its real execution-mode implementation (a
401
+ # torch-free thin-client function), so ``simulo.save_output("summary",
386
402
  # path)`` type-checks with the precise signature instead of ``Any``.
387
- from simulo._client.artifacts import save_artifact as save_artifact
388
-
389
403
  # ``run`` (the scenario execution loop) aliases its typing FACADE — a
390
404
  # plain FUNCTION facade, not a class one (there is no ``RunProtocol`` to
391
405
  # alias against; ``run`` is not a contract method). Without this, ``run``
@@ -400,6 +414,7 @@ if TYPE_CHECKING:
400
414
  # where drift against the real function is caught (a live, unmocked
401
415
  # signature comparison in ``simulo-backend``'s test suite, not mypy).
402
416
  from simulo._client.facades import run as run
417
+ from simulo._client.outputs import save_output as save_output
403
418
 
404
419
  # RELOCATED authoring value types (Plan C — Waves C1, C2, then C4's
405
420
  # ``RecordConfig``): the runtime object IS the real, dependency-free
@@ -25,9 +25,9 @@ import time
25
25
  from pathlib import Path
26
26
  from typing import Any, NoReturn, Optional
27
27
 
28
- from simulo.interfaces.ids import JobId
28
+ from simulo.interfaces.ids import JobId, JobPublicId
29
29
  from simulo.interfaces.platform.enums import JobStatus
30
- from simulo.interfaces.platform.runs import TERMINAL_JOB_STATUSES
30
+ from simulo.interfaces.platform.runs import JOB_SCOPE_MINE, TERMINAL_JOB_STATUSES
31
31
 
32
32
  #: Poll cadence for cloud ``.get()``. Module-level so tests can shrink it.
33
33
  _POLL_INTERVAL_S = 1.0
@@ -62,8 +62,8 @@ class JobHandle:
62
62
  """Handle to a submitted job — local-disk package, or a cloud job id.
63
63
 
64
64
  Returned by :meth:`JobFunction.spawn`. In cloud mode it carries the
65
- server-assigned ``job_id`` plus enough context (``base_url`` / ``token``)
66
- to poll it; ``execute_hint()`` still names the local package path
65
+ server-assigned machine ``job_id``, human-facing ``public_id``, and enough
66
+ context (``base_url`` / ``token``) to poll it; ``execute_hint()`` still names the local package path
67
67
  (preserved for debugging) but ``get()`` talks to the platform instead of
68
68
  raising.
69
69
  """
@@ -74,11 +74,13 @@ class JobHandle:
74
74
  package_path: Path,
75
75
  job_name: str,
76
76
  *,
77
+ public_id: Optional[JobPublicId] = None,
77
78
  cloud: bool = False,
78
79
  base_url: Optional[str] = None,
79
80
  token: Optional[str] = None,
80
81
  ) -> None:
81
82
  self._job_id = job_id
83
+ self._public_id = public_id
82
84
  self._package_path = package_path
83
85
  self._job_name = job_name
84
86
  self._cloud = cloud
@@ -90,6 +92,11 @@ class JobHandle:
90
92
  """Opaque id for this submission — server-assigned in cloud mode."""
91
93
  return self._job_id
92
94
 
95
+ @property
96
+ def public_id(self) -> Optional[JobPublicId]:
97
+ """Human-facing Job ID in cloud mode; ``None`` for local package-only submits."""
98
+ return self._public_id
99
+
93
100
  @property
94
101
  def package_path(self) -> Path:
95
102
  """Directory the package was written to (``<out>/<bundle_dir_name(package_id)>/``)."""
@@ -121,7 +128,9 @@ class JobHandle:
121
128
 
122
129
  def logs_hint(self) -> str:
123
130
  """The command that streams this job's logs (cloud mode)."""
124
- return f"simulo logs {self._job_id} --follow"
131
+ if self._public_id is None:
132
+ return "simulo jobs"
133
+ return f"simulo logs {self._public_id} --follow"
125
134
 
126
135
  def get(self, timeout: Optional[float] = None) -> Any:
127
136
  """Return the job's result.
@@ -143,23 +152,29 @@ class JobHandle:
143
152
  client = JobsApiClient(self._base_url, token=self._token)
144
153
  deadline = None if timeout is None else time.monotonic() + timeout
145
154
  while True:
146
- record = client.get_job(str(self._job_id))
155
+ record = client.get_job(str(self._job_id), scope=JOB_SCOPE_MINE)
147
156
  status = str(record.get("status"))
148
157
  if status in _TERMINAL_STATUS_STRINGS:
149
158
  if status == _COMPLETED_STATUS:
150
- return client.get_result(str(self._job_id))
159
+ return client.get_result(str(self._job_id), scope=JOB_SCOPE_MINE)
151
160
  exit_code = record.get("exit_code")
152
161
  raise JobFailedError(
153
- f"Job {self._job_name!r} ({self._job_id}) {status} (exit_code={exit_code}).\n"
162
+ f"{self._display_name()} {status} (exit_code={exit_code}).\n"
154
163
  f" View its logs with:\n {self.logs_hint()}"
155
164
  )
156
165
  if deadline is not None and time.monotonic() >= deadline:
157
166
  raise JobTimeoutError(
158
- f"Job {self._job_name!r} ({self._job_id}) did not reach a terminal status within "
167
+ f"{self._display_name()} did not reach a terminal status within "
159
168
  f"{timeout}s (still {status!r}). Check it with:\n {self.logs_hint()}"
160
169
  )
161
170
  time.sleep(_POLL_INTERVAL_S)
162
171
 
172
+ def _display_name(self) -> str:
173
+ """A user-facing label that never falls back to the machine UUID."""
174
+ if self._public_id is None:
175
+ return f"Job {self._job_name!r}"
176
+ return f"Job {self._job_name!r} ({self._public_id})"
177
+
163
178
  def _raise_local_disk_unavailable(self) -> NoReturn:
164
179
  raise JobResultUnavailable(
165
180
  f"No result is available locally for job {self._job_name!r}: submitting writes the "
@@ -38,7 +38,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator, Mapping, Optional, Se
38
38
  from simulo._client import config, credentials, registry
39
39
  from simulo._client._runner import JobFailedError, JobHandle, JobResultUnavailable
40
40
  from simulo._client.runtime import DEFAULT_RUNTIME
41
- from simulo.interfaces.ids import JobId
41
+ from simulo.interfaces.ids import JobId, parse_job_public_id
42
42
  from simulo.interfaces.platform import JobCallbackProtocol
43
43
  from simulo.interfaces.platform.app import Mount
44
44
  from simulo.interfaces.platform.enums import ResumePolicy
@@ -379,9 +379,8 @@ def _spawn_cloud(
379
379
  the identical rule: the seed reference is a SUBMIT-time job property,
380
380
  never a package property — it rides the job-create call only, verbatim
381
381
  (the control plane resolves prefixes and ``:best``/``:latest``). When the
382
- server honors it, the RESOLVED provenance is announced on stderr (the
383
- full source-job UUID — the confirmation that a typed prefix resolved to
384
- the job the user meant); when an older server ignores it, ``create_job``
382
+ server honors it, the resolved provenance is announced on stderr without
383
+ exposing its machine UUID; when an older server ignores it, ``create_job``
385
384
  raises rather than let a fresh run masquerade as a seeded one.
386
385
 
387
386
  ``skip_preflight`` (``simulo run --skip-preflight``) bypasses the
@@ -420,7 +419,7 @@ def _spawn_cloud(
420
419
  from simulo._client.asset_pins import DEFAULT_ASSET_RUNTIME, collect_asset_refs, resolve_and_pin_assets
421
420
  from simulo._client.packaging import MANIFEST_FILENAME, ensure_tar
422
421
  from simulo._client.preflight_api import PreflightApiClient
423
- from simulo._client.submit_api import SubmitApiClient
422
+ from simulo._client.submit_api import SubmitApiClient, SubmitApiError
424
423
  from simulo.interfaces.platform.manifest import Manifest
425
424
 
426
425
  manifest_text = (package_path / MANIFEST_FILENAME).read_text(encoding="utf-8")
@@ -500,17 +499,41 @@ def _spawn_cloud(
500
499
  app_name=app_name,
501
500
  )
502
501
 
502
+ raw_public_id = record.get("public_id")
503
+ try:
504
+ if not isinstance(raw_public_id, str):
505
+ raise TypeError("public_id is not a string")
506
+ public_id = parse_job_public_id(raw_public_id)
507
+ except (TypeError, ValueError):
508
+ # A current public cloud response must carry the human-facing Job ID.
509
+ # The machine UUID is intentionally not used as display fallback: the
510
+ # job may already have been created, so give a neutral recovery path.
511
+ raise SubmitApiError(
512
+ "The platform created the job but did not return a usable Job ID. "
513
+ "Run `simulo jobs` to find the submission before retrying."
514
+ ) from None
515
+
503
516
  if seed_from is not None:
504
517
  # create_job has already guaranteed the seed dict is present (the
505
518
  # loud-degradation guard) — this line is the human confirmation that
506
- # the ref (possibly a prefix) resolved to the intended source job.
519
+ # the ref resolved to the intended source job. Seed provenance still
520
+ # carries machine UUIDs only, so keep this confirmation neutral rather
521
+ # than leaking a UUID while that response contract catches up.
507
522
  seed = record.get("seed") or {}
508
523
  print(
509
- f"Continuing from job {seed.get('job_id')} ({seed.get('name')}, {seed.get('kind')} checkpoint)",
524
+ f"Continuing from selected job ({seed.get('name')}, {seed.get('kind')} checkpoint)",
510
525
  file=sys.stderr,
511
526
  )
512
527
 
513
- return JobHandle(JobId(str(record["job_id"])), package_path, job_name, cloud=True, base_url=base_url, token=token)
528
+ return JobHandle(
529
+ JobId(str(record["job_id"])),
530
+ package_path,
531
+ job_name,
532
+ public_id=public_id,
533
+ cloud=True,
534
+ base_url=base_url,
535
+ token=token,
536
+ )
514
537
 
515
538
 
516
539
  class JobFunction:
@@ -97,8 +97,8 @@ class CancelApiClient:
97
97
  unavailable_hint=_UNAVAILABLE_HINT,
98
98
  )
99
99
  if not isinstance(payload, dict):
100
- raise CancelApiError(f"Malformed cancel response for job {job_id!r}: expected a JSON object.")
100
+ raise CancelApiError("Malformed cancel response: expected a JSON object.")
101
101
  missing = [field for field in _REQUIRED_FIELDS if field not in payload]
102
102
  if missing:
103
- raise CancelApiError(f"Malformed cancel response for job {job_id!r}: missing field(s) {missing}.")
103
+ raise CancelApiError(f"Malformed cancel response: missing field(s) {missing}.")
104
104
  return payload