simantic 0.2.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.
simantic/auth.py ADDED
@@ -0,0 +1,221 @@
1
+ """Credentials for the Simantic backend.
2
+
3
+ Reads and writes `~/.sim_id`, the same `EMAIL=`/`API_KEY=` file the CLIs use.
4
+ Deliberately not a second credential store: authenticating here authenticates
5
+ the CLIs too, and vice versa.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+ import webbrowser
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ VALIDATE_URL = "https://drjdhqfvrttolueolzif.supabase.co/functions/v1/validate-token"
20
+ LOGIN_START_URL = "https://drjdhqfvrttolueolzif.supabase.co/functions/v1/cli-login-start"
21
+ LOGIN_POLL_URL = "https://drjdhqfvrttolueolzif.supabase.co/functions/v1/cli-login-poll"
22
+
23
+ #: Personal access tokens carry this prefix; anything else is a paste error.
24
+ TOKEN_PREFIX = "smtc_"
25
+
26
+
27
+ class AuthError(RuntimeError):
28
+ """The token is malformed, rejected, or could not be stored."""
29
+
30
+
31
+ class NotAuthenticated(AuthError):
32
+ """No credentials on this machine."""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Credentials:
37
+ email: str
38
+ api_key: str
39
+
40
+
41
+ def sim_id_path() -> Path:
42
+ """`~/.sim_id`, honouring $HOME so tests can redirect it."""
43
+ home = os.environ.get("HOME")
44
+ if not home:
45
+ raise AuthError("$HOME is not set")
46
+ return Path(home) / ".sim_id"
47
+
48
+
49
+ def load() -> Credentials:
50
+ """Read stored credentials, or raise NotAuthenticated."""
51
+ path = sim_id_path()
52
+ try:
53
+ text = path.read_text()
54
+ except OSError:
55
+ raise NotAuthenticated(
56
+ f"no credentials at {path} — run `simantic auth`"
57
+ ) from None
58
+
59
+ fields = {}
60
+ for line in text.splitlines():
61
+ key, sep, value = line.partition("=")
62
+ if sep:
63
+ fields[key.strip()] = value.strip()
64
+
65
+ api_key = fields.get("API_KEY")
66
+ if not api_key:
67
+ raise NotAuthenticated(f"{path} has no API_KEY — run `simantic auth`")
68
+ return Credentials(email=fields.get("EMAIL", ""), api_key=api_key)
69
+
70
+
71
+ def check_token(token: str) -> None:
72
+ """Reject tokens that cannot be valid, before spending a round trip.
73
+
74
+ The ASCII check is not cosmetic: a token pasted with a non-breaking space
75
+ or newline would otherwise be smuggled into an Authorization header.
76
+ """
77
+ if not token:
78
+ raise AuthError("no token provided")
79
+ if not token.startswith(TOKEN_PREFIX):
80
+ raise AuthError(
81
+ f"not an {TOKEN_PREFIX} token — create one on the dashboard's "
82
+ "/account/api page"
83
+ )
84
+ if not all(0x21 <= ord(c) <= 0x7E for c in token):
85
+ raise AuthError(
86
+ "token contains whitespace or non-ASCII characters — re-copy it "
87
+ "from /account/api"
88
+ )
89
+
90
+
91
+ def validate(token: str, *, timeout: float = 10) -> str:
92
+ """Ask the backend to accept the token; return the account's email."""
93
+ check_token(token)
94
+ request = urllib.request.Request(
95
+ os.environ.get("SIMANTIC_VALIDATE_URL", VALIDATE_URL),
96
+ data=b"",
97
+ method="POST",
98
+ headers={"Authorization": f"Bearer {token}"},
99
+ )
100
+ try:
101
+ with urllib.request.urlopen(request, timeout=timeout) as response:
102
+ body = response.read().decode(errors="replace")
103
+ except urllib.error.HTTPError as exc:
104
+ if exc.code == 401:
105
+ raise AuthError(
106
+ "token rejected by the backend — check it on the dashboard's "
107
+ "/account/api page"
108
+ ) from None
109
+ detail = exc.read().decode(errors="replace").strip()[:200]
110
+ raise AuthError(f"validate-token returned HTTP {exc.code}: {detail}") from None
111
+ except urllib.error.URLError as exc:
112
+ raise AuthError(f"cannot reach the simantic backend: {exc.reason}") from None
113
+
114
+ return _email_from(body)
115
+
116
+
117
+ def _email_from(body: str) -> str:
118
+ """The email in a validate-token response, or "" if it carries none."""
119
+ try:
120
+ data = json.loads(body)
121
+ except json.JSONDecodeError:
122
+ return ""
123
+ if isinstance(data, dict):
124
+ for key in ("email", "user_email"):
125
+ value = data.get(key)
126
+ if isinstance(value, str):
127
+ return value
128
+ return ""
129
+
130
+
131
+ def save(token: str, email: str) -> Path:
132
+ """Store credentials 0600, atomically.
133
+
134
+ Written to a temp file that is 0600 from birth and then renamed, so there
135
+ is no world-readable window and no truncate-in-place of a file the CLIs
136
+ may be reading concurrently.
137
+ """
138
+ path = sim_id_path()
139
+ tmp = path.with_suffix(".tmp")
140
+ try:
141
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
142
+ try:
143
+ os.write(fd, f"EMAIL={email}\nAPI_KEY={token}\n".encode())
144
+ finally:
145
+ os.close(fd)
146
+ os.replace(tmp, path)
147
+ except OSError as exc:
148
+ tmp.unlink(missing_ok=True)
149
+ raise AuthError(f"cannot write {path}: {exc}") from None
150
+ return path
151
+
152
+
153
+ def login(token: str) -> Credentials:
154
+ """Validate a token and store it. The whole `simantic auth` flow."""
155
+ email = validate(token)
156
+ save(token, email)
157
+ return Credentials(email=email, api_key=token)
158
+
159
+
160
+ def _post_json(url: str, body: dict, *, timeout: float) -> dict:
161
+ """POST a JSON body, return the JSON response. Shared by start and poll."""
162
+ request = urllib.request.Request(
163
+ url,
164
+ data=json.dumps(body).encode(),
165
+ method="POST",
166
+ headers={"Content-Type": "application/json"},
167
+ )
168
+ try:
169
+ with urllib.request.urlopen(request, timeout=timeout) as response:
170
+ return json.loads(response.read())
171
+ except urllib.error.HTTPError as exc:
172
+ detail = exc.read().decode(errors="replace").strip()[:200]
173
+ raise AuthError(f"{url} returned HTTP {exc.code}: {detail}") from None
174
+ except urllib.error.URLError as exc:
175
+ raise AuthError(f"cannot reach the simantic backend: {exc.reason}") from None
176
+ except json.JSONDecodeError as exc:
177
+ raise AuthError(f"{url} returned invalid JSON: {exc}") from None
178
+
179
+
180
+ def browser_login(*, open_browser: bool = True, timeout: float = 600) -> Credentials:
181
+ """Authenticate via the device flow: a browser tab, not a pasted token.
182
+
183
+ Mirrors `gh auth login` — the code is printed so it is visible even when
184
+ the browser can't be opened (headless, SSH), and the URL is printed
185
+ unconditionally as the fallback for that case.
186
+ """
187
+ start_url = os.environ.get("SIMANTIC_CLI_LOGIN_START_URL", LOGIN_START_URL)
188
+ poll_url = os.environ.get("SIMANTIC_CLI_LOGIN_POLL_URL", LOGIN_POLL_URL)
189
+
190
+ start = _post_json(start_url, {}, timeout=10)
191
+ device_code = start.get("device_code")
192
+ user_code = start.get("user_code")
193
+ verify_url = start.get("verify_url")
194
+ interval = start.get("interval", 3)
195
+ if not device_code or not user_code or not verify_url:
196
+ raise AuthError(f"{start_url} returned an incomplete response")
197
+
198
+ print(f"First copy your one-time code: {user_code}")
199
+ print(f"Then open this URL in your browser to confirm: {verify_url}")
200
+ if open_browser:
201
+ try:
202
+ webbrowser.open(verify_url)
203
+ except Exception:
204
+ pass # the URL above is already the fallback
205
+
206
+ deadline = time.monotonic() + timeout
207
+ while time.monotonic() < deadline:
208
+ time.sleep(interval)
209
+ result = _post_json(poll_url, {"device_code": device_code}, timeout=10)
210
+ status = result.get("status")
211
+ if status == "approved":
212
+ token, email = result.get("token"), result.get("email", "")
213
+ if not token:
214
+ raise AuthError(f"{poll_url} approved without a token")
215
+ save(token, email)
216
+ return Credentials(email=email, api_key=token)
217
+ if status == "pending":
218
+ continue
219
+ raise AuthError(f"sign-in {status}")
220
+
221
+ raise AuthError("timed out waiting for browser approval")
simantic/engine.py ADDED
@@ -0,0 +1,91 @@
1
+ """Loading the simulation engine into this process.
2
+
3
+ `Simantic.Core` is a .NET library; `sim` is one face of it and this package is
4
+ another. `simantic install` (or `$SIMANTIC_SIM`) gives us the directory that
5
+ holds `sim`, `Simantic.Core.dll` and the runtime config, which is everything
6
+ needed to host the engine here via pythonnet. Nothing is spawned: the Python
7
+ objects *are* the session.
8
+
9
+ One emulation per process — the engine keeps process-global state — so
10
+ parallel runs are parallel processes (`ProcessPoolExecutor`).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ from functools import cache
18
+ from pathlib import Path
19
+
20
+ from . import install
21
+ from ._locate import BinaryNotFound
22
+ from .mcu import sim_binary
23
+
24
+ ENV_DIR = "SIMANTIC_ENGINE_DIR"
25
+
26
+
27
+ class EngineNotFound(RuntimeError):
28
+ """The engine assemblies could not be located or loaded."""
29
+
30
+
31
+ def _is_engine(d: Path) -> bool:
32
+ return (d / "Simantic.Core.dll").exists() and (d / "sim.runtimeconfig.json").exists()
33
+
34
+
35
+ def engine_dir(explicit: str | os.PathLike[str] | None = None, *, fetch: bool = True) -> Path:
36
+ """The directory holding Simantic.Core.dll and sim.runtimeconfig.json.
37
+
38
+ Order: an explicit path, $SIMANTIC_ENGINE_DIR, a development `sim` whose
39
+ publish directory is beside it ($SIMANTIC_SIM), then the managed install
40
+ under ~/.simantic/engine. When nothing is there, the engine is fetched
41
+ from the public release — so the first `Sim(...)` after `pip install`
42
+ just works.
43
+ """
44
+ candidates = []
45
+ if explicit is not None:
46
+ candidates.append(Path(explicit))
47
+ if os.environ.get(ENV_DIR):
48
+ candidates.append(Path(os.environ[ENV_DIR]))
49
+ try:
50
+ candidates.append(sim_binary().resolve().parent)
51
+ except BinaryNotFound:
52
+ pass
53
+ for d in candidates:
54
+ if _is_engine(d):
55
+ return d
56
+ managed = install.installed_engine()
57
+ if managed is not None:
58
+ return managed
59
+ if fetch:
60
+ try:
61
+ return install.install_engine()
62
+ except install.InstallError as exc:
63
+ raise EngineNotFound(f"could not fetch the engine: {exc}") from None
64
+ raise EngineNotFound(
65
+ f"Simantic.Core.dll not found. Run `simantic install engine`, or set ${ENV_DIR}."
66
+ )
67
+
68
+
69
+ @cache
70
+ def load(explicit: str | os.PathLike[str] | None = None):
71
+ """Host the .NET runtime and import Simantic.Core. Returns the Session namespace."""
72
+ d = engine_dir(explicit)
73
+ try:
74
+ from pythonnet import load as load_runtime
75
+ except ImportError as exc: # pragma: no cover - dependency declared in pyproject
76
+ raise EngineNotFound("pythonnet is required to host the engine: pip install pythonnet") from exc
77
+ # A managed engine carries its own runtime in dotnet/; a development
78
+ # publish directory relies on the machine's ($DOTNET_ROOT / default).
79
+ bundled = d / "dotnet"
80
+ if (bundled / "host").is_dir():
81
+ load_runtime("coreclr", runtime_config=str(d / "sim.runtimeconfig.json"), dotnet_root=str(bundled))
82
+ else:
83
+ load_runtime("coreclr", runtime_config=str(d / "sim.runtimeconfig.json"))
84
+ import clr # noqa: F401 (provided by pythonnet after load)
85
+
86
+ if str(d) not in sys.path:
87
+ sys.path.append(str(d))
88
+ clr.AddReference("Simantic.Core")
89
+ import Simantic.Core.Emulation.Session as session_ns # type: ignore[import-not-found]
90
+
91
+ return session_ns
simantic/fixtures.py ADDED
@@ -0,0 +1,144 @@
1
+ """test.yaml fixture manifests: loading, and resolving from a model library.
2
+
3
+ A fixture names an MCU (`mcu: STM32F401RE`), not a platform file. Normally
4
+ `sim` resolves that name for you against your account; models are not
5
+ distributed with this package.
6
+
7
+ If you have a local model library, point $SIMANTIC_MCU_LIB at it and models
8
+ resolve from there instead, without a round trip. That path is also what a
9
+ fixture's `overlay` fragment needs, since an overlay edits platform text
10
+ before it reaches the simulator. Resolution is delegated to the library's own
11
+ tooling rather than reimplemented here, so the two cannot drift.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ from dataclasses import dataclass
21
+ from functools import cache
22
+ from pathlib import Path
23
+
24
+ import yaml
25
+
26
+ #: Points at a local model library. Optional: without it, models resolve
27
+ #: through `sim` instead.
28
+ MCU_LIB_ENV = "SIMANTIC_MCU_LIB"
29
+
30
+
31
+ class ModelLibraryUnavailable(RuntimeError):
32
+ """No usable model library, so MCU names cannot be resolved locally."""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Manifest:
37
+ """A single-machine test.yaml. Multi-machine fixtures are not covered."""
38
+
39
+ path: Path
40
+ mcu: str
41
+ elf: str
42
+ timeout: int
43
+ expect: list[str]
44
+ expect_absent: list[str]
45
+ overlay: str | None = None
46
+
47
+ @property
48
+ def elf_path(self) -> Path:
49
+ return self.path.parent / self.elf
50
+
51
+ @property
52
+ def overlay_path(self) -> Path | None:
53
+ return self.path.parent / self.overlay if self.overlay else None
54
+
55
+
56
+ class UnsupportedManifest(ValueError):
57
+ """The manifest describes a fixture this SDK cannot run yet."""
58
+
59
+
60
+ def load_manifest(path: str | os.PathLike[str]) -> Manifest:
61
+ """Parse a test.yaml, or raise UnsupportedManifest with the reason."""
62
+ path = Path(path)
63
+ with open(path) as fh:
64
+ data = yaml.safe_load(fh) or {}
65
+
66
+ if "machines" in data:
67
+ raise UnsupportedManifest(
68
+ "multi-machine fixture: needs the sim --scenario runner, which this "
69
+ "SDK does not drive yet"
70
+ )
71
+ if "mcu" not in data:
72
+ raise UnsupportedManifest("manifest names no mcu")
73
+
74
+ return Manifest(
75
+ path=path,
76
+ mcu=data["mcu"],
77
+ elf=data["elf"],
78
+ timeout=int(data.get("timeout", 15)),
79
+ expect=list(data.get("expect") or []),
80
+ expect_absent=list(data.get("expect_absent") or []),
81
+ overlay=data.get("overlay"),
82
+ )
83
+
84
+
85
+ def mcu_lib_root() -> Path:
86
+ """The configured model library, or raise ModelLibraryUnavailable."""
87
+ configured = os.environ.get(MCU_LIB_ENV)
88
+ if not configured:
89
+ raise ModelLibraryUnavailable(
90
+ f"set ${MCU_LIB_ENV} to a local model library to resolve MCU models"
91
+ )
92
+ root = Path(configured)
93
+ if not (root / "scripts" / "parse_replx.py").exists():
94
+ raise ModelLibraryUnavailable(
95
+ f"${MCU_LIB_ENV} is {root}, which has no scripts/parse_replx.py "
96
+ "(submodule not initialised?)"
97
+ )
98
+ return root
99
+
100
+
101
+ @cache
102
+ def resolved_models() -> Path:
103
+ """Resolve every model once per process; return the output dir.
104
+
105
+ parse_replx.py fills `using` directives and strips comments. The result
106
+ is cached for the process because resolving the whole library per test
107
+ would dominate a suite's runtime.
108
+ """
109
+ root = mcu_lib_root()
110
+ dest = Path(tempfile.mkdtemp(prefix="simantic-models-"))
111
+ subprocess.run(
112
+ [sys.executable, str(root / "scripts" / "parse_replx.py"), "models.yaml", str(dest)],
113
+ cwd=root,
114
+ check=True,
115
+ stdout=subprocess.DEVNULL,
116
+ )
117
+ return dest
118
+
119
+
120
+ def platform_for(manifest: Manifest, workdir: Path) -> Path:
121
+ """The .replx to hand `sim --repl`, with any overlay fragment appended."""
122
+ return platform_path(manifest.mcu, manifest.overlay_path, workdir)
123
+
124
+
125
+ def platform_path(mcu: str, overlay: Path | None, workdir: Path) -> Path:
126
+ """Resolve `mcu` from the local model library, appending `overlay` if given."""
127
+ base = resolved_models() / f"{mcu}.replx"
128
+ if not base.exists():
129
+ raise ModelLibraryUnavailable(f"mcu {mcu} is not in the model library")
130
+
131
+ if overlay is None:
132
+ return base
133
+
134
+ # The platform grammar has no comment syntax — `//` and `#` notes are
135
+ # stripped during resolution — so comment-only lines must go before the
136
+ # fragment is appended.
137
+ body = "\n".join(
138
+ line
139
+ for line in overlay.read_text().splitlines()
140
+ if not line.lstrip().startswith(("#", "//"))
141
+ )
142
+ merged = workdir / f"{mcu}-overlaid.replx"
143
+ merged.write_text(base.read_text().rstrip() + "\n\n" + body.strip() + "\n")
144
+ return merged