simulo 0.26.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. simulo/__init__.py +433 -0
  2. simulo/_client/__init__.py +6 -0
  3. simulo/_client/_entrypoint.py +313 -0
  4. simulo/_client/_mounts.py +25 -0
  5. simulo/_client/_runner.py +186 -0
  6. simulo/_client/_secure_downloads.py +1181 -0
  7. simulo/_client/app.py +1308 -0
  8. simulo/_client/asset.py +331 -0
  9. simulo/_client/asset_api.py +517 -0
  10. simulo/_client/asset_package.py +1103 -0
  11. simulo/_client/asset_pins.py +187 -0
  12. simulo/_client/builtin_aliases.py +107 -0
  13. simulo/_client/bundle.py +254 -0
  14. simulo/_client/cancel_api.py +104 -0
  15. simulo/_client/cli.py +9063 -0
  16. simulo/_client/config.py +186 -0
  17. simulo/_client/credentials.py +210 -0
  18. simulo/_client/discovery.py +214 -0
  19. simulo/_client/export_api.py +212 -0
  20. simulo/_client/export_bundle.py +296 -0
  21. simulo/_client/facades.py +581 -0
  22. simulo/_client/http.py +414 -0
  23. simulo/_client/identity_api.py +117 -0
  24. simulo/_client/install_samples.py +267 -0
  25. simulo/_client/jobs_api.py +224 -0
  26. simulo/_client/learning.py +393 -0
  27. simulo/_client/login.py +319 -0
  28. simulo/_client/mode.py +29 -0
  29. simulo/_client/outputs.py +116 -0
  30. simulo/_client/packaging.py +445 -0
  31. simulo/_client/preflight_api.py +186 -0
  32. simulo/_client/preflight_render.py +200 -0
  33. simulo/_client/registry.py +98 -0
  34. simulo/_client/runtime.py +185 -0
  35. simulo/_client/runtime_display.py +90 -0
  36. simulo/_client/seed_ref.py +76 -0
  37. simulo/_client/stub.py +41 -0
  38. simulo/_client/submit_api.py +1057 -0
  39. simulo/_client/templates/__init__.py +21 -0
  40. simulo/_client/templates/inference/app.py.tmpl +316 -0
  41. simulo/_client/templates/inference/simuloignore.tmpl +30 -0
  42. simulo/_client/templates/scenario/app.py.tmpl +93 -0
  43. simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
  44. simulo/_client/templates/training/app.py.tmpl +235 -0
  45. simulo/_client/templates/training/simuloignore.tmpl +29 -0
  46. simulo/_client/view_fragment.py +21 -0
  47. simulo/_client/view_session_api.py +122 -0
  48. simulo/_client/volume.py +71 -0
  49. simulo/callbacks.py +274 -0
  50. simulo/py.typed +0 -0
  51. simulo-0.26.0.dist-info/METADATA +130 -0
  52. simulo-0.26.0.dist-info/RECORD +55 -0
  53. simulo-0.26.0.dist-info/WHEEL +5 -0
  54. simulo-0.26.0.dist-info/entry_points.txt +2 -0
  55. simulo-0.26.0.dist-info/top_level.txt +1 -0
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
+ }