simulo 0.12.0__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {simulo-0.12.0/src/simulo.egg-info → simulo-0.13.0}/PKG-INFO +22 -5
  2. {simulo-0.12.0 → simulo-0.13.0}/PYPI.md +17 -3
  3. {simulo-0.12.0 → simulo-0.13.0}/pyproject.toml +26 -2
  4. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/app.py +184 -17
  5. simulo-0.13.0/src/simulo/_client/asset.py +328 -0
  6. simulo-0.13.0/src/simulo/_client/asset_api.py +591 -0
  7. simulo-0.13.0/src/simulo/_client/asset_package.py +1086 -0
  8. simulo-0.13.0/src/simulo/_client/asset_pins.py +187 -0
  9. simulo-0.13.0/src/simulo/_client/builtin_aliases.py +107 -0
  10. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/bundle.py +37 -13
  11. simulo-0.13.0/src/simulo/_client/cli.py +5011 -0
  12. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/config.py +74 -24
  13. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/http.py +90 -4
  14. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/jobs_api.py +20 -4
  15. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/learning.py +28 -2
  16. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/login.py +28 -5
  17. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/manifest.py +3 -8
  18. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/packaging.py +1 -1
  19. simulo-0.13.0/src/simulo/_client/runtime.py +90 -0
  20. simulo-0.13.0/src/simulo/_client/runtime_display.py +90 -0
  21. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/submit_api.py +110 -0
  22. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/templates/inference/app.py.tmpl +14 -20
  23. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/templates/scenario/app.py.tmpl +7 -6
  24. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/templates/training/app.py.tmpl +14 -14
  25. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/volume.py +20 -1
  26. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/callbacks.py +15 -3
  27. {simulo-0.12.0 → simulo-0.13.0/src/simulo.egg-info}/PKG-INFO +22 -5
  28. {simulo-0.12.0 → simulo-0.13.0}/src/simulo.egg-info/SOURCES.txt +5 -0
  29. {simulo-0.12.0 → simulo-0.13.0}/src/simulo.egg-info/requires.txt +5 -1
  30. simulo-0.12.0/src/simulo/_client/asset.py +0 -119
  31. simulo-0.12.0/src/simulo/_client/cli.py +0 -2364
  32. simulo-0.12.0/src/simulo/_client/runtime.py +0 -189
  33. {simulo-0.12.0 → simulo-0.13.0}/MANIFEST.in +0 -0
  34. {simulo-0.12.0 → simulo-0.13.0}/setup.cfg +0 -0
  35. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/__init__.py +0 -0
  36. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/__init__.py +0 -0
  37. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/_entrypoint.py +0 -0
  38. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/_mounts.py +0 -0
  39. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/_runner.py +0 -0
  40. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/cancel_api.py +0 -0
  41. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/credentials.py +0 -0
  42. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/discovery.py +0 -0
  43. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/mode.py +0 -0
  44. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/registry.py +0 -0
  45. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/seed_ref.py +0 -0
  46. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/stub.py +0 -0
  47. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/templates/__init__.py +0 -0
  48. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/templates/inference/simuloignore.tmpl +0 -0
  49. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/templates/scenario/simuloignore.tmpl +0 -0
  50. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/templates/training/simuloignore.tmpl +0 -0
  51. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/_client/view_session_api.py +0 -0
  52. {simulo-0.12.0 → simulo-0.13.0}/src/simulo/py.typed +0 -0
  53. {simulo-0.12.0 → simulo-0.13.0}/src/simulo.egg-info/dependency_links.txt +0 -0
  54. {simulo-0.12.0 → simulo-0.13.0}/src/simulo.egg-info/entry_points.txt +0 -0
  55. {simulo-0.12.0 → simulo-0.13.0}/src/simulo.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulo
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: Simulo SDK and CLI — define robotics simulation and training apps in Python and run them on the Simulo cloud.
5
5
  Author-email: Simulo Team <team@simulo.ai>
6
6
  License: BSD-3-Clause
