simulo 0.26.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. simulo/__init__.py +433 -0
  2. simulo/_client/__init__.py +6 -0
  3. simulo/_client/_entrypoint.py +313 -0
  4. simulo/_client/_mounts.py +25 -0
  5. simulo/_client/_runner.py +186 -0
  6. simulo/_client/_secure_downloads.py +1181 -0
  7. simulo/_client/app.py +1308 -0
  8. simulo/_client/asset.py +331 -0
  9. simulo/_client/asset_api.py +517 -0
  10. simulo/_client/asset_package.py +1103 -0
  11. simulo/_client/asset_pins.py +187 -0
  12. simulo/_client/builtin_aliases.py +107 -0
  13. simulo/_client/bundle.py +254 -0
  14. simulo/_client/cancel_api.py +104 -0
  15. simulo/_client/cli.py +9063 -0
  16. simulo/_client/config.py +186 -0
  17. simulo/_client/credentials.py +210 -0
  18. simulo/_client/discovery.py +214 -0
  19. simulo/_client/export_api.py +212 -0
  20. simulo/_client/export_bundle.py +296 -0
  21. simulo/_client/facades.py +581 -0
  22. simulo/_client/http.py +414 -0
  23. simulo/_client/identity_api.py +117 -0
  24. simulo/_client/install_samples.py +267 -0
  25. simulo/_client/jobs_api.py +224 -0
  26. simulo/_client/learning.py +393 -0
  27. simulo/_client/login.py +319 -0
  28. simulo/_client/mode.py +29 -0
  29. simulo/_client/outputs.py +116 -0
  30. simulo/_client/packaging.py +445 -0
  31. simulo/_client/preflight_api.py +186 -0
  32. simulo/_client/preflight_render.py +200 -0
  33. simulo/_client/registry.py +98 -0
  34. simulo/_client/runtime.py +185 -0
  35. simulo/_client/runtime_display.py +90 -0
  36. simulo/_client/seed_ref.py +76 -0
  37. simulo/_client/stub.py +41 -0
  38. simulo/_client/submit_api.py +1057 -0
  39. simulo/_client/templates/__init__.py +21 -0
  40. simulo/_client/templates/inference/app.py.tmpl +316 -0
  41. simulo/_client/templates/inference/simuloignore.tmpl +30 -0
  42. simulo/_client/templates/scenario/app.py.tmpl +93 -0
  43. simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
  44. simulo/_client/templates/training/app.py.tmpl +235 -0
  45. simulo/_client/templates/training/simuloignore.tmpl +29 -0
  46. simulo/_client/view_fragment.py +21 -0
  47. simulo/_client/view_session_api.py +122 -0
  48. simulo/_client/volume.py +71 -0
  49. simulo/callbacks.py +274 -0
  50. simulo/py.typed +0 -0
  51. simulo-0.26.0.dist-info/METADATA +130 -0
  52. simulo-0.26.0.dist-info/RECORD +55 -0
  53. simulo-0.26.0.dist-info/WHEEL +5 -0
  54. simulo-0.26.0.dist-info/entry_points.txt +2 -0
  55. simulo-0.26.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,200 @@
