simulo 0.26.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simulo/__init__.py +433 -0
- simulo/_client/__init__.py +6 -0
- simulo/_client/_entrypoint.py +313 -0
- simulo/_client/_mounts.py +25 -0
- simulo/_client/_runner.py +186 -0
- simulo/_client/_secure_downloads.py +1181 -0
- simulo/_client/app.py +1308 -0
- simulo/_client/asset.py +331 -0
- simulo/_client/asset_api.py +517 -0
- simulo/_client/asset_package.py +1103 -0
- simulo/_client/asset_pins.py +187 -0
- simulo/_client/builtin_aliases.py +107 -0
- simulo/_client/bundle.py +254 -0
- simulo/_client/cancel_api.py +104 -0
- simulo/_client/cli.py +9063 -0
- simulo/_client/config.py +186 -0
- simulo/_client/credentials.py +210 -0
- simulo/_client/discovery.py +214 -0
- simulo/_client/export_api.py +212 -0
- simulo/_client/export_bundle.py +296 -0
- simulo/_client/facades.py +581 -0
- simulo/_client/http.py +414 -0
- simulo/_client/identity_api.py +117 -0
- simulo/_client/install_samples.py +267 -0
- simulo/_client/jobs_api.py +224 -0
- simulo/_client/learning.py +393 -0
- simulo/_client/login.py +319 -0
- simulo/_client/mode.py +29 -0
- simulo/_client/outputs.py +116 -0
- simulo/_client/packaging.py +445 -0
- simulo/_client/preflight_api.py +186 -0
- simulo/_client/preflight_render.py +200 -0
- simulo/_client/registry.py +98 -0
- simulo/_client/runtime.py +185 -0
- simulo/_client/runtime_display.py +90 -0
- simulo/_client/seed_ref.py +76 -0
- simulo/_client/stub.py +41 -0
- simulo/_client/submit_api.py +1057 -0
- simulo/_client/templates/__init__.py +21 -0
- simulo/_client/templates/inference/app.py.tmpl +316 -0
- simulo/_client/templates/inference/simuloignore.tmpl +30 -0
- simulo/_client/templates/scenario/app.py.tmpl +93 -0
- simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
- simulo/_client/templates/training/app.py.tmpl +235 -0
- simulo/_client/templates/training/simuloignore.tmpl +29 -0
- simulo/_client/view_fragment.py +21 -0
- simulo/_client/view_session_api.py +122 -0
- simulo/_client/volume.py +71 -0
- simulo/callbacks.py +274 -0
- simulo/py.typed +0 -0
- simulo-0.26.0.dist-info/METADATA +130 -0
- simulo-0.26.0.dist-info/RECORD +55 -0
- simulo-0.26.0.dist-info/WHEEL +5 -0
- simulo-0.26.0.dist-info/entry_points.txt +2 -0
- simulo-0.26.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
simulo/_client/volume.py
ADDED
|
@@ -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
|