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
simulo/_client/login.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""``simulo login`` — OAuth 2.0 Authorization Code + PKCE via browser loopback.
|
|
2
|
+
|
|
3
|
+
Stdlib port of the ``simulo-backend`` parked CLI's ``simulo.cli.commands.auth``
|
|
4
|
+
(``secrets``/``hashlib``/``http.server``/``webbrowser``/``urllib`` — no
|
|
5
|
+
``click``, no ``httpx``). Mirrors the project's CLI-auth specification: PKCE generation, the loopback bind + capture flow,
|
|
6
|
+
the manual out-of-band fallback for headless/remote-SSH environments, and the
|
|
7
|
+
``/auth/cli/callback`` -> credentials-file field mapping.
|
|
8
|
+
|
|
9
|
+
The one addition versus the backend CLI: the saved credentials also record
|
|
10
|
+
``api_base_url`` (the base URL this login was performed against), so
|
|
11
|
+
subsequent commands resolve the right platform without re-stating
|
|
12
|
+
``SIMULO_ENV``/``SIMULO_API_URL`` every time (see ``config.resolve_base_url``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import hashlib
|
|
19
|
+
import http.server
|
|
20
|
+
import secrets
|
|
21
|
+
import socket
|
|
22
|
+
import time
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import webbrowser
|
|
25
|
+
from typing import Any, Optional
|
|
26
|
+
from uuid import UUID
|
|
27
|
+
|
|
28
|
+
from simulo._client import config
|
|
29
|
+
from simulo._client import http as http_client
|
|
30
|
+
from simulo._client.credentials import save_credentials
|
|
31
|
+
|
|
32
|
+
CANDIDATE_PORTS = (8421, 8422, 8423)
|
|
33
|
+
LOOPBACK_PATH = "/cli-callback"
|
|
34
|
+
LOGIN_TIMEOUT_SECONDS = 300
|
|
35
|
+
|
|
36
|
+
#: Same endpoint the backend CLI's SimuloClient calls — unauthenticated (the
|
|
37
|
+
#: login endpoint itself; see cli-auth SKILL Control-Plane Endpoints table).
|
|
38
|
+
CLI_CALLBACK_PATH = "/auth/cli/callback"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LoginError(RuntimeError):
|
|
42
|
+
"""``simulo login`` could not complete (bad state, rejected code, no client id)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _require_canonical_organization_id(value: Any) -> str:
|
|
46
|
+
"""Validate the UUID-backed organization binding before persisting it."""
|
|
47
|
+
if not isinstance(value, str):
|
|
48
|
+
raise LoginError("Login failed: malformed organization identity from the control plane.")
|
|
49
|
+
try:
|
|
50
|
+
canonical = str(UUID(value))
|
|
51
|
+
except ValueError as exc:
|
|
52
|
+
raise LoginError("Login failed: malformed organization identity from the control plane.") from exc
|
|
53
|
+
if canonical != value:
|
|
54
|
+
raise LoginError("Login failed: malformed organization identity from the control plane.")
|
|
55
|
+
return canonical
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# PKCE helpers
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def generate_pkce() -> tuple[str, str]:
|
|
64
|
+
"""Return ``(code_verifier, code_challenge)``.
|
|
65
|
+
|
|
66
|
+
Verifier: 64 random bytes -> base64url-encoded (>=86 chars, URL-safe).
|
|
67
|
+
Challenge: SHA-256(verifier) -> base64url-encoded.
|
|
68
|
+
"""
|
|
69
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode()
|
|
70
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
71
|
+
return verifier, challenge
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Loopback port binding + code capture
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def bind_loopback() -> Optional[socket.socket]:
|
|
80
|
+
"""Try to bind ``127.0.0.1`` on each candidate port in order.
|
|
81
|
+
|
|
82
|
+
Returns the bound socket on the first success, or ``None`` if all fail.
|
|
83
|
+
Binds to ``127.0.0.1`` ONLY — never ``0.0.0.0``.
|
|
84
|
+
|
|
85
|
+
Backlog of 5 (not 1) matches ``socketserver.TCPServer.request_queue_size``,
|
|
86
|
+
the depth ``HTTPServer`` itself would have bound with under the old
|
|
87
|
+
close-then-rebind implementation of :func:`capture_code` — this socket is
|
|
88
|
+
now handed to ``HTTPServer`` directly (never rebound), so the depth has to
|
|
89
|
+
be set here to stay equivalent. Matters in practice for the same reason
|
|
90
|
+
:func:`capture_code` already serves in a loop rather than answering a
|
|
91
|
+
single request: a browser can fire a prefetch (e.g. ``/favicon.ico``)
|
|
92
|
+
essentially back-to-back with the real OAuth redirect, and a backlog of 1
|
|
93
|
+
leaves less room to queue a second pending connection while the first is
|
|
94
|
+
being accepted.
|
|
95
|
+
"""
|
|
96
|
+
for port in CANDIDATE_PORTS:
|
|
97
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
98
|
+
try:
|
|
99
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
100
|
+
sock.bind(("127.0.0.1", port))
|
|
101
|
+
sock.listen(5)
|
|
102
|
+
return sock
|
|
103
|
+
except OSError:
|
|
104
|
+
sock.close()
|
|
105
|
+
continue
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def capture_code(server_socket: socket.socket, expected_state: str) -> str:
|
|
110
|
+
"""Block until the browser redirects back, validate state, return the code.
|
|
111
|
+
|
|
112
|
+
Renders a success page so the browser tab can be closed by the user. The
|
|
113
|
+
already-bound, already-listening ``server_socket`` (put in listen state by
|
|
114
|
+
:func:`bind_loopback` before the browser was ever opened) is handed straight
|
|
115
|
+
to the ``HTTPServer`` — it is never closed and rebound. Closing + rebinding
|
|
116
|
+
used to leave a window in which nothing was listening on the port, with no
|
|
117
|
+
signal that the replacement server was ready, so the browser's OAuth
|
|
118
|
+
callback could arrive during that gap (and tests had to paper over it with
|
|
119
|
+
sleeps). Reusing the live socket keeps the port continuously accepting from
|
|
120
|
+
the moment ``bind_loopback`` returned.
|
|
121
|
+
|
|
122
|
+
Serves requests in a loop (bounded by :data:`LOGIN_TIMEOUT_SECONDS` in
|
|
123
|
+
total) rather than a single ``handle_request()`` call — a browser routinely
|
|
124
|
+
fires an unrelated request at the loopback port before the real OAuth
|
|
125
|
+
redirect (e.g. a ``/favicon.ico`` prefetch for the success page's tab);
|
|
126
|
+
handling exactly one request would let that prefetch consume the only
|
|
127
|
+
request the server ever answers and report a spurious "Login timed out"
|
|
128
|
+
a moment before the real callback arrives. Only a request to
|
|
129
|
+
:data:`LOOPBACK_PATH` ever populates ``result``, so every other path is
|
|
130
|
+
404'd and the server keeps waiting.
|
|
131
|
+
"""
|
|
132
|
+
result: dict[str, str] = {}
|
|
133
|
+
|
|
134
|
+
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
135
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A002 — http.server API
|
|
136
|
+
pass # suppress default stderr logging
|
|
137
|
+
|
|
138
|
+
def do_GET(self) -> None: # noqa: N802 — must match BaseHTTPRequestHandler
|
|
139
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
140
|
+
if parsed.path != LOOPBACK_PATH:
|
|
141
|
+
# Not the OAuth callback (a browser prefetch, e.g.
|
|
142
|
+
# /favicon.ico) — 404 it and keep serving; never populates
|
|
143
|
+
# `result`, so it can't be mistaken for the real redirect.
|
|
144
|
+
self.send_error(404)
|
|
145
|
+
return
|
|
146
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
147
|
+
code = (params.get("code") or [""])[0]
|
|
148
|
+
state = (params.get("state") or [""])[0]
|
|
149
|
+
|
|
150
|
+
if not secrets.compare_digest(state, expected_state):
|
|
151
|
+
result["error"] = "state_mismatch"
|
|
152
|
+
self.send_error(400, "state mismatch")
|
|
153
|
+
return
|
|
154
|
+
if not code:
|
|
155
|
+
result["error"] = "missing_code"
|
|
156
|
+
self.send_error(400, "missing code")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
result["code"] = code
|
|
160
|
+
self.send_response(200)
|
|
161
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
162
|
+
self.end_headers()
|
|
163
|
+
self.wfile.write(
|
|
164
|
+
b"<html><body>"
|
|
165
|
+
b"<h2>simulo: sign-in complete</h2>"
|
|
166
|
+
b"<p>You may close this window.</p>"
|
|
167
|
+
b"</body></html>"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
host, port = server_socket.getsockname()
|
|
171
|
+
# bind_and_activate=False skips HTTPServer's own bind()/listen(); we then
|
|
172
|
+
# swap in the socket bind_loopback() already bound and put in listen state,
|
|
173
|
+
# so the listener is continuously up (no close-then-rebind gap). server_close
|
|
174
|
+
# at the end closes this socket — the caller hands ownership over.
|
|
175
|
+
httpd = http.server.HTTPServer((host, port), _Handler, bind_and_activate=False)
|
|
176
|
+
httpd.socket.close() # discard the unused fresh socket HTTPServer created in __init__
|
|
177
|
+
httpd.socket = server_socket
|
|
178
|
+
|
|
179
|
+
deadline = time.monotonic() + LOGIN_TIMEOUT_SECONDS
|
|
180
|
+
while "code" not in result and "error" not in result:
|
|
181
|
+
remaining = deadline - time.monotonic()
|
|
182
|
+
if remaining <= 0:
|
|
183
|
+
break
|
|
184
|
+
httpd.timeout = remaining
|
|
185
|
+
httpd.handle_request()
|
|
186
|
+
httpd.server_close()
|
|
187
|
+
|
|
188
|
+
if "error" in result:
|
|
189
|
+
raise LoginError(f"Login failed: {result['error']}")
|
|
190
|
+
if "code" not in result:
|
|
191
|
+
raise LoginError("Login timed out. Run `simulo login` again.")
|
|
192
|
+
return result["code"]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# Manual fallback (headless / remote-SSH environments)
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def manual_fallback_code(authorize_url: str, expected_state: str, *, prompt: Any = input, echo: Any = print) -> str:
|
|
201
|
+
"""Print the authorize URL; prompt the user to paste back the code + fingerprint.
|
|
202
|
+
|
|
203
|
+
The out-of-band ``/cli-callback`` page shows the code and the first 8
|
|
204
|
+
characters of the state as a fingerprint the user must confirm here — the
|
|
205
|
+
CLI never prints its own expected fingerprint (that would collapse the
|
|
206
|
+
two-channel verification into one channel).
|
|
207
|
+
"""
|
|
208
|
+
echo(
|
|
209
|
+
"\nCould not open a local browser callback port.\n"
|
|
210
|
+
"Open this URL in a browser on another machine:\n\n"
|
|
211
|
+
f" {authorize_url}\n\n"
|
|
212
|
+
"After signing in, the page will display a short code and an 8-character\n"
|
|
213
|
+
"state fingerprint. Paste both below.\n"
|
|
214
|
+
)
|
|
215
|
+
code = str(prompt("Paste the code: ")).strip()
|
|
216
|
+
entered_fingerprint = str(prompt("Enter the 8-character fingerprint shown on the page: ")).strip()
|
|
217
|
+
|
|
218
|
+
state_fingerprint = expected_state[:8]
|
|
219
|
+
if not secrets.compare_digest(entered_fingerprint, state_fingerprint):
|
|
220
|
+
raise LoginError("Login failed: state mismatch")
|
|
221
|
+
return code
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Orchestration
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _build_authorize_url(settings: config.LoginSettings, *, redirect_uri: str, state: str, challenge: str) -> str:
|
|
230
|
+
return f"{settings.cognito_hosted_ui}/oauth2/authorize?" + urllib.parse.urlencode(
|
|
231
|
+
{
|
|
232
|
+
"client_id": settings.cli_app_client_id,
|
|
233
|
+
"response_type": "code",
|
|
234
|
+
"scope": "openid email profile",
|
|
235
|
+
"redirect_uri": redirect_uri,
|
|
236
|
+
"state": state,
|
|
237
|
+
"code_challenge": challenge,
|
|
238
|
+
"code_challenge_method": "S256",
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def perform_login(
|
|
244
|
+
*,
|
|
245
|
+
settings: Optional[config.LoginSettings] = None,
|
|
246
|
+
open_browser: Any = webbrowser.open,
|
|
247
|
+
) -> dict[str, Any]:
|
|
248
|
+
"""Run the full PKCE loopback (or manual-fallback) login flow.
|
|
249
|
+
|
|
250
|
+
On success, writes ``~/.simulo/credentials`` (0600) and returns the saved
|
|
251
|
+
dict. Raises :class:`LoginError` on any failure (empty client id, state
|
|
252
|
+
mismatch, rejected code, malformed response).
|
|
253
|
+
"""
|
|
254
|
+
resolved = settings or config.resolve_login_settings()
|
|
255
|
+
|
|
256
|
+
# Guard: an empty client id causes a silent 401 after the full OAuth
|
|
257
|
+
# round-trip (Cognito accepts a blank client_id but the control plane
|
|
258
|
+
# rejects the token) — fail fast with a clear message instead.
|
|
259
|
+
if not resolved.cli_app_client_id:
|
|
260
|
+
raise LoginError(
|
|
261
|
+
"SIMULO_CLI_APP_CLIENT_ID is not set.\n"
|
|
262
|
+
" For staging: SIMULO_ENV=staging simulo login\n"
|
|
263
|
+
" For dev: set SIMULO_CLI_APP_CLIENT_ID=<client-id>"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
verifier, challenge = generate_pkce()
|
|
267
|
+
state = secrets.token_urlsafe(32)
|
|
268
|
+
|
|
269
|
+
loopback = bind_loopback()
|
|
270
|
+
if loopback is not None:
|
|
271
|
+
_, port = loopback.getsockname()
|
|
272
|
+
redirect_uri = f"http://127.0.0.1:{port}{LOOPBACK_PATH}"
|
|
273
|
+
else:
|
|
274
|
+
redirect_uri = f"{resolved.console_base_url}{LOOPBACK_PATH}"
|
|
275
|
+
|
|
276
|
+
authorize_url = _build_authorize_url(resolved, redirect_uri=redirect_uri, state=state, challenge=challenge)
|
|
277
|
+
|
|
278
|
+
if loopback is not None:
|
|
279
|
+
print("Opening your browser to sign in...")
|
|
280
|
+
open_browser(authorize_url)
|
|
281
|
+
code = capture_code(loopback, state)
|
|
282
|
+
else:
|
|
283
|
+
code = manual_fallback_code(authorize_url, state)
|
|
284
|
+
|
|
285
|
+
# code_verifier is intentionally NOT logged; it must never appear in any log.
|
|
286
|
+
try:
|
|
287
|
+
response = http_client.request_json(
|
|
288
|
+
"POST",
|
|
289
|
+
resolved.api_base_url.rstrip("/") + CLI_CALLBACK_PATH,
|
|
290
|
+
json_body={"code": code, "code_verifier": verifier, "redirect_uri": redirect_uri},
|
|
291
|
+
unavailable_hint="Check SIMULO_API_URL / SIMULO_ENV.",
|
|
292
|
+
)
|
|
293
|
+
except http_client.HttpHTTPError as exc:
|
|
294
|
+
raise LoginError("Login failed: the control plane rejected the sign-in callback. Try again.") from exc
|
|
295
|
+
except http_client.HttpUnavailable as exc:
|
|
296
|
+
raise LoginError("Login failed: Cannot reach the Simulo platform. Check SIMULO_API_URL / SIMULO_ENV.") from exc
|
|
297
|
+
|
|
298
|
+
if not isinstance(response, dict):
|
|
299
|
+
raise LoginError("Login failed: malformed response from the control plane.")
|
|
300
|
+
required = ("access_token", "refresh_token", "expires_at", "user_email", "organization_id")
|
|
301
|
+
missing = [field for field in required if field not in response]
|
|
302
|
+
if missing:
|
|
303
|
+
raise LoginError(f"Login failed: response missing field(s) {missing}.")
|
|
304
|
+
organization_id = _require_canonical_organization_id(response["organization_id"])
|
|
305
|
+
|
|
306
|
+
# Explicit mapping — never save the raw response dict. Note: the response
|
|
307
|
+
# uses 'organization_id'; the credentials file uses 'active_organization_id'.
|
|
308
|
+
creds: dict[str, Any] = {
|
|
309
|
+
"version": 1,
|
|
310
|
+
"access_token": response["access_token"],
|
|
311
|
+
"refresh_token": response["refresh_token"],
|
|
312
|
+
"expires_at": response["expires_at"],
|
|
313
|
+
"user_email": response["user_email"],
|
|
314
|
+
"active_organization_id": organization_id,
|
|
315
|
+
"api_base_url": resolved.api_base_url,
|
|
316
|
+
"console_base_url": resolved.console_base_url,
|
|
317
|
+
}
|
|
318
|
+
save_credentials(creds)
|
|
319
|
+
return creds
|
simulo/_client/mode.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Execution mode for the thin client, driven by the ``SIMULO_MODE`` env var.
|
|
2
|
+
|
|
3
|
+
A single mode-aware package serves two roles:
|
|
4
|
+
|
|
5
|
+
* ``discovery`` — *submit*: import the user app to harvest metadata and **write
|
|
6
|
+
the package** (manifest + source bundle) WITHOUT running any job body or
|
|
7
|
+
resolving heavy imports (``torch`` / Isaac / …). This is the only thing the user
|
|
8
|
+
does locally (``simulo run app.py``, the canonical — and only — submit command).
|
|
9
|
+
* ``execution`` — *execute*: the ``simulo-backend`` runner imports the packaged
|
|
10
|
+
app to actually run a job body; ``runtime.imports()`` performs real imports.
|
|
11
|
+
|
|
12
|
+
``discovery`` is the **default** so any bare ``import`` of a user app stays
|
|
13
|
+
torch-free unless told otherwise; ``simulo run`` submits on that default.
|
|
14
|
+
Execution is always set *explicitly* by the backend runner (``simulo-backend
|
|
15
|
+
run-package``), which is the sole executor — so defaulting to the torch-free
|
|
16
|
+
submit path can never mis-train.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
DISCOVERY = "discovery"
|
|
22
|
+
EXECUTION = "execution"
|
|
23
|
+
|
|
24
|
+
_ENV_VAR = "SIMULO_MODE"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def current_mode() -> str:
|
|
28
|
+
"""Return the active mode from ``SIMULO_MODE`` (defaults to ``discovery``)."""
|
|
29
|
+
return os.environ.get(_ENV_VAR, DISCOVERY)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Execution-time job-output registration — the real ``simulo.save_output``.
|
|
2
|
+
|
|
3
|
+
This module is the **execution half** of the ``simulo.save_output`` lazy
|
|
4
|
+
surface (the tier-2 declaration described in
|
|
5
|
+
:mod:`simulo.interfaces.platform.outputs`). The name resolves mode-aware,
|
|
6
|
+
exactly like the learning names (the thin client's internal learning module):
|
|
7
|
+
|
|
8
|
+
* **discovery** (submit, the default) — ``simulo.save_output`` resolves to an
|
|
9
|
+
inert internal ``Stub``. Job bodies never execute at submit,
|
|
10
|
+
so the only way the name is touched is a module-level call in a user app —
|
|
11
|
+
which stays harmless and registers nothing, exactly like a module-level
|
|
12
|
+
``simulo.Scene(...)``. Discovery therefore never needs — and never imports —
|
|
13
|
+
the backend registry, keeping ``import simulo`` torch-free and backend-free.
|
|
14
|
+
* **execution** (the ``simulo-backend`` runner on a worker) — the name resolves
|
|
15
|
+
to :func:`save_output` below, which validates through the contract's
|
|
16
|
+
:class:`~simulo.interfaces.platform.outputs.Output` and registers into
|
|
17
|
+
the process-local ``simulo.outputs`` registry the runner drains to
|
|
18
|
+
``--outputs-out`` once the job body returns.
|
|
19
|
+
|
|
20
|
+
The registry import happens **inside** :func:`save_output`, at call time:
|
|
21
|
+
this module itself stays importable in a submit-only environment (the
|
|
22
|
+
``TYPE_CHECKING`` alias in ``simulo/__init__.py`` names it, and the test suite
|
|
23
|
+
imports it directly), while ``simulo.outputs`` ships with ``simulo-backend``
|
|
24
|
+
and exists only where jobs execute.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import importlib
|
|
30
|
+
import os
|
|
31
|
+
from typing import Union
|
|
32
|
+
|
|
33
|
+
from simulo.interfaces.platform import Output, OutputKind
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_output(
|
|
37
|
+
name: str,
|
|
38
|
+
path: Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"],
|
|
39
|
+
kind: Union[OutputKind, str] = OutputKind.FILE,
|
|
40
|
+
) -> bool:
|
|
41
|
+
"""Register metadata for a file produced while this job is running.
|
|
42
|
+
|
|
43
|
+
This validates the name, path, and kind, then records them in the running
|
|
44
|
+
job. Returning successfully makes eligible registrations available to the
|
|
45
|
+
worker for upload; a file becomes available through ``simulo outputs``
|
|
46
|
+
only after the upload is persisted. A cancelled, timed-out, crashed, or
|
|
47
|
+
otherwise failed run does not reach the successful registry drain, and a
|
|
48
|
+
refused or unsuccessful upload can also leave a registration undelivered.
|
|
49
|
+
Use a named :class:`simulo.Volume` instead when another job needs shared
|
|
50
|
+
durable files.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
name: The registration's logical filename. It must be one safe
|
|
54
|
+
filename, never a path: separators, control characters, dots-only
|
|
55
|
+
tokens, empty values, and over-length names are rejected with
|
|
56
|
+
``ValueError``.
|
|
57
|
+
path: Filesystem path of the produced file. ``str``, ``bytes``, or any
|
|
58
|
+
``os.PathLike`` (a ``pathlib.Path`` works); bytes-ish values are
|
|
59
|
+
``os.fsdecode``-d, so a real non-UTF-8 filename registers fine.
|
|
60
|
+
Validated for usability: type, length, non-empty, and no NUL.
|
|
61
|
+
kind: What the output is — an
|
|
62
|
+
:class:`~simulo.interfaces.platform.enums.OutputKind` or its wire
|
|
63
|
+
string (e.g. ``"report"``, ``"dataset"``). Defaults to
|
|
64
|
+
:attr:`~simulo.interfaces.platform.enums.OutputKind.FILE`, the
|
|
65
|
+
kind for a general produced file.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
``True`` when the registration was kept; ``False`` when it was dropped
|
|
69
|
+
because the run already registered 1000 files. Register final output,
|
|
70
|
+
not a new scratch file on every simulation step.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ValueError: ``name`` is not a safe single filename, ``path`` is not
|
|
74
|
+
usable as a path, or ``kind`` is not a known output kind — all
|
|
75
|
+
surfaced at the call site before anything is registered.
|
|
76
|
+
ModuleNotFoundError: The job runtime is missing a required dependency.
|
|
77
|
+
RuntimeError: Called outside a supported job execution context.
|
|
78
|
+
"""
|
|
79
|
+
if isinstance(path, (bytes, os.PathLike)):
|
|
80
|
+
# ``os.fsdecode`` maps a bytes / PathLike path to the exact ``str`` the
|
|
81
|
+
# registry round-trips (surrogateescape — a real non-UTF-8 filename
|
|
82
|
+
# stays registrable). Any OTHER type falls through untouched so the
|
|
83
|
+
# contract's validator raises its documented ``ValueError``.
|
|
84
|
+
#
|
|
85
|
+
# This call is the one place an argument problem could escape as
|
|
86
|
+
# something other than that ``ValueError``: a ``PathLike`` whose
|
|
87
|
+
# ``__fspath__`` returns non-str/bytes raises ``TypeError``, and
|
|
88
|
+
# anything ``__fspath__`` itself raises (``OSError``, ``KeyError``, …)
|
|
89
|
+
# propagates verbatim. Both mean precisely what the contract calls an
|
|
90
|
+
# unusable ``path``, so both are re-raised as the contract's
|
|
91
|
+
# ``ValueError`` with the original chained on ``__cause__``: a caller
|
|
92
|
+
# who wrapped an optional registration in ``except ValueError`` must
|
|
93
|
+
# not be blown past by an ``__fspath__`` quirk.
|
|
94
|
+
try:
|
|
95
|
+
path = os.fsdecode(path)
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"simulo.save_output() could not read a filesystem path out of the {type(path).__name__} "
|
|
99
|
+
f"passed as path: {exc}"
|
|
100
|
+
) from exc
|
|
101
|
+
output = Output(name=name, path=path, kind=kind) # type: ignore[arg-type]
|
|
102
|
+
try:
|
|
103
|
+
outputs = importlib.import_module("simulo.outputs")
|
|
104
|
+
except ModuleNotFoundError as exc:
|
|
105
|
+
if exc.name != "simulo.outputs":
|
|
106
|
+
# ``simulo.outputs`` is present and IT failed to import something.
|
|
107
|
+
# Relabelling that as "simulo-backend is not installed here" sends
|
|
108
|
+
# the reader hunting a missing distribution that is in fact
|
|
109
|
+
# installed; the real missing name is already in this exception.
|
|
110
|
+
raise
|
|
111
|
+
raise RuntimeError(
|
|
112
|
+
"simulo.save_output() needs the simulo-backend job registry (simulo.outputs), which is "
|
|
113
|
+
"not installed here. It is called from a job body running on the platform; at submit the "
|
|
114
|
+
"name resolves to an inert stub and this code is never reached."
|
|
115
|
+
) from exc
|
|
116
|
+
return bool(outputs.register(output))
|