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,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))