@@ -15,8 +15,11 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Typing :: Typed
16
16
  Requires-Python: >=3.11
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: simulo-interfaces<0.5,>=0.4
18
+ Requires-Dist: simulo-interfaces<0.6,>=0.5
19
19
  Requires-Dist: mcap<2,>=1.3
20
+ Requires-Dist: defusedxml>=0.7.1
21
+ Provides-Extra: assets
22
+ Requires-Dist: usd-core<27,>=25.5; extra == "assets"
20
23
  Provides-Extra: dev
21
24
  Requires-Dist: black>=24.0; extra == "dev"
22
25
  Requires-Dist: isort>=5.13; extra == "dev"
@@ -52,7 +55,7 @@ The fastest way to start: scaffold a runnable app.
52
55
 
53
56
  ```bash
54
57
  simulo create mybot
55
- # Created mybot/app.py and mybot/.simuloignore (training app).
58
+ # Created training app in mybot/ (app.py + .simuloignore).
56
59
  cd mybot
57
60
  ```
58
61
 
@@ -69,8 +72,7 @@ run it.
69
72
  # app.py
70
73
  import simulo
71
74
 
72
- runtime = simulo.Runtime.from_registry("simulo/python-cpu:3.11")
73
- app = simulo.App("hello", runtime=runtime)
75
+ app = simulo.App("hello")
74
76
 
75
77
 
76
78
  @app.job(timeout=5 * 60)
@@ -100,4 +102,19 @@ no-op. To continue training from an earlier job's checkpoint instead, pass
100
102
  `--from <job-ref>[:best|:latest]`. Job ids, and their unambiguous prefixes (`>=4` characters),
101
103
  work anywhere the CLI takes a job id.
102
104
 