1
+ """Shared preflight finding rendering — ``simulo prepare`` (``cli.py``)
2
+ and ``simulo run``'s preflight phase (``app.py``) render
3
+ the identical wire finding format (:class:`simulo.interfaces.platform.
4
+ manifest.PreflightFinding`) through this ONE module rather than each keeping
5
+ its own copy of the per-code text and the control-character defense — two
6
+ independent copies is exactly how a code's rendering (or the sanitizer)
7
+ drifts between the two commands. For the same reason, the outcome-level
8
+ rendering (fail-open notice, the ``✗``/``⚠`` finding loops) also lives here —
9
+ see :func:`render_preflight_skip_notice` / :func:`render_preflight_findings`
10
+ — rather than as two copies in ``cli.py`` and ``app.py``.
11
+
12
+ The finding ``code`` strings below are the server's own vocabulary
13
+ (``simulo_control_plane.preflight.constants``) — deliberately NOT part of
14
+ the ``simulo-interfaces`` contract, which pins only ``FINDING_CLASSIFICATIONS``
15
+ (``blocking``/``advisory``): codes are an APPEND-ONLY server vocabulary a
16
+ client can never fully enumerate ahead of time, so a code this module
17
+ doesn't recognise (including one invented after this client shipped) still
18
+ renders — generically, via :func:`render_preflight_finding_text`'s fallback
19
+ — never a failure to render. Mirrored here as bare string literals rather
20
+ than imported for that reason.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ import sys
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ if TYPE_CHECKING: # typing-only; avoids a module-scope dependency on the HTTP client
30
+ from simulo._client.preflight_api import PreflightOutcome
31
+ from simulo.interfaces.platform.manifest import PreflightFinding
32
+
33
+ PIP_VERSION_CONFLICT_CODE = "pip_version_conflict"
34
+ RUNTIME_MANIFEST_UNAVAILABLE_CODE = "runtime_manifest_unavailable"
35
+ UNKNOWN_RUNTIME_CODE = "unknown_runtime"
36
+ PACKAGE_TOO_LARGE_CODE = "package_too_large"
37
+ JOB_TIMEOUT_INVALID_CODE = "job_timeout_invalid"
38
+ RESERVED_RUNTIME_ENV_CODE = "reserved_runtime_env_keys"
39
+ NO_ACTIVE_WORKER_CODE = "no_active_worker"
40
+
41
+ #: The blocking finding for a manifest that names a runtime this platform
42
+ #: has never reported. Its own finding TEXT still renders through the
43
+ #: generic ``details["message"]`` fallback below — the server owns that
44
+ #: sentence and this change leaves it untouched — but ``app.py`` needs this
45
+ #: code as a value to switch on (not string-matching a rendered message)
46
+ #: for the separate, generic "Pass --skip-preflight" follow-on hint it
47
+ #: prints after a blocking finding: see :func:`app._skip_preflight_hint`.
48
+ RUNTIME_NOT_AVAILABLE_CODE = "runtime_not_available"
49
+
50
+ #: C0 (0x00-0x1F minus TAB/LF, which legitimate multi-line detail text may
51
+ #: use) and C1 (0x7F-0x9F, including DEL) control characters. Used to strip
52
+ #: ESC/CR/OSC-framing bytes out of server-supplied finding text before it
53
+ #: ever reaches a terminal — see :func:`sanitize_server_text`.
54
+ _CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
55
+
56
+
57
+ def sanitize_server_text(text: str) -> str:
58
+ """Strip C0/C1 control characters from server-supplied text.
59
+
60
+ Findings (``code``/``details["message"]``/``package``/``keys`` etc.) and
61
+ the fail-open ``notice`` are all server-supplied strings the CLI prints
62
+ straight to a terminal. A compromised control plane — or a user pointed
63
+ at an attacker-run ``SIMULO_API_URL`` — could embed ANSI/OSC
64
+ sequences: e.g. ``"\\x1b[2K\\x1b[1G\\u2713 ..."`` (erase-line + cursor-
65
+ home, then a fake checkmark) would repaint the line this CLI just wrote
66
+ with ``✗`` as if it were a clean pass — the exit code stays honest (1),
67
+ but the user reads the opposite of the truth, exactly the outcome
68
+ preflight exists to prevent. Dropping the bare ESC/CR/OSC-framing bytes
69
+ breaks any such sequence back into inert literal text; TAB and LF are
70
+ kept so a legitimate multi-line message still reads sensibly.
71
+ """
72
+ return _CONTROL_CHARS_RE.sub("", text)
73
+
74
+
75
+ def render_preflight_finding_text(code: str, details: dict[str, Any]) -> tuple[str, list[str]]:
76
+ """One finding -> ``(title, extra detail lines)``. Never raises.
77
+
78
+ The top-level shape validation each caller's parser performs (``code``
79
+ is a str, ``details`` is a dict) does not know each code's expected
80
+ ``details`` field types, so a structurally-valid-but-unexpected payload
81
+ (e.g. a future server bug, or a code this module recognises whose
82
+ ``details`` shape has since drifted) could otherwise raise here (a
83
+ non-string ``keys`` list failing ``", ".join``, for instance). The NFR
84
+ "users never see a stack trace" applies to this render step too, so any
85
+ failure degrades to the same generic fallback an unrecognised CODE gets.
86
+
87
+ Every returned string is passed through :func:`sanitize_server_text`
88
+ here — the single choke point every finding's rendered text passes
89
+ through on its way out, regardless of which branch (present or future)
90
+ produced it — rather than at each individual call site inside
91
+ :func:`_render_finding_text_unsafe`, which is exactly the kind
92
+ of place a newly-added branch could forget it.
93
+ """
94
+ try:
95
+ title, lines = _render_finding_text_unsafe(code, details)
96
+ except Exception: # noqa: BLE001 - rendering must never crash the caller
97
+ message = details.get("message") if isinstance(details, dict) else None
98
+ if isinstance(message, str) and message:
99
+ title, lines = message, []
100
+ else:
101
+ title, lines = code, []
102
+ return sanitize_server_text(title), [sanitize_server_text(line) for line in lines]
103
+
104
+
105
+ def _render_finding_text_unsafe(code: str, details: dict[str, Any]) -> tuple[str, list[str]]:
106
+ """One finding -> ``(title, extra detail lines)``; may raise on an
107
+ unexpected ``details`` shape — see :func:`render_preflight_finding_text`,
108
+ which wraps this and never lets that happen.
109
+
110
+ The server ships only ``code``/``classification``/``details`` — never
111
+ English — so this is where the CLI owns
112
+ the copy. An unrecognised code still renders: via ``details["message"]``
113
+ when present (every seed-checkpoint finding carries one — see
114
+ ``simulo_control_plane.preflight.service._seed_findings``), else the
115
+ bare code string.
116
+ """
117
+ if code == PIP_VERSION_CONFLICT_CODE:
118
+ package = details.get("package", "?")
119
+ requested = details.get("requested", "")
120
+ platform_version = details.get("platform", "?")
121
+ title = (
122
+ f"{package}{requested} cannot be satisfied — the platform runtime has "
123
+ f"{package}=={platform_version} installed"
124
+ )
125
+ lines = []
126
+ if details.get("manifest_freshness") == "stale":
127
+ lines.append("(the platform's reported package inventory is stale — informational only)")
128
+ return title, lines
129
+ if code == RUNTIME_MANIFEST_UNAVAILABLE_CODE:
130
+ return "no runtime package inventory is available yet — pip version checks were skipped", []
131
+ if code == UNKNOWN_RUNTIME_CODE:
132
+ return (
133
+ f"unknown runtime {details.get('runtime_id')!r} — this job will run on the "
134
+ f"platform default ({details.get('default_runtime_id')!r})",
135
+ [],
136
+ )
137
+ if code == PACKAGE_TOO_LARGE_CODE:
138
+ return (
139
+ f"the package would be {details.get('archive_size')} bytes, over the platform's "
140
+ f"{details.get('max_package_bytes')}-byte limit",
141
+ [],
142
+ )
143
+ if code == JOB_TIMEOUT_INVALID_CODE:
144
+ return (
145
+ f"job {details.get('job')!r} declares an invalid timeout ({details.get('timeout')!r}) — "
146
+ "it must be a positive whole number of seconds",
147
+ [],
148
+ )
149
+ if code == RESERVED_RUNTIME_ENV_CODE:
150
+ keys = details.get("keys") or []
151
+ return f"runtime_env sets reserved key(s) the platform ignores at execution: {', '.join(keys)}", []
152
+ if code == NO_ACTIVE_WORKER_CODE:
153
+ return "no active compute worker observed right now — the job would queue until capacity is available", []
154
+ message = details.get("message")
155
+ if isinstance(message, str) and message:
156
+ return message, []
157
+ return code, []
158
+
159
+
160
+ def render_preflight_skip_notice(outcome: PreflightOutcome) -> None:
161
+ """Fail-open path: print ``outcome.notice`` to stderr, sanitized like
162
+ every other server-supplied string this module renders.
163
+
164
+ Shared by ``simulo prepare`` (``cli.py``) and ``simulo run``'s preflight
165
+ phase (``app.py``, PR-A6) so the fail-open message reads identically from
166
+ both commands — see the module docstring for why the two never keep
167
+ their own copy of this rendering.
168
+ """
169
+ notice = sanitize_server_text(outcome.notice) if outcome.notice else outcome.notice
170
+ print(f"note: {notice}", file=sys.stderr)
171
+
172
+
173
+ def render_preflight_findings(outcome: PreflightOutcome) -> tuple[list[PreflightFinding], list[PreflightFinding]]:
174
+ """Print every finding in a NON-skipped *outcome* — ``✗`` for
175
+ ``blocking``, ``⚠`` for ``advisory``, each with its indented detail
176
+ lines — and return ``(blocking, advisory)`` so the caller decides what
177
+ happens next.
178
+
179
+ Only the print-and-classify step is shared: ``simulo prepare``
180
+ (``cli.py``) turns a non-empty ``blocking`` list into a process exit
181
+ code, ``simulo run``'s preflight phase (``app.py``) turns it into a
182
+ raised ``PreflightBlockedError`` instead — that "what happens on a
183
+ blocking finding" behavior differs by design and stays in each caller.
184
+
185
+ Must only be called when ``outcome.skipped`` is False — see
186
+ :func:`render_preflight_skip_notice` for the fail-open branch.
187
+ """
188
+ blocking = [finding for finding in outcome.findings if finding.classification == "blocking"]
189
+ advisory = [finding for finding in outcome.findings if finding.classification == "advisory"]
190
+ for finding in blocking:
191
+ title, detail_lines = render_preflight_finding_text(finding.code, finding.details)
192
+ print(f"✗ {title}")
193
+ for line in detail_lines:
194
+ print(f" {line}")
195
+ for finding in advisory:
196
+ title, detail_lines = render_preflight_finding_text(finding.code, finding.details)
197
+ print(f"⚠ {title}")
198
+ for line in detail_lines:
199
+ print(f" {line}")
200
+ return blocking, advisory
@@ -0,0 +1,98 @@
1
+ """Process-global registry of declared ``App`` instances.
2
+
3
+ Each ``App`` self-registers on construction. ``find_app`` resolves the single
4
+ app for a discovery import — either by scanning an imported module's globals
5
+ (the precise, contamination-free path used by ``discovery.discover``) or by
6
+ falling back to the process-wide registry.
7
+
8
+ The registry holds plain objects (typed against ``AppProtocol``) so this module
9
+ depends only on the contract layer and never imports the concrete ``App`` at
10
+ module scope — that would create an import cycle (``app`` imports ``registry``).
11
+ ``find_app`` therefore lazy-imports the concrete ``App`` class and identifies
12
+ app instances by that **concrete type**, never by the structural
13
+ ``AppProtocol``: under ``@runtime_checkable`` on Python 3.11, a Protocol
14
+ ``isinstance`` check is satisfied by ``hasattr`` alone, so a discovery import
15
+ stub (which answers *every* attribute) would otherwise be mis-counted as an App.
16
+ """
17
+
18
+ from pathlib import Path
19
+ from types import ModuleType
20
+ from typing import Optional
21
+
22
+ from simulo.interfaces.exceptions import ContractViolationError, ResourceNotFoundError
23
+ from simulo.interfaces.platform.app import AppProtocol
24
+
25
+ _REGISTRY: list[AppProtocol] = []
26
+
27
+
28
+ def register(app: AppProtocol) -> None:
29
+ """Record a freshly-constructed app (idempotent on identity)."""
30
+ if not any(existing is app for existing in _REGISTRY):
31
+ _REGISTRY.append(app)
32
+
33
+
34
+ def clear() -> None:
35
+ """Drop all registered apps. Intended for tests."""
36
+ _REGISTRY.clear()
37
+
38
+
39
+ def find_app(module: Optional[ModuleType] = None, name: Optional[str] = None) -> AppProtocol:
40
+ """Resolve the single ``App`` to package.
41
+
42
+ When ``module`` is given, scan that module's globals (deterministic per file);
43
+ otherwise scan the process-wide registry. ``name`` optionally disambiguates.
44
+ Raises if zero or more than one candidate remains.
45
+
46
+ Candidates are identified by the **concrete ``App`` class** — never by the
47
+ structural ``AppProtocol`` — so a discovery import stub left bound in the
48
+ module's globals (e.g. ``torch`` after ``with runtime.imports(): import
49
+ torch``) is never mistaken for an App. The stub never self-registers, so the
50
+ registry fallback is inherently stub-free; the concrete-type filter on the
51
+ module-globals path closes the same gap there.
52
+ """
53
+ # Lazy import to avoid the registry -> app import cycle (``app`` imports
54
+ # ``registry`` at module scope to self-register on construction).
55
+ from simulo._client.app import App
56
+
57
+ if module is not None:
58
+ candidates: list[AppProtocol] = [
59
+ value for value in vars(module).values() if isinstance(value, App) and not isinstance(value, ModuleType)
60
+ ]
61
+ else:
62
+ candidates = [app for app in _REGISTRY if isinstance(app, App)]
63
+
64
+ if name is not None:
65
+ candidates = [app for app in candidates if app.name == name]
66
+
67
+ # Deduplicate by identity — the same app object may appear in both a module
68
+ # global and the registry, or under several aliases.
69
+ unique: list[AppProtocol] = []
70
+ for app in candidates:
71
+ if not any(existing is app for existing in unique):
72
+ unique.append(app)
73
+
74
+ if not unique:
75
+ if module is not None:
76
+ # Prefer the user's own file name over the synthetic
77
+ # ``_simulo_user_<stem>`` module name a discovery import runs
78
+ # under (``discovery._load_module_from_path``) — ``__file__`` is
79
+ # set by ``importlib.util.module_from_spec`` whenever the spec
80
+ # carries a location (it always does on that path), so this is
81
+ # available without any extra plumbing. Falls back to the module
82
+ # name for a module with no ``__file__`` (e.g. a hand-built
83
+ # ``ModuleType`` in a test, or the process-wide registry scan).
84
+ file_name = getattr(module, "__file__", None)
85
+ where = f"file {Path(file_name).name!r}" if file_name else f"module {module.__name__!r}"
86
+ else:
87
+ where = "the app registry"
88
+ suffix = f" named {name!r}" if name is not None else ""
89
+ raise ResourceNotFoundError(f"No simulo.App{suffix} found in {where}.")
90
+ if len(unique) > 1:
91
+ # ``str(...)`` is defensive: every candidate is now a concrete ``App``
92
+ # with a ``str`` name, but never sort/format-compare a non-``str`` name.
93
+ found = ", ".join(sorted(str(app.name) for app in unique))
94
+ raise ContractViolationError(
95
+ f"Ambiguous: found {len(unique)} simulo.App instances ({found}). "
96
+ "Define exactly one App per packaged file, or pass an explicit name."
97
+ )
98
+ return unique[0]
@@ -0,0 +1,185 @@
1
+ """``Runtime`` — a Simulo-curated runtime (implements ``RuntimeProtocol``).
2
+
3
+ Simulo owns the runtime: every job executes in a prebuilt, Simulo-managed
4
+ environment that ships everything a robotics training job needs — the
5
+ simulation engine, GPU stack, and the scientific-Python toolchain. You never
6
+ bring your own image or registry; you *pick* a Simulo runtime (today there is
7
+ one, :data:`DEFAULT_RUNTIME`, and it is the default — the common path writes
8
+ nothing) and may layer a guarded set of extras on top: :meth:`Runtime.env`
9
+ merges environment variables into the job's execution environment, and
10
+ :meth:`Runtime.pip_install` declares additional **public-PyPI** packages the
11
+ Simulo image builder installs on top of the base runtime. There is no
12
+ private-registry, URL, VCS, or local-path install surface — a package the
13
+ base runtime already provides cannot be overridden (see
14
+ ``simulo.interfaces.platform.is_platform_pinned``).
15
+
16
+ Locally this class is a lean metadata recorder: ``from_registry`` names the
17
+ Simulo runtime, ``env`` accumulates environment overrides, ``pip_install``
18
+ records ordered public-PyPI dependency layers, ``torch_jit`` is a no-op
19
+ marker at submit (and real ``torch.jit.script`` on the worker), and
20
+ ``imports`` is the remote-only-import boundary (Rule #3) — a real
21
+ ``nullcontext`` in execution mode, a stub-installing context manager in
22
+ discovery mode. Nothing is ever installed on your machine at submit.
23
+ """
24
+
25
+ import copy
26
+ import importlib
27
+ from contextlib import AbstractContextManager, nullcontext
28
+ from typing import Any, Callable, Mapping, TypeVar, cast
29
+
30
+ from simulo._client.discovery import stub_import_context
31
+ from simulo._client.mode import DISCOVERY, EXECUTION, current_mode
32
+ from simulo.interfaces.platform import canonical_pypi_name
33
+
34
+ F = TypeVar("F", bound=Callable[..., object])
35
+
36
+
37
+ def _reject_non_pypi_spec(spec: str) -> None:
38
+ """Raise ``SystemExit`` if *spec* is not a plain public-PyPI requirement.
39
+
40
+ Delegates the actual grammar check to
41
+ ``simulo.interfaces.platform.canonical_pypi_name`` — the SAME positive
42
+ allowlist ``is_platform_pinned`` fails closed on — rather than
43
+ duplicating a rule in this package too (review round 2, critic MINOR: a
44
+ duplicated rule is how one copy gets hardened and the other doesn't).
45
+ Here the reject happens at CALL time with a message naming the actual
46
+ problem (not a misleading "platform-pinned" one at submit time).
47
+ """
48
+ if canonical_pypi_name(spec) is None:
49
+ raise SystemExit(f"pip_install accepts PyPI package specs only — not URLs, paths, or VCS refs: {spec}")
50
+
51
+
52
+ class Runtime:
53
+ """A Simulo-curated runtime a job executes in. Torch-free metadata recorder.
54
+
55
+ You normally never construct one: every :class:`simulo.App` defaults to
56
+ :data:`DEFAULT_RUNTIME`. Pick a Simulo runtime by name and layer your own
57
+ environment variables and public-PyPI packages on top::
58
+
59
+ runtime = (
60
+ simulo.Runtime.from_registry("simulo/gpu-rl:2026.06")
61
+ .pip_install("wandb==0.17.0")
62
+ .env({"WANDB_PROJECT": "cartpole"})
63
+ )
64
+ app = simulo.App("cartpole", runtime=runtime)
65
+
66
+ Nothing is installed on your machine — the runtime is Simulo-built and
67
+ lives in the cloud; submit only records which runtime the app picked plus
68
+ the declared env/dependency layers, and the cloud materialises them at
69
+ execution time.
70
+ """
71
+
72
+ def __init__(self, runtime_id: str) -> None:
73
+ self._runtime_id = runtime_id
74
+ self._env: dict[str, str] = {}
75
+ self._remote_imports: list[str] = []
76
+ self._torch_jit_marks: list[str] = []
77
+ self._pip_layers: list[dict[str, Any]] = []
78
+
79
+ @classmethod
80
+ def from_registry(cls, image: str) -> "Runtime":
81
+ """Pick a Simulo runtime by name (e.g. ``"simulo/gpu-rl:2026.06"``)."""
82
+ return cls(runtime_id=image)
83
+
84
+ @property
85
+ def runtime_id(self) -> str:
86
+ """The Simulo runtime name passed to :meth:`from_registry`."""
87
+ return self._runtime_id
88
+
89
+ @property
90
+ def runtime_env(self) -> Mapping[str, str]:
91
+ """The accumulated :meth:`env` overrides (a copy)."""
92
+ return dict(self._env)
93
+
94
+ @property
95
+ def pip_dependencies(self) -> list[dict[str, Any]]:
96
+ """Ordered public-PyPI dependency layers (defensive deep copy).
97
+
98
+ Each layer is ``{"packages": [...], "pre": bool}`` — one entry per
99
+ :meth:`pip_install` call, in call order. The Simulo image builder
100
+ installs these on top of the base runtime; submit only records them.
101
+ """
102
+ return copy.deepcopy(self._pip_layers)
103
+
104
+ def env(self, variables: Mapping[str, str]) -> "Runtime":
105
+ """Merge environment variables into the job's execution environment.
106
+
107
+ Later calls win on key conflicts. Returns ``self`` for chaining.
108
+ Guarded: runner/worker-owned keys
109
+ (``simulo.interfaces.platform.RESERVED_RUNTIME_ENV_KEYS`` /
110
+ ``RESERVED_RUNTIME_ENV_PREFIXES``) are never honoured at execution —
111
+ enforcement lives at the runner/worker, which also sanitises the pip
112
+ environment so no variable declared here can redirect the image
113
+ builder's package installs.
114
+ """
115
+ self._env.update(variables)
116
+ return self
117
+
118
+ def pip_install(self, *packages: str, pre: bool = False) -> "Runtime":
119
+ """Declare public-PyPI packages to add on top of the Simulo runtime.
120
+
121
+ Each *package* is a plain PyPI requirement — ``name``, optionally
122
+ with extras and a version specifier (``"wandb==0.17.0"``,
123
+ ``"transformers[torch]>=4.40"``). URLs, VCS refs
124
+ (``git+...``), local paths, and PEP 508 direct references
125
+ (``name @ url``) are rejected: the Simulo image builder installs from
126
+ public PyPI only. ``pre=True`` allows pre-release versions for this
127
+ layer.
128
+
129
+ Each call appends an **ordered layer**; the builder installs the
130
+ layers in order on top of the base runtime. Submit only *records* the
131
+ layers — nothing is installed locally. A package the base runtime
132
+ already provides (torch, numpy, the CUDA wheels, the ``simulo``
133
+ distributions — ``simulo.interfaces.platform.PLATFORM_PINNED_PACKAGES``)
134
+ cannot be re-declared here; submit fails fast if one is.
135
+
136
+ Called with no *packages* (``pip_install()``), this is a no-op — no
137
+ layer is recorded — rather than appending a vacuous empty layer for
138
+ every downstream consumer (the manifest, the worker) to special-case.
139
+
140
+ Returns ``self`` for chaining.
141
+ """
142
+ for spec in packages:
143
+ _reject_non_pypi_spec(spec)
144
+ if packages:
145
+ self._pip_layers.append({"packages": list(packages), "pre": pre})
146
+ return self
147
+
148
+ def torch_jit(self, fn: F) -> F:
149
+ """Record the function's qualname; JIT-compile only on the worker.
150
+
151
+ In discovery (submit) this is a no-op marker — it records the qualname and
152
+ returns ``fn`` unchanged, so no ``torch`` import happens locally. In
153
+ execution mode (the backend runner, which has the heavy runtime) it
154
+ replaces ``@torch.jit.script``: ``torch`` is imported lazily inside the
155
+ method so module load stays lean.
156
+ """
157
+ self._torch_jit_marks.append(fn.__qualname__)
158
+ if current_mode() == EXECUTION:
159
+ # Lazy import via importlib (only the worker has torch). importlib keeps
160
+ # module load lean AND avoids a static ``import torch`` that a torch-free
161
+ # type-check would flag.
162
+ script = getattr(importlib.import_module("torch.jit"), "script")
163
+ return cast(F, script(fn))
164
+ return fn
165
+
166
+ def imports(self) -> AbstractContextManager[None]:
167
+ """Remote-only-import boundary.
168
+
169
+ In execution mode this is a plain ``nullcontext`` (the imports really
170
+ happen). In discovery mode it installs a stub import finder that records
171
+ the deferred top-level imports without resolving them — how heavy
172
+ libraries (``torch`` and friends) stay off your machine at submit.
173
+ """
174
+ if current_mode() == DISCOVERY:
175
+ return stub_import_context(self._remote_imports)
176
+ return nullcontext()
177
+
178
+
179
+ #: The Simulo runtime every :class:`simulo.App` executes in unless it picks a
180
+ #: different one. Simulo-owned and prebuilt — it ships the full simulation +
181
+ #: GPU training stack, so the common path declares no runtime at all. Shared
182
+ #: across every App that omits ``runtime=``: an app that wants its own
183
+ #: ``env()`` / ``pip_install()`` layers should construct its own Runtime via
184
+ #: :meth:`Runtime.from_registry` rather than mutating this shared default.
185
+ DEFAULT_RUNTIME = Runtime.from_registry("simulo/gpu-rl:2026.06")
@@ -0,0 +1,90 @@
1
+ """The ONE place a validation record's ``runtime`` field is turned into
2
+ copy a user should see (W7 operator directive: "the user must never see
3
+ the engine — it's an implementation detail").
4
+
5
+ The wire/DB value (``"isaac"`` today — control-plane's
6
+ ``DEFAULT_ASSET_VALIDATION_RUNTIME``, mirrored here via interfaces'
7
+ ``DEFAULT_ASSET_RUNTIME`` so the two can never drift) is never renamed — only
8
+ how the CLI prints it changes. Every
9
+ human-readable print/echo site that renders a validation record's
10
+ ``runtime`` field, or echoes a user-supplied ``--runtime`` value back as
11
+ prose, routes through :func:`display_runtime` so a future engine swap (or a
12
+ second runtime) is a one-line change here — never a grep-and-replace across
13
+ ``cli.py`` / ``asset_pins.py``.
14
+
15
+ Also maps the ``runtime`` FIELD inside ``--json`` / ``--report`` structured
16
+ output (:func:`with_display_runtime` for a validation record or a report
17
+ document, :func:`with_display_validations` for a version's ``validations``
18
+ list). A user reads and STORES that JSON, so the engine alias must not appear
19
+ there either — the field carries the neutral label exactly like the human
20
+ lines, so the two agree. (This closes the gap the earlier human-print scrub
21
+ left: the machine-readable surfaces were still shipping raw ``"isaac"``.)
22
+
23
+ Deliberately NOT mapped: the ``--runtime <alias>`` flag value itself (the real
24
+ argument sent to ``client.revalidate(...)`` / ``resolve_assets(...)``) and
25
+ copy-pasteable command hints (e.g. "revalidate with `--runtime isaac`") — both
26
+ carry the actual wire value a user or script passes back, not a display label.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections.abc import Mapping
32
+ from typing import Any
33
+
34
+ from simulo.interfaces.platform.asset_catalog import DEFAULT_ASSET_RUNTIME
35
+
36
+ #: What the user sees instead of the engine's internal alias.
37
+ _ENGINE_DISPLAY_LABEL = "Simulo Cloud"
38
+
39
+
40
+ def display_runtime(value: Any) -> str:
41
+ """Map a wire ``runtime`` value to what the user should see.
42
+
43
+ The platform's engine alias (``DEFAULT_ASSET_RUNTIME``, currently
44
+ ``"isaac"``, matched case-insensitively) becomes ``"Simulo Cloud"``.
45
+ Any other value (an unrecognized or future runtime alias) passes
46
+ through title-cased — a defensive default, never a raw unmapped
47
+ internal name. ``None``/empty renders as ``""``.
48
+ """
49
+ text = "" if value is None else str(value)
50
+ if text.strip().lower() == DEFAULT_ASSET_RUNTIME.lower():
51
+ return _ENGINE_DISPLAY_LABEL
52
+ return text.title()
53
+
54
+
55
+ def with_display_runtime(record: Any) -> Any:
56
+ """Return a copy of a validation record / report document with its
57
+ ``"runtime"`` field mapped through :func:`display_runtime`.
58
+
59
+ Called at the ``--json`` / ``--report`` emission sites so a record a user
60
+ reads or stores shows the neutral label, never the raw engine alias. A
61
+ no-op passthrough for a non-mapping (defensive — ``None``, a missing
62
+ report) or a record with no ``"runtime"`` key (or a ``None`` one). The
63
+ input is never mutated. Idempotent: a record already carrying
64
+ ``"Simulo Cloud"`` is returned unchanged (``display_runtime`` passes the
65
+ neutral label straight through)."""
66
+ if not isinstance(record, Mapping):
67
+ return record
68
+ mapped = dict(record)
69
+ if mapped.get("runtime") is not None:
70
+ mapped["runtime"] = display_runtime(mapped["runtime"])
71
+ return mapped
72
+
73
+
74
+ def with_display_validations(version: Any) -> Any:
75
+ """Return a copy of a version-detail dict whose ``validations`` records
76
+ each have their ``"runtime"`` mapped for display (see
77
+ :func:`with_display_runtime`).
78
+
79
+ ``inspect --json`` dumps a whole version document, whose
80
+ ``validations[*].runtime`` are the raw wire aliases; this is the one place
81
+ that maps them. A missing or non-list ``validations`` passes through
82
+ untouched, and the rest of the version dict is copied shallowly and never
83
+ mutated."""
84
+ if not isinstance(version, Mapping):
85
+ return version
86
+ mapped = dict(version)
87
+ validations = mapped.get("validations")
88
+ if isinstance(validations, list):
89
+ mapped["validations"] = [with_display_runtime(record) for record in validations]
90
+ return mapped
@@ -0,0 +1,76 @@
1
+ """The client's parse of a ``seed_from_job_id`` wire value (``--from``).
2
+
3
+ ONE definition, used by every client-side caller that needs to understand a
4
+ ``--from`` value: the CLI's pre-flight syntax check (the thin client's CLI module)
5
+ and the test suite's fake control plane (``tests/conftest.py``). Nothing here
6
+ resolves a ref — existence, prefix resolution, terminal status and output
7
+ availability are the control plane's org-scoped call, answered in the submit
8
+ request itself.
9
+
10
+ **This must stay byte-for-byte equivalent to the server's rule** — the control
11
+ plane's ``simulo_control_plane.jobs.constants.parse_seed_job_ref``, which
12
+ ``jobs/service.py`` calls to resolve a seed source. A divergence is silent in
13
+ the worst way: the client accepts a value the server rejects (the user pays a
14
+ full package build + archive upload for a typo the client claimed to catch
15
+ locally), or rejects one the server would have accepted.
16
+
17
+ That equivalence is not a comment — it is asserted, over an exhaustive table
18
+ of cases, by ``tests/integration/test_contract_mirror.py`` at the repo root,
19
+ which loads the control plane's rule from its actual source file and runs both
20
+ implementations against the same inputs. The grammar both sides implement is
21
+ pinned in the contract package as
22
+ :data:`simulo.interfaces.platform.submit.SEED_JOB_REF_GRAMMAR`.
23
+
24
+ The rule (server order, exactly):
25
+
26
+ * ``":"`` is not in the job-ref alphabet (UUID text or an id prefix), so its
27
+ presence is an unambiguous suffix signal. No ``":"`` → the whole value is
28
+ the ref and ``kind`` defaults to ``MODEL_KINDS[0]`` (``"best"``).
29
+ * Otherwise split on the LAST ``":"`` and reject — never silently fall back —
30
+ when either side is empty or the kind is unrecognized. ``":latest"`` is NOT
31
+ a bare ref named ``latest``; ``"8b52:"`` is NOT an implicit ``:best``.
32
+ "Never a silent fall-back" is this wave's whole point.
33
+ * ``kind`` is stripped and lowercased before the membership test, so
34
+ ``"8b52:BEST"`` is accepted (the server accepts it; a client that rejected
35
+ it would be gratuitously stricter than the wire).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from simulo.interfaces.platform.submit import MAX_SEED_REF_CHARS, MODEL_KINDS
41
+
42
+ __all__ = ["MAX_SEED_REF_CHARS", "SeedJobRefError", "parse_seed_job_ref"]
43
+
44
+
45
+ class SeedJobRefError(ValueError):
46
+ """A ``--from`` value that violates the pinned seed-job-ref grammar.
47
+
48
+ Carries the server's own ``seed_artifact_invalid`` semantics: the control
49
+ plane answers 422 with that code for every value this raises on.
50
+ """
51
+
52
+
53
+ def parse_seed_job_ref(raw: str) -> tuple[str, str]:
54
+ """Split a ``seed_from_job_id`` wire value into ``(job_ref, kind)``.
55
+
56
+ Raises :class:`SeedJobRefError` on any value the control plane answers
57
+ ``422 seed_artifact_invalid`` for. Does NOT enforce
58
+ :data:`MAX_SEED_REF_CHARS` — length is a separate, schema-level bound on
59
+ the server (Pydantic ``max_length``), and keeping it out of the parse is
60
+ what lets this function stay exactly equal to the server's parse.
61
+ """
62
+ if ":" in raw:
63
+ ref, _, suffix = raw.rpartition(":")
64
+ kind = suffix.strip().lower()
65
+ if not ref or not kind:
66
+ raise SeedJobRefError(
67
+ "expects <job-ref> or <job-ref>:best / <job-ref>:latest — a job reference "
68
+ "and, optionally, a non-empty output kind after ':'."
69
+ )
70
+ if kind not in MODEL_KINDS:
71
+ kinds = " or ".join(f"':{name}'" for name in MODEL_KINDS)
72
+ raise SeedJobRefError(f"unknown output suffix ':{suffix}' — use {kinds} (default: ':{MODEL_KINDS[0]}').")
73
+ return ref, kind
74
+ if not raw:
75
+ raise SeedJobRefError("the job reference is empty.")
76
+ return raw, MODEL_KINDS[0]