simulo 0.26.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simulo/__init__.py +433 -0
- simulo/_client/__init__.py +6 -0
- simulo/_client/_entrypoint.py +313 -0
- simulo/_client/_mounts.py +25 -0
- simulo/_client/_runner.py +186 -0
- simulo/_client/_secure_downloads.py +1181 -0
- simulo/_client/app.py +1308 -0
- simulo/_client/asset.py +331 -0
- simulo/_client/asset_api.py +517 -0
- simulo/_client/asset_package.py +1103 -0
- simulo/_client/asset_pins.py +187 -0
- simulo/_client/builtin_aliases.py +107 -0
- simulo/_client/bundle.py +254 -0
- simulo/_client/cancel_api.py +104 -0
- simulo/_client/cli.py +9063 -0
- simulo/_client/config.py +186 -0
- simulo/_client/credentials.py +210 -0
- simulo/_client/discovery.py +214 -0
- simulo/_client/export_api.py +212 -0
- simulo/_client/export_bundle.py +296 -0
- simulo/_client/facades.py +581 -0
- simulo/_client/http.py +414 -0
- simulo/_client/identity_api.py +117 -0
- simulo/_client/install_samples.py +267 -0
- simulo/_client/jobs_api.py +224 -0
- simulo/_client/learning.py +393 -0
- simulo/_client/login.py +319 -0
- simulo/_client/mode.py +29 -0
- simulo/_client/outputs.py +116 -0
- simulo/_client/packaging.py +445 -0
- simulo/_client/preflight_api.py +186 -0
- simulo/_client/preflight_render.py +200 -0
- simulo/_client/registry.py +98 -0
- simulo/_client/runtime.py +185 -0
- simulo/_client/runtime_display.py +90 -0
- simulo/_client/seed_ref.py +76 -0
- simulo/_client/stub.py +41 -0
- simulo/_client/submit_api.py +1057 -0
- simulo/_client/templates/__init__.py +21 -0
- simulo/_client/templates/inference/app.py.tmpl +316 -0
- simulo/_client/templates/inference/simuloignore.tmpl +30 -0
- simulo/_client/templates/scenario/app.py.tmpl +93 -0
- simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
- simulo/_client/templates/training/app.py.tmpl +235 -0
- simulo/_client/templates/training/simuloignore.tmpl +29 -0
- simulo/_client/view_fragment.py +21 -0
- simulo/_client/view_session_api.py +122 -0
- simulo/_client/volume.py +71 -0
- simulo/callbacks.py +274 -0
- simulo/py.typed +0 -0
- simulo-0.26.0.dist-info/METADATA +130 -0
- simulo-0.26.0.dist-info/RECORD +55 -0
- simulo-0.26.0.dist-info/WHEEL +5 -0
- simulo-0.26.0.dist-info/entry_points.txt +2 -0
- simulo-0.26.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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]
|