105
+ ## Monitor jobs and retrieve results
106
+
107
+ ```bash
108
+ simulo jobs # your jobs and their statuses
109
+ simulo logs --follow # stream the latest job's logs live
110
+ simulo result # the completed job's result JSON
111
+ simulo models # list / download trained checkpoints (sha256-verified)
112
+ simulo recordings # download MCAP flight recordings
113
+ simulo view # watch the live 3D scene (jobs submitted with --viewstream)
114
+ simulo cancel # cancel a queued or running job
115
+ ```
116
+
117
+ Commands that take a job id default to the latest job, announcing what they
118
+ resolved to on stderr.
119
+
103
120
  Learn more at [simulo.ai](https://simulo.ai).
@@ -20,7 +20,7 @@ The fastest way to start: scaffold a runnable app.
20
20
 
21
21
  ```bash
22
22
  simulo create mybot
23
- # Created mybot/app.py and mybot/.simuloignore (training app).
23
+ # Created training app in mybot/ (app.py + .simuloignore).
24
24
  cd mybot
25
25
  ```
26
26
 
@@ -37,8 +37,7 @@ run it.
37
37
  # app.py
38
38
  import simulo
39
39
 
40
- runtime = simulo.Runtime.from_registry("simulo/python-cpu:3.11")
41
- app = simulo.App("hello", runtime=runtime)
40
+ app = simulo.App("hello")
42
41
 
43
42
 
44
43
  @app.job(timeout=5 * 60)
@@ -68,4 +67,19 @@ no-op. To continue training from an earlier job's checkpoint instead, pass
68
67
  `--from <job-ref>[:best|:latest]`. Job ids, and their unambiguous prefixes (`>=4` characters),
69
68
  work anywhere the CLI takes a job id.
70
69
 
70
+ ## Monitor jobs and retrieve results
71
+
72
+ ```bash
73
+ simulo jobs # your jobs and their statuses
74
+ simulo logs --follow # stream the latest job's logs live
75
+ simulo result # the completed job's result JSON
76
+ simulo models # list / download trained checkpoints (sha256-verified)
77
+ simulo recordings # download MCAP flight recordings
78
+ simulo view # watch the live 3D scene (jobs submitted with --viewstream)
79
+ simulo cancel # cancel a queued or running job
80
+ ```
81
+
82
+ Commands that take a job id default to the latest job, announcing what they
83
+ resolved to on stderr.
84
+
71
85
  Learn more at [simulo.ai](https://simulo.ai).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulo"
7
- version = "0.12.0"
7
+ version = "0.13.0"
8
8
  description = "Simulo SDK and CLI — define robotics simulation and training apps in Python and run them on the Simulo cloud."
9
9
  readme = "PYPI.md"
10
10
  requires-python = ">=3.11"
@@ -25,9 +25,32 @@ classifiers = [
25
25
  # guard, resolved only at job-execution time — never in this distribution.
26
26
  # mcap: used by `simulo recordings` to read back a downloaded recording and
27
27
  # verify it (message count) — MIT-licensed, deps only lz4+zstandard.
28
- dependencies = ["simulo-interfaces>=0.4,<0.5", "mcap>=1.3,<2"]
28
+ dependencies = [
29
+ "simulo-interfaces>=0.5,<0.6",
30
+ "mcap>=1.3,<2",
31
+ # Hardened XML parsing for URDF ingestion (USD Asset Catalogs, PR-12 fix
32
+ # loop 2, security NIT): stdlib xml.etree.ElementTree relies on
33
+ # libexpat's own (practical-but-not-contractual) entity-expansion
34
+ # defaults to stay XXE/billion-laughs-safe; defusedxml pins that
35
+ # protection explicitly. Pure Python, zero transitive dependencies,
36
+ # ~25 KB — genuinely "cheap": adding it does not compromise the
37
+ # featherweight/no-USD-extra promise below (that promise is about
38
+ # avoiding usd-core's tens of MB, not about being literally
39
+ # zero-dependency).
40
+ "defusedxml>=0.7.1",
41
+ ]
29
42
 
30
43
  [project.optional-dependencies]
44
+ # `assets` — the FIRST user-facing extras group: USD package inspection for
45
+ # `simulo asset publish`/`validate` of USD trees (dependency closure + entry
46
+ # detection via pxr.UsdUtils). usd-core is simulator-free but tens of MB, so
47
+ # it must never tax users who don't publish USD: the import is lazy, ONLY in
48
+ # the USD inspection path (`_client/asset_package.py::_load_pxr_usdutils`),
49
+ # and a missing extra fails with the copyable `pip install "simulo[assets]"`.
50
+ # URDF publishing needs no OPT-IN extra (defusedxml + path math, both tiny
51
+ # always-installed base dependencies above — see that dependency's own
52
+ # comment).
53
+ assets = ["usd-core>=25.5,<27"]
31
54
  dev = ["black>=24.0", "isort>=5.13", "ruff>=0.5", "mypy>=1.10", "pytest>=8.2", "build>=1.2", "twine>=5.1"]
32
55
  # `dev` carries build/twine but deliberately not commitizen; `release` below
33
56
  # adds commitizen for cutting version bumps without a second build/twine copy.
@@ -71,6 +94,7 @@ addopts = "--import-mode=importlib"
71
94
  markers = [
72
95
  "isaac: requires NVIDIA Isaac Sim / IsaacLab (skipped on dev machines and in PR CI)",
73
96
  "gpu: requires a CUDA GPU (skipped on dev machines and in PR CI)",
97
+ "requires_execution_backend: end-to-end test that needs a live execution backend in the loop; skipped in CPU/unit runs",
74
98
  ]
75
99
 
76
100
  [tool.isort]
@@ -34,6 +34,7 @@ from typing import Any, Callable, Iterator, Mapping, Optional, Sequence, TypeVar
34
34
 
35
35
  from simulo._client import config, credentials, registry
36
36
  from simulo._client._runner import JobFailedError, JobHandle, JobResultUnavailable
37
+ from simulo._client.runtime import DEFAULT_RUNTIME
37
38
  from simulo.interfaces.ids import JobId
38
39
  from simulo.interfaces.platform import JobCallbackProtocol, Mount
39
40
  from simulo.interfaces.platform.enums import ResumePolicy
@@ -135,6 +136,44 @@ def seed_from_requested(seed_ref: Optional[str]) -> Iterator[None]:
135
136
  _SEED_FROM_REF = previous
136
137
 
137
138
 
139
+ # ---------------------------------------------------------------------------
140
+ # Asset-pin ambient signals — `simulo run --frozen` / `--strict-assets` scope
141
+ # these for one submit, exactly like `--viewstream` / `--from` above and for the
142
+ # same "the spawn() is deep inside the user's @app.local_entrypoint" reason. They
143
+ # govern submit-time asset pin RESOLUTION (§6), which — like viewstream/seed —
144
+ # happens only on the cloud path (a local-disk submit has no control plane to
145
+ # resolve against) and NEVER touches `args` or the manifest, so they can't move
146
+ # `package_id`'s sha256.
147
+ # ---------------------------------------------------------------------------
148
+
149
+ _FROZEN_ASSETS_REQUESTED = False
150
+ _STRICT_ASSETS_REQUESTED = False
151
+
152
+
153
+ @contextlib.contextmanager
154
+ def frozen_assets_requested(enabled: bool) -> Iterator[None]:
155
+ """Scope ``--frozen`` (fail on any unpinned asset ref) for one submit call."""
156
+ global _FROZEN_ASSETS_REQUESTED
157
+ previous = _FROZEN_ASSETS_REQUESTED
158
+ _FROZEN_ASSETS_REQUESTED = enabled
159
+ try:
160
+ yield
161
+ finally:
162
+ _FROZEN_ASSETS_REQUESTED = previous
163
+
164
+
165
+ @contextlib.contextmanager
166
+ def strict_assets_requested(enabled: bool) -> Iterator[None]:
167
+ """Scope ``--strict-assets`` (asset warnings become errors) for one submit call."""
168
+ global _STRICT_ASSETS_REQUESTED
169
+ previous = _STRICT_ASSETS_REQUESTED
170
+ _STRICT_ASSETS_REQUESTED = enabled
171
+ try:
172
+ yield
173
+ finally:
174
+ _STRICT_ASSETS_REQUESTED = previous
175
+
176
+
138
177
  def _require_no_seed_for_local_submit(reason: str) -> None:
139
178
  """Refuse a local-disk submit that carries an ambient ``--from`` seed."""
140
179
  if _SEED_FROM_REF is None:
@@ -210,6 +249,8 @@ def _spawn_cloud(
210
249
  *,
211
250
  viewstream: bool = False,
212
251
  seed_from: Optional[str] = None,
252
+ frozen_assets: bool = False,
253
+ strict_assets: bool = False,
213
254
  ) -> JobHandle:
214
255
  """Tar the bundle, upload it, create the job, and return a cloud JobHandle.
215
256
 
@@ -227,7 +268,16 @@ def _spawn_cloud(
227
268
  full source-job UUID — the confirmation that a typed prefix resolved to
228
269
  the job the user meant); when an older server ignores it, ``create_job``
229
270
  raises rather than let a fresh run masquerade as a seeded one.
271
+
272
+ Registry-asset pins (§6) follow the SAME sibling-of-``args`` rule. Every
273
+ ``Asset.from_registry`` ref the app mounted is resolved to an exact
274
+ version in one batch BEFORE the package is uploaded — so a bad/missing/
275
+ unpinned ref fails in seconds rather than after the upload — the
276
+ ``Resolved assets:`` block is printed, ``--frozen`` / ``--strict-assets``
277
+ are enforced, and the resolved ``[{ref, digest}]`` pins ride the
278
+ job-create call's top-level ``assets`` field, never ``args``.
230
279
  """
280
+ from simulo._client.asset_pins import DEFAULT_ASSET_RUNTIME, collect_asset_refs, resolve_and_pin_assets
231
281
  from simulo._client.packaging import MANIFEST_FILENAME, ensure_tar
232
282
  from simulo._client.submit_api import SubmitApiClient
233
283
 
@@ -261,6 +311,18 @@ def _spawn_cloud(
261
311
  token = creds["access_token"] if creds else os.environ.get("SIMULO_API_TOKEN")
262
312
  client = SubmitApiClient(base_url, token=token)
263
313
 
314
+ # Resolve every mounted registry-asset ref to an exact pin FIRST — before the
315
+ # package is uploaded — so a missing/unpinned/unvalidated ref fails in seconds
316
+ # (§6). Returns the top-level `assets: [{ref, digest}]` for create_job; `[]`
317
+ # when the app mounts no registry assets (no resolve call is made then).
318
+ asset_pins = resolve_and_pin_assets(
319
+ client,
320
+ collect_asset_refs(manifest),
321
+ runtime=DEFAULT_ASSET_RUNTIME,
322
+ frozen=frozen_assets,
323
+ strict=strict_assets,
324
+ )
325
+
264
326
  client.create_package(
265
327
  package_id=package_id,
266
328
  source_digest=source_digest,
@@ -275,6 +337,7 @@ def _spawn_cloud(
275
337
  args=args,
276
338
  viewstream=viewstream,
277
339
  seed_from_job_id=seed_from,
340
+ assets=asset_pins,
278
341
  )
279
342
 
280
343
  if seed_from is not None:
@@ -291,7 +354,13 @@ def _spawn_cloud(
291
354
 
292
355
 
293
356
  class JobFunction:
294
- """Proxy returned by ``@app.job`` — superset of ``JobFunctionProtocol``."""
357
+ """What ``@app.job`` returns: the original function, wrapped for submission.
358
+
359
+ Calling it runs the body in-process, exactly as if it were undecorated (that
360
+ is also how the platform invokes it on a worker). :meth:`spawn` and
361
+ :meth:`submit` package and submit it instead. You never construct one
362
+ yourself — decorate a function and use it from your ``@app.local_entrypoint``.
363
+ """
295
364
 
296
365
  def __init__(self, app: "App", spec: JobSpec) -> None:
297
366
  self._app = app
@@ -307,32 +376,47 @@ class JobFunction:
307
376
  return self._spec.fn(*args, **kwargs)
308
377
 
309
378
  def spawn(self, **kwargs: Any) -> JobHandle:
310
- """Submit this job: write the package to disk, then submit it.
379
+ """Submit this job to the Simulo cloud (once logged in), or write it locally.
311
380
 
312
381
  Submitting writes the manifest + source bundle (memoised once per app),
313
- recording this job's name and ``kwargs`` into the manifest so the executor
314
- can invoke ``fn(**kwargs)`` later.
382
+ recording this job's name and ``kwargs`` into the manifest so the job can
383
+ be run later with ``fn(**kwargs)``.
315
384
 
316
- **Cloud mode** — credentials exist, or ``SIMULO_API_URL``/``SIMULO_ENV``
317
- names a target (and ``SIMULO_SUBMIT`` is not ``local``): the package is
318
- tarred, uploaded, and a job is created on the control plane. The
319
- returned :class:`JobHandle` carries the SERVER-assigned ``job_id`` and
320
- ``.get()`` streams the real result.
385
+ **Cloud mode** — credentials exist (``simulo login``), or
386
+ ``SIMULO_API_URL``/``SIMULO_ENV`` names a target (and ``SIMULO_SUBMIT`` is
387
+ not ``local``): the package is tarred, uploaded, and a job is created on
388
+ the Simulo cloud. The returned :class:`JobHandle` carries the
389
+ SERVER-assigned ``job_id`` and ``.get()`` streams the real result.
321
390
 
322
391
  **Local-disk mode** (the default with no cloud signal present): NEVER
323
- trains and NEVER invokes the backend — the handle exposes only
324
- ``.package_path`` / ``.job_id`` (a locally-derived id); execution is a
325
- separate step (``simulo-backend run-package <package> --job <name>``).
392
+ trains and never runs the job — the handle exposes only
393
+ ``.package_path`` / ``.job_id`` (a locally-derived id), and ``.get()``
394
+ raises, pointing you at ``simulo login`` to submit to the cloud instead.
326
395
 
327
396
  Positional arguments are rejected: a job body receives only the keyword
328
397
  arguments recorded in the manifest.
329
398
  """
330
399
  # Lazy import keeps app.py free of a module-scope dependency on the
331
400
  # packager (and any import cycle); spawn is a runtime call.
401
+ from simulo._client.asset import current_captured_assets
332
402
  from simulo._client.bundle import ensure_bundle
333
403
 
334
404
  app_file, project_root = _app_source_location(self._spec.module)
335
- package_path = ensure_bundle(self._app, app_file, project_root, job_name=self._spec.name, args=dict(kwargs))
405
+ # Construction-capture (freeze doc "PR-9 amendments"): every Asset
406
+ # constructed so far in the active `simulo run` capture scope (armed
407
+ # around the whole discovery import + entrypoint run — see cli.py) is
408
+ # unioned into this bundle's `resources`, even when never explicitly
409
+ # mounted via App(mounts=...) — an unmounted §7.1 handle
410
+ # (`forklift = simulo.Asset.from_registry(...)`, used only via
411
+ # `Robot(asset=forklift)`) must still get resolved and pinned.
412
+ package_path = ensure_bundle(
413
+ self._app,
414
+ app_file,
415
+ project_root,
416
+ job_name=self._spec.name,
417
+ args=dict(kwargs),
418
+ extra_assets=current_captured_assets(),
419
+ )
336
420
 
337
421
  # SIMULO_SUBMIT=local is checked FIRST, before touching credentials at
338
422
  # all — the escape hatch must short-circuit even when the saved token
@@ -351,6 +435,8 @@ class JobFunction:
351
435
  creds,
352
436
  viewstream=_VIEWSTREAM_REQUESTED,
353
437
  seed_from=_SEED_FROM_REF,
438
+ frozen_assets=_FROZEN_ASSETS_REQUESTED,
439
+ strict_assets=_STRICT_ASSETS_REQUESTED,
354
440
  )
355
441
  else:
356
442
  _require_no_seed_for_local_submit("no cloud target is configured")
@@ -444,19 +530,50 @@ def _arm_nothing_submitted_hint(app: "App") -> None:
444
530
 
445
531
 
446
532
  class App:
447
- """User-facing job container: ``app = simulo.App(name, runtime=...)``."""
533
+ """The user-facing job container — declare it once, at module scope.
534
+
535
+ An app names your project and (optionally) mounts assets/volumes into
536
+ every job. Its jobs execute in a Simulo-curated runtime —
537
+ :data:`~simulo._client.runtime.DEFAULT_RUNTIME` unless the app picks a
538
+ different Simulo runtime via ``runtime=``. Decorate functions with
539
+ :meth:`job` to make them submittable, and one function with
540
+ :meth:`local_entrypoint` to define what ``simulo run app.py`` does::
541
+
542
+ import simulo
543
+
544
+ app = simulo.App("cartpole")
545
+
546
+ @app.job(gpu="A10G")
547
+ def train(iterations: int = 1000):
548
+ ...
549
+
550
+ @app.local_entrypoint
551
+ def main(iterations: int = 1000):
552
+ train.spawn(iterations=iterations)
553
+
554
+ Args:
555
+ name: Non-empty app name, recorded in the package manifest.
556
+ runtime: Optionally pick a different Simulo runtime for this app's
557
+ jobs (``simulo.Runtime.from_registry("simulo/gpu-rl:2026.06")``).
558
+ Omitted means the platform default.
559
+ mounts: Optional mapping of mount name to :class:`simulo.Asset` /
560
+ :class:`simulo.Volume`, shared by every job of this app.
561
+
562
+ Raises:
563
+ ValueError: if ``name`` is empty or whitespace-only.
564
+ """
448
565
 
449
566
  def __init__(
450
567
  self,
451
568
  name: str,
452
569
  *,
453
- runtime: object,
570
+ runtime: Optional[object] = None,
454
571
  mounts: Optional[Mapping[str, Mount]] = None,
455
572
  ) -> None:
456
573
  if not name or not name.strip():
457
574
  raise ValueError("App name must be a non-empty string.")
458
575
  self._name = name
459
- self._runtime = runtime
576
+ self._runtime = runtime if runtime is not None else DEFAULT_RUNTIME
460
577
  self._mounts: dict[str, Mount] = dict(mounts or {})
461
578
  self._jobs: dict[str, JobSpec] = {}
462
579
  self._local_entrypoints: dict[str, Callable[..., object]] = {}
@@ -467,22 +584,27 @@ class App:
467
584
 
468
585
  @property
469
586
  def name(self) -> str:
587
+ """The app's name, as passed at construction."""
470
588
  return self._name
471
589
 
472
590
  @property
473
591
  def runtime(self) -> Any:
592
+ """The Simulo runtime this app's jobs execute in (the default unless picked)."""
474
593
  return self._runtime
475
594
 
476
595
  @property
477
596
  def mounts(self) -> Mapping[str, Mount]:
597
+ """The assets/volumes mounted into every job of this app, by mount name (a copy)."""
478
598
  return dict(self._mounts)
479
599
 
480
600
  @property
481
601
  def jobs(self) -> Mapping[str, JobSpec]:
602
+ """The registered job specs, by job name (a copy)."""
482
603
  return dict(self._jobs)
483
604
 
484
605
  @property
485
606
  def local_entrypoint_names(self) -> tuple[str, ...]:
607
+ """Names of the functions registered with ``@app.local_entrypoint``."""
486
608
  return tuple(self._local_entrypoints)
487
609
 
488
610
  @overload
@@ -511,7 +633,52 @@ class App:
511
633
  retries: int = 0,
512
634
  **extra: Any,
513
635
  ) -> Union[JobFunction, Callable[[F], JobFunction]]:
514
- """Register a job function. Records metadata; never runs the body."""
636
+ """Register a function as a job of this app. Records metadata; never runs the body.
637
+
638
+ Usable bare (``@app.job``) or with options (``@app.job(gpu="A10G")``).
639
+ Returns a :class:`JobFunction` wrapping the original function — calling
640
+ it still runs the body in-process; ``.spawn()`` / ``.submit()`` submit it
641
+ to the platform instead.
642
+
643
+ Args:
644
+ fn: The function being decorated (bare-decorator form only — never
645
+ pass it alongside keyword options).
646
+ callbacks: Lifecycle callbacks from :mod:`simulo.callbacks` (e.g.
647
+ ``ResumableCheckpoint``), recorded into the package manifest and
648
+ honored at execution.
649
+ resume: How the job resumes from a discovered checkpoint
650
+ (:class:`~simulo.interfaces.platform.enums.ResumePolicy`,
651
+ default ``AUTO``).
652
+ gpu: GPU resource request (e.g. ``"A10G"``), recorded in the job's
653
+ resource spec.
654
+ timeout: Wall-clock limit for one managed execution, in seconds
655
+ (a positive integer; validated HERE, at decoration time — a
656
+ zero, negative, or non-integer value raises ``ValueError``
657
+ immediately rather than failing later at run time on the
658
+ worker). Enforced by the platform worker: a job still
659
+ running at the limit is killed and reported ``failed`` with
660
+ reason ``timeout``. When omitted (``None``, the default),
661
+ the platform ceiling applies (1 hour by default); a declared
662
+ value larger than the ceiling is clamped down to it. The
663
+ ceiling is an operator knob on the worker
664
+ (``MAX_JOB_TIMEOUT_S=<seconds>``) for long unattended
665
+ training.
666
+ retries: Automatic re-attempts after a failure (default 0). All
667
+ attempts share one ``timeout`` budget.
668
+ **extra: Additional resource requests, recorded verbatim in the
669
+ job's resource spec.
670
+
671
+ Raises:
672
+ ValueError: ``timeout`` is not ``None`` and is not a positive
673
+ integer (mirrors the worker's own
674
+ ``worker._effective_job_timeout_s`` guard — bare ``bool`` is
675
+ excluded even though it is an ``int`` subclass).
676
+ """
677
+ if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, int) or timeout < 1):
678
+ raise ValueError(
679
+ "@app.job(timeout=...) must be a positive integer (seconds), or omitted/None to "
680
+ f"use the platform ceiling — got {timeout!r}."
681
+ )
515
682
 
516
683
  def decorate(func: F) -> JobFunction:
517
684
  resources: dict[str, Any] = {}