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/config.py
ADDED
|
@@ -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
|