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
simulo/_client/app.py
ADDED
|
@@ -0,0 +1,1308 @@
|
|
|
1
|
+
"""``App`` / ``JobFunction`` / ``JobHandle`` — the ``@app.job`` surface.
|
|
2
|
+
|
|
3
|
+
``App`` implements ``AppProtocol`` and self-registers on construction so submit can
|
|
4
|
+
discover it. ``@app.job`` records job metadata (it never runs the body at
|
|
5
|
+
decoration time) and returns a ``JobFunction`` proxy that is a superset of
|
|
6
|
+
``JobFunctionProtocol``.
|
|
7
|
+
|
|
8
|
+
**Submit, not execute.** ``spawn`` and ``submit`` are the *submit* surface: they
|
|
9
|
+
write the package (manifest + source bundle) to disk via ``bundle.ensure_bundle``,
|
|
10
|
+
recording this job's name + kwargs into the manifest, and return without training.
|
|
11
|
+
``spawn`` returns a :class:`JobHandle` exposing ``.package_path`` / ``.job_id``;
|
|
12
|
+
``submit`` returns a ``JobId``. Neither invokes the backend — execution is owned
|
|
13
|
+
entirely by the ``simulo-backend`` runner (``simulo-backend run-package``), the
|
|
14
|
+
sole executor. The thin client imports no part of ``simulo.backend``.
|
|
15
|
+
|
|
16
|
+
**One user command.** ``simulo run app.py [--flags]`` is the canonical — and only —
|
|
17
|
+
submit command: it runs the registered ``@app.entrypoint`` (or, without one, maps
|
|
18
|
+
the flags onto the job's own signature), whose ``spawn`` call ends at exactly one
|
|
19
|
+
submit. A direct ``python app.py`` never submits — ``@app.entrypoint`` detects the
|
|
20
|
+
``__main__`` run, prints the exact equivalent ``simulo run`` command to stderr, and
|
|
21
|
+
exits 2. An entrypoint-less file gets the SAME message and exit code from a
|
|
22
|
+
same-copy at-exit guard (see :func:`_print_nothing_submitted_hint`).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import atexit
|
|
26
|
+
import contextlib
|
|
27
|
+
import functools
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import shlex
|
|
32
|
+
import sys
|
|
33
|
+
import types
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterator, Mapping, Optional, Sequence, TypeVar, Union, overload
|
|
37
|
+
|
|
38
|
+
from simulo._client import config, credentials, registry
|
|
39
|
+
from simulo._client._runner import JobFailedError, JobHandle, JobResultUnavailable
|
|
40
|
+
from simulo._client.runtime import DEFAULT_RUNTIME
|
|
41
|
+
from simulo.interfaces.ids import JobId, parse_job_public_id
|
|
42
|
+
from simulo.interfaces.platform import JobCallbackProtocol
|
|
43
|
+
from simulo.interfaces.platform.app import Mount
|
|
44
|
+
from simulo.interfaces.platform.enums import ResumePolicy, SystemType
|
|
45
|
+
from simulo.interfaces.platform.systems import SYSTEM_SPECS
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING: # typing-only; avoids a module-scope dependency on the HTTP client
|
|
48
|
+
from simulo._client.preflight_api import PreflightOutcome
|
|
49
|
+
from simulo.interfaces.platform.manifest import PreflightFinding
|
|
50
|
+
|
|
51
|
+
F = TypeVar("F", bound=Callable[..., object])
|
|
52
|
+
|
|
53
|
+
__all__ = ["App", "JobFunction", "JobHandle", "JobFailedError", "JobResultUnavailable", "JobSpec"]
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Spawn recorder — lets `simulo run` see every JobHandle an entrypoint's
|
|
57
|
+
# spawn()/submit() calls produced, without threading a return value through
|
|
58
|
+
# arbitrary user code. Used to decide the default post-run behavior (follow a
|
|
59
|
+
# single cloud job's logs; print attach hints for zero/many).
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
_SPAWN_RECORDER: Optional[list["JobHandle"]] = None
|
|
63
|
+
|
|
64
|
+
# True once ANY spawn()/submit() was ATTEMPTED in this process, recorder scope
|
|
65
|
+
# or not — set on ENTRY to ``spawn()`` (intent to submit), never on success.
|
|
66
|
+
# Read by the at-exit "nothing was submitted" guard. Intent, not success, is
|
|
67
|
+
# deliberate: a cloud submit that dies AFTER ``create_job`` was sent (e.g. a
|
|
68
|
+
# read timeout on the response) may have left a job RUNNING AND BILLING —
|
|
69
|
+
# telling that user "Nothing was submitted" (and exiting 2) invites a re-run
|
|
70
|
+
# that duplicates the GPU job. A failed attempt already printed its own
|
|
71
|
+
# traceback; the guard has nothing truthful to add.
|
|
72
|
+
_ANY_SPAWN_ATTEMPTED = False
|
|
73
|
+
|
|
74
|
+
# True once any ``@app.job``-wrapped function was CALLED in-process — the
|
|
75
|
+
# documented "run the body locally, exactly as if undecorated" mode. A direct
|
|
76
|
+
# ``python app.py`` that called a job did real, intentional work; it is not a
|
|
77
|
+
# silent no-op and must keep exit code 0.
|
|
78
|
+
_ANY_JOB_INVOKED = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _record_spawn(handle: "JobHandle") -> None:
|
|
82
|
+
if _SPAWN_RECORDER is not None:
|
|
83
|
+
_SPAWN_RECORDER.append(handle)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@contextlib.contextmanager
|
|
87
|
+
def spawn_recorder() -> Iterator[list["JobHandle"]]:
|
|
88
|
+
"""Collect every :class:`JobHandle` produced by ``spawn()`` in this scope."""
|
|
89
|
+
global _SPAWN_RECORDER
|
|
90
|
+
previous = _SPAWN_RECORDER
|
|
91
|
+
handles: list[JobHandle] = []
|
|
92
|
+
_SPAWN_RECORDER = handles
|
|
93
|
+
try:
|
|
94
|
+
yield handles
|
|
95
|
+
finally:
|
|
96
|
+
_SPAWN_RECORDER = previous
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Viewstream ambient signal — `simulo run --viewstream` scopes this for the
|
|
101
|
+
# duration of one submit so a cloud `spawn()` deep inside the user's
|
|
102
|
+
# @app.entrypoint (which `simulo run` does not call directly) can
|
|
103
|
+
# attach it to the job-create call. Deliberately NOT a `spawn()`/`submit()`
|
|
104
|
+
# kwarg: those kwargs feed the job's `args`, whose canonical form is the
|
|
105
|
+
# `package_id` sha256 — a streaming flag must never change the package hash
|
|
106
|
+
# (the live-visualization plan's wire-contract rule). Mirrors the
|
|
107
|
+
# `spawn_recorder` pattern above for the same "can't thread a return value
|
|
108
|
+
# through arbitrary user code" reason.
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
_VIEWSTREAM_REQUESTED = False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@contextlib.contextmanager
|
|
115
|
+
def viewstream_requested(enabled: bool) -> Iterator[None]:
|
|
116
|
+
"""Scope ``--viewstream`` for one ``simulo run`` submit call."""
|
|
117
|
+
global _VIEWSTREAM_REQUESTED
|
|
118
|
+
previous = _VIEWSTREAM_REQUESTED
|
|
119
|
+
_VIEWSTREAM_REQUESTED = enabled
|
|
120
|
+
try:
|
|
121
|
+
yield
|
|
122
|
+
finally:
|
|
123
|
+
_VIEWSTREAM_REQUESTED = previous
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Seed ambient signal — `simulo run --from <job-ref>[:best|:latest]` scopes the
|
|
128
|
+
# seed reference for one submit (explicit-run-intent plan), exactly like the
|
|
129
|
+
# viewstream signal above and for the same two reasons: the actual `spawn()`
|
|
130
|
+
# happens deep inside the user's @app.entrypoint, and the seed is a
|
|
131
|
+
# SUBMIT-time job property that must never enter `args` (whose canonical form
|
|
132
|
+
# feeds the `package_id` sha256) or the package manifest. It rides the
|
|
133
|
+
# `POST /v1/jobs` body as a top-level sibling of `args`, passed through
|
|
134
|
+
# verbatim — the control plane resolves the ref (org-scoped, prefixes and
|
|
135
|
+
# `:best`/`:latest` included) in the same request.
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
_SEED_FROM_REF: Optional[str] = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class SeedRequiresCloudError(RuntimeError):
|
|
142
|
+
"""``--from`` was passed but the submit resolved to the local-disk path.
|
|
143
|
+
|
|
144
|
+
A local-disk submit only writes a package — no control plane exists to
|
|
145
|
+
resolve the seed reference or hand the checkpoint to a worker, so the
|
|
146
|
+
resulting run would silently be FRESH instead of seeded (the same
|
|
147
|
+
wrong-result hazard the loud-degradation check guards on the cloud
|
|
148
|
+
path). Refused loudly instead of ignored.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@contextlib.contextmanager
|
|
153
|
+
def seed_from_requested(seed_ref: Optional[str]) -> Iterator[None]:
|
|
154
|
+
"""Scope ``--from``'s seed reference for one ``simulo run`` submit call."""
|
|
155
|
+
global _SEED_FROM_REF
|
|
156
|
+
previous = _SEED_FROM_REF
|
|
157
|
+
_SEED_FROM_REF = seed_ref
|
|
158
|
+
try:
|
|
159
|
+
yield
|
|
160
|
+
finally:
|
|
161
|
+
_SEED_FROM_REF = previous
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Asset-pin ambient signals — `simulo run --frozen` / `--strict-assets` scope
|
|
166
|
+
# these for one submit, exactly like `--viewstream` / `--from` above and for the
|
|
167
|
+
# same "the spawn() is deep inside the user's @app.entrypoint" reason. They
|
|
168
|
+
# govern submit-time asset pin RESOLUTION, which — like viewstream/seed —
|
|
169
|
+
# happens only on the cloud path (a local-disk submit has no control plane to
|
|
170
|
+
# resolve against) and NEVER touches `args` or the manifest, so they can't move
|
|
171
|
+
# `package_id`'s sha256.
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
_FROZEN_ASSETS_REQUESTED = False
|
|
175
|
+
_STRICT_ASSETS_REQUESTED = False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@contextlib.contextmanager
|
|
179
|
+
def frozen_assets_requested(enabled: bool) -> Iterator[None]:
|
|
180
|
+
"""Scope ``--frozen`` (fail on any unpinned asset ref) for one submit call."""
|
|
181
|
+
global _FROZEN_ASSETS_REQUESTED
|
|
182
|
+
previous = _FROZEN_ASSETS_REQUESTED
|
|
183
|
+
_FROZEN_ASSETS_REQUESTED = enabled
|
|
184
|
+
try:
|
|
185
|
+
yield
|
|
186
|
+
finally:
|
|
187
|
+
_FROZEN_ASSETS_REQUESTED = previous
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@contextlib.contextmanager
|
|
191
|
+
def strict_assets_requested(enabled: bool) -> Iterator[None]:
|
|
192
|
+
"""Scope ``--strict-assets`` (asset warnings become errors) for one submit call."""
|
|
193
|
+
global _STRICT_ASSETS_REQUESTED
|
|
194
|
+
previous = _STRICT_ASSETS_REQUESTED
|
|
195
|
+
_STRICT_ASSETS_REQUESTED = enabled
|
|
196
|
+
try:
|
|
197
|
+
yield
|
|
198
|
+
finally:
|
|
199
|
+
_STRICT_ASSETS_REQUESTED = previous
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Preflight ambient signal — `simulo run --skip-preflight` (PR-A6, Plan A)
|
|
204
|
+
# scopes the escape hatch for one submit call, exactly like the flags above
|
|
205
|
+
# and for the same "the cloud spawn() is deep inside the user's
|
|
206
|
+
# @app.entrypoint" reason. Cloud-submit only: a local-disk submit never had
|
|
207
|
+
# a preflight call to skip in the first place (no control plane target, and
|
|
208
|
+
# `simulo run`'s OFFLINE default must stay a pure disk write with zero
|
|
209
|
+
# network calls — see the module's "Submit, not execute" docstring rule).
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
_SKIP_PREFLIGHT_REQUESTED = False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@contextlib.contextmanager
|
|
216
|
+
def skip_preflight_requested(enabled: bool) -> Iterator[None]:
|
|
217
|
+
"""Scope ``--skip-preflight`` for one ``simulo run`` submit call.
|
|
218
|
+
|
|
219
|
+
When True, ``_spawn_cloud`` makes NO preflight HTTP request at all for
|
|
220
|
+
this submission — bypassing the PHASE, not "call it and ignore the
|
|
221
|
+
answer". This is the documented escape hatch, so a ``blocking`` finding
|
|
222
|
+
can never become an unbypassable wall: a control plane a user cannot reach, or is
|
|
223
|
+
deliberately avoiding, must never be able to block this flag from
|
|
224
|
+
working.
|
|
225
|
+
"""
|
|
226
|
+
global _SKIP_PREFLIGHT_REQUESTED
|
|
227
|
+
previous = _SKIP_PREFLIGHT_REQUESTED
|
|
228
|
+
_SKIP_PREFLIGHT_REQUESTED = enabled
|
|
229
|
+
try:
|
|
230
|
+
yield
|
|
231
|
+
finally:
|
|
232
|
+
_SKIP_PREFLIGHT_REQUESTED = previous
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class PreflightBlockedError(RuntimeError):
|
|
236
|
+
"""A ``simulo run`` submit was refused by a ``blocking`` preflight finding.
|
|
237
|
+
|
|
238
|
+
Raised from ``_spawn_cloud`` AFTER the finding text is already printed
|
|
239
|
+
(mirrors ``simulo prepare``'s rendering) and BEFORE any other network
|
|
240
|
+
call for this submission: no asset-pin resolution, no package
|
|
241
|
+
registration, no archive upload, no job creation. ``--skip-preflight``
|
|
242
|
+
(:func:`skip_preflight_requested`) is the escape hatch that makes this
|
|
243
|
+
unreachable for a submission that wants to bypass the check entirely.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _require_no_seed_for_local_submit(reason: str) -> None:
|
|
248
|
+
"""Refuse a local-disk submit that carries an ambient ``--from`` seed."""
|
|
249
|
+
if _SEED_FROM_REF is None:
|
|
250
|
+
return
|
|
251
|
+
raise SeedRequiresCloudError(
|
|
252
|
+
f"--from {_SEED_FROM_REF!r} needs a cloud control plane to resolve the checkpoint, but "
|
|
253
|
+
f"{reason} — a local-disk submit only writes the package and cannot continue from another "
|
|
254
|
+
"job's checkpoint, so the run would silently start fresh instead. Log in first "
|
|
255
|
+
"(`simulo login`) or point SIMULO_API_URL/SIMULO_ENV at a control plane (and unset "
|
|
256
|
+
"SIMULO_SUBMIT=local), or drop --from for a fresh local run."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass
|
|
261
|
+
class JobSpec:
|
|
262
|
+
"""Recorded metadata for one ``@app.job``-decorated function."""
|
|
263
|
+
|
|
264
|
+
name: str
|
|
265
|
+
module: str
|
|
266
|
+
qualname: str
|
|
267
|
+
fn: Callable[..., object]
|
|
268
|
+
resources: dict[str, Any] = field(default_factory=dict)
|
|
269
|
+
timeout: Optional[int] = None
|
|
270
|
+
retries: int = 0
|
|
271
|
+
resume: ResumePolicy = ResumePolicy.AUTO
|
|
272
|
+
callbacks: tuple[JobCallbackProtocol, ...] = ()
|
|
273
|
+
mounts: dict[str, Mount] = field(default_factory=dict)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _app_source_location(module_name: str) -> tuple[Path, Path]:
|
|
277
|
+
"""Resolve the on-disk app file (and its directory) for a job's module.
|
|
278
|
+
|
|
279
|
+
The app is defined in the same file as its jobs, so the job's ``__module__``
|
|
280
|
+
locates the source to package. Raises clearly if the module has no ``__file__``
|
|
281
|
+
(e.g. an interactively-defined app, which cannot be submitted as a package).
|
|
282
|
+
"""
|
|
283
|
+
module = sys.modules.get(module_name)
|
|
284
|
+
file = getattr(module, "__file__", None)
|
|
285
|
+
if not file:
|
|
286
|
+
raise RuntimeError(
|
|
287
|
+
f"Cannot locate the source file for module {module_name!r} to package. "
|
|
288
|
+
"submit()/spawn() require the app to be defined in a .py file."
|
|
289
|
+
)
|
|
290
|
+
app_file = Path(file).resolve()
|
|
291
|
+
return app_file, app_file.parent
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _cloud_mode_active(creds: Optional[dict[str, Any]]) -> bool:
|
|
295
|
+
"""Cloud submit is active when credentials exist or the target is named explicitly.
|
|
296
|
+
|
|
297
|
+
Callers MUST check ``is_submit_forced_local`` (the client config module)
|
|
298
|
+
themselves BEFORE resolving ``creds`` — see :func:`_spawn_local` below.
|
|
299
|
+
That check is deliberately NOT repeated here: by the time this function is
|
|
300
|
+
called, ``SIMULO_SUBMIT=local`` has already short-circuited to the
|
|
301
|
+
local-disk path, so this function only ever runs when a credentials
|
|
302
|
+
read/refresh was already appropriate to attempt.
|
|
303
|
+
"""
|
|
304
|
+
if config.has_explicit_cloud_env():
|
|
305
|
+
return True
|
|
306
|
+
return creds is not None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _spawn_local(job_name: str, package_path: Path) -> JobHandle:
|
|
310
|
+
"""The local-disk submit path: a locally-derived job id, no network call."""
|
|
311
|
+
job_id = JobId(f"submitted-{job_name}-{package_path.name}")
|
|
312
|
+
return JobHandle(job_id, package_path, job_name)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _skip_preflight_hint(blocking: "Sequence[PreflightFinding]") -> str:
|
|
316
|
+
"""The follow-on sentence :func:`_handle_preflight_outcome` appends after
|
|
317
|
+
its blocking-finding count, chosen from the WHOLE batch rather than one
|
|
318
|
+
finding at a time.
|
|
319
|
+
|
|
320
|
+
For most blocking codes this is the plain hint every prior release has
|
|
321
|
+
printed: the flag still bypasses the whole preflight phase, and nothing
|
|
322
|
+
here makes a finding unbypassable — ``skip_preflight_requested``'s
|
|
323
|
+
docstring rule ("a blocking finding can never become an unbypassable
|
|
324
|
+
wall") is unchanged.
|
|
325
|
+
|
|
326
|
+
``runtime_not_available`` is the one exception, and only the ADVICE
|
|
327
|
+
changes, not the flag's behavior. The generic hint's two stated reasons
|
|
328
|
+
to bypass are "preflight is wrong about your app" and "the platform is
|
|
329
|
+
unreachable" — this finding is neither: it fires only when the
|
|
330
|
+
requested runtime id is absent from an ESTABLISHED inventory (at least
|
|
331
|
+
one worker has actually reported a runtime, so the known set describes
|
|
332
|
+
the fleet rather than merely restating the default); when nothing has
|
|
333
|
+
ever reported, that same absence is the advisory ``unknown_runtime``
|
|
334
|
+
instead (see ``preflight/service.py``'s ``_runtime_availability_finding``).
|
|
335
|
+
That established-inventory fact is real regardless of workload — the
|
|
336
|
+
same trigger fires for an unavailable GPU runtime as for an unavailable
|
|
337
|
+
CPU one. Bypassing it does not route around a wrong opinion, it
|
|
338
|
+
re-enables the exact silent substitution onto the platform's GPU-backed
|
|
339
|
+
default runtime that this finding exists to prevent, so the generic
|
|
340
|
+
hint is replaced with one naming that substitution WITHOUT inventing a
|
|
341
|
+
rate, an estimate, or any claim about what the workload itself needs —
|
|
342
|
+
there is no rate card (``PreflightResponse.estimate`` is reserved and
|
|
343
|
+
always ``null``; see ``preflight/schemas.py``/``routes.py``). Keyed on
|
|
344
|
+
``finding.code`` (the server's own vocabulary — see
|
|
345
|
+
``preflight_render.py``'s module docstring), never on message text.
|
|
346
|
+
|
|
347
|
+
``--skip-preflight`` bypasses the entire preflight call, not one finding
|
|
348
|
+
in isolation (see ``skip_preflight_requested``), so if ANY finding in
|
|
349
|
+
*blocking* is ``runtime_not_available``, the qualified sentence governs
|
|
350
|
+
the whole raise even when an ordinary, safely-bypassable finding is also
|
|
351
|
+
blocking alongside it — bypassing would still re-enable that finding's
|
|
352
|
+
harm too.
|
|
353
|
+
"""
|
|
354
|
+
from simulo._client.preflight_render import RUNTIME_NOT_AVAILABLE_CODE
|
|
355
|
+
|
|
356
|
+
if any(finding.code == RUNTIME_NOT_AVAILABLE_CODE for finding in blocking):
|
|
357
|
+
return (
|
|
358
|
+
"--skip-preflight would submit anyway, but not onto the runtime you asked for — this "
|
|
359
|
+
"platform doesn't offer it, so the job would run on the platform's default runtime "
|
|
360
|
+
"instead, which is GPU-backed."
|
|
361
|
+
)
|
|
362
|
+
return "Pass --skip-preflight to submit anyway (not recommended)."
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _handle_preflight_outcome(outcome: "PreflightOutcome", *, job_name: str) -> None:
|
|
366
|
+
"""Print + decide for one preflight answer inside ``_spawn_cloud``.
|
|
367
|
+
|
|
368
|
+
Fail-open (``outcome.skipped``): print the notice to stderr exactly like
|
|
369
|
+
``simulo prepare`` does, then return — the submit proceeds as if
|
|
370
|
+
preflight had not run at all (see ``preflight_api``'s module docstring;
|
|
371
|
+
preflight failing HARD instead of failing open must never gain a second
|
|
372
|
+
instance on this path).
|
|
373
|
+
|
|
374
|
+
A well-formed response renders EVERY finding to stdout — ``✗`` for
|
|
375
|
+
``blocking``, ``⚠`` for ``advisory`` — using the exact same per-code copy,
|
|
376
|
+
control-character sanitization, and outcome-level rendering ``simulo
|
|
377
|
+
prepare`` uses (``preflight_render.py``'s ``render_preflight_skip_notice``/
|
|
378
|
+
``render_preflight_findings``), so the two commands read identically for
|
|
379
|
+
the same finding rather than each keeping its own copy of that loop.
|
|
380
|
+
Advisory findings (including the ``no_active_worker`` finding — the actual fix
|
|
381
|
+
for "submit accepts a job with zero workers and the client silently
|
|
382
|
+
follows an empty stream") print and the submit proceeds regardless. Any
|
|
383
|
+
``blocking`` finding raises :class:`PreflightBlockedError` with a short
|
|
384
|
+
stderr summary — the detailed ``✗`` lines are already on stdout by the
|
|
385
|
+
time this raises, so the exception message is a summary + the
|
|
386
|
+
``--skip-preflight`` hint (:func:`_skip_preflight_hint`), not a
|
|
387
|
+
duplicate render.
|
|
388
|
+
"""
|
|
389
|
+
from simulo._client.preflight_render import render_preflight_findings, render_preflight_skip_notice
|
|
390
|
+
|
|
391
|
+
if outcome.skipped:
|
|
392
|
+
render_preflight_skip_notice(outcome)
|
|
393
|
+
return
|
|
394
|
+
blocking, _advisory = render_preflight_findings(outcome)
|
|
395
|
+
if not blocking:
|
|
396
|
+
return
|
|
397
|
+
raise PreflightBlockedError(
|
|
398
|
+
f"{len(blocking)} preflight issue(s) blocked job {job_name!r} — nothing was uploaded and no "
|
|
399
|
+
f"job was created. {_skip_preflight_hint(blocking)}"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _spawn_cloud(
|
|
404
|
+
job_name: str,
|
|
405
|
+
package_path: Path,
|
|
406
|
+
creds: Optional[dict[str, Any]],
|
|
407
|
+
*,
|
|
408
|
+
viewstream: bool = False,
|
|
409
|
+
seed_from: Optional[str] = None,
|
|
410
|
+
frozen_assets: bool = False,
|
|
411
|
+
strict_assets: bool = False,
|
|
412
|
+
skip_preflight: bool = False,
|
|
413
|
+
app_name: Optional[str] = None,
|
|
414
|
+
) -> JobHandle:
|
|
415
|
+
"""Tar the bundle, preflight it, upload it, create the job, and return a
|
|
416
|
+
cloud JobHandle.
|
|
417
|
+
|
|
418
|
+
``app_name`` (live-view wave) is the submitting ``App``'s
|
|
419
|
+
declared name, forwarded as an OPTIONAL top-level sibling of ``args`` on
|
|
420
|
+
the job-create call only (the same package-hash-invariance rule as
|
|
421
|
+
``viewstream``/``seed_from`` below) so the platform's live viewer can
|
|
422
|
+
show WHAT is being watched. Best-effort display metadata: an older
|
|
423
|
+
control plane ignores it, and omitting it changes nothing about the run.
|
|
424
|
+
|
|
425
|
+
``viewstream`` rides ONLY the job-create call, as an optional top-level
|
|
426
|
+
body field (never inside ``args``, which feeds the ``package_id`` sha256
|
|
427
|
+
— see the live-visualization plan's wire-contract rule): a package's
|
|
428
|
+
content-addressed identity must never change based on whether this
|
|
429
|
+
particular execution asked for a live stream.
|
|
430
|
+
|
|
431
|
+
The requested GPU system tier (system-tier-catalog-1362, M50-persistence)
|
|
432
|
+
is NOT a parameter here — it is read from the already-parsed manifest's
|
|
433
|
+
``jobs[job_name]["resources"]["system"]`` (the wire value
|
|
434
|
+
``@app.job(system=...)`` already validated and recorded at decoration
|
|
435
|
+
time) and forwarded to ``create_job`` below as an optional top-level
|
|
436
|
+
sibling of ``args``, for the identical package-hash-invariance reason as
|
|
437
|
+
``viewstream``.
|
|
438
|
+
|
|
439
|
+
``seed_from`` (``simulo run --from``, explicit-run-intent plan) follows
|
|
440
|
+
the identical rule: the seed reference is a SUBMIT-time job property,
|
|
441
|
+
never a package property — it rides the job-create call only, verbatim
|
|
442
|
+
(the control plane resolves prefixes and ``:best``/``:latest``). When the
|
|
443
|
+
server honors it, the resolved provenance is announced on stderr without
|
|
444
|
+
exposing its machine UUID; when an older server ignores it, ``create_job``
|
|
445
|
+
raises rather than let a fresh run masquerade as a seeded one.
|
|
446
|
+
|
|
447
|
+
``skip_preflight`` (``simulo run --skip-preflight``) bypasses the
|
|
448
|
+
preflight phase below entirely — no HTTP request is made — rather than
|
|
449
|
+
calling it and discarding the answer.
|
|
450
|
+
|
|
451
|
+
**Preflight** runs FIRST — before asset-pin
|
|
452
|
+
resolution, before ``create_package``, before ``upload_archive``, before
|
|
453
|
+
``create_job`` — so a ``blocking`` finding uploads nothing and submits
|
|
454
|
+
nothing: a :class:`PreflightBlockedError` unwinds this call before any of
|
|
455
|
+
those network calls happen. ``job_name``/``archive_size`` are both known
|
|
456
|
+
here (unlike ``simulo prepare``, which checks a whole app with neither),
|
|
457
|
+
so this call is more precise than prepare's: the server's timeout check
|
|
458
|
+
scopes to just this job, and the archive-size check runs for real. The
|
|
459
|
+
already-built ``archive_bytes`` (local disk only — no network yet) feed
|
|
460
|
+
``archive_size`` at zero extra cost. ``assets`` is deliberately NOT
|
|
461
|
+
passed: ``PreflightApiClient.evaluate`` accepts it for wire-tuple
|
|
462
|
+
parity, but resolving pins requires its own network round trip that
|
|
463
|
+
normally happens further down this function, and the control plane's
|
|
464
|
+
``evaluate_preflight`` does not read ``body.assets`` in any check today
|
|
465
|
+
(verified against ``simulo_control_plane.preflight.service`` — no
|
|
466
|
+
finding consumes it) — running that resolve early would buy nothing and
|
|
467
|
+
would cost the fail-open contract an extra failure mode to reason about.
|
|
468
|
+
``viewstream`` is passed for the same tuple-parity reason even though no
|
|
469
|
+
check consumes it either: it costs nothing (already a plain bool, no
|
|
470
|
+
resolution needed).
|
|
471
|
+
|
|
472
|
+
Registry-asset pins follow the SAME sibling-of-``args`` rule. Every
|
|
473
|
+
``Asset.from_registry`` ref the app mounted is resolved to an exact
|
|
474
|
+
version in one batch BEFORE the package is uploaded — so a bad/missing/
|
|
475
|
+
unpinned ref fails in seconds rather than after the upload — the
|
|
476
|
+
``Resolved assets:`` block is printed, ``--frozen`` / ``--strict-assets``
|
|
477
|
+
are enforced, and the resolved ``[{ref, digest}]`` pins ride the
|
|
478
|
+
job-create call's top-level ``assets`` field, never ``args``.
|
|
479
|
+
"""
|
|
480
|
+
from simulo._client.asset_pins import DEFAULT_ASSET_RUNTIME, collect_asset_refs, resolve_and_pin_assets
|
|
481
|
+
from simulo._client.packaging import MANIFEST_FILENAME, ensure_tar
|
|
482
|
+
from simulo._client.preflight_api import PreflightApiClient
|
|
483
|
+
from simulo._client.submit_api import SubmitApiClient, SubmitApiError
|
|
484
|
+
from simulo.interfaces.platform.manifest import Manifest
|
|
485
|
+
|
|
486
|
+
manifest_text = (package_path / MANIFEST_FILENAME).read_text(encoding="utf-8")
|
|
487
|
+
manifest = json.loads(manifest_text)
|
|
488
|
+
# The manifest's package_id is the wire identity (``sha256:<hex>``) and is
|
|
489
|
+
# the ONLY source of truth for it — every manifest ``package()`` writes
|
|
490
|
+
# carries one (see packaging.py). The local directory name is NOT a valid
|
|
491
|
+
# fallback: it is a short, non-invertible, filesystem-safe form of the id
|
|
492
|
+
# (see ``packaging.bundle_dir_name`` — truncated for Windows MAX_PATH),
|
|
493
|
+
# so guessing an id back from it would either be impossible (can't invert
|
|
494
|
+
# a truncated hash) or, worse, silently put a WRONG/incomplete id on the
|
|
495
|
+
# wire. An id-less manifest means a bundle written by a client older than
|
|
496
|
+
# this fix, or a corrupted/foreign directory — fail loudly and tell the
|
|
497
|
+
# caller how to recover, never guess.
|
|
498
|
+
package_id = manifest.get("package_id")
|
|
499
|
+
if not package_id:
|
|
500
|
+
raise RuntimeError(
|
|
501
|
+
f"Package at {package_path} has no package_id in its manifest (it predates this client "
|
|
502
|
+
"version, or its manifest is missing/corrupted). Re-run `simulo run` (or call .spawn() "
|
|
503
|
+
"again) to rebuild it before submitting to the cloud."
|
|
504
|
+
)
|
|
505
|
+
package_id = str(package_id)
|
|
506
|
+
source_digest = str(manifest.get("source_digest", ""))
|
|
507
|
+
args = dict(manifest.get("args") or {})
|
|
508
|
+
# The requested GPU system tier (system-tier-catalog-1362, M50-persistence)
|
|
509
|
+
# — already validated at `@app.job(system=...)` decoration time and
|
|
510
|
+
# recorded on THIS job's own manifest entry as `resources["system"]`
|
|
511
|
+
# (the tier's wire value, e.g. "tier1"; see `decorate()`'s
|
|
512
|
+
# `resources["system"] = system.value`). Read from the already-parsed
|
|
513
|
+
# manifest rather than threaded in as a new parameter, since the value
|
|
514
|
+
# already lives there, keyed by this same `job_name`.
|
|
515
|
+
job_entry = manifest.get("jobs", {}).get(job_name) or {}
|
|
516
|
+
system = (job_entry.get("resources") or {}).get("system")
|
|
517
|
+
|
|
518
|
+
tar_path = ensure_tar(package_path)
|
|
519
|
+
archive_bytes = tar_path.read_bytes()
|
|
520
|
+
archive_sha256 = "sha256:" + hashlib.sha256(archive_bytes).hexdigest()
|
|
521
|
+
|
|
522
|
+
base_url = config.resolve_base_url(creds)
|
|
523
|
+
token = creds["access_token"] if creds else os.environ.get("SIMULO_API_TOKEN")
|
|
524
|
+
|
|
525
|
+
if not skip_preflight:
|
|
526
|
+
# This manifest was just written, moments ago, by THIS process's own
|
|
527
|
+
# `manifest.to_json()` (see `bundle.py`) — never foreign or
|
|
528
|
+
# cross-version bytes — so `Manifest.from_json` re-parsing it here
|
|
529
|
+
# can never hit the TypeError-on-unknown-key hazard PR-A1's security
|
|
530
|
+
# review flagged for UNTRUSTED input; this input is our own.
|
|
531
|
+
outcome = PreflightApiClient(base_url, token=token).evaluate(
|
|
532
|
+
Manifest.from_json(manifest_text),
|
|
533
|
+
job_name=job_name,
|
|
534
|
+
archive_size=len(archive_bytes),
|
|
535
|
+
seed_from_job_id=seed_from,
|
|
536
|
+
viewstream=viewstream,
|
|
537
|
+
)
|
|
538
|
+
_handle_preflight_outcome(outcome, job_name=job_name)
|
|
539
|
+
|
|
540
|
+
client = SubmitApiClient(base_url, token=token)
|
|
541
|
+
|
|
542
|
+
# Resolve every mounted registry-asset ref to an exact pin FIRST — before the
|
|
543
|
+
# package is uploaded — so a missing/unpinned/unvalidated ref fails in seconds.
|
|
544
|
+
# Returns the top-level `assets: [{ref, digest}]` for create_job; `[]`
|
|
545
|
+
# when the app mounts no registry assets (no resolve call is made then).
|
|
546
|
+
asset_pins = resolve_and_pin_assets(
|
|
547
|
+
client,
|
|
548
|
+
collect_asset_refs(manifest),
|
|
549
|
+
runtime=DEFAULT_ASSET_RUNTIME,
|
|
550
|
+
frozen=frozen_assets,
|
|
551
|
+
strict=strict_assets,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
client.create_package(
|
|
555
|
+
package_id=package_id,
|
|
556
|
+
source_digest=source_digest,
|
|
557
|
+
job_name=job_name,
|
|
558
|
+
archive_sha256=archive_sha256,
|
|
559
|
+
archive_size=len(archive_bytes),
|
|
560
|
+
)
|
|
561
|
+
client.upload_archive(package_id, archive_bytes)
|
|
562
|
+
record = client.create_job(
|
|
563
|
+
package_id=package_id,
|
|
564
|
+
job_name=job_name,
|
|
565
|
+
args=args,
|
|
566
|
+
viewstream=viewstream,
|
|
567
|
+
seed_from_job_id=seed_from,
|
|
568
|
+
assets=asset_pins,
|
|
569
|
+
app_name=app_name,
|
|
570
|
+
system=system,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
raw_public_id = record.get("public_id")
|
|
574
|
+
try:
|
|
575
|
+
if not isinstance(raw_public_id, str):
|
|
576
|
+
raise TypeError("public_id is not a string")
|
|
577
|
+
public_id = parse_job_public_id(raw_public_id)
|
|
578
|
+
except (TypeError, ValueError):
|
|
579
|
+
# A current public cloud response must carry the human-facing Job ID.
|
|
580
|
+
# The machine UUID is intentionally not used as display fallback: the
|
|
581
|
+
# job may already have been created, so give a neutral recovery path.
|
|
582
|
+
raise SubmitApiError(
|
|
583
|
+
"The platform created the job but did not return a usable Job ID. "
|
|
584
|
+
"Run `simulo jobs` to find the submission before retrying."
|
|
585
|
+
) from None
|
|
586
|
+
|
|
587
|
+
if seed_from is not None:
|
|
588
|
+
# create_job has already guaranteed the seed dict is present (the
|
|
589
|
+
# loud-degradation guard) — this line is the human confirmation that
|
|
590
|
+
# the ref resolved to the intended source job. Seed provenance still
|
|
591
|
+
# carries machine UUIDs only, so keep this confirmation neutral rather
|
|
592
|
+
# than leaking a UUID while that response contract catches up.
|
|
593
|
+
seed = record.get("seed") or {}
|
|
594
|
+
print(
|
|
595
|
+
f"Continuing from selected job ({seed.get('name')}, {seed.get('kind')} checkpoint)",
|
|
596
|
+
file=sys.stderr,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
return JobHandle(
|
|
600
|
+
JobId(str(record["job_id"])),
|
|
601
|
+
package_path,
|
|
602
|
+
job_name,
|
|
603
|
+
public_id=public_id,
|
|
604
|
+
cloud=True,
|
|
605
|
+
base_url=base_url,
|
|
606
|
+
token=token,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
class JobFunction:
|
|
611
|
+
"""What ``@app.job`` returns: the original function, wrapped for submission.
|
|
612
|
+
|
|
613
|
+
Calling it runs the body in-process, exactly as if it were undecorated (that
|
|
614
|
+
is also how the platform invokes it on a worker). :meth:`spawn` and
|
|
615
|
+
:meth:`submit` package and submit it instead. You never construct one
|
|
616
|
+
yourself — decorate a function and use it from your ``@app.entrypoint``.
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
def __init__(self, app: "App", spec: JobSpec) -> None:
|
|
620
|
+
self._app = app
|
|
621
|
+
self._spec = spec
|
|
622
|
+
functools.update_wrapper(self, spec.fn)
|
|
623
|
+
|
|
624
|
+
@property
|
|
625
|
+
def spec(self) -> JobSpec:
|
|
626
|
+
return self._spec
|
|
627
|
+
|
|
628
|
+
def __call__(self, *args: object, **kwargs: object) -> object:
|
|
629
|
+
"""Run the original job body in-process (execution mode)."""
|
|
630
|
+
global _ANY_JOB_INVOKED
|
|
631
|
+
_ANY_JOB_INVOKED = True
|
|
632
|
+
return self._spec.fn(*args, **kwargs)
|
|
633
|
+
|
|
634
|
+
def spawn(self, **kwargs: Any) -> JobHandle:
|
|
635
|
+
"""Submit this job to the Simulo cloud (once logged in), or write it locally.
|
|
636
|
+
|
|
637
|
+
Submitting writes the manifest + source bundle (memoised once per app),
|
|
638
|
+
recording this job's name and ``kwargs`` into the manifest so the job can
|
|
639
|
+
be run later with ``fn(**kwargs)``.
|
|
640
|
+
|
|
641
|
+
**Cloud mode** — credentials exist (``simulo login``), or
|
|
642
|
+
``SIMULO_API_URL``/``SIMULO_ENV`` names a target (and ``SIMULO_SUBMIT`` is
|
|
643
|
+
not ``local``): the package is tarred, uploaded, and a job is created on
|
|
644
|
+
the Simulo cloud. The returned :class:`JobHandle` carries the
|
|
645
|
+
SERVER-assigned ``job_id`` and ``.get()`` streams the real result.
|
|
646
|
+
|
|
647
|
+
**Local-disk mode** (the default with no cloud signal present): NEVER
|
|
648
|
+
trains and never runs the job — the handle exposes only
|
|
649
|
+
``.package_path`` / ``.job_id`` (a locally-derived id), and ``.get()``
|
|
650
|
+
raises, pointing you at ``simulo login`` to submit to the cloud instead.
|
|
651
|
+
|
|
652
|
+
Positional arguments are rejected: a job body receives only the keyword
|
|
653
|
+
arguments recorded in the manifest.
|
|
654
|
+
"""
|
|
655
|
+
# Intent-to-submit is recorded FIRST — before any packaging or network
|
|
656
|
+
# I/O — so a submit that fails partway (or times out AFTER the server
|
|
657
|
+
# accepted the job) can never be mislabelled "Nothing was submitted"
|
|
658
|
+
# by the at-exit guard. See _ANY_SPAWN_ATTEMPTED.
|
|
659
|
+
global _ANY_SPAWN_ATTEMPTED
|
|
660
|
+
_ANY_SPAWN_ATTEMPTED = True
|
|
661
|
+
# Lazy import keeps app.py free of a module-scope dependency on the
|
|
662
|
+
# packager (and any import cycle); spawn is a runtime call.
|
|
663
|
+
from simulo._client.asset import current_captured_assets
|
|
664
|
+
from simulo._client.bundle import ensure_bundle
|
|
665
|
+
|
|
666
|
+
app_file, project_root = _app_source_location(self._spec.module)
|
|
667
|
+
# Construction-capture (authored-ref mount invariant): every Asset
|
|
668
|
+
# constructed so far in the active `simulo run` capture scope (armed
|
|
669
|
+
# around the whole discovery import + entrypoint run — see cli.py) is
|
|
670
|
+
# unioned into this bundle's `resources`, even when never explicitly
|
|
671
|
+
# mounted via App(mounts=...) — an unmounted registry handle
|
|
672
|
+
# (`forklift = simulo.Asset.from_registry(...)`, used only via
|
|
673
|
+
# `Robot(asset=forklift)`) must still get resolved and pinned.
|
|
674
|
+
package_path = ensure_bundle(
|
|
675
|
+
self._app,
|
|
676
|
+
app_file,
|
|
677
|
+
project_root,
|
|
678
|
+
job_name=self._spec.name,
|
|
679
|
+
args=dict(kwargs),
|
|
680
|
+
extra_assets=current_captured_assets(),
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
# SIMULO_SUBMIT=local is checked FIRST, before touching credentials at
|
|
684
|
+
# all — the escape hatch must short-circuit even when the saved token
|
|
685
|
+
# is expired (which would otherwise trigger a network refresh, or
|
|
686
|
+
# fail outright if the refresh token itself is no longer valid). A
|
|
687
|
+
# local-disk submit must never depend on the network succeeding.
|
|
688
|
+
if config.is_submit_forced_local():
|
|
689
|
+
_require_no_seed_for_local_submit("SIMULO_SUBMIT=local forces a local-disk submit")
|
|
690
|
+
handle: JobHandle = _spawn_local(self._spec.name, package_path)
|
|
691
|
+
else:
|
|
692
|
+
creds = credentials.try_get_valid_credentials()
|
|
693
|
+
if _cloud_mode_active(creds):
|
|
694
|
+
handle = _spawn_cloud(
|
|
695
|
+
self._spec.name,
|
|
696
|
+
package_path,
|
|
697
|
+
creds,
|
|
698
|
+
viewstream=_VIEWSTREAM_REQUESTED,
|
|
699
|
+
seed_from=_SEED_FROM_REF,
|
|
700
|
+
frozen_assets=_FROZEN_ASSETS_REQUESTED,
|
|
701
|
+
strict_assets=_STRICT_ASSETS_REQUESTED,
|
|
702
|
+
skip_preflight=_SKIP_PREFLIGHT_REQUESTED,
|
|
703
|
+
app_name=self._app.name,
|
|
704
|
+
)
|
|
705
|
+
else:
|
|
706
|
+
_require_no_seed_for_local_submit("no cloud target is configured")
|
|
707
|
+
handle = _spawn_local(self._spec.name, package_path)
|
|
708
|
+
_record_spawn(handle)
|
|
709
|
+
return handle
|
|
710
|
+
|
|
711
|
+
def submit(self, *args: object, **kwargs: object) -> JobId:
|
|
712
|
+
"""Submit this job (keyword args only): write the package, return its id.
|
|
713
|
+
|
|
714
|
+
Like :meth:`spawn` but returns just the ``JobId`` (``JobFunctionProtocol``).
|
|
715
|
+
It writes the package and never executes; nothing is dispatched.
|
|
716
|
+
"""
|
|
717
|
+
if args:
|
|
718
|
+
raise TypeError("JobFunction.submit() accepts keyword arguments only.")
|
|
719
|
+
return self.spawn(**kwargs).job_id
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _cli_redirect_message(job_names: Sequence[str] = ()) -> str:
|
|
723
|
+
"""The ONE stderr message for a direct ``python app.py`` run — both guard paths.
|
|
724
|
+
|
|
725
|
+
Names the exact equivalent ``simulo run`` command: ``sys.argv[0]`` is the app
|
|
726
|
+
file as the user typed it and ``sys.argv[1:]`` are their flags verbatim
|
|
727
|
+
(``shlex.join`` quotes anything that needs it), so the printed command is
|
|
728
|
+
copy-pasteable as-is.
|
|
729
|
+
|
|
730
|
+
The decoration-time redirect (``@app.entrypoint``) and the at-exit guard for
|
|
731
|
+
entrypoint-less files both print exactly this copy — one mistake, one
|
|
732
|
+
message, one exit code (2). ``job_names`` is passed only by the at-exit
|
|
733
|
+
guard when the file declares MORE THAN ONE job: a bare ``simulo run`` errors
|
|
734
|
+
there, so the suggested command gains ``--job NAME`` and the valid names are
|
|
735
|
+
listed (in declaration order — for staged apps that is the intended run
|
|
736
|
+
order).
|
|
737
|
+
"""
|
|
738
|
+
command = shlex.join(["simulo", "run", *sys.argv])
|
|
739
|
+
job_lines = ""
|
|
740
|
+
if len(job_names) > 1:
|
|
741
|
+
command += " --job NAME"
|
|
742
|
+
job_lines = f"\nJobs in {sys.argv[0]} (in declaration order): {', '.join(job_names)}.\n"
|
|
743
|
+
return (
|
|
744
|
+
"Simulo apps are submitted with the Simulo CLI, not `python`:\n"
|
|
745
|
+
"\n"
|
|
746
|
+
f" {command}\n"
|
|
747
|
+
f"{job_lines}"
|
|
748
|
+
"\n"
|
|
749
|
+
"Nothing was submitted."
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
# Arm the "nothing was submitted" nudge at most once per process — only the first
|
|
754
|
+
# App declared by a directly-run script registers the at-exit hook.
|
|
755
|
+
_NOTHING_SUBMITTED_HINT_ARMED = False
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _direct_run_frame_globals() -> Optional[dict[str, Any]]:
|
|
759
|
+
"""The running script's module globals when this is a direct ``python app.py``, else None.
|
|
760
|
+
|
|
761
|
+
The app literal is written at module scope in the user's file, so the first
|
|
762
|
+
frame *outside this module* that constructs the App is that file's module
|
|
763
|
+
body. A direct ``python app.py`` runs it as ``__main__`` with a real
|
|
764
|
+
``__file__``; ``simulo run`` and the backend runner import the file under its
|
|
765
|
+
own module name (never ``__main__``), and ``python -c`` / typed REPL and
|
|
766
|
+
notebook cells have no ``__file__`` — none of which arm the hint. Walking
|
|
767
|
+
out of this module (rather than counting frames) keeps it correct
|
|
768
|
+
regardless of internal call depth.
|
|
769
|
+
|
|
770
|
+
NOTE this check alone cannot distinguish ``python app.py`` from a HOST
|
|
771
|
+
process running the file under the ``__main__`` name — ``runpy.run_path``,
|
|
772
|
+
IPython's ``%run``, and similar harnesses set BOTH ``__name__ ==
|
|
773
|
+
"__main__"`` and a real ``__file__`` while executing it. The decoration-time
|
|
774
|
+
redirect is safe there anyway (it raises ``SystemExit``, which hosts catch);
|
|
775
|
+
the at-exit guard — which must never ``os._exit`` a host process — does its
|
|
776
|
+
own host discrimination (see :func:`_arm_nothing_submitted_hint` and
|
|
777
|
+
:func:`_print_nothing_submitted_hint`).
|
|
778
|
+
"""
|
|
779
|
+
frame: Optional[types.FrameType] = sys._getframe(1)
|
|
780
|
+
while frame is not None and frame.f_globals.get("__name__") == __name__:
|
|
781
|
+
frame = frame.f_back
|
|
782
|
+
if frame is None: # pragma: no cover - no caller outside this module
|
|
783
|
+
return None
|
|
784
|
+
g = frame.f_globals
|
|
785
|
+
if g.get("__name__") == "__main__" and g.get("__file__"):
|
|
786
|
+
return g
|
|
787
|
+
return None
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _is_direct_script_run() -> bool:
|
|
791
|
+
"""True when ``App(...)`` is being evaluated under the ``__main__`` name."""
|
|
792
|
+
return _direct_run_frame_globals() is not None
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# True once the direct run's exit is ALREADY EXPLAINED by something other than
|
|
796
|
+
# a silent no-op: an unhandled exception (recorded by the wrapped
|
|
797
|
+
# ``sys.excepthook`` — which CPython calls for every uncaught non-SystemExit
|
|
798
|
+
# exception, including KeyboardInterrupt) or an explicit ``sys.exit(...)``
|
|
799
|
+
# (recorded by the wrapped ``sys.exit``, any code including 0). Either way the
|
|
800
|
+
# at-exit guard must stay silent and MUST NOT override the process's real exit
|
|
801
|
+
# code — a caller has to be able to tell "wrong invocation" (2) from "your app
|
|
802
|
+
# crashed" (its own code) from "your app chose to exit" (its own code).
|
|
803
|
+
#
|
|
804
|
+
# Coverage is precise, not total. COVERED: any ``sys.exit(...)`` RESOLVED AT
|
|
805
|
+
# CALL TIME (the overwhelmingly common spelling), and any uncaught
|
|
806
|
+
# non-SystemExit exception reaching the excepthook chain in place at arm time.
|
|
807
|
+
# NOT covered — all verified empirically on CPython 3.11, all pinned or
|
|
808
|
+
# documented, none detectable without process-global mutation:
|
|
809
|
+
#
|
|
810
|
+
# * a literal ``raise SystemExit(n)`` at module scope, any ``n`` — CPython
|
|
811
|
+
# special-cases SystemExit in ``_PyErr_PrintEx`` (the excepthook is never
|
|
812
|
+
# called for it) and ``sys.exit`` is bypassed, so no supported hook observes
|
|
813
|
+
# it before atexit runs, and the pending exit code is invisible to Python by
|
|
814
|
+
# then (pinned residuals: redirect + exit 2, destroying a chosen nonzero
|
|
815
|
+
# code);
|
|
816
|
+
# * a reference to ``sys.exit`` captured BEFORE ``App(...)`` armed the wrapper
|
|
817
|
+
# (``from sys import exit`` / ``_saved = sys.exit``) — the call bypasses the
|
|
818
|
+
# wrapper;
|
|
819
|
+
# * a third-party ``sys.excepthook`` installed AFTER arming that does not
|
|
820
|
+
# chain to the previous hook — it replaces our recorder outright.
|
|
821
|
+
#
|
|
822
|
+
# Covering the literal raise would require replacing ``builtins.SystemExit``
|
|
823
|
+
# process-wide (rejected: making ``except SystemExit`` resolve to a subclass
|
|
824
|
+
# silently stops it catching C-level real SystemExit raises — a worse,
|
|
825
|
+
# less-debuggable breakage than these residuals). Consequence: such a run is
|
|
826
|
+
# indistinguishable from a clean did-nothing exit and gets the redirect +
|
|
827
|
+
# exit 2.
|
|
828
|
+
_EXPLICIT_EXIT_OR_FAILURE = False
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _arm_direct_run_exit_tracking() -> None:
|
|
832
|
+
"""Wrap ``sys.excepthook`` and ``sys.exit`` to record a non-no-op exit.
|
|
833
|
+
|
|
834
|
+
Armed only for a directly-run ``python app.py`` (never under ``simulo
|
|
835
|
+
run``, the worker, or pytest imports). Both wrappers delegate to the
|
|
836
|
+
previous hook/function unchanged — behavior-neutral, record-only.
|
|
837
|
+
"""
|
|
838
|
+
previous_excepthook = sys.excepthook
|
|
839
|
+
|
|
840
|
+
def _recording_excepthook(exc_type: Any, exc: Any, tb: Any) -> Any:
|
|
841
|
+
global _EXPLICIT_EXIT_OR_FAILURE
|
|
842
|
+
_EXPLICIT_EXIT_OR_FAILURE = True
|
|
843
|
+
return previous_excepthook(exc_type, exc, tb)
|
|
844
|
+
|
|
845
|
+
sys.excepthook = _recording_excepthook
|
|
846
|
+
|
|
847
|
+
previous_exit = sys.exit
|
|
848
|
+
|
|
849
|
+
# ``functools.wraps`` keeps the observable surface close to the builtin
|
|
850
|
+
# (``__name__``/``__qualname__``/``__module__``/``__doc__``, and
|
|
851
|
+
# ``__wrapped__`` pointing at the real ``sys.exit``); the positional-only
|
|
852
|
+
# marker keeps ``sys.exit(code=5)`` a ``TypeError`` exactly as on the
|
|
853
|
+
# builtin, so the wrapper never ACCEPTS a spelling the real function
|
|
854
|
+
# rejects.
|
|
855
|
+
@functools.wraps(previous_exit)
|
|
856
|
+
def _recording_exit(code: Any = None, /) -> Any:
|
|
857
|
+
global _EXPLICIT_EXIT_OR_FAILURE
|
|
858
|
+
_EXPLICIT_EXIT_OR_FAILURE = True
|
|
859
|
+
return previous_exit(code)
|
|
860
|
+
|
|
861
|
+
sys.exit = _recording_exit
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
# The ``__main__`` MODULE OBJECT the guard was armed for. Host discrimination
|
|
865
|
+
# is by module IDENTITY, not by ``__file__``: CPython's
|
|
866
|
+
# ``PyRun_SimpleFileObject`` DELETES ``__main__.__file__`` after the script
|
|
867
|
+
# body completes (measured — it is already None by atexit time even for a
|
|
868
|
+
# genuine ``python app.py``), so a file-path comparison cannot tell a genuine
|
|
869
|
+
# run from a host at exit. Identity can: ``runpy.run_path`` / IPython ``%run``
|
|
870
|
+
# install a TEMPORARY module at ``sys.modules["__main__"]`` while the app body
|
|
871
|
+
# (and therefore arming) runs, and restore the host's own ``__main__`` before
|
|
872
|
+
# the host exits — so at atexit the identity no longer matches and the guard
|
|
873
|
+
# stays silent. Under a genuine ``python app.py`` the module object is the
|
|
874
|
+
# same one for the whole process life. (Holding a strong reference here also
|
|
875
|
+
# keeps the comparison meaningful — the temp module cannot be recycled into a
|
|
876
|
+
# false identity match.)
|
|
877
|
+
_ARMED_MAIN_MODULE: Optional[Any] = None
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _print_nothing_submitted_hint(app: "App") -> None:
|
|
881
|
+
"""At interpreter exit, redirect a direct run that did NOTHING AT ALL.
|
|
882
|
+
|
|
883
|
+
A present ``@app.entrypoint`` redirects the ``__main__`` run to the CLI (and
|
|
884
|
+
exits 2) at DECORATION time, mid-import, long before this fires — that early
|
|
885
|
+
``SystemExit`` is what stops a file ending in ``if __name__ == "__main__":
|
|
886
|
+
main()`` from ever reaching ``main()`` and submitting. An entrypoint-less
|
|
887
|
+
file has no decoration to intercept, so this at-exit guard covers it with
|
|
888
|
+
the SAME copy (:func:`_cli_redirect_message`, job-count-aware) and the SAME
|
|
889
|
+
exit code — one mistake, one message, either way.
|
|
890
|
+
|
|
891
|
+
It fires ONLY when every one of these holds:
|
|
892
|
+
|
|
893
|
+
* **this process's ``__main__`` is still the very module the guard was
|
|
894
|
+
armed in** (identity, not ``__file__`` — see ``_ARMED_MAIN_MODULE`` for
|
|
895
|
+
why a file comparison cannot work at atexit): a HOST that ran the file
|
|
896
|
+
under the ``__main__`` name (``runpy.run_path``, IPython's ``%run``,
|
|
897
|
+
embedding harnesses) swaps its own ``__main__`` back before it exits, so
|
|
898
|
+
at atexit the identity no longer matches and the guard stays silent — it
|
|
899
|
+
must never ``os._exit`` a host process that merely ran the app to
|
|
900
|
+
completion;
|
|
901
|
+
* jobs are declared and no entrypoint is registered;
|
|
902
|
+
* no submit was ATTEMPTED (``_ANY_SPAWN_ATTEMPTED`` — intent, not success:
|
|
903
|
+
a submit that died mid-flight may have left a cloud job running, and its
|
|
904
|
+
own traceback already explained the exit);
|
|
905
|
+
* no job body was invoked in-process (``_ANY_JOB_INVOKED`` — calling
|
|
906
|
+
``work(x=7)`` locally is documented, deliberate work, not a no-op);
|
|
907
|
+
* the run neither crashed nor explicitly exited
|
|
908
|
+
(``_EXPLICIT_EXIT_OR_FAILURE`` — a real failure's exit code must never
|
|
909
|
+
be replaced by 2, and ``sys.exit(0)`` after local analysis is a choice,
|
|
910
|
+
not a mistake).
|
|
911
|
+
|
|
912
|
+
Exit code: raising ``SystemExit`` inside an ``atexit`` callback is IGNORED
|
|
913
|
+
by CPython ("Exception ignored in atexit callback"; the process still exits
|
|
914
|
+
0), so after printing the guard uses ``os._exit(2)``. That skips the rest
|
|
915
|
+
of interpreter finalization, so it first does the flushing finalization
|
|
916
|
+
would have done — ``logging.shutdown()`` (flushes+closes logging handlers;
|
|
917
|
+
a ``MemoryHandler``'s buffer is lost without it, and logging's own atexit
|
|
918
|
+
hook was registered at import time, BEFORE ours, so LIFO order means
|
|
919
|
+
``os._exit`` would otherwise skip it) and explicit ``sys.stdout``/
|
|
920
|
+
``sys.stderr`` flushes — inside a ``try`` whose ``finally`` holds the
|
|
921
|
+
``os._exit(2)``: a user handler whose ``flush`` raises (or an
|
|
922
|
+
already-closed stream) must not downgrade the exit to 0 by blowing up
|
|
923
|
+
before the exit call.
|
|
924
|
+
|
|
925
|
+
Honest residuals: a user file handle left open at exit — referenced or
|
|
926
|
+
not — is not flushed here, and any atexit handler registered before
|
|
927
|
+
``App(...)`` (i.e. at import time) is skipped. And "did nothing" means
|
|
928
|
+
"never touched a job or submit": a run that did real UNRELATED local work
|
|
929
|
+
(printing, writing files) without touching any job still gets the redirect
|
|
930
|
+
and exit 2 — that is the intended message for a jobs-declaring file, but
|
|
931
|
+
it IS a behavior change from the pre-0.15 exit-0 nudge for such scripts.
|
|
932
|
+
``os._exit`` is nonetheless required: with a plain ``SystemExit`` here the
|
|
933
|
+
did-nothing case would exit 0, and the unified exit code is the contract.
|
|
934
|
+
"""
|
|
935
|
+
if _ARMED_MAIN_MODULE is None or sys.modules.get("__main__") is not _ARMED_MAIN_MODULE:
|
|
936
|
+
return # a host process ran the app; its exit is not ours to take
|
|
937
|
+
if app.entrypoint_names or not app.jobs or _ANY_SPAWN_ATTEMPTED or _ANY_JOB_INVOKED or _EXPLICIT_EXIT_OR_FAILURE:
|
|
938
|
+
return
|
|
939
|
+
import logging
|
|
940
|
+
|
|
941
|
+
print(_cli_redirect_message(job_names=list(app.jobs)), file=sys.stderr)
|
|
942
|
+
try:
|
|
943
|
+
logging.shutdown()
|
|
944
|
+
sys.stdout.flush()
|
|
945
|
+
sys.stderr.flush()
|
|
946
|
+
finally:
|
|
947
|
+
os._exit(2)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _arm_nothing_submitted_hint(app: "App") -> None:
|
|
951
|
+
"""Register the at-exit no-op redirect once, for a directly-run script only.
|
|
952
|
+
|
|
953
|
+
Requires the constructing frame's globals to BE the current ``__main__``
|
|
954
|
+
module's ``__dict__`` — the extra identity check keeps a harness that
|
|
955
|
+
merely ``exec``s the file with ``{"__name__": "__main__", "__file__":
|
|
956
|
+
...}`` globals (without installing a module) from ever arming the
|
|
957
|
+
``os._exit`` path in ITS process. ``runpy``-style hosts DO pass this check
|
|
958
|
+
(they install a real temporary ``__main__``), and are excluded at exit by
|
|
959
|
+
the module-identity comparison instead (see ``_ARMED_MAIN_MODULE``).
|
|
960
|
+
"""
|
|
961
|
+
global _NOTHING_SUBMITTED_HINT_ARMED, _ARMED_MAIN_MODULE
|
|
962
|
+
if _NOTHING_SUBMITTED_HINT_ARMED:
|
|
963
|
+
return
|
|
964
|
+
frame_globals = _direct_run_frame_globals()
|
|
965
|
+
if frame_globals is None:
|
|
966
|
+
return
|
|
967
|
+
main_module = sys.modules.get("__main__")
|
|
968
|
+
if main_module is None or frame_globals is not getattr(main_module, "__dict__", None):
|
|
969
|
+
return
|
|
970
|
+
_NOTHING_SUBMITTED_HINT_ARMED = True
|
|
971
|
+
_ARMED_MAIN_MODULE = main_module
|
|
972
|
+
_arm_direct_run_exit_tracking()
|
|
973
|
+
atexit.register(_print_nothing_submitted_hint, app)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
class App:
|
|
977
|
+
"""The user-facing job container — declare it once, at module scope.
|
|
978
|
+
|
|
979
|
+
An app names your project and (optionally) mounts assets/volumes into
|
|
980
|
+
every job. Its jobs execute in a Simulo-curated runtime — the platform's
|
|
981
|
+
default runtime unless the app picks a
|
|
982
|
+
different Simulo runtime via ``runtime=``. Decorate functions with
|
|
983
|
+
:meth:`job` to make them submittable — ``simulo run app.py [--flags]``
|
|
984
|
+
submits the sole job with the flags mapped onto its own signature (``--job
|
|
985
|
+
NAME`` chooses among several). Optionally decorate one function with
|
|
986
|
+
:meth:`entrypoint` to orchestrate what ``simulo run app.py`` does instead::
|
|
987
|
+
|
|
988
|
+
import simulo
|
|
989
|
+
|
|
990
|
+
app = simulo.App("cartpole")
|
|
991
|
+
|
|
992
|
+
@app.job(system=simulo.SystemType.TIER_1)
|
|
993
|
+
def train(iterations: int = 1000):
|
|
994
|
+
...
|
|
995
|
+
|
|
996
|
+
@app.entrypoint
|
|
997
|
+
def main(iterations: int = 1000):
|
|
998
|
+
train.spawn(iterations=iterations)
|
|
999
|
+
|
|
1000
|
+
Args:
|
|
1001
|
+
name: Non-empty app name, recorded in the package manifest.
|
|
1002
|
+
runtime: Optionally pick a different Simulo runtime for this app's
|
|
1003
|
+
jobs (``simulo.Runtime.from_registry("simulo/gpu-rl:2026.06")``).
|
|
1004
|
+
Omitted means the platform default.
|
|
1005
|
+
mounts: Optional mapping of mount name to :class:`simulo.Asset` /
|
|
1006
|
+
:class:`simulo.Volume`, shared by every job of this app.
|
|
1007
|
+
|
|
1008
|
+
Raises:
|
|
1009
|
+
ValueError: if ``name`` is empty or whitespace-only.
|
|
1010
|
+
"""
|
|
1011
|
+
|
|
1012
|
+
def __init__(
|
|
1013
|
+
self,
|
|
1014
|
+
name: str,
|
|
1015
|
+
*,
|
|
1016
|
+
runtime: Optional[object] = None,
|
|
1017
|
+
mounts: Optional[Mapping[str, Mount]] = None,
|
|
1018
|
+
) -> None:
|
|
1019
|
+
if not name or not name.strip():
|
|
1020
|
+
raise ValueError("App name must be a non-empty string.")
|
|
1021
|
+
self._name = name
|
|
1022
|
+
self._runtime = runtime if runtime is not None else DEFAULT_RUNTIME
|
|
1023
|
+
self._mounts: dict[str, Mount] = dict(mounts or {})
|
|
1024
|
+
self._jobs: dict[str, JobSpec] = {}
|
|
1025
|
+
self._entrypoints: dict[str, Callable[..., object]] = {}
|
|
1026
|
+
registry.register(self)
|
|
1027
|
+
# If this is a directly-run ``python app.py`` with jobs but no
|
|
1028
|
+
# ``@app.entrypoint``, redirect at exit instead of silently no-op'ing
|
|
1029
|
+
# (same copy + exit code as the decoration-time redirect).
|
|
1030
|
+
_arm_nothing_submitted_hint(self)
|
|
1031
|
+
|
|
1032
|
+
@property
|
|
1033
|
+
def name(self) -> str:
|
|
1034
|
+
"""The app's name, as passed at construction."""
|
|
1035
|
+
return self._name
|
|
1036
|
+
|
|
1037
|
+
@property
|
|
1038
|
+
def runtime(self) -> Any:
|
|
1039
|
+
"""The Simulo runtime this app's jobs execute in (the default unless picked)."""
|
|
1040
|
+
return self._runtime
|
|
1041
|
+
|
|
1042
|
+
@property
|
|
1043
|
+
def mounts(self) -> Mapping[str, Mount]:
|
|
1044
|
+
"""The assets/volumes mounted into every job of this app, by mount name (a copy)."""
|
|
1045
|
+
return dict(self._mounts)
|
|
1046
|
+
|
|
1047
|
+
@property
|
|
1048
|
+
def jobs(self) -> Mapping[str, JobSpec]:
|
|
1049
|
+
"""The registered job specs, by job name (a copy)."""
|
|
1050
|
+
return dict(self._jobs)
|
|
1051
|
+
|
|
1052
|
+
@property
|
|
1053
|
+
def entrypoint_names(self) -> tuple[str, ...]:
|
|
1054
|
+
"""Names of the functions registered with ``@app.entrypoint``."""
|
|
1055
|
+
return tuple(self._entrypoints)
|
|
1056
|
+
|
|
1057
|
+
@overload
|
|
1058
|
+
def job(self, fn: F) -> JobFunction: ...
|
|
1059
|
+
|
|
1060
|
+
@overload
|
|
1061
|
+
def job(
|
|
1062
|
+
self,
|
|
1063
|
+
*,
|
|
1064
|
+
callbacks: Sequence[JobCallbackProtocol] = ...,
|
|
1065
|
+
resume: ResumePolicy = ...,
|
|
1066
|
+
system: Optional[SystemType] = ...,
|
|
1067
|
+
timeout: Optional[int] = ...,
|
|
1068
|
+
retries: int = ...,
|
|
1069
|
+
**extra: Any,
|
|
1070
|
+
) -> Callable[[F], JobFunction]: ...
|
|
1071
|
+
|
|
1072
|
+
def job(
|
|
1073
|
+
self,
|
|
1074
|
+
fn: Optional[F] = None,
|
|
1075
|
+
*,
|
|
1076
|
+
callbacks: Sequence[JobCallbackProtocol] = (),
|
|
1077
|
+
resume: ResumePolicy = ResumePolicy.AUTO,
|
|
1078
|
+
system: Optional[SystemType] = None,
|
|
1079
|
+
timeout: Optional[int] = None,
|
|
1080
|
+
retries: int = 0,
|
|
1081
|
+
**extra: Any,
|
|
1082
|
+
) -> Union[JobFunction, Callable[[F], JobFunction]]:
|
|
1083
|
+
"""Register a function as a job of this app. Records metadata; never runs the body.
|
|
1084
|
+
|
|
1085
|
+
Usable bare (``@app.job``) or with options
|
|
1086
|
+
(``@app.job(system=simulo.SystemType.TIER_1)``).
|
|
1087
|
+
Returns a :class:`JobFunction` wrapping the original function — calling
|
|
1088
|
+
it still runs the body in-process; ``.spawn()`` / ``.submit()`` submit it
|
|
1089
|
+
to the platform instead.
|
|
1090
|
+
|
|
1091
|
+
Args:
|
|
1092
|
+
fn: The function being decorated (bare-decorator form only — never
|
|
1093
|
+
pass it alongside keyword options).
|
|
1094
|
+
callbacks: Lifecycle callbacks from :mod:`simulo.callbacks` (e.g.
|
|
1095
|
+
``ResumableCheckpoint``), recorded into the package manifest and
|
|
1096
|
+
honored at execution.
|
|
1097
|
+
resume: How the job resumes from a discovered checkpoint
|
|
1098
|
+
(:class:`~simulo.interfaces.platform.enums.ResumePolicy`,
|
|
1099
|
+
default ``AUTO``).
|
|
1100
|
+
system: The GPU system tier to request, as a
|
|
1101
|
+
:class:`simulo.SystemType` member (e.g.
|
|
1102
|
+
``simulo.SystemType.TIER_1``), recorded in the job's resource
|
|
1103
|
+
spec as ``resources["system"]`` (the tier's wire value, e.g.
|
|
1104
|
+
``"tier1"``). Validated HERE, at decoration time, against the
|
|
1105
|
+
published catalog: a value that is not a ``SystemType`` member
|
|
1106
|
+
(a raw string such as ``"tier1"`` included) raises
|
|
1107
|
+
``TypeError``, and a tier the platform cannot provision yet
|
|
1108
|
+
raises ``ValueError``. ``simulo systems`` lists every tier and
|
|
1109
|
+
which are available today. The selection is recorded on the
|
|
1110
|
+
job; it does not yet choose hardware.
|
|
1111
|
+
timeout: Wall-clock limit for one managed execution, in seconds
|
|
1112
|
+
(a positive integer; validated HERE, at decoration time — a
|
|
1113
|
+
zero, negative, or non-integer value raises ``ValueError``
|
|
1114
|
+
immediately rather than failing later at run time on the
|
|
1115
|
+
worker). Enforced by the platform worker: a job still
|
|
1116
|
+
running at the limit is killed and reported ``failed`` with
|
|
1117
|
+
reason ``timeout``. When omitted (``None``, the default),
|
|
1118
|
+
the platform ceiling applies (1 hour by default); a declared
|
|
1119
|
+
value larger than the ceiling is clamped down to it. The
|
|
1120
|
+
ceiling is an operator knob on the worker
|
|
1121
|
+
(``MAX_JOB_TIMEOUT_S=<seconds>``) for long unattended
|
|
1122
|
+
training.
|
|
1123
|
+
retries: Automatic re-attempts after a failure (default 0). All
|
|
1124
|
+
attempts share one ``timeout`` budget.
|
|
1125
|
+
**extra: Additional resource requests, recorded verbatim in the
|
|
1126
|
+
job's resource spec. Three names are RESERVED and rejected —
|
|
1127
|
+
``produces``, ``consumes`` and ``gpu`` (see Raises below);
|
|
1128
|
+
everything else passes through to the resource spec unchanged.
|
|
1129
|
+
|
|
1130
|
+
Raises:
|
|
1131
|
+
ValueError: ``timeout`` is not ``None`` and is not a positive
|
|
1132
|
+
integer. ``True`` and ``False`` are not accepted as timeouts.
|
|
1133
|
+
Also raised when ``system`` names a tier the platform cannot
|
|
1134
|
+
provision yet (``SYSTEM_SPECS[system].available`` is false).
|
|
1135
|
+
TypeError: ``produces=`` or ``consumes=`` was passed. Outputs are
|
|
1136
|
+
not declared on the decorator. Callbacks such as
|
|
1137
|
+
``ResumableCheckpoint`` already declare their supported
|
|
1138
|
+
outputs. Return small JSON values from the job, or use a named
|
|
1139
|
+
volume for files another job needs. ``consumes=`` is reserved;
|
|
1140
|
+
continue from a checkpoint with
|
|
1141
|
+
``simulo run --from <job>[:latest|:best]`` instead.
|
|
1142
|
+
Also raised for ``gpu=`` (the free-text GPU request this
|
|
1143
|
+
parameter replaced; it would otherwise fall through
|
|
1144
|
+
``**extra`` into the resource spec unvalidated) and for a
|
|
1145
|
+
``system`` value that is not a :class:`simulo.SystemType`
|
|
1146
|
+
member.
|
|
1147
|
+
"""
|
|
1148
|
+
if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, int) or timeout < 1):
|
|
1149
|
+
raise ValueError(
|
|
1150
|
+
"@app.job(timeout=...) must be a positive integer (seconds), or omitted/None to "
|
|
1151
|
+
f"use the platform ceiling — got {timeout!r}."
|
|
1152
|
+
)
|
|
1153
|
+
if "produces" in extra:
|
|
1154
|
+
raise TypeError(
|
|
1155
|
+
"@app.job(produces=...) is not supported. Callbacks such as "
|
|
1156
|
+
"ResumableCheckpoint and DebugOnAnomaly already declare their supported outputs. "
|
|
1157
|
+
"Return small JSON values from the job, or use a named Volume for files another "
|
|
1158
|
+
"job needs. Remove produces=."
|
|
1159
|
+
)
|
|
1160
|
+
if "consumes" in extra:
|
|
1161
|
+
raise TypeError(
|
|
1162
|
+
"@app.job(consumes=...) is reserved for a future input declaration and does nothing "
|
|
1163
|
+
"today. To chain jobs, run with `simulo run --from <job>[:latest|:best]`. Remove "
|
|
1164
|
+
"consumes=."
|
|
1165
|
+
)
|
|
1166
|
+
# ``gpu`` was a real named parameter until the system-tier catalog
|
|
1167
|
+
# replaced it (#1362). Removing it from the signature alone does NOT
|
|
1168
|
+
# reject ``gpu="L4"``: CPython routes the unknown keyword into
|
|
1169
|
+
# ``**extra`` and it would land in the resource spec exactly as before,
|
|
1170
|
+
# unvalidated. This guard is what makes the rename a rejection.
|
|
1171
|
+
if "gpu" in extra:
|
|
1172
|
+
raise TypeError(
|
|
1173
|
+
"@app.job(gpu=...) is no longer supported. Request a GPU system tier instead: "
|
|
1174
|
+
"@app.job(system=simulo.SystemType.TIER_1). Run `simulo systems` to list the "
|
|
1175
|
+
"tiers and which are available today. Remove gpu=."
|
|
1176
|
+
)
|
|
1177
|
+
if system is not None:
|
|
1178
|
+
# Static lookups against the published catalog — no fleet state
|
|
1179
|
+
# is consulted. A raw string is refused even when it spells a
|
|
1180
|
+
# valid wire value, so the one accepted spelling is the enum.
|
|
1181
|
+
if not isinstance(system, SystemType):
|
|
1182
|
+
raise TypeError(
|
|
1183
|
+
"@app.job(system=...) must be a simulo.SystemType member, e.g. "
|
|
1184
|
+
f"system=simulo.SystemType.TIER_1; got {system!r}. Run `simulo systems` to "
|
|
1185
|
+
"list the tiers."
|
|
1186
|
+
)
|
|
1187
|
+
spec = SYSTEM_SPECS[system]
|
|
1188
|
+
if not spec.available:
|
|
1189
|
+
available = ", ".join(
|
|
1190
|
+
f"SystemType.{tier.name} ({entry.gpu_model})"
|
|
1191
|
+
for tier, entry in SYSTEM_SPECS.items()
|
|
1192
|
+
if entry.available
|
|
1193
|
+
)
|
|
1194
|
+
raise ValueError(
|
|
1195
|
+
f"@app.job(system=SystemType.{system.name}) ({spec.gpu_model}) is not available on "
|
|
1196
|
+
f"the platform yet. Available today: {available}. Run `simulo systems` for the "
|
|
1197
|
+
"full catalog."
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
def decorate(func: F) -> JobFunction:
|
|
1201
|
+
resources: dict[str, Any] = {}
|
|
1202
|
+
if system is not None:
|
|
1203
|
+
resources["system"] = system.value
|
|
1204
|
+
resources.update(extra)
|
|
1205
|
+
spec = JobSpec(
|
|
1206
|
+
name=func.__name__,
|
|
1207
|
+
module=func.__module__,
|
|
1208
|
+
qualname=func.__qualname__,
|
|
1209
|
+
fn=func,
|
|
1210
|
+
resources=resources,
|
|
1211
|
+
timeout=timeout,
|
|
1212
|
+
retries=retries,
|
|
1213
|
+
resume=resume,
|
|
1214
|
+
callbacks=tuple(callbacks),
|
|
1215
|
+
mounts=dict(self._mounts),
|
|
1216
|
+
)
|
|
1217
|
+
self._jobs[spec.name] = spec
|
|
1218
|
+
return JobFunction(self, spec)
|
|
1219
|
+
|
|
1220
|
+
if fn is not None:
|
|
1221
|
+
return decorate(fn)
|
|
1222
|
+
return decorate
|
|
1223
|
+
|
|
1224
|
+
def entrypoint(self, fn: F) -> F:
|
|
1225
|
+
"""Register an entrypoint; redirect to the CLI if the file is ``python``-ed.
|
|
1226
|
+
|
|
1227
|
+
Returns ``fn`` unchanged. ``simulo run app.py [--flags]`` — the canonical
|
|
1228
|
+
(and only) submit command — imports the file as a NON-``__main__`` module
|
|
1229
|
+
and invokes the registered entrypoint explicitly (mapping ``--flag value``
|
|
1230
|
+
onto the entrypoint's parameters). An entrypoint is OPTIONAL: without
|
|
1231
|
+
one, ``simulo run`` maps the flags onto the job's own signature
|
|
1232
|
+
(``--job NAME`` chooses among several); register one only to
|
|
1233
|
+
orchestrate multiple submits yourself.
|
|
1234
|
+
|
|
1235
|
+
When the decorator is applied from a directly-run ``python app.py`` —
|
|
1236
|
+
detected by frame-walking to the first frame outside this module and
|
|
1237
|
+
checking that its ``__name__ == "__main__"`` with a real ``__file__`` —
|
|
1238
|
+
nothing is submitted: the exact equivalent ``simulo run`` command (rebuilt
|
|
1239
|
+
verbatim from ``sys.argv``) is printed to stderr and the process exits 2.
|
|
1240
|
+
The ``SystemExit`` fires at DECORATION time, mid-import, so a file ending
|
|
1241
|
+
in ``if __name__ == "__main__": main()`` never reaches ``main()`` — an
|
|
1242
|
+
at-exit-only guard would let ``main()`` run and, with saved credentials,
|
|
1243
|
+
actually submit a cloud job. Gating on the *calling frame* rather than
|
|
1244
|
+
``fn.__module__`` means the redirect fires even when the entrypoint
|
|
1245
|
+
function is imported from another module. TYPED Jupyter/REPL cells
|
|
1246
|
+
(``__name__ == "__main__"`` but no ``__file__``) never trigger it;
|
|
1247
|
+
hosts that run the file under the ``__main__`` name with a real
|
|
1248
|
+
``__file__`` — ``runpy.run_path``, IPython's ``%run`` — DO see the
|
|
1249
|
+
redirect, and that is safe by mechanism: this path raises
|
|
1250
|
+
``SystemExit`` (which such hosts catch and display) rather than
|
|
1251
|
+
hard-exiting the host process.
|
|
1252
|
+
"""
|
|
1253
|
+
self._entrypoints[fn.__name__] = fn
|
|
1254
|
+
if _is_direct_script_run():
|
|
1255
|
+
print(_cli_redirect_message(), file=sys.stderr)
|
|
1256
|
+
raise SystemExit(2)
|
|
1257
|
+
return fn
|
|
1258
|
+
|
|
1259
|
+
def resolve_job(self, name: str) -> Callable[..., object]:
|
|
1260
|
+
"""Return the underlying body for a registered job.
|
|
1261
|
+
|
|
1262
|
+
Used by the backend runner to execute the job — and a PUBLIC path for
|
|
1263
|
+
running a job body in-process (``app.resolve_job("work")(x=7)``), so it
|
|
1264
|
+
counts as deliberate work for the at-exit guard exactly like calling
|
|
1265
|
+
the :class:`JobFunction` wrapper does.
|
|
1266
|
+
"""
|
|
1267
|
+
global _ANY_JOB_INVOKED
|
|
1268
|
+
_ANY_JOB_INVOKED = True
|
|
1269
|
+
return self._jobs[name].fn
|
|
1270
|
+
|
|
1271
|
+
def resolve_job_function(self, name: str) -> "JobFunction":
|
|
1272
|
+
"""Return a :class:`JobFunction` for a registered job (so it can ``spawn``).
|
|
1273
|
+
|
|
1274
|
+
Used by ``simulo run`` to submit a file's sole ``@app.job`` when it
|
|
1275
|
+
declares no ``@app.entrypoint``.
|
|
1276
|
+
"""
|
|
1277
|
+
return JobFunction(self, self._jobs[name])
|
|
1278
|
+
|
|
1279
|
+
def resolve_entrypoint(self, name: str) -> Callable[..., object]:
|
|
1280
|
+
"""Return a registered ``@app.entrypoint`` callable by name.
|
|
1281
|
+
|
|
1282
|
+
Used by ``simulo run`` to invoke the entrypoint in-process; the
|
|
1283
|
+
entrypoint's own ``spawn()``/``submit()`` calls then write the package.
|
|
1284
|
+
"""
|
|
1285
|
+
return self._entrypoints[name]
|
|
1286
|
+
|
|
1287
|
+
def __getattr__(self, name: str) -> Any:
|
|
1288
|
+
removed = _REMOVED_APP_ATTRS.get(name)
|
|
1289
|
+
if removed is not None:
|
|
1290
|
+
raise AttributeError(removed)
|
|
1291
|
+
raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
# The pre-0.15 names, removed outright (full replacement — no alias, no
|
|
1295
|
+
# deprecation period). ``App.__getattr__`` fires only when normal lookup
|
|
1296
|
+
# fails, so this costs nothing on the live surface; it exists so a user
|
|
1297
|
+
# upgrading from simulo 0.14.x gets an actionable error naming the replacement
|
|
1298
|
+
# instead of a bare "'App' object has no attribute 'local_entrypoint'".
|
|
1299
|
+
# Module-level and never mutated (not a class attribute — keeps it out of the
|
|
1300
|
+
# class namespace and away from mutable-class-attribute pitfalls).
|
|
1301
|
+
_REMOVED_APP_ATTRS: Mapping[str, str] = {
|
|
1302
|
+
"local_entrypoint": (
|
|
1303
|
+
"@app.local_entrypoint was replaced by @app.entrypoint in simulo 0.15 "
|
|
1304
|
+
"— rename the decorator; its behavior is unchanged."
|
|
1305
|
+
),
|
|
1306
|
+
"local_entrypoint_names": "App.local_entrypoint_names was renamed to App.entrypoint_names in simulo 0.15.",
|
|
1307
|
+
"resolve_local_entrypoint": "App.resolve_local_entrypoint was renamed to App.resolve_entrypoint in simulo 0.15.",
|
|
1308
|
+
}
|