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
@@ -0,0 +1,235 @@
1
+ """{{APP_NAME}} — a Simulo training app (scaffolded by `simulo create`).
2
+
3
+ Balances a pole on a cart with PPO, out of the box. Submit it with::
4
+
5
+ simulo run app.py --num-envs 4096 --max-iterations 200
6
+
7
+ The CLI maps each ``--flag`` onto ``train``'s own parameters — no extra
8
+ boilerplate; run with no flags to use the defaults. Submitting only
9
+ writes/uploads the job — it does NOT train on this machine. Once logged in
10
+ (``simulo login``), the SAME command uploads and runs this on the Simulo cloud
11
+ instead, following its logs automatically.
12
+
13
+ WHERE TO EDIT
14
+ -------------
15
+ * ``{{APP_NAME}}Task`` — ``build()`` the scene and write the observation / reward /
16
+ termination logic for your own robot and task.
17
+ * ``train`` (the ``@app.job``) — training hyperparameters and what gets returned;
18
+ its parameters ARE the app's CLI flags (``num_envs`` -> ``--num-envs``).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from typing import Any, Tuple
25
+
26
+ import simulo
27
+
28
+ # The cartpole robot — a global-catalog asset. Swap the ref for your own robot
29
+ # (``simulo asset publish`` your own USD/URDF, or browse the catalog with
30
+ # ``simulo asset list --global``).
31
+ cartpole = simulo.Asset.from_registry("simulo/robot/cartpole:v1")
32
+
33
+ # A named, durable, writable volume for the trained checkpoint (created on first
34
+ # use). Locally the runner backs it with ~/.simulo/volumes/{{APP_NAME}}-checkpoints/;
35
+ # ``vol.path`` only resolves inside a running job (execution mode).
36
+ vol = simulo.Volume.from_name("{{APP_NAME}}-checkpoints", create_if_missing=True)
37
+
38
+ # Advanced: pick a different Simulo runtime — App("name", runtime=simulo.Runtime.from_registry("simulo/gpu-rl:2026.06")). See the Runtimes docs.
39
+ app = simulo.App("{{APP_NAME}}", mounts={"/out": vol})
40
+
41
+ # The ONE module-level heavy import, deferred under the runtime guard so submit
42
+ # records it as a remote import instead of resolving it.
43
+ with app.runtime.imports():
44
+ import torch # noqa: F401 (resolved only in execution mode, on the worker)
45
+
46
+
47
+ @app.runtime.torch_jit
48
+ def _compute_rewards(
49
+ rew_scale_alive: float,
50
+ rew_scale_terminated: float,
51
+ rew_scale_pole_pos: float,
52
+ rew_scale_cart_vel: float,
53
+ rew_scale_pole_vel: float,
54
+ pole_pos: torch.Tensor,
55
+ pole_vel: torch.Tensor,
56
+ cart_pos: torch.Tensor,
57
+ cart_vel: torch.Tensor,
58
+ reset_terminated: torch.Tensor,
59
+ ) -> torch.Tensor:
60
+ """JIT-compiled reward kernel. EDIT: score your own task here."""
61
+ pole_pos = pole_pos.squeeze()
62
+ pole_vel = pole_vel.squeeze()
63
+ cart_pos = cart_pos.squeeze()
64
+ cart_vel = cart_vel.squeeze()
65
+ reset_terminated = reset_terminated.squeeze()
66
+
67
+ rew_alive = rew_scale_alive * (1.0 - reset_terminated.float())
68
+ rew_termination = rew_scale_terminated * reset_terminated.float()
69
+ rew_pole_pos = rew_scale_pole_pos * torch.square(pole_pos)
70
+ rew_cart_vel = rew_scale_cart_vel * torch.abs(cart_vel)
71
+ rew_pole_vel = rew_scale_pole_vel * torch.abs(pole_vel)
72
+
73
+ reward: torch.Tensor = rew_alive + rew_termination + rew_pole_pos + rew_cart_vel + rew_pole_vel
74
+ # Keep the (num_envs,) per-env reward contract even when num_envs == 1.
75
+ return reward.view(-1)
76
+
77
+
78
+ class {{APP_NAME}}Task(simulo.Task):
79
+ """Balance a pole on a cart. EDIT: replace with your own robot and task.
80
+
81
+ Observation (4-dim): pole angle, pole angular velocity, cart position, cart
82
+ velocity. Action (1-dim): scaled horizontal force on the cart.
83
+ """
84
+
85
+ observation_dim = 4
86
+ action_dim = 1
87
+
88
+ episode_length_s = 5.0
89
+ action_scale = 100.0 # [N]
90
+
91
+ max_cart_pos = 3.0 # [m]
92
+ initial_pole_angle_range = (-0.25, 0.25) # fraction of pi [rad]
93
+
94
+ rew_scale_alive = 1.0
95
+ rew_scale_terminated = -2.0
96
+ rew_scale_pole_pos = -1.0
97
+ rew_scale_cart_vel = -0.01
98
+ rew_scale_pole_vel = -0.005
99
+
100
+ # Framework-injected at runtime by ``simulo.core.Task`` / ``LearningEnv``
101
+ # (declared here only so the type checker sees the names the methods read;
102
+ # the annotations are PEP 563 strings and never shadow the inherited values).
103
+ device: str
104
+ max_episode_length: int
105
+ episode_length_buf: torch.Tensor
106
+ reset_terminated: torch.Tensor
107
+
108
+ def build(self, scene: simulo.Scene) -> None:
109
+ # EDIT: swap the ground / light / robot below for your own scene.
110
+ scene.add(simulo.Terrain.plane(name="ground"), at="/", per_environment=False)
111
+ scene.add(
112
+ simulo.Light.dome(name="light", intensity=2000.0, color=(0.75, 0.75, 0.75)),
113
+ at="/",
114
+ per_environment=False,
115
+ )
116
+ self.robot = simulo.Robot(asset=cartpole, initial_pose=simulo.Pose.identity())
117
+ scene.add(self.robot, at="/World/Robot")
118
+
119
+ def on_start(self, env: simulo.LearningEnv) -> None:
120
+ self._cart_dof_idx = self.robot.find_joints("slider_to_cart")
121
+ self._pole_dof_idx = self.robot.find_joints("cart_to_pole")
122
+ # robot.state is the supported, typed way to read live state (robot.internals
123
+ # is the unstable engine escape hatch — see the Scene, Robot & World docs).
124
+ self._joint_pos = self.robot.state.joint_positions
125
+ self._joint_vel = self.robot.state.joint_velocities
126
+
127
+ def get_observations(self) -> torch.Tensor:
128
+ # EDIT: return your own observation vector.
129
+ pole_idx = self._pole_dof_idx[0]
130
+ cart_idx = self._cart_dof_idx[0]
131
+ pole_pos = self._joint_pos[:, pole_idx].view(-1, 1)
132
+ pole_vel = self._joint_vel[:, pole_idx].view(-1, 1)
133
+ cart_pos = self._joint_pos[:, cart_idx].view(-1, 1)
134
+ cart_vel = self._joint_vel[:, cart_idx].view(-1, 1)
135
+ return torch.cat((pole_pos, pole_vel, cart_pos, cart_vel), dim=-1)
136
+
137
+ def get_rewards(self) -> torch.Tensor:
138
+ return _compute_rewards(
139
+ self.rew_scale_alive,
140
+ self.rew_scale_terminated,
141
+ self.rew_scale_pole_pos,
142
+ self.rew_scale_cart_vel,
143
+ self.rew_scale_pole_vel,
144
+ self._joint_pos[:, self._pole_dof_idx[0]],
145
+ self._joint_vel[:, self._pole_dof_idx[0]],
146
+ self._joint_pos[:, self._cart_dof_idx[0]],
147
+ self._joint_vel[:, self._cart_dof_idx[0]],
148
+ self.reset_terminated,
149
+ )
150
+
151
+ def get_dones(self) -> Tuple[torch.Tensor, torch.Tensor]:
152
+ # EDIT: your own termination condition.
153
+ self._joint_pos = self.robot.state.joint_positions
154
+ self._joint_vel = self.robot.state.joint_velocities
155
+ pole_idx = self._pole_dof_idx[0]
156
+ cart_idx = self._cart_dof_idx[0]
157
+ truncated = self.episode_length_buf >= self.max_episode_length - 1
158
+ cart_out = torch.abs(self._joint_pos[:, cart_idx]) > self.max_cart_pos
159
+ pole_fallen = torch.abs(self._joint_pos[:, pole_idx]) > math.pi / 2
160
+ terminated = cart_out | pole_fallen
161
+ return terminated, truncated
162
+
163
+ def apply_actions(self, actions: torch.Tensor) -> None:
164
+ self.robot.set_joint_effort_target(self.action_scale * actions, joint_ids=self._cart_dof_idx)
165
+
166
+ def reset_idx(self, env_ids: torch.Tensor) -> None:
167
+ num_resets = len(env_ids)
168
+ if num_resets == 0:
169
+ return
170
+ self.robot.reset(env_ids)
171
+ pole_idx = self._pole_dof_idx[0]
172
+ # robot.state has no default-joint-value equivalent, so this stays on the
173
+ # internals escape hatch (there is nothing unstable about reading it here,
174
+ # just no supported, typed name for it yet).
175
+ joint_pos = self.robot.internals.default_joint_pos[env_ids].clone()
176
+ random_angles = torch.empty(num_resets, device=self.device).uniform_(
177
+ self.initial_pole_angle_range[0] * math.pi,
178
+ self.initial_pole_angle_range[1] * math.pi,
179
+ )
180
+ joint_pos[:, pole_idx] += random_angles
181
+ # set_joint_state writes both positions and velocities through the engine's
182
+ # own command path (Articulation.write_joint_state_to_sim ->
183
+ # write_joint_{position,velocity}_to_sim), which updates robot.state's
184
+ # backing buffers in place AND pushes to the physics view in the same call.
185
+ # self._joint_pos / self._joint_vel are the SAME objects as those buffers
186
+ # (the hold-safety contract on core/robot.py), so they are already current
187
+ # after this call — no separate write into either tensor is needed, and
188
+ # robot.state's contract is never write into a member's tensor directly.
189
+ joint_vel = self.robot.internals.default_joint_vel[env_ids]
190
+ self.robot.set_joint_state(joint_pos, velocities=joint_vel, env_ids=env_ids)
191
+
192
+
193
+ # Retries are safe: ResumableCheckpoint declares periodic checkpoints (every 50
194
+ # iterations) and resume defaults to "auto", so a retried / preempted run resumes
195
+ # from the latest checkpoint instead of restarting.
196
+ # system= picks a GPU tier from the published catalog; run `simulo systems` to see them.
197
+ @app.job(
198
+ system=simulo.SystemType.TIER_1,
199
+ timeout=8 * 60 * 60,
200
+ retries=2,
201
+ callbacks=[simulo.callbacks.ResumableCheckpoint(every=50)],
202
+ )
203
+ def train(num_envs: int = 4096, max_iterations: int = 200) -> dict[str, Any]:
204
+ """Train a policy with PPO and save the checkpoint. EDIT: swap the trainer/algorithm.
205
+
206
+ Args:
207
+ num_envs: Number of parallel environments to simulate.
208
+ max_iterations: Number of PPO policy-update iterations.
209
+
210
+ Returns:
211
+ A JSON-serialisable dict: the saved ``checkpoint`` path plus training stats.
212
+ """
213
+ env = simulo.LearningEnv(
214
+ task={{APP_NAME}}Task(),
215
+ num_envs=num_envs,
216
+ device="cuda",
217
+ dt=1.0 / 120.0,
218
+ physics_steps_per_action=2,
219
+ env_spacing=4.0,
220
+ headless=True,
221
+ seed=42,
222
+ )
223
+ trainer = simulo.RLTrainer(env=env, algorithm="PPO", device="cuda", seed=42)
224
+
225
+ stats = trainer.train(max_iterations=max_iterations)
226
+
227
+ # Persist the trained policy to the durable volume (resolved in execution mode).
228
+ checkpoint = f"{vol.path}/{{APP_NAME}}_final.pt"
229
+ trainer.save(checkpoint)
230
+
231
+ # Close the trainer before the environment so skrl releases its resources first.
232
+ trainer.close()
233
+ env.close()
234
+
235
+ return {"checkpoint": checkpoint, "num_envs": num_envs, **stats}
@@ -0,0 +1,29 @@
1
+ # Gitignore-style excludes for `simulo run` / `simulo create --type training`'s
2
+ # {{APP_NAME}}. Applied on top of the packager's own built-in excludes (assets/,
3
+ # datasets/, checkpoints/, outputs/, logs/, .git/, __pycache__/, .simulo/, and
4
+ # *.usd(a|c)/*.pt/*.pth/*.onnx/*.mcap/*.mp4/*.pyc) — the extra patterns below cover
5
+ # things that default set does not.
6
+
7
+ # Trained checkpoints and exported policies (already written to a Volume via
8
+ # vol.path — never need to be part of the packaged source bundle).
9
+ *.pt
10
+ *.pth
11
+ *.ckpt
12
+
13
+ # Python virtual environments.
14
+ .venv/
15
+ venv/
16
+ env/
17
+
18
+ # Local secrets / environment overrides — never bundle these.
19
+ .env
20
+ .env.*
21
+
22
+ # Editor / OS cruft.
23
+ .DS_Store
24
+ *.swp
25
+
26
+ # Data blobs.
27
+ *.npy
28
+ *.npz
29
+ data/
@@ -0,0 +1,21 @@
1
+ """Dependency-free version-2 viewer-fragment builder shared by the CLI contract test."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import urllib.parse
6
+
7
+
8
+ def build_view_fragment_url(
9
+ viewer_url: str,
10
+ *,
11
+ session_token: str,
12
+ gateway_url: str,
13
+ job_id: str,
14
+ ) -> str:
15
+ """Build the exact frozen version-2 browser URL.
16
+
17
+ The launch secret rides the URL fragment, never the query string. The
18
+ names are an exact reconciliation contract with Console and viewer.
19
+ """
20
+ fragment = urllib.parse.urlencode({"launch": session_token, "gateway": gateway_url, "job": job_id, "v": "2"})
21
+ return f"{viewer_url}#{fragment}"
@@ -0,0 +1,122 @@
1
+ """HTTP client for the live-view session mint endpoint (stdlib only, torch-free).
2
+
3
+ Wire contract (live-visualization plan, control-plane half; see
4
+ ``simulo_control_plane.jobs.view_sessions.mint_view_session``):
5
+ ``POST /v1/jobs/{job_id}/view-session`` mints a short-lived (TTL <= 5 min)
6
+ gateway-audience session for watching a running ``--viewstream`` job's live
7
+ 3D scene over the browser. Success (200) response body::
8
+
9
+ {"viewerUrl": "...", "gatewayUrl": "...", "sessionToken": "...", "expiresAt": "..."}
10
+
11
+ Error responses use the standard ``{"error": {"code", "message", "request_id"}}``
12
+ envelope. The PINNED codes this client's caller (the thin client's CLI module)
13
+ depends on by name (do not rename without updating both sides) — all raised
14
+ as :class:`ViewSessionApiHTTPError` with ``exc.code``/``exc.status`` set from
15
+ the body/status:
16
+
17
+ * 409 ``stream_endpoint_not_ready`` — the ONLY transient/retryable one: the
18
+ worker claim->registration gap, or a lapsed-lease-pending-reap window.
19
+ * 409 ``job_not_running`` — the job is no longer running (a race after the
20
+ caller confirmed ``running``, or a job that finished between poll and mint).
21
+ * 409 ``job_not_viewstream`` — the job was never submitted with ``--viewstream``.
22
+ * 404 ``job_not_found`` — unknown or cross-org job id (anti-enumeration).
23
+ This is the mint route's ONLY pinned 404 code. The caller
24
+ (the thin client's ``_mint_view_session``) relies on that: by the time it
25
+ mints, ``get_job`` has already confirmed the job exists, so it treats any
26
+ OTHER 404 — e.g. Starlette's default unmatched-route 404 (wrapped in the
27
+ standard envelope with ``code == "Not Found"`` by the control plane's
28
+ global ``HTTPException`` handler) or a non-envelope 404 from a proxy in
29
+ front of it (``code == "http_error"``, this client's fallback code when
30
+ the body isn't the structured envelope) — as proof the ``/view-session``
31
+ route itself isn't deployed in this environment (e.g. prod, which is
32
+ staging-only for view sessions today), and degrades gracefully instead of
33
+ printing the raw ``code: message``.
34
+ * 503 ``view_sessions_disabled`` — the control plane has view sessions
35
+ fail-closed disabled (its gateway service token is not configured, so the
36
+ live-view chain has nothing to talk to).
37
+ * 429 ``rate_limit_exceeded`` — per-user mint rate limit (30/min).
38
+
39
+ The route path is a LOCAL constant here rather than something imported from
40
+ ``simulo.interfaces.platform.runs`` — deliberately: PR3 owns the control
41
+ plane's implementation (and any shared wire-contract module it lands
42
+ alongside it), and this client codes directly against the exact path/response
43
+ shape the plan specifies so the two PRs can land independently without a
44
+ file-level merge conflict. Reconcile into a shared
45
+ ``simulo.interfaces.platform`` constant in a follow-up once PR3 has merged,
46
+ if a shared module materializes.
47
+
48
+ Shares request plumbing (structured-error parsing, the https-when-token
49
+ guard, the foreign-host bearer rule) with ``jobs_api.py``/``submit_api.py``
50
+ via ``http.py`` — one request path, not reimplemented a third time.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import urllib.parse
56
+ from typing import Any, Optional
57
+
58
+ from simulo._client import http
59
+ from simulo.interfaces.platform.runs import JOB_SCOPE_MINE
60
+
61
+ #: ``POST`` — mint a live-view session for a running ``--viewstream`` job.
62
+ #: ``.format(job_id=...)``.
63
+ VIEW_SESSION_ROUTE_TEMPLATE = "/v1/jobs/{job_id}/view-session"
64
+
65
+ _REQUEST_TIMEOUT_S = 10.0 # every outbound call has an explicit timeout (NFR)
66
+ _UNAVAILABLE_HINT = "Check SIMULO_API_URL / SIMULO_ENV, and that you are logged in (`simulo login`)."
67
+
68
+ #: Required fields of a well-formed mint response (see module docstring).
69
+ _REQUIRED_FIELDS = ("viewerUrl", "gatewayUrl", "sessionToken", "expiresAt")
70
+
71
+ # Backward-compatible-style aliases — same convention as jobs_api.py /
72
+ # submit_api.py: these are literally ``http.HttpError``/``http.HttpHTTPError``
73
+ # (not new subclasses), so callers that already catch ``JobsApiError`` (also
74
+ # ``http.HttpError``) catch these too without any extra wiring.
75
+ ViewSessionApiError = http.HttpError
76
+ ViewSessionApiHTTPError = http.HttpHTTPError
77
+
78
+
79
+ class ViewSessionApiClient:
80
+ """Client for ``POST /v1/jobs/{job_id}/view-session``."""
81
+
82
+ def __init__(self, base_url: str, *, token: Optional[str] = None) -> None:
83
+ if not base_url.startswith(("http://", "https://")):
84
+ raise ViewSessionApiError(f"API base URL must be an http(s) URL, got {base_url!r}.")
85
+ self._base_url = base_url.rstrip("/")
86
+ self._token = token
87
+
88
+ @property
89
+ def base_url(self) -> str:
90
+ return self._base_url
91
+
92
+ def create_view_session(self, job_id: str, *, scope: str = JOB_SCOPE_MINE) -> dict[str, Any]:
93
+ """Mint a view session for *job_id*.
94
+
95
+ Raises :class:`ViewSessionApiHTTPError` on any non-2xx response — the
96
+ caller (the thin client's CLI module) distinguishes the ONE retryable
97
+ claim->registration-gap error CODE (``stream_endpoint_not_ready``,
98
+ pinned with PR3) from every other code (e.g. ``job_not_viewstream``,
99
+ which is also a 409 — classify by ``exc.code``, never by
100
+ ``exc.status`` alone), letting non-retryable ones propagate unchanged
101
+ so the server's own error message reaches the user.
102
+ """
103
+ path = VIEW_SESSION_ROUTE_TEMPLATE.format(job_id=http.quote_path_segment(job_id))
104
+ payload = http.request_json(
105
+ "POST",
106
+ self._base_url + path + "?" + urllib.parse.urlencode({"scope": scope}),
107
+ token=self._token,
108
+ api_base_url=self._base_url,
109
+ json_body={},
110
+ timeout=_REQUEST_TIMEOUT_S,
111
+ unavailable_hint=_UNAVAILABLE_HINT,
112
+ )
113
+ if not isinstance(payload, dict):
114
+ raise ViewSessionApiError("Malformed view-session response: expected a JSON object.")
115
+ # Security NIT: report only the MISSING field NAMES, never the raw
116
+ # payload — a malformed response could otherwise echo a partial
117
+ # sessionToken (or other sensitive value) into an exception message
118
+ # that ends up printed to the terminal or captured in a log.
119
+ missing = [field for field in _REQUIRED_FIELDS if field not in payload]
120
+ if missing:
121
+ raise ViewSessionApiError(f"Malformed view-session response: missing field(s) {missing}.")
122
+ return payload
@@ -0,0 +1,71 @@
1
+ """``Volume`` — a named, durable, writable mount (implements ``VolumeProtocol``).
2
+
3
+ Locally a volume is a metadata handle. Its on-disk ``path`` only exists in
4
+ execution mode, where the backend runner has created the directory and exported
5
+ ``SIMULO_VOLUME_<name>``.
6
+ """
7
+
8
+ import os
9
+
10
+ from simulo._client._mounts import volume_env_key
11
+ from simulo._client.mode import EXECUTION, current_mode
12
+ from simulo.interfaces.exceptions import ResourceNotFoundError
13
+
14
+
15
+ class Volume:
16
+ """A named, durable, writable volume mounted into running jobs.
17
+
18
+ Declare it as an app mount and read/write :attr:`path` from the job body::
19
+
20
+ checkpoints = simulo.Volume.from_name("checkpoints", create_if_missing=True)
21
+ app = simulo.App("train", runtime=runtime, mounts={"checkpoints": checkpoints})
22
+
23
+ At submit time a volume is only a metadata handle — :attr:`path` resolves
24
+ exclusively inside a running job, after the platform has mounted it.
25
+
26
+ Args:
27
+ name: Non-empty volume name, unique within your workspace.
28
+ create_if_missing: Create the volume on first use instead of failing.
29
+
30
+ Raises:
31
+ ValueError: if ``name`` is empty or whitespace-only.
32
+ """
33
+
34
+ def __init__(self, name: str, *, create_if_missing: bool = False) -> None:
35
+ if not name or not name.strip():
36
+ raise ValueError("Volume name must be a non-empty string.")
37
+ self._name = name
38
+ self._create_if_missing = create_if_missing
39
+
40
+ @classmethod
41
+ def from_name(cls, name: str, *, create_if_missing: bool = False) -> "Volume":
42
+ """Get a handle to the volume called ``name`` — the canonical constructor."""
43
+ return cls(name, create_if_missing=create_if_missing)
44
+
45
+ @property
46
+ def name(self) -> str:
47
+ """The volume's name."""
48
+ return self._name
49
+
50
+ @property
51
+ def create_if_missing(self) -> bool:
52
+ """Whether the platform creates the volume on first use instead of failing."""
53
+ return self._create_if_missing
54
+
55
+ @property
56
+ def path(self) -> str:
57
+ """Local mount path — only meaningful inside a running job (execution).
58
+
59
+ Reads ``SIMULO_VOLUME_<name>`` set by the runner. Raises outside
60
+ execution mode, or if the runner did not mount this volume.
61
+ """
62
+ if current_mode() != EXECUTION:
63
+ raise ResourceNotFoundError(
64
+ f"Volume {self._name!r}.path is only available inside a running job "
65
+ f"(SIMULO_MODE={EXECUTION!r}); it is not resolvable during packaging."
66
+ )
67
+ key = volume_env_key(self._name)
68
+ mount = os.environ.get(key)
69
+ if mount is None:
70
+ raise ResourceNotFoundError(f"Volume {self._name!r} is not mounted (env var {key} is unset).")
71
+ return mount