simulo 0.25.0__tar.gz → 0.27.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 (61) hide show
  1. {simulo-0.25.0/src/simulo.egg-info → simulo-0.27.0}/PKG-INFO +15 -2
  2. {simulo-0.25.0 → simulo-0.27.0}/PYPI.md +13 -0
  3. {simulo-0.25.0 → simulo-0.27.0}/pyproject.toml +2 -2
  4. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/__init__.py +6 -5
  5. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/app.py +81 -12
  6. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/cli.py +137 -9
  7. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/http.py +3 -1
  8. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/install_samples.py +138 -4
  9. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/preflight_api.py +4 -12
  10. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/submit_api.py +11 -0
  11. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/templates/inference/app.py.tmpl +4 -3
  12. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/templates/scenario/app.py.tmpl +2 -1
  13. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/templates/training/app.py.tmpl +2 -1
  14. {simulo-0.25.0 → simulo-0.27.0/src/simulo.egg-info}/PKG-INFO +15 -2
  15. {simulo-0.25.0 → simulo-0.27.0}/src/simulo.egg-info/requires.txt +1 -1
  16. {simulo-0.25.0 → simulo-0.27.0}/MANIFEST.in +0 -0
  17. {simulo-0.25.0 → simulo-0.27.0}/setup.cfg +0 -0
  18. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/__init__.py +0 -0
  19. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/_entrypoint.py +0 -0
  20. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/_mounts.py +0 -0
  21. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/_runner.py +0 -0
  22. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/_secure_downloads.py +0 -0
  23. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/asset.py +0 -0
  24. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/asset_api.py +0 -0
  25. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/asset_package.py +0 -0
  26. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/asset_pins.py +0 -0
  27. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/builtin_aliases.py +0 -0
  28. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/bundle.py +0 -0
  29. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/cancel_api.py +0 -0
  30. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/config.py +0 -0
  31. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/credentials.py +0 -0
  32. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/discovery.py +0 -0
  33. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/export_api.py +0 -0
  34. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/export_bundle.py +0 -0
  35. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/facades.py +0 -0
  36. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/identity_api.py +0 -0
  37. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/jobs_api.py +0 -0
  38. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/learning.py +0 -0
  39. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/login.py +0 -0
  40. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/mode.py +0 -0
  41. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/outputs.py +0 -0
  42. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/packaging.py +0 -0
  43. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/preflight_render.py +0 -0
  44. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/registry.py +0 -0
  45. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/runtime.py +0 -0
  46. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/runtime_display.py +0 -0
  47. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/seed_ref.py +0 -0
  48. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/stub.py +0 -0
  49. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/templates/__init__.py +0 -0
  50. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/templates/inference/simuloignore.tmpl +0 -0
  51. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/templates/scenario/simuloignore.tmpl +0 -0
  52. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/templates/training/simuloignore.tmpl +0 -0
  53. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/view_fragment.py +0 -0
  54. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/view_session_api.py +0 -0
  55. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/_client/volume.py +0 -0
  56. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/callbacks.py +0 -0
  57. {simulo-0.25.0 → simulo-0.27.0}/src/simulo/py.typed +0 -0
  58. {simulo-0.25.0 → simulo-0.27.0}/src/simulo.egg-info/SOURCES.txt +0 -0
  59. {simulo-0.25.0 → simulo-0.27.0}/src/simulo.egg-info/dependency_links.txt +0 -0
  60. {simulo-0.25.0 → simulo-0.27.0}/src/simulo.egg-info/entry_points.txt +0 -0
  61. {simulo-0.25.0 → simulo-0.27.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.25.0
3
+ Version: 0.27.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,7 +15,7 @@ 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.19,>=0.18.0
18
+ Requires-Dist: simulo-interfaces<0.20,>=0.19.0
19
19
  Requires-Dist: mcap<2,>=1.3
20
20
  Requires-Dist: defusedxml>=0.7.1
21
21
  Requires-Dist: usd-core<27,>=26.5
@@ -75,6 +75,19 @@ To download a curated collection of runnable projects, use `simulo install sampl
75
75
  It creates `./simulo-samples` as a normal Git clone, so `cd simulo-samples` and
76
76
  `cat README.md` open the sample index.
77
77
 
78
+ Choose a destination with `--directory` and a Git branch, tag, or commit with
79
+ `--ref`. You can use either option on its own or combine them:
80
+
81
+ ```bash
82
+ simulo install samples --directory robotics-examples --ref main
83
+ cd robotics-examples
84
+ ```
85
+
86
+ The destination must not exist, and its parent directory must already exist.
87
+ The directory appears only after the clone and requested checkout succeed.
88
+ Use a tag or full commit ID instead of `main` to select a fixed revision.
89
+ With `--ref`, the checkout is detached at the selected commit.
90
+
78
91
  ### Or write an app by hand
79
92
 
80
93
  ```python
@@ -35,6 +35,19 @@ To download a curated collection of runnable projects, use `simulo install sampl
35
35
  It creates `./simulo-samples` as a normal Git clone, so `cd simulo-samples` and
36
36
  `cat README.md` open the sample index.
37
37
 
38
+ Choose a destination with `--directory` and a Git branch, tag, or commit with
39
+ `--ref`. You can use either option on its own or combine them:
40
+
41
+ ```bash
42
+ simulo install samples --directory robotics-examples --ref main
43
+ cd robotics-examples
44
+ ```
45
+
46
+ The destination must not exist, and its parent directory must already exist.
47
+ The directory appears only after the clone and requested checkout succeed.
48
+ Use a tag or full commit ID instead of `main` to select a fixed revision.
49
+ With `--ref`, the checkout is detached at the selected commit.
50
+
38
51
  ### Or write an app by hand
39
52
 
40
53
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulo"
7
- version = "0.25.0"
7
+ version = "0.27.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"
@@ -26,7 +26,7 @@ classifiers = [
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
28
  dependencies = [
29
- "simulo-interfaces>=0.18.0,<0.19",
29
+ "simulo-interfaces>=0.19.0,<0.20",
30
30
  "mcap>=1.3,<2",
31
31
  # Hardened XML parsing for URDF ingestion (USD Asset Catalogs, PR-12 fix
32
32
  # loop 2, security NIT): stdlib xml.etree.ElementTree relies on
@@ -29,10 +29,10 @@ missed that way before the rule replaced it.
29
29
  Three kinds of name live on ``simulo``:
30
30
 
31
31
  * **Platform** names — ``App / Runtime / Volume / Asset / Output /
32
- OutputKind / callbacks`` — are **real, eager** attributes bound below.
33
- They are always torch-free (``Output`` is the ``simulo-interfaces``
34
- contract dataclass, ``OutputKind`` its wire-stable ``StrEnum``, both
35
- re-exported).
32
+ OutputKind / SystemType / callbacks`` — are **real, eager** attributes bound
33
+ below. They are always torch-free (``Output`` is the ``simulo-interfaces``
34
+ contract dataclass, ``OutputKind`` and ``SystemType`` its wire-stable
35
+ ``StrEnum``\\ s, all three re-exported).
36
36
  * **Contract vocabulary** — the shared error hierarchy (``SimuloError`` and its
37
37
  subclasses), the ``NewType`` identifier aliases (``JobId``, ``ProjectId``, …),
38
38
  and the authoring value types that are not already carried by the lazy
@@ -152,7 +152,7 @@ from simulo.interfaces.authoring import (
152
152
  USDWorld,
153
153
  VisualMarkerBase,
154
154
  )
155
- from simulo.interfaces.platform import Output, OutputKind
155
+ from simulo.interfaces.platform import Output, OutputKind, SystemType
156
156
 
157
157
  # Public surface = eager platform exports (real attributes; ``__getattr__`` never
158
158
  # fires for these) + the lazily-resolved learning names. Kept a static literal so
@@ -162,6 +162,7 @@ __all__ = [
162
162
  "App",
163
163
  "Output",
164
164
  "OutputKind",
165
+ "SystemType",
165
166
  "Runtime",
166
167
  "Volume",
167
168
  "Asset",
@@ -41,7 +41,8 @@ from simulo._client.runtime import DEFAULT_RUNTIME
41
41
  from simulo.interfaces.ids import JobId, parse_job_public_id
42
42
  from simulo.interfaces.platform import JobCallbackProtocol
43
43
  from simulo.interfaces.platform.app import Mount
44
- from simulo.interfaces.platform.enums import ResumePolicy
44
+ from simulo.interfaces.platform.enums import ResumePolicy, SystemType
45
+ from simulo.interfaces.platform.systems import SYSTEM_SPECS
45
46
 
46
47
  if TYPE_CHECKING: # typing-only; avoids a module-scope dependency on the HTTP client
47
48
  from simulo._client.preflight_api import PreflightOutcome
@@ -427,6 +428,14 @@ def _spawn_cloud(
427
428
  content-addressed identity must never change based on whether this
428
429
  particular execution asked for a live stream.
429
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
+
430
439
  ``seed_from`` (``simulo run --from``, explicit-run-intent plan) follows
431
440
  the identical rule: the seed reference is a SUBMIT-time job property,
432
441
  never a package property — it rides the job-create call only, verbatim
@@ -496,6 +505,15 @@ def _spawn_cloud(
496
505
  package_id = str(package_id)
497
506
  source_digest = str(manifest.get("source_digest", ""))
498
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")
499
517
 
500
518
  tar_path = ensure_tar(package_path)
501
519
  archive_bytes = tar_path.read_bytes()
@@ -549,6 +567,7 @@ def _spawn_cloud(
549
567
  seed_from_job_id=seed_from,
550
568
  assets=asset_pins,
551
569
  app_name=app_name,
570
+ system=system,
552
571
  )
553
572
 
554
573
  raw_public_id = record.get("public_id")
@@ -970,7 +989,7 @@ class App:
970
989
 
971
990
  app = simulo.App("cartpole")
972
991
 
973
- @app.job(gpu="A10G")
992
+ @app.job(system=simulo.SystemType.TIER_1)
974
993
  def train(iterations: int = 1000):
975
994
  ...
976
995
 
@@ -1044,7 +1063,7 @@ class App:
1044
1063
  *,
1045
1064
  callbacks: Sequence[JobCallbackProtocol] = ...,
1046
1065
  resume: ResumePolicy = ...,
1047
- gpu: Optional[str] = ...,
1066
+ system: Optional[SystemType] = ...,
1048
1067
  timeout: Optional[int] = ...,
1049
1068
  retries: int = ...,
1050
1069
  **extra: Any,
@@ -1056,14 +1075,15 @@ class App:
1056
1075
  *,
1057
1076
  callbacks: Sequence[JobCallbackProtocol] = (),
1058
1077
  resume: ResumePolicy = ResumePolicy.AUTO,
1059
- gpu: Optional[str] = None,
1078
+ system: Optional[SystemType] = None,
1060
1079
  timeout: Optional[int] = None,
1061
1080
  retries: int = 0,
1062
1081
  **extra: Any,
1063
1082
  ) -> Union[JobFunction, Callable[[F], JobFunction]]:
1064
1083
  """Register a function as a job of this app. Records metadata; never runs the body.
1065
1084
 
1066
- Usable bare (``@app.job``) or with options (``@app.job(gpu="A10G")``).
1085
+ Usable bare (``@app.job``) or with options
1086
+ (``@app.job(system=simulo.SystemType.TIER_1)``).
1067
1087
  Returns a :class:`JobFunction` wrapping the original function — calling
1068
1088
  it still runs the body in-process; ``.spawn()`` / ``.submit()`` submit it
1069
1089
  to the platform instead.
@@ -1077,8 +1097,17 @@ class App:
1077
1097
  resume: How the job resumes from a discovered checkpoint
1078
1098
  (:class:`~simulo.interfaces.platform.enums.ResumePolicy`,
1079
1099
  default ``AUTO``).
1080
- gpu: GPU resource request (e.g. ``"A10G"``), recorded in the job's
1081
- resource spec.
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.
1082
1111
  timeout: Wall-clock limit for one managed execution, in seconds
1083
1112
  (a positive integer; validated HERE, at decoration time — a
1084
1113
  zero, negative, or non-integer value raises ``ValueError``
@@ -1094,13 +1123,15 @@ class App:
1094
1123
  retries: Automatic re-attempts after a failure (default 0). All
1095
1124
  attempts share one ``timeout`` budget.
1096
1125
  **extra: Additional resource requests, recorded verbatim in the
1097
- job's resource spec. Two names are RESERVED and rejected —
1098
- ``produces`` and ``consumes`` (see Raises below); everything
1099
- else passes through to the resource spec unchanged.
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.
1100
1129
 
1101
1130
  Raises:
1102
1131
  ValueError: ``timeout`` is not ``None`` and is not a positive
1103
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).
1104
1135
  TypeError: ``produces=`` or ``consumes=`` was passed. Outputs are
1105
1136
  not declared on the decorator. Callbacks such as
1106
1137
  ``ResumableCheckpoint`` already declare their supported
@@ -1108,6 +1139,11 @@ class App:
1108
1139
  volume for files another job needs. ``consumes=`` is reserved;
1109
1140
  continue from a checkpoint with
1110
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.
1111
1147
  """
1112
1148
  if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, int) or timeout < 1):
1113
1149
  raise ValueError(
@@ -1127,11 +1163,44 @@ class App:
1127
1163
  "today. To chain jobs, run with `simulo run --from <job>[:latest|:best]`. Remove "
1128
1164
  "consumes=."
1129
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
+ )
1130
1199
 
1131
1200
  def decorate(func: F) -> JobFunction:
1132
1201
  resources: dict[str, Any] = {}
1133
- if gpu is not None:
1134
- resources["gpu"] = gpu
1202
+ if system is not None:
1203
+ resources["system"] = system.value
1135
1204
  resources.update(extra)
1136
1205
  spec = JobSpec(
1137
1206
  name=func.__name__,
@@ -57,6 +57,14 @@ wheel's own bundled resources.
57
57
  ``./simulo-samples`` with normal Git history. It uses neither client settings nor
58
58
  saved sign-in state; a failed clone is removed before it can appear at that path.
59
59
 
60
+ ``simulo systems [--json]`` lists the published GPU system tiers (``SystemType`` /
61
+ ``SYSTEM_SPECS`` from ``simulo-interfaces``) that ``@app.job(system=...)`` accepts —
62
+ table or ``--json``. This reads only that static, in-package catalog: no client
63
+ construction, no credential resolution, no network call. Today's fleet is one shared
64
+ machine, so the listing is descriptive rather than a live capability check — the
65
+ tier a job requests is recorded on the job, not scheduled against, and the table's
66
+ own footer says so.
67
+
60
68
  **Auth** (``simulo login`` / ``logout`` / ``whoami``) drives the OAuth 2.0 +
61
69
  PKCE loopback flow and manages
62
70
  ``~/.simulo/credentials``.
@@ -123,6 +131,7 @@ import json
123
131
  import math
124
132
  import os
125
133
  import re
134
+ import shlex
126
135
  import shutil
127
136
  import socket
128
137
  import sys
@@ -186,7 +195,12 @@ from simulo._client.discovery import discover, import_app_module
186
195
  from simulo._client.export_api import DEFAULT_EXPORT_FORMAT, ExportApiClient, ExportApiError
187
196
  from simulo._client.export_bundle import PolicyBundleError, extract_policy_bundle
188
197
  from simulo._client.identity_api import IdentityApiClient, IdentityApiError
189
- from simulo._client.install_samples import InstallSamplesError, InstallSamplesUsageError, install_samples
198
+ from simulo._client.install_samples import (
199
+ DEFAULT_SAMPLES_DIRECTORY_NAME,
200
+ InstallSamplesError,
201
+ InstallSamplesUsageError,
202
+ install_samples,
203
+ )
190
204
  from simulo._client.jobs_api import JobsApiClient, JobsApiError, JobsApiHTTPError, JobsApiUnavailable
191
205
  from simulo._client.login import LoginError, perform_login
192
206
  from simulo._client.mode import DISCOVERY
@@ -214,6 +228,7 @@ from simulo.interfaces.ids import (
214
228
  parse_output_public_id,
215
229
  parse_recording_public_id,
216
230
  )
231
+ from simulo.interfaces.platform import SYSTEM_SPECS, SystemType
217
232
  from simulo.interfaces.platform.asset_catalog import (
218
233
  ASSET_ARCHIVE_TOO_LARGE_CODE,
219
234
  ASSET_KINDS,
@@ -6982,6 +6997,77 @@ def _print_table(headers: tuple[str, ...], rows: Sequence[tuple[str, ...]]) -> N
6982
6997
  print(" ".join(cell.ljust(width) for cell, width in zip(line, widths)).rstrip())
6983
6998
 
6984
6999
 
7000
+ #: One line, plain language, no jargon: forecloses the exact misreading issue
7001
+ #: #1512 documents in the wild (the samples repository's "no GPU requested"
7002
+ #: silently being read as "schedules faster" — untrue on a single-fleet
7003
+ #: platform). A numbered tier ladder invites the identical misreading in the
7004
+ #: other direction — "higher tier, faster/better scheduling" — for
7005
+ #: ``system=SystemType.TIER_4``, so this explicitly rules out BOTH readings,
7006
+ #: not just states the record-only fact and hopes the reader draws the right
7007
+ #: conclusion. Printed by the table view only: ``--json`` is a machine
7008
+ #: contract and never carries prose.
7009
+ _SYSTEMS_FOOTER = (
7010
+ "Your `system=` choice is recorded on the job. It does not select your hardware today: "
7011
+ "no tier schedules faster or trains better than another, and neither does leaving "
7012
+ "`system=` out entirely."
7013
+ )
7014
+
7015
+
7016
+ def _system_tier_label(system: SystemType) -> str:
7017
+ """The human-facing tier label for the table view, e.g. ``SystemType.TIER_1`` ->
7018
+ ``"Tier 1 (tier1)"``. The parenthesized wire value is the exact string
7019
+ ``system=`` accepts (what ``--json`` prints under the ``"system"`` key);
7020
+ the table would otherwise show a reader nothing they could type."""
7021
+ return f"{system.name.replace('_', ' ').title()} ({system.value})"
7022
+
7023
+
7024
+ def _cmd_systems(args: argparse.Namespace) -> int:
7025
+ """``simulo systems [--json]`` — list the published GPU system tiers.
7026
+
7027
+ Reads only the static, in-package ``SYSTEM_SPECS`` catalog from
7028
+ ``simulo-interfaces``: no client construction, no credential resolution,
7029
+ no network call of any kind. Always lists all four tiers, including the
7030
+ two not yet available — the point is to show what exists and what the
7031
+ fleet can provision TODAY, not to hide the rest.
7032
+
7033
+ The raw AWS SKU behind a tier never appears in either output — the whole
7034
+ reason this catalog names abstract tiers instead of a hardware SKU (see
7035
+ ``SystemSpec``'s own docstring for the field that stays off every
7036
+ user-facing surface, including this one).
7037
+ """
7038
+ if args.json:
7039
+ records = [
7040
+ {
7041
+ "system": system.value,
7042
+ "gpu_model": spec.gpu_model,
7043
+ "vram_gb": spec.vram_gb,
7044
+ "vcpu": spec.vcpu,
7045
+ "memory_gb": spec.memory_gb,
7046
+ "max_parallel_envs": spec.max_parallel_envs,
7047
+ "available": spec.available,
7048
+ }
7049
+ for system, spec in SYSTEM_SPECS.items()
7050
+ ]
7051
+ print(json.dumps(records, indent=2))
7052
+ return 0
7053
+
7054
+ rows = [
7055
+ (
7056
+ _system_tier_label(system),
7057
+ spec.gpu_model,
7058
+ f"{spec.vram_gb} GB",
7059
+ str(spec.vcpu),
7060
+ f"{spec.memory_gb} GB",
7061
+ str(spec.max_parallel_envs) if spec.max_parallel_envs is not None else "not yet measured",
7062
+ "available" if spec.available else "not yet available",
7063
+ )
7064
+ for system, spec in SYSTEM_SPECS.items()
7065
+ ]
7066
+ _print_table(("TIER", "GPU MODEL", "VRAM", "VCPU", "MEMORY", "MAX PARALLEL ENVS", "STATUS"), rows)
7067
+ print(_SYSTEMS_FOOTER)
7068
+ return 0
7069
+
7070
+
6985
7071
  #: The frozen local-development cache root for ``simulo asset get``:
6986
7072
  #: ``~/.simulo/assets/<org>/<kind>/<name>/v<N>/`` — colon-free segments,
6987
7073
  #: NTFS-safe. Mirrors ``credentials.CREDENTIALS_PATH``'s
@@ -8013,7 +8099,7 @@ def _build_parser() -> argparse.ArgumentParser:
8013
8099
  prog="simulo",
8014
8100
  description=(
8015
8101
  "Simulo thin client — submit an app to the Simulo cloud and observe its runs "
8016
- "(simulo jobs / logs / result / recordings / models / outputs / export / view / cancel)."
8102
+ "(simulo systems / jobs / logs / result / recordings / models / outputs / export / view / cancel)."
8017
8103
  ),
8018
8104
  )
8019
8105
  # ``action="version"`` fires (prints + exits 0) while argparse is still
@@ -8269,14 +8355,33 @@ def _build_parser() -> argparse.ArgumentParser:
8269
8355
  install_sub = install.add_subparsers(dest="install_command", required=True)
8270
8356
  samples = install_sub.add_parser(
8271
8357
  "samples",
8272
- help="Clone the sample collection into ./simulo-samples with full Git history.",
8358
+ help="Clone the sample collection into ./simulo-samples by default with full Git history.",
8359
+ )
8360
+ samples.add_argument(
8361
+ "--directory",
8362
+ metavar="PATH",
8363
+ type=Path,
8364
+ help="Install into PATH instead of ./simulo-samples. PATH's parent directory must already exist.",
8365
+ )
8366
+ samples.add_argument(
8367
+ "--ref",
8368
+ metavar="REF",
8369
+ help="Check out Git branch, tag, or commit REF after cloning.",
8273
8370
  )
8274
8371
 
8275
8372
  def _handle_install_samples(args: argparse.Namespace, extras: Sequence[str]) -> int:
8276
8373
  if extras:
8277
8374
  samples.error(f"unrecognized arguments: {' '.join(extras)}")
8375
+ directory = args.directory
8376
+ if directory is None:
8377
+ parent_directory = Path.cwd()
8378
+ directory_name = DEFAULT_SAMPLES_DIRECTORY_NAME
8379
+ else:
8380
+ target = directory if directory.is_absolute() else Path.cwd() / directory
8381
+ parent_directory = target.parent
8382
+ directory_name = target.name
8278
8383
  try:
8279
- installed = install_samples(Path.cwd())
8384
+ installed = install_samples(parent_directory, directory_name=directory_name, ref=args.ref)
8280
8385
  except InstallSamplesUsageError as exc:
8281
8386
  print(f"error: {exc}", file=sys.stderr)
8282
8387
  print("Nothing was installed.", file=sys.stderr)
@@ -8285,12 +8390,21 @@ def _build_parser() -> argparse.ArgumentParser:
8285
8390
  print(f"error: {exc}", file=sys.stderr)
8286
8391
  print("Nothing was installed.", file=sys.stderr)
8287
8392
  return 1
8288
- print(
8289
- f"Installed the Simulo samples in {installed.path} "
8290
- "(a normal Git clone: history, branches, and git pull all work)."
8291
- )
8393
+ if args.ref is None:
8394
+ print(
8395
+ f"Installed the Simulo samples in {installed.path} "
8396
+ "(a normal Git clone: history, branches, and git pull all work)."
8397
+ )
8398
+ else:
8399
+ print(
8400
+ f"Installed the Simulo samples in {installed.path} "
8401
+ f"(a normal Git clone pinned to {args.ref!r}; the checkout is detached)."
8402
+ )
8292
8403
  print("Next steps:")
8293
- print(" cd simulo-samples")
8404
+ if directory is None:
8405
+ print(" cd simulo-samples")
8406
+ else:
8407
+ print(f" cd {shlex.quote(str(installed.path))}")
8294
8408
  print(" cat README.md")
8295
8409
  print("README.md is the sample index; each sample folder has its own app.py to run with simulo run.")
8296
8410
  return 0
@@ -8757,6 +8871,20 @@ def _build_parser() -> argparse.ArgumentParser:
8757
8871
  )
8758
8872
  models.set_defaults(func=_observe_handler(models, _cmd_models))
8759
8873
 
8874
+ systems = sub.add_parser(
8875
+ "systems",
8876
+ help=(
8877
+ "List the published GPU system tiers `@app.job(system=...)` accepts — table or "
8878
+ "--json. Reads only the in-package catalog: no network call."
8879
+ ),
8880
+ )
8881
+ systems.add_argument(
8882
+ "--json",
8883
+ action="store_true",
8884
+ help="Print the tier catalog as a JSON array instead of a table (automation-friendly).",
8885
+ )
8886
+ systems.set_defaults(func=_observe_handler(systems, _cmd_systems))
8887
+
8760
8888
  # `simulo export` — one command, no arguments, on the happy path. Every
8761
8889
  # option below overrides a default that was already correct (#654): the
8762
8890
  # latest job, its best checkpoint, ONNX, download, unpack. A new REQUIRED
@@ -48,7 +48,7 @@ class HttpError(RuntimeError):
48
48
 
49
49
 
50
50
  class HttpUnavailable(HttpError):
51
- """The endpoint cannot be reached at all (nothing listening / DNS failure)."""
51
+ """The endpoint is unreachable or its response times out."""
52
52
 
53
53
 
54
54
  class HttpHTTPError(HttpError):
@@ -373,6 +373,8 @@ def request_bytes(
373
373
  raise _structured_http_error(exc) from exc
374
374
  except urllib.error.URLError as exc:
375
375
  raise HttpUnavailable(_unavailable_message(url, unavailable_hint, exc.reason)) from exc
376
+ except TimeoutError as exc:
377
+ raise HttpUnavailable(_unavailable_message(url, unavailable_hint, exc)) from exc
376
378
 
377
379
 
378
380
  def request_json(
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import re
6
7
  import shutil
7
8
  import subprocess
8
9
  import sys
@@ -134,6 +135,8 @@ def _remove_staging_directory(staging: Path, identity: tuple[int, int]) -> None:
134
135
  print(f"warning: partial samples install path no longer belongs to this invocation: {staging}", file=sys.stderr)
135
136
  return
136
137
  try:
138
+ if not os.path.islink(staging):
139
+ os.chmod(staging, 0o700)
137
140
  shutil.rmtree(staging, onerror=_remove_read_only_path)
138
141
  except OSError:
139
142
  print(f"warning: could not remove partial samples install at {staging}", file=sys.stderr)
@@ -160,6 +163,11 @@ def _target_exists_error(target: Path) -> InstallSamplesUsageError:
160
163
  return InstallSamplesUsageError(f"{target} already exists.")
161
164
 
162
165
 
166
+ def _path_exists(path: Path) -> bool:
167
+ """Return whether *path* exists, including a dangling symbolic link."""
168
+ return os.path.lexists(path)
169
+
170
+
163
171
  def _git_is_reachable_through_working_directory(
164
172
  named_git_path: Path, git_executable: Path, working_directory: Path
165
173
  ) -> bool:
@@ -190,11 +198,14 @@ def install_samples(
190
198
  *,
191
199
  repository_url: str = SAMPLES_REPOSITORY_URL,
192
200
  directory_name: str = DEFAULT_SAMPLES_DIRECTORY_NAME,
201
+ ref: str | None = None,
193
202
  timeout_s: int = CLONE_TIMEOUT_S,
194
203
  ) -> InstalledSamples:
195
204
  """Clone samples into *parent_directory* and reveal them atomically."""
196
205
  target = parent_directory / directory_name
197
- if target.exists():
206
+ if not parent_directory.is_dir():
207
+ raise InstallSamplesUsageError(f"Parent directory {parent_directory} does not exist or is not a directory.")
208
+ if _path_exists(target):
198
209
  raise _target_exists_error(target)
199
210
 
200
211
  git_path = shutil.which("git")
@@ -223,7 +234,10 @@ def install_samples(
223
234
  staging_identity = _directory_identity(staging)
224
235
 
225
236
  print("Cloning the samples with full history; this can take a minute.", file=sys.stderr)
226
- argv = [str(git_executable), "clone", "--", repository_url, str(staging)]
237
+ argv = [str(git_executable), "clone"]
238
+ if ref is not None:
239
+ argv.append("--no-checkout")
240
+ argv.extend(["--", repository_url, str(staging)])
227
241
  try:
228
242
  completed = _run_git(argv, timeout_s=timeout_s, env=_git_environment())
229
243
  except subprocess.TimeoutExpired as exc:
@@ -249,11 +263,131 @@ def install_samples(
249
263
  explain_public_repository=True,
250
264
  )
251
265
 
252
- if not _clone_has_sample_files(staging):
266
+ if ref is None and not _clone_has_sample_files(staging):
253
267
  _remove_staging_directory(staging, staging_identity)
254
268
  raise _clone_failure("Git clone produced an empty working tree.", completed.stderr)
255
269
 
256
- if target.exists():
270
+ if ref is not None:
271
+ try:
272
+ resolved = _run_git(
273
+ [
274
+ str(git_executable),
275
+ "-C",
276
+ str(staging),
277
+ "rev-parse",
278
+ "--verify",
279
+ "--end-of-options",
280
+ f"{ref}^{{commit}}",
281
+ ],
282
+ timeout_s=timeout_s,
283
+ env=_git_environment(),
284
+ )
285
+ except subprocess.TimeoutExpired as exc:
286
+ _remove_staging_directory(staging, staging_identity)
287
+ raise _clone_failure(f"Git could not resolve samples reference {ref!r}: timed out.", exc.stderr) from None
288
+ except KeyboardInterrupt:
289
+ _remove_staging_directory(staging, staging_identity)
290
+ raise
291
+ except OSError as exc:
292
+ _remove_staging_directory(staging, staging_identity)
293
+ reason = exc.strerror or str(exc)
294
+ raise InstallSamplesError(f"Could not resolve samples reference {ref!r}: {reason}.") from None
295
+ if resolved.returncode != 0:
296
+ try:
297
+ resolved = _run_git(
298
+ [
299
+ str(git_executable),
300
+ "-C",
301
+ str(staging),
302
+ "rev-parse",
303
+ "--verify",
304
+ "--end-of-options",
305
+ f"refs/remotes/origin/{ref}^{{commit}}",
306
+ ],
307
+ timeout_s=timeout_s,
308
+ env=_git_environment(),
309
+ )
310
+ except subprocess.TimeoutExpired as exc:
311
+ _remove_staging_directory(staging, staging_identity)
312
+ raise _clone_failure(
313
+ f"Git could not resolve samples reference {ref!r}: timed out.", exc.stderr
314
+ ) from None
315
+ except KeyboardInterrupt:
316
+ _remove_staging_directory(staging, staging_identity)
317
+ raise
318
+ except OSError as exc:
319
+ _remove_staging_directory(staging, staging_identity)
320
+ reason = exc.strerror or str(exc)
321
+ raise InstallSamplesError(f"Could not resolve samples reference {ref!r}: {reason}.") from None
322
+ if resolved.returncode != 0:
323
+ _remove_staging_directory(staging, staging_identity)
324
+ raise _clone_failure(f"Git could not resolve samples reference {ref!r}.", resolved.stderr)
325
+ commit = resolved.stdout.strip()
326
+ if re.fullmatch(r"[0-9a-fA-F]{40,64}", commit) is None:
327
+ _remove_staging_directory(staging, staging_identity)
328
+ raise InstallSamplesError(f"Git could not resolve samples reference {ref!r} to a commit.")
329
+
330
+ try:
331
+ checked_out = _run_git(
332
+ [str(git_executable), "-C", str(staging), "checkout", "--detach", commit],
333
+ timeout_s=timeout_s,
334
+ env=_git_environment(),
335
+ )
336
+ except subprocess.TimeoutExpired as exc:
337
+ _remove_staging_directory(staging, staging_identity)
338
+ raise _clone_failure(f"Git could not check out samples reference {ref!r}: timed out.", exc.stderr) from None
339
+ except KeyboardInterrupt:
340
+ _remove_staging_directory(staging, staging_identity)
341
+ raise
342
+ except OSError as exc:
343
+ _remove_staging_directory(staging, staging_identity)
344
+ reason = exc.strerror or str(exc)
345
+ raise InstallSamplesError(f"Could not check out samples reference {ref!r}: {reason}.") from None
346
+ if checked_out.returncode != 0:
347
+ _remove_staging_directory(staging, staging_identity)
348
+ raise _clone_failure(f"Git could not check out samples reference {ref!r}.", checked_out.stderr)
349
+ if not _clone_has_sample_files(staging):
350
+ _remove_staging_directory(staging, staging_identity)
351
+ raise _clone_failure("Git checkout produced an empty working tree.", checked_out.stderr)
352
+
353
+ try:
354
+ head = _run_git(
355
+ [str(git_executable), "-C", str(staging), "rev-parse", "--verify", "HEAD"],
356
+ timeout_s=timeout_s,
357
+ env=_git_environment(),
358
+ )
359
+ worktree = _run_git(
360
+ [
361
+ str(git_executable),
362
+ "-C",
363
+ str(staging),
364
+ "diff",
365
+ "--quiet",
366
+ commit,
367
+ "--",
368
+ ],
369
+ timeout_s=timeout_s,
370
+ env=_git_environment(),
371
+ )
372
+ except subprocess.TimeoutExpired as exc:
373
+ _remove_staging_directory(staging, staging_identity)
374
+ raise _clone_failure(f"Git could not verify samples reference {ref!r}: timed out.", exc.stderr) from None
375
+ except KeyboardInterrupt:
376
+ _remove_staging_directory(staging, staging_identity)
377
+ raise
378
+ except OSError as exc:
379
+ _remove_staging_directory(staging, staging_identity)
380
+ reason = exc.strerror or str(exc)
381
+ raise InstallSamplesError(f"Could not verify samples reference {ref!r}: {reason}.") from None
382
+ if head.returncode != 0 or worktree.returncode not in (0, 1):
383
+ _remove_staging_directory(staging, staging_identity)
384
+ stderr = head.stderr if head.returncode != 0 else worktree.stderr
385
+ raise _clone_failure(f"Git could not verify samples reference {ref!r}.", stderr)
386
+ if head.stdout.strip().lower() != commit.lower() or worktree.returncode != 0:
387
+ _remove_staging_directory(staging, staging_identity)
388
+ raise InstallSamplesError("Git checkout did not leave the requested samples revision clean.")
389
+
390
+ if _path_exists(target):
257
391
  _remove_staging_directory(staging, staging_identity)
258
392
  raise _target_exists_error(target)
259
393
 
@@ -17,18 +17,10 @@ always returns a :class:`PreflightOutcome`, either a genuine
17
17
  for the CLI to print verbatim before proceeding. Only a genuinely
18
18
  well-formed 200 response can carry a ``blocking`` finding.
19
19
 
20
- The final ``except Exception`` in :meth:`PreflightApiClient.evaluate` is
21
- deliberate, not sloppy: measured against this codebase's own ``http.py``,
22
- ``urllib.request.urlopen``'s connect/send-phase failures surface as
23
- ``urllib.error.URLError`` (wrapped here as the thin client's ``HttpUnavailable``),
24
- but a peer that accepts the connection and then simply never answers (this
25
- PR's own connection-timeout test reproduces it) times out INSIDE
26
- ``http.client.HTTPConnection.getresponse()`` — a call site ``http.py``'s
27
- existing ``except urllib.error.URLError`` does not wrap, so a bare
28
- ``socket.timeout``/``TimeoutError`` can reach this call site unwrapped. The
29
- broad catch is the safety net for exactly that gap (and for anything else
30
- unanticipated) — preflight must not add a second instance of this failure class to the
31
- submit path.
20
+ The shared HTTP helper wraps connection failures and response timeouts as
21
+ ``HttpUnavailable``. The final ``except Exception`` in
22
+ :meth:`PreflightApiClient.evaluate` preserves the fail-open contract for other
23
+ unexpected errors: only a well-formed findings response may block submission.
32
24
  """
33
25
 
34
26
  from __future__ import annotations
@@ -420,6 +420,7 @@ class SubmitApiClient:
420
420
  seed_from_job_id: Optional[str] = None,
421
421
  assets: Optional[list[dict[str, str]]] = None,
422
422
  app_name: Optional[str] = None,
423
+ system: Optional[str] = None,
423
424
  ) -> dict[str, Any]:
424
425
  """``POST /v1/jobs`` — create the job that executes *package_id*.
425
426
 
@@ -441,6 +442,14 @@ class SubmitApiClient:
441
442
  predates the live-visualization plan's ``JobSubmitRequest`` field
442
443
  simply ignores an absent key, same as it ignores any unknown field.
443
444
 
445
+ ``system`` (system-tier-catalog-1362, M50-persistence) is the
446
+ already-validated tier wire value (e.g. ``"tier1"``) from the job's
447
+ own manifest ``resources["system"]`` — an OPTIONAL top-level sibling
448
+ of ``args`` for the same package-hash-invariance reason as
449
+ ``viewstream`` above. Sent only when truthy: a control plane that
450
+ predates this field ignores the extra key, and the run proceeds
451
+ exactly as it did before ``system=`` existed.
452
+
444
453
  ``seed_from_job_id`` (``simulo run --from``, explicit-run-intent
445
454
  plan) follows the exact same sibling-of-``args`` rule for the exact
446
455
  same package-hash reason — a seed is a property of THIS submission,
@@ -500,6 +509,8 @@ class SubmitApiClient:
500
509
  body["app_name"] = app_name
501
510
  if viewstream:
502
511
  body["viewstream"] = True
512
+ if system:
513
+ body["system"] = system
503
514
  if seed_from_job_id is not None:
504
515
  body[_SEED_FROM_JOB_ID_FIELD] = seed_from_job_id
505
516
  if assets:
@@ -209,8 +209,9 @@ def _make_env(num_envs: int) -> Any:
209
209
  )
210
210
 
211
211
 
212
+ # system= picks a GPU tier from the published catalog; run `simulo systems` to see them.
212
213
  @app.job(
213
- gpu="L4",
214
+ system=simulo.SystemType.TIER_1,
214
215
  timeout=8 * 60 * 60,
215
216
  retries=2,
216
217
  callbacks=[simulo.callbacks.ResumableCheckpoint(every=50)],
@@ -239,7 +240,7 @@ def train(num_envs: int = 512, max_iterations: int = 20) -> dict[str, Any]:
239
240
  return {"checkpoint": checkpoint, "policy": policy_path, "num_envs": num_envs, **stats}
240
241
 
241
242
 
242
- @app.job(gpu="L4", timeout=2 * 60 * 60)
243
+ @app.job(system=simulo.SystemType.TIER_1, timeout=2 * 60 * 60)
243
244
  def evaluate(num_episodes: int = 10, num_rounds: int = 5) -> dict[str, Any]:
244
245
  """Evaluate the saved checkpoint over several rounds; aggregate with numpy."""
245
246
  import json
@@ -283,7 +284,7 @@ def evaluate(num_episodes: int = 10, num_rounds: int = 5) -> dict[str, Any]:
283
284
  return {"report": report_path, **report}
284
285
 
285
286
 
286
- @app.job(gpu="L4", timeout=1 * 60 * 60)
287
+ @app.job(system=simulo.SystemType.TIER_1, timeout=1 * 60 * 60)
287
288
  def rollout(num_steps: int = 200) -> dict[str, Any]:
288
289
  """Inference path: play the exported TorchScript policy and record it to MCAP.
289
290
 
@@ -64,7 +64,8 @@ class {{APP_NAME}}Scenario(simulo.Scenario): # -> ScenarioProtocol at submit, r
64
64
  app = simulo.App("{{APP_NAME}}")
65
65
 
66
66
 
67
- @app.job(gpu="L4", timeout=30 * 60)
67
+ # system= picks a GPU tier from the published catalog; run `simulo systems` to see them.
68
+ @app.job(system=simulo.SystemType.TIER_1, timeout=30 * 60)
68
69
  def simulate(num_steps: int = 1000, num_envs: int = 4) -> dict[str, Any]:
69
70
  """Run the scenario headless on the worker. EDIT: device / step count / env count.
70
71
 
@@ -193,8 +193,9 @@ class {{APP_NAME}}Task(simulo.Task):
193
193
  # Retries are safe: ResumableCheckpoint declares periodic checkpoints (every 50
194
194
  # iterations) and resume defaults to "auto", so a retried / preempted run resumes
195
195
  # from the latest checkpoint instead of restarting.
196
+ # system= picks a GPU tier from the published catalog; run `simulo systems` to see them.
196
197
  @app.job(
197
- gpu="L4",
198
+ system=simulo.SystemType.TIER_1,
198
199
  timeout=8 * 60 * 60,
199
200
  retries=2,
200
201
  callbacks=[simulo.callbacks.ResumableCheckpoint(every=50)],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulo
3
- Version: 0.25.0
3
+ Version: 0.27.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,7 +15,7 @@ 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.19,>=0.18.0
18
+ Requires-Dist: simulo-interfaces<0.20,>=0.19.0
19
19
  Requires-Dist: mcap<2,>=1.3
20
20
  Requires-Dist: defusedxml>=0.7.1
21
21
  Requires-Dist: usd-core<27,>=26.5
@@ -75,6 +75,19 @@ To download a curated collection of runnable projects, use `simulo install sampl
75
75
  It creates `./simulo-samples` as a normal Git clone, so `cd simulo-samples` and
76
76
  `cat README.md` open the sample index.
77
77
 
78
+ Choose a destination with `--directory` and a Git branch, tag, or commit with
79
+ `--ref`. You can use either option on its own or combine them:
80
+
81
+ ```bash
82
+ simulo install samples --directory robotics-examples --ref main
83
+ cd robotics-examples
84
+ ```
85
+
86
+ The destination must not exist, and its parent directory must already exist.
87
+ The directory appears only after the clone and requested checkout succeed.
88
+ Use a tag or full commit ID instead of `main` to select a fixed revision.
89
+ With `--ref`, the checkout is detached at the selected commit.
90
+
78
91
  ### Or write an app by hand
79
92
 
80
93
  ```python
@@ -1,4 +1,4 @@
1
- simulo-interfaces<0.19,>=0.18.0
1
+ simulo-interfaces<0.20,>=0.19.0
2
2
  mcap<2,>=1.3
3
3
  defusedxml>=0.7.1
4
4
  usd-core<27,>=26.5
File without changes
File without changes
File without changes
File without changes