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,186 @@
1
+ """Base-URL + login-endpoint resolution for the cloud submit surface (stdlib only).
2
+
3
+ Every HTTP-talking module in the thin client (``jobs_api``, ``submit_api``, ``login``)
4
+ resolves "where is the platform?" through this module so the precedence rule is
5
+ defined exactly once. Resolution order for the API base URL:
6
+
7
+ 1. ``SIMULO_API_URL`` — an explicit override always wins.
8
+ 2. ``SIMULO_ENV`` (``staging``/``prod``) — a named-environment preset.
9
+ 3. ``api_base_url`` recorded in ``~/.simulo/credentials`` at login time (the URL
10
+ the user last logged in against).
11
+ 4. The production preset (``https://api.simulo.ai``) — the cloud is the default
12
+ target, so a bare ``simulo login`` signs in to production. The local
13
+ stand-in (``simulo-backend serve``,
14
+ ``http://127.0.0.1:<JOBS_API_DEFAULT_PORT>``) is targeted EXPLICITLY
15
+ (``SIMULO_API_URL=http://127.0.0.1:8765``) — never by default.
16
+
17
+ This bottom-of-the-chain default decides only WHERE resolution points once a
18
+ cloud call is being made — it is NOT a cloud *signal*. The offline-vs-cloud
19
+ submit decision (``JobFunction.spawn``) keys off :func:`has_explicit_cloud_env`
20
+ (is ``SIMULO_API_URL``/``SIMULO_ENV`` actually SET?) and saved credentials, so
21
+ a bare ``simulo run`` with no login still writes a local package and never
22
+ auto-submits to production.
23
+
24
+ Named-environment presets mirror the (pydantic-based) ``simulo.cli.config``
25
+ presets in the ``simulo-backend`` parked CLI 1:1 — the two clients log in
26
+ against the same Cognito app clients and the same control plane, so the
27
+ values must never drift between the two implementations.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ from dataclasses import dataclass
34
+ from typing import Any, Mapping, Optional
35
+
36
+ from simulo.interfaces.platform.runs import JOBS_API_DEFAULT_PORT
37
+
38
+ #: Explicit API base-URL override. Highest precedence.
39
+ API_URL_ENV = "SIMULO_API_URL"
40
+ #: Named-environment preset selector (``staging``/``prod``).
41
+ ENV_PRESET_ENV = "SIMULO_ENV"
42
+ #: Explicit local-only override — ``SIMULO_SUBMIT=local`` forces offline submit
43
+ #: even when credentials or ``SIMULO_API_URL``/``SIMULO_ENV`` are present.
44
+ SUBMIT_MODE_ENV = "SIMULO_SUBMIT"
45
+ #: Value of :data:`SUBMIT_MODE_ENV` that forces offline (local-disk) submit.
46
+ SUBMIT_MODE_LOCAL = "local"
47
+
48
+ COGNITO_HOSTED_UI_ENV = "SIMULO_COGNITO_HOSTED_UI"
49
+ CLI_APP_CLIENT_ID_ENV = "SIMULO_CLI_APP_CLIENT_ID"
50
+ CONSOLE_BASE_URL_ENV = "SIMULO_CONSOLE_BASE_URL"
51
+
52
+ #: The local stand-in (``simulo-backend serve``). NOT a resolution default —
53
+ #: local dev targets it explicitly (``SIMULO_API_URL=http://127.0.0.1:8765``).
54
+ LOCAL_DEFAULT_BASE_URL = f"http://127.0.0.1:{JOBS_API_DEFAULT_PORT}"
55
+
56
+ # Mirrors simulo-backend's sdk/src/simulo/cli/config.py _ENV_PRESETS exactly —
57
+ # the two clients share one Cognito app-client-id set per environment. Every
58
+ # preset MUST define all four keys: resolve_login_settings indexes them
59
+ # directly (a partial preset should fail loudly, not fall back silently).
60
+ _ENV_PRESETS: Mapping[str, Mapping[str, str]] = {
61
+ "staging": {
62
+ "api_base_url": "https://api.staging.simulo.ai",
63
+ "cognito_hosted_ui": "https://auth.staging.simulo.ai",
64
+ "cli_app_client_id": "284of1ali7881488nc9n26jskt",
65
+ "console_base_url": "https://console.staging.simulo.ai",
66
+ },
67
+ "prod": {
68
+ "api_base_url": "https://api.simulo.ai",
69
+ "cognito_hosted_ui": "https://auth.simulo.ai",
70
+ "cli_app_client_id": "1dc72f0scqbtguj66b74k8a244",
71
+ "console_base_url": "https://console.simulo.ai",
72
+ },
73
+ }
74
+
75
+ #: The preset used when ``SIMULO_ENV`` is unset/empty: production. A bare
76
+ #: ``simulo login`` signs in to the real cloud; every explicit signal —
77
+ #: ``SIMULO_API_URL``, ``SIMULO_ENV``, the individual login env vars above,
78
+ #: and the login-time ``api_base_url`` recorded in the credentials file —
79
+ #: wins over this default. It plays NO part in the offline-vs-cloud submit
80
+ #: decision (see :func:`has_explicit_cloud_env` and the module docstring).
81
+ DEFAULT_ENV_PRESET = "prod"
82
+
83
+
84
+ class UnknownEnvPresetError(ValueError):
85
+ """``SIMULO_ENV`` was set to a value that is not a known preset."""
86
+
87
+
88
+ def _preset() -> Optional[Mapping[str, str]]:
89
+ """Return the active ``SIMULO_ENV`` preset dict, or ``None`` if unset."""
90
+ raw = os.environ.get(ENV_PRESET_ENV, "").strip().lower()
91
+ if not raw:
92
+ return None
93
+ preset = _ENV_PRESETS.get(raw)
94
+ if preset is None:
95
+ raise UnknownEnvPresetError(
96
+ f"Unknown {ENV_PRESET_ENV} value: {os.environ.get(ENV_PRESET_ENV)!r}. "
97
+ f"Supported values: {sorted(_ENV_PRESETS)}"
98
+ )
99
+ return preset
100
+
101
+
102
+ def resolve_base_url(credentials: Optional[Mapping[str, Any]] = None) -> str:
103
+ """Resolve the API base URL: env override -> env preset -> credentials -> the prod default.
104
+
105
+ Credentials outrank the trailing production default deliberately: after
106
+ ``SIMULO_ENV=staging simulo login``, a bare ``simulo run``/``simulo jobs``
107
+ (no env vars set) must keep targeting the platform the user logged in
108
+ against, not jump to production.
109
+ """
110
+ explicit = os.environ.get(API_URL_ENV)
111
+ if explicit:
112
+ return explicit
113
+ preset = _preset()
114
+ if preset is not None and preset.get("api_base_url"):
115
+ return preset["api_base_url"]
116
+ if credentials is not None:
117
+ from_creds = credentials.get("api_base_url")
118
+ if isinstance(from_creds, str) and from_creds:
119
+ return from_creds
120
+ return _ENV_PRESETS[DEFAULT_ENV_PRESET]["api_base_url"]
121
+
122
+
123
+ def resolve_console_base_url(credentials: Optional[Mapping[str, Any]] = None) -> str:
124
+ """Resolve the console base URL: env override -> env preset -> credentials -> the prod default.
125
+
126
+ Mirrors :func:`resolve_base_url` exactly, field-for-field. Used by the
127
+ login flow (``login.py``) to build the OAuth loopback ``redirect_uri``
128
+ when no local loopback port can be bound, and to record the console
129
+ origin the user authenticated against in ``~/.simulo/credentials``.
130
+
131
+ Also used by ``simulo asset`` output (via ``cli._asset_console_base_url``)
132
+ to resolve the base URL for its console deep link — a staging-logged-in
133
+ user must never see a production console link. That deep link is printed
134
+ only when ``cli._console_reachable`` confirms the console is actually
135
+ up — never a dead link to a console that isn't running/deployed.
136
+ """
137
+ explicit = os.environ.get(CONSOLE_BASE_URL_ENV)
138
+ if explicit:
139
+ return explicit
140
+ preset = _preset()
141
+ if preset is not None and preset.get("console_base_url"):
142
+ return preset["console_base_url"]
143
+ if credentials is not None:
144
+ from_creds = credentials.get("console_base_url")
145
+ if isinstance(from_creds, str) and from_creds:
146
+ return from_creds
147
+ return _ENV_PRESETS[DEFAULT_ENV_PRESET]["console_base_url"]
148
+
149
+
150
+ def is_submit_forced_local() -> bool:
151
+ """``True`` when ``SIMULO_SUBMIT=local`` explicitly disables cloud submit."""
152
+ return os.environ.get(SUBMIT_MODE_ENV, "").strip().lower() == SUBMIT_MODE_LOCAL
153
+
154
+
155
+ def has_explicit_cloud_env() -> bool:
156
+ """``True`` when ``SIMULO_API_URL`` or ``SIMULO_ENV`` names a cloud target explicitly."""
157
+ return bool(os.environ.get(API_URL_ENV)) or bool(os.environ.get(ENV_PRESET_ENV))
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class LoginSettings:
162
+ """Resolved settings for the ``simulo login`` PKCE flow."""
163
+
164
+ api_base_url: str
165
+ cognito_hosted_ui: str
166
+ cli_app_client_id: str
167
+ console_base_url: str
168
+
169
+
170
+ def resolve_login_settings() -> LoginSettings:
171
+ """Resolve login-flow settings: individual env vars win over the ``SIMULO_ENV`` preset (prod when unset).
172
+
173
+ A bare ``simulo login`` therefore signs in to the production cloud. Local
174
+ dev keeps working unchanged because it sets the individual env vars
175
+ explicitly (``SIMULO_API_URL`` / ``SIMULO_CLI_APP_CLIENT_ID`` /
176
+ ``SIMULO_COGNITO_HOSTED_UI`` / ``SIMULO_CONSOLE_BASE_URL`` — see
177
+ ``scripts/dev-local-env.sh`` and demos/WALKTHROUGH.md Part 1), and those
178
+ are checked FIRST, field by field.
179
+ """
180
+ preset = _preset() or _ENV_PRESETS[DEFAULT_ENV_PRESET]
181
+ return LoginSettings(
182
+ api_base_url=os.environ.get(API_URL_ENV) or preset["api_base_url"],
183
+ cognito_hosted_ui=os.environ.get(COGNITO_HOSTED_UI_ENV) or preset["cognito_hosted_ui"],
184
+ cli_app_client_id=os.environ.get(CLI_APP_CLIENT_ID_ENV) or preset["cli_app_client_id"],
185
+ console_base_url=os.environ.get(CONSOLE_BASE_URL_ENV) or preset["console_base_url"],
186
+ )
@@ -0,0 +1,210 @@
1
+ """Credentials file helpers — ``~/.simulo/credentials`` (mode 0600).
2
+
3
+ Mirrors the credentials file format the ``simulo-backend`` parked CLI writes
4
+ (``simulo.cli.auth.credentials``)
5
+ field-for-field, plus one addition: ``api_base_url``, the base URL this
6
+ client logged in against (so a bare ``simulo jobs`` on a laptop that only ever
7
+ ran ``SIMULO_ENV=staging simulo login`` still targets staging without the env
8
+ var set on every subsequent invocation — see ``config.resolve_base_url``).
9
+
10
+ Credentials file schema (version 1)::
11
+
12
+ {
13
+ "version": 1,
14
+ "access_token": "<jwt>",
15
+ "refresh_token": "<opaque>",
16
+ "expires_at": "2026-04-24T15:00:00Z",
17
+ "active_organization_id": "<org-uuid>",
18
+ "user_email": "user@example.com",
19
+ "api_base_url": "https://api.staging.simulo.ai"
20
+ }
21
+
22
+ Field names are canonical — do NOT rename without also updating
23
+ ``cli-auth/SKILL.md`` and the ``simulo-backend`` CLI's own credentials module.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import stat
31
+ from datetime import datetime, timedelta, timezone
32
+ from pathlib import Path
33
+ from typing import Any, Optional
34
+
35
+ from simulo._client import config, http
36
+
37
+ CREDENTIALS_PATH = Path.home() / ".simulo" / "credentials"
38
+
39
+ #: Refresh path is on the client-facing control plane, not a routes-module
40
+ #: constant (it belongs to the identity/auth surface, not the submit/runs
41
+ #: contract) — see ``.claude/skills/cli-auth/SKILL.md`` Control-Plane Endpoints.
42
+ TOKEN_REFRESH_PATH = "/auth/token/refresh"
43
+
44
+ #: Refresh a token this many seconds before its recorded expiry, to absorb the
45
+ #: round-trip latency of the call that is about to use it.
46
+ _EXPIRY_SKEW_S = 30.0
47
+
48
+
49
+ def _posix_permissions_enforced() -> bool:
50
+ """Whether POSIX file-mode bits are meaningful (and enforced) here.
51
+
52
+ On Windows (``os.name == "nt"``) ``os.chmod`` can only toggle the
53
+ read-only flag and ``st_mode`` reports ``0o666`` for any writable file —
54
+ the group/other bits can never be cleared, so a mode check would always
55
+ reject the very file :func:`save_credentials` just wrote (P1, 2026-07-11:
56
+ ``simulo login`` succeeded and then every credential-using command was
57
+ permanently locked out, with a ``chmod`` remedy that does not exist on
58
+ Windows). Windows protects ``%USERPROFILE%`` with ACLs instead, so both
59
+ the trailing ``chmod`` in :func:`save_credentials` and the fail-loud mode
60
+ check in :func:`load_credentials` are skipped there. On every POSIX
61
+ platform the 0600 write + unsafe-permissions check are a real security
62
+ invariant and behave exactly as before.
63
+ """
64
+ return os.name != "nt"
65
+
66
+
67
+ class CredentialsError(RuntimeError):
68
+ """Base error for the local credentials file: missing, unsafe, or malformed."""
69
+
70
+
71
+ class TokenRefreshError(CredentialsError):
72
+ """Silent refresh of an expired access token failed."""
73
+
74
+ def __init__(self, message: str, *, code: Optional[str] = None) -> None:
75
+ super().__init__(message)
76
+ self.code = code
77
+
78
+
79
+ def save_credentials(data: dict[str, Any]) -> None:
80
+ """Write *data* to the credentials file with mode 0600, atomically.
81
+
82
+ Creates the parent directory (``~/.simulo``) with mode 0700 if absent.
83
+ Opens the file via ``os.open`` with the target mode passed at creation
84
+ time (rather than ``write_text`` followed by a separate ``chmod``) so the
85
+ file's permissions are 0600 from the instant it is created — never a
86
+ world/group-readable file for however brief a window before the chmod
87
+ call lands (a TOCTOU gap another local process could win). The trailing
88
+ ``chmod`` still runs because ``os.open``'s ``mode`` argument only applies
89
+ when the file is newly created (an overwrite of a pre-existing file
90
+ ignores it, and is also masked by ``umask`` in either case).
91
+
92
+ On Windows the trailing ``chmod`` is skipped (`_posix_permissions_enforced`):
93
+ mode bits cannot express owner-only there, and the file is protected by the
94
+ user profile's ACLs instead — the loader's mode check is skipped to match.
95
+ (``os.open``'s mode and ``mkdir``'s mode are harmless no-ops on Windows and
96
+ stay unconditional.)
97
+ """
98
+ CREDENTIALS_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
99
+ payload = json.dumps(data, indent=2)
100
+ fd = os.open(CREDENTIALS_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
101
+ with os.fdopen(fd, "w") as handle:
102
+ handle.write(payload)
103
+ if _posix_permissions_enforced():
104
+ CREDENTIALS_PATH.chmod(0o600)
105
+
106
+
107
+ def credentials_file_exists() -> bool:
108
+ """``True`` when a credentials file is present (no permission check)."""
109
+ return CREDENTIALS_PATH.exists()
110
+
111
+
112
+ def load_credentials() -> dict[str, Any]:
113
+ """Read and return the credentials dict.
114
+
115
+ Fails loudly (:class:`CredentialsError`) if:
116
+ - the file does not exist (not logged in)
117
+ - the file has group- or world-readable permissions (security invariant;
118
+ POSIX only — on Windows ``st_mode`` reports ``0o666`` for any writable
119
+ file and ``chmod`` cannot clear the group/other bits, so the check is
120
+ meaningless there and is skipped: see `_posix_permissions_enforced`)
121
+ """
122
+ if not CREDENTIALS_PATH.exists():
123
+ raise CredentialsError("Not logged in. Run: simulo login")
124
+ if _posix_permissions_enforced():
125
+ mode = CREDENTIALS_PATH.stat().st_mode
126
+ if mode & (stat.S_IRWXG | stat.S_IRWXO):
127
+ raise CredentialsError(
128
+ f"Credentials file {CREDENTIALS_PATH} has unsafe permissions. Run: chmod 600 {CREDENTIALS_PATH}"
129
+ )
130
+ loaded: Any = json.loads(CREDENTIALS_PATH.read_text())
131
+ if not isinstance(loaded, dict):
132
+ raise CredentialsError(f"Credentials file {CREDENTIALS_PATH} is malformed (not a JSON object).")
133
+ return loaded
134
+
135
+
136
+ def clear_credentials() -> None:
137
+ """Remove the credentials file if it exists."""
138
+ if CREDENTIALS_PATH.exists():
139
+ CREDENTIALS_PATH.unlink()
140
+
141
+
142
+ def is_expired(creds: dict[str, Any], *, skew_s: float = _EXPIRY_SKEW_S) -> bool:
143
+ """``True`` when ``creds["expires_at"]`` is at or before now (plus *skew_s*)."""
144
+ expires_at = datetime.fromisoformat(str(creds["expires_at"]))
145
+ if expires_at.tzinfo is None:
146
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
147
+ return datetime.now(timezone.utc) + timedelta(seconds=skew_s) >= expires_at
148
+
149
+
150
+ def refresh_access_token(creds: dict[str, Any], *, base_url: str) -> dict[str, Any]:
151
+ """``POST {base_url}/auth/token/refresh``; merge + persist the new access token.
152
+
153
+ Preserves ``refresh_token`` / ``active_organization_id`` / every other field
154
+ from the existing credentials — the refresh response carries only
155
+ ``access_token`` + ``expires_at`` (see cli-auth SKILL "Refresh response").
156
+ """
157
+ url = base_url.rstrip("/") + TOKEN_REFRESH_PATH
158
+ # The refresh token travels in the POST BODY here, not a bearer header, so
159
+ # ``http.request_json``'s own https-when-token guard (which only inspects
160
+ # the ``token=`` header-attachment path) never sees it. It is just as
161
+ # sensitive as a bearer — apply the same https-or-loopback check
162
+ # explicitly, BEFORE the request is ever attempted, so a misconfigured
163
+ # plain-http non-loopback SIMULO_API_URL/SIMULO_ENV can never leak it.
164
+ try:
165
+ http.require_https_or_loopback_for_bearer(url)
166
+ except http.InsecureBearerError as exc:
167
+ raise TokenRefreshError(str(exc)) from exc
168
+ try:
169
+ payload = http.request_json(
170
+ "POST",
171
+ url,
172
+ json_body={"refresh_token": creds.get("refresh_token")},
173
+ unavailable_hint="Cannot refresh the session — check your network connection.",
174
+ )
175
+ except http.HttpHTTPError as exc:
176
+ raise TokenRefreshError("Session refresh was rejected. Run: simulo login", code=exc.code) from exc
177
+ except http.HttpUnavailable as exc:
178
+ raise TokenRefreshError(
179
+ "Cannot refresh the session — check your network connection. Run: simulo login"
180
+ ) from exc
181
+ if not isinstance(payload, dict) or "access_token" not in payload or "expires_at" not in payload:
182
+ raise TokenRefreshError("Malformed response from the token refresh endpoint.")
183
+ new_creds = {**creds, "access_token": payload["access_token"], "expires_at": payload["expires_at"]}
184
+ save_credentials(new_creds)
185
+ return new_creds
186
+
187
+
188
+ def get_valid_credentials(*, base_url: Optional[str] = None) -> dict[str, Any]:
189
+ """Load credentials, silently refreshing the access token if it has expired.
190
+
191
+ Raises :class:`CredentialsError` (not logged in / unsafe permissions) or
192
+ :class:`TokenRefreshError` (refresh attempted and failed) — never returns
193
+ an expired token.
194
+ """
195
+ creds = load_credentials()
196
+ if not is_expired(creds):
197
+ return creds
198
+ resolved_base_url = base_url or config.resolve_base_url(creds)
199
+ return refresh_access_token(creds, base_url=resolved_base_url)
200
+
201
+
202
+ def try_get_valid_credentials(*, base_url: Optional[str] = None) -> Optional[dict[str, Any]]:
203
+ """Like :func:`get_valid_credentials`, but ``None`` when simply not logged in.
204
+
205
+ Still raises on unsafe permissions or a failed refresh — those are real
206
+ errors, not "offline mode" signals.
207
+ """
208
+ if not CREDENTIALS_PATH.exists():
209
+ return None
210
+ return get_valid_credentials(base_url=base_url)
@@ -0,0 +1,214 @@
1
+ """Discovery-mode import interception + the ``discover(app_file)`` entrypoint.
2
+
3
+ In discovery mode the thin client imports a user app to harvest metadata, but it
4
+ must NOT resolve the heavy imports the user wrote inside ``runtime.imports()``
5
+ (``torch`` / Isaac / …) — those only exist in the cloud. ``_StubImportFinder`` is
6
+ a *find-phase fallback*: installed at the END of ``sys.meta_path`` (so it is only
7
+ consulted when every real finder has already failed), it satisfies an otherwise
8
+ unresolvable import with a harmless stub module and records the top-level name as
9
+ a deferred remote import.
10
+
11
+ Because it is a find-phase fallback it cannot rescue a module that is *found* and
12
+ then raises while executing — hence the authoring rule that only leaf, uninstalled
13
+ packages belong in the ``runtime.imports()`` guard.
14
+ """
15
+
16
+ import importlib.util
17
+ import os
18
+ import sys
19
+ from contextlib import AbstractContextManager
20
+ from importlib.abc import Loader, MetaPathFinder
21
+ from importlib.machinery import ModuleSpec
22
+ from pathlib import Path
23
+ from types import FrameType, ModuleType, TracebackType
24
+ from typing import Optional, Sequence
25
+
26
+ from simulo._client.mode import DISCOVERY
27
+ from simulo._client.registry import find_app
28
+ from simulo._client.stub import Stub
29
+ from simulo.interfaces.platform.app import AppProtocol
30
+
31
+ #: Prefix of the synthetic module name a user app file is imported under (see
32
+ #: ``_load_module_from_path``). The stub finder uses it to recognise imports the
33
+ #: user app wrote directly, versus transitive imports issued by a real library.
34
+ _USER_MODULE_PREFIX = "_simulo_user_"
35
+
36
+
37
+ def _import_is_user_initiated() -> bool:
38
+ """True if the in-flight import statement lives in the user app module.
39
+
40
+ Walks outward from the finder frame, skipping the import machinery and this
41
+ module, to the frame that executed the ``import`` statement. The stub finder
42
+ must satisfy only the *leaf* imports the user wrote inside ``runtime.imports()``
43
+ (e.g. ``import torch``). It must NOT satisfy imports a genuinely-installed
44
+ library issues transitively while loading — on a GPU/dev box where ``torch``
45
+ is present, ``import torch`` resolves for real and torch then probes optional
46
+ deps (``import monkeytype``) with ``try/except ImportError``; stubbing those
47
+ turns an expected ImportError into a broken half-real module and crashes the
48
+ real library. Declining transitive imports lets them raise naturally.
49
+
50
+ The user app is the synthetic ``_simulo_user_*`` module (``simulo run`` / the
51
+ packager imports the file by path) OR the ``__main__`` module (``python
52
+ app.py`` runs the file directly). Both are first-party authoring contexts.
53
+ """
54
+ frame: Optional[FrameType] = sys._getframe(1) # caller == find_spec
55
+ while frame is not None:
56
+ name = str(frame.f_globals.get("__name__", ""))
57
+ if name == __name__ or name == "importlib" or name.startswith("importlib."):
58
+ frame = frame.f_back
59
+ continue
60
+ return name.startswith(_USER_MODULE_PREFIX) or name == "__main__"
61
+ return False
62
+
63
+
64
+ class _StubModule(ModuleType):
65
+ """A module whose missing attributes resolve to :class:`Stub` placeholders."""
66
+
67
+ __path__: list[str]
68
+
69
+ def __getattr__(self, item: str) -> Stub:
70
+ if item.startswith("__") and item.endswith("__"):
71
+ raise AttributeError(item)
72
+ return Stub(f"{self.__name__}.{item}")
73
+
74
+
75
+ class _StubLoader(Loader):
76
+ """Loader that materialises :class:`_StubModule` instances."""
77
+
78
+ def create_module(self, spec: ModuleSpec) -> ModuleType:
79
+ module = _StubModule(spec.name)
80
+ # Mark as a package so ``import torch.nn`` re-enters the finder for the
81
+ # submodule rather than failing outright.
82
+ module.__path__ = []
83
+ return module
84
+
85
+ def exec_module(self, module: ModuleType) -> None:
86
+ return None
87
+
88
+
89
+ class _StubImportFinder(MetaPathFinder):
90
+ """Find-phase fallback that stubs otherwise-unresolvable imports.
91
+
92
+ Records each intercepted *top-level* module name into ``remote_imports`` and
93
+ every stubbed fullname into ``stubbed`` (so the context manager can purge them
94
+ from ``sys.modules`` on exit, keeping torch & friends out of the process).
95
+ """
96
+
97
+ def __init__(self, remote_imports: list[str], stubbed: list[str]) -> None:
98
+ self._remote_imports = remote_imports
99
+ self._stubbed = stubbed
100
+ self._loader = _StubLoader()
101
+
102
+ def find_spec(
103
+ self,
104
+ fullname: str,
105
+ path: Optional[Sequence[str]] = None,
106
+ target: Optional[ModuleType] = None,
107
+ ) -> Optional[ModuleSpec]:
108
+ # Only satisfy imports the user app wrote directly. A transitive import
109
+ # issued by a genuinely-installed library (e.g. torch probing the optional
110
+ # `monkeytype`) must be declined so it raises ImportError as that library
111
+ # expects, instead of becoming a broken half-stubbed module.
112
+ if not _import_is_user_initiated():
113
+ return None
114
+ top = fullname.split(".", 1)[0]
115
+ if top not in self._remote_imports:
116
+ self._remote_imports.append(top)
117
+ if fullname not in self._stubbed:
118
+ self._stubbed.append(fullname)
119
+ return ModuleSpec(fullname, self._loader, is_package=True)
120
+
121
+
122
+ class _DiscoveryImports(AbstractContextManager[None]):
123
+ """Context manager installing :class:`_StubImportFinder` for the duration of
124
+ a ``with runtime.imports():`` block in discovery mode.
125
+
126
+ On exit it removes the finder and purges every stub it created from
127
+ ``sys.modules`` so heavy libraries are never observable post-discovery.
128
+ """
129
+
130
+ def __init__(self, remote_imports: list[str]) -> None:
131
+ self._remote_imports = remote_imports
132
+ self._stubbed: list[str] = []
133
+ self._finder: Optional[_StubImportFinder] = None
134
+
135
+ def __enter__(self) -> None:
136
+ self._finder = _StubImportFinder(self._remote_imports, self._stubbed)
137
+ # Append (not insert) so real finders always win — we are a fallback.
138
+ sys.meta_path.append(self._finder)
139
+ return None
140
+
141
+ def __exit__(
142
+ self,
143
+ exc_type: Optional[type[BaseException]],
144
+ exc: Optional[BaseException],
145
+ traceback: Optional[TracebackType],
146
+ ) -> None:
147
+ if self._finder is not None:
148
+ try:
149
+ sys.meta_path.remove(self._finder)
150
+ except ValueError:
151
+ pass
152
+ self._finder = None
153
+ for name in self._stubbed:
154
+ sys.modules.pop(name, None)
155
+
156
+
157
+ def stub_import_context(remote_imports: list[str]) -> AbstractContextManager[None]:
158
+ """Build the discovery-mode ``runtime.imports()`` context manager."""
159
+ return _DiscoveryImports(remote_imports)
160
+
161
+
162
+ def _load_module_from_path(app_file: Path) -> ModuleType:
163
+ """Import a user app file by path under a synthetic module name."""
164
+ resolved = app_file.resolve()
165
+ parent = str(resolved.parent)
166
+ # Let the app import sibling local modules during discovery. Append (not
167
+ # ``insert(0, ...)``) so a user file named like a stdlib module cannot shadow
168
+ # the standard library during discovery. ``discover`` restores ``sys.path``.
169
+ if parent not in sys.path:
170
+ sys.path.append(parent)
171
+ module_name = f"{_USER_MODULE_PREFIX}{resolved.stem}"
172
+ spec = importlib.util.spec_from_file_location(module_name, resolved)
173
+ if spec is None or spec.loader is None:
174
+ raise ImportError(f"Could not load a Python module from {resolved}")
175
+ module = importlib.util.module_from_spec(spec)
176
+ # Register before exec so dataclasses / typing references resolve.
177
+ sys.modules[module_name] = module
178
+ spec.loader.exec_module(module)
179
+ return module
180
+
181
+
182
+ def import_app_module(app_file: "str | Path") -> ModuleType:
183
+ """Import a user app file by path under the CURRENT ``SIMULO_MODE``.
184
+
185
+ Unlike :func:`discover`, this does not force discovery mode or restore it — the
186
+ caller sets the mode it wants (``simulo run`` sets ``discovery`` for a
187
+ torch-free submit and keeps it set while invoking the entrypoint). The module's
188
+ parent is appended to ``sys.path`` so sibling-module imports resolve.
189
+ """
190
+ return _load_module_from_path(Path(app_file))
191
+
192
+
193
+ def discover(app_file: "str | Path", *, name: Optional[str] = None) -> AppProtocol:
194
+ """Import ``app_file`` in discovery mode and return its declared ``App``.
195
+
196
+ Sets ``SIMULO_MODE=discovery``, imports the module by path (running no job
197
+ bodies and stubbing guarded heavy imports), and resolves the single app via
198
+ the imported module's globals.
199
+
200
+ Process-global state (``SIMULO_MODE`` and ``sys.path``) is restored on exit
201
+ so discovery leaves no residue for whatever runs next in the same process.
202
+ """
203
+ previous_mode = os.environ.get("SIMULO_MODE")
204
+ previous_sys_path = list(sys.path)
205
+ os.environ["SIMULO_MODE"] = DISCOVERY
206
+ try:
207
+ module = _load_module_from_path(Path(app_file))
208
+ return find_app(module=module, name=name)
209
+ finally:
210
+ sys.path[:] = previous_sys_path
211
+ if previous_mode is None:
212
+ os.environ.pop("SIMULO_MODE", None)
213
+ else:
214
+ os.environ["SIMULO_MODE"] = previous_mode