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/install.py ADDED
@@ -0,0 +1,313 @@
1
+ """Fetching simulator binaries.
2
+
3
+ Reads the same release manifest the CLIs self-update from, so a binary
4
+ installed here is the same artifact `sim update` would have produced:
5
+
6
+ releases/<product>/<channel>.json
7
+ {"version": "0.4.0",
8
+ "artifacts": {"osx-arm64": {"url": ..., "sha256": ...}, ...}}
9
+
10
+ The channel is just the manifest name, so publishing a pre-release means
11
+ uploading a second pointer beside latest.json rather than standing up
12
+ anything new.
13
+
14
+ Binaries land in ~/.simantic/bin, which the resolver searches. Nothing is
15
+ written into site-packages: an installed package may be read-only, and a
16
+ binary there would vanish on the next upgrade.
17
+
18
+ Releases are public objects, keyed by version, so fetching needs no account;
19
+ checksums from the manifest are what make a download trustworthy.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import io
26
+ import json
27
+ import os
28
+ import shutil
29
+ import platform
30
+ import stat
31
+ import tarfile
32
+ import urllib.error
33
+ import urllib.parse
34
+ import urllib.request
35
+ import zipfile
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+
39
+
40
+ RELEASES_URL = "https://drjdhqfvrttolueolzif.supabase.co/storage/v1/object/public/releases"
41
+
42
+
43
+ def releases_url() -> str:
44
+ """Where manifests are served from. $SIMANTIC_RELEASES_URL overrides.
45
+
46
+ Overridable so a release can be rehearsed against a staging host (or a
47
+ plain directory served over HTTP) before it is published.
48
+ """
49
+ return os.environ.get("SIMANTIC_RELEASES_URL", RELEASES_URL).rstrip("/")
50
+
51
+
52
+ #: The manifest to read. $SIMANTIC_CHANNEL selects a pre-release channel.
53
+ DEFAULT_CHANNEL = "latest"
54
+
55
+
56
+ def default_channel() -> str:
57
+ return os.environ.get("SIMANTIC_CHANNEL") or DEFAULT_CHANNEL
58
+
59
+
60
+ class InstallError(RuntimeError):
61
+ """The binary could not be fetched or verified."""
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Artifact:
66
+ version: str
67
+ url: str
68
+ sha256: str | None
69
+
70
+
71
+ def simantic_home() -> Path:
72
+ """Where managed binaries live. $SIMANTIC_HOME overrides."""
73
+ configured = os.environ.get("SIMANTIC_HOME")
74
+ if configured:
75
+ return Path(configured)
76
+ home = os.environ.get("HOME")
77
+ if not home:
78
+ raise InstallError("$HOME is not set; set $SIMANTIC_HOME instead")
79
+ return Path(home) / ".simantic"
80
+
81
+
82
+ def bin_dir() -> Path:
83
+ return simantic_home() / "bin"
84
+
85
+
86
+ def current_rid() -> str:
87
+ """The release-manifest key for this machine.
88
+
89
+ Mirrors the .NET runtime identifiers the release workflow publishes under,
90
+ so both installers read the same manifest keys.
91
+ """
92
+ machine = platform.machine().lower()
93
+ arch = {
94
+ "x86_64": "x64", "amd64": "x64",
95
+ "arm64": "arm64", "aarch64": "arm64",
96
+ }.get(machine)
97
+ system = {"darwin": "osx", "linux": "linux", "windows": "win"}.get(
98
+ platform.system().lower()
99
+ )
100
+ if not arch or not system:
101
+ raise InstallError(
102
+ f"unsupported platform: {platform.system()} {platform.machine()}"
103
+ )
104
+ return f"{system}-{arch}"
105
+
106
+
107
+ #: Binary name -> release product prefix. A product that has published no
108
+ #: manifest yet fails with a clear message rather than a stray 404.
109
+ PRODUCTS = {
110
+ "sim": "cli",
111
+ "analog-cli": "analog",
112
+ "pyrite": "pyrite",
113
+ "pyrite-mcp": "pyrite",
114
+ }
115
+
116
+
117
+ def fetch_manifest(
118
+ binary: str, *, channel: str | None = None, timeout: float = 30
119
+ ) -> dict:
120
+ product = PRODUCTS.get(binary)
121
+ if product is None:
122
+ raise InstallError(
123
+ f"unknown binary {binary!r}; expected one of {sorted(PRODUCTS)}"
124
+ )
125
+ channel = channel or default_channel()
126
+ request = urllib.request.Request(f"{releases_url()}/{product}/{channel}.json")
127
+ try:
128
+ with urllib.request.urlopen(request, timeout=timeout) as response:
129
+ return json.loads(response.read())
130
+ except urllib.error.HTTPError as exc:
131
+ raise InstallError(
132
+ f"no published releases for {binary!r} (HTTP {exc.code} from {url}). "
133
+ "Install the binary yourself and point $SIMANTIC_* at it."
134
+ ) from None
135
+ except urllib.error.URLError as exc:
136
+ raise InstallError(f"cannot reach the release server: {exc.reason}") from None
137
+ except json.JSONDecodeError as exc:
138
+ raise InstallError(f"release manifest is not valid JSON: {exc}") from None
139
+
140
+
141
+ def resolve(
142
+ binary: str, *, rid: str | None = None, channel: str | None = None
143
+ ) -> Artifact:
144
+ """The artifact this machine should download."""
145
+ manifest = fetch_manifest(binary, channel=channel)
146
+ version = manifest.get("version")
147
+ artifacts = manifest.get("artifacts")
148
+ if not version or not isinstance(artifacts, dict):
149
+ raise InstallError("release manifest is missing version or artifacts")
150
+
151
+ rid = rid or current_rid()
152
+ entry = artifacts.get(rid)
153
+ if not entry or not entry.get("url"):
154
+ available = ", ".join(sorted(artifacts)) or "none"
155
+ raise InstallError(
156
+ f"no {rid} build in {binary} release {version} (available: {available})"
157
+ )
158
+ return Artifact(version=version, url=entry["url"], sha256=entry.get("sha256"))
159
+
160
+
161
+ def download(artifact: Artifact, *, timeout: float = 300) -> bytes:
162
+ """Fetch the artifact and verify its checksum before it is trusted."""
163
+ request = urllib.request.Request(artifact.url)
164
+ try:
165
+ with urllib.request.urlopen(request, timeout=timeout) as response:
166
+ payload = response.read()
167
+ except urllib.error.HTTPError as exc:
168
+ raise InstallError(f"download failed: HTTP {exc.code}") from None
169
+ except urllib.error.URLError as exc:
170
+ raise InstallError(f"download failed: {exc.reason}") from None
171
+
172
+ if artifact.sha256:
173
+ actual = hashlib.sha256(payload).hexdigest()
174
+ if actual.lower() != artifact.sha256.strip().lower():
175
+ raise InstallError(
176
+ f"checksum mismatch (expected {artifact.sha256}, got {actual}). "
177
+ "Refusing to install."
178
+ )
179
+ return payload
180
+
181
+
182
+ def _extract(payload: bytes, binary: str) -> bytes:
183
+ """The executable inside a release archive, or the payload if it is raw.
184
+
185
+ Both archive formats the release workflows produce are handled: zip and
186
+ gzipped tar. Anything else is passed through, because older manifests
187
+ pointed straight at the executable.
188
+ """
189
+ if payload.startswith(b"PK\x03\x04"):
190
+ with zipfile.ZipFile(io.BytesIO(payload)) as archive:
191
+ names = [n for n in archive.namelist() if not n.endswith("/")]
192
+ return _pick(names, binary, archive.read)
193
+ if payload.startswith(b"\x1f\x8b"):
194
+ with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
195
+ names = [m.name for m in archive.getmembers() if m.isfile()]
196
+
197
+ def read(name: str) -> bytes:
198
+ handle = archive.extractfile(name)
199
+ if handle is None: # pragma: no cover - filtered to files above
200
+ raise InstallError(f"cannot read {name!r} from the archive")
201
+ return handle.read()
202
+
203
+ return _pick(names, binary, read)
204
+ return payload
205
+
206
+
207
+ def _pick(names: list[str], binary: str, read) -> bytes:
208
+ """The entry that is the executable, by name or by being the only one."""
209
+ for name in names:
210
+ # Release archives sometimes nest the binary under a directory.
211
+ if name == binary or name.endswith(f"/{binary}"):
212
+ return read(name)
213
+ if len(names) == 1:
214
+ return read(names[0])
215
+ raise InstallError(
216
+ f"release archive has no {binary!r} entry (contains: {', '.join(names)})"
217
+ )
218
+
219
+
220
+ def install(binary: str, *, force: bool = False, channel: str | None = None) -> Path:
221
+ """Download `binary` into the managed bin directory; return its path."""
222
+ target = bin_dir() / binary
223
+ if target.exists() and not force:
224
+ return target
225
+
226
+ artifact = resolve(binary, channel=channel)
227
+ executable = _extract(download(artifact), binary)
228
+
229
+ target.parent.mkdir(parents=True, exist_ok=True)
230
+ # Stage beside the target so the rename is atomic on the same filesystem,
231
+ # and a partial download can never be left looking like a usable binary.
232
+ tmp = target.with_name(f".{binary}.incoming")
233
+ try:
234
+ tmp.write_bytes(executable)
235
+ tmp.chmod(tmp.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
236
+ os.replace(tmp, target)
237
+ except OSError as exc:
238
+ tmp.unlink(missing_ok=True)
239
+ raise InstallError(f"cannot write {target}: {exc}") from None
240
+ return target
241
+
242
+
243
+ # -- the engine: Simantic.Core + a private .NET runtime, for in-process use ----
244
+
245
+ ENGINE_KEY = "engine"
246
+
247
+
248
+ def engine_root() -> Path:
249
+ """Where engine releases live: ~/.simantic/engine/<version>/."""
250
+ return simantic_home() / "engine"
251
+
252
+
253
+ def installed_engine() -> Path | None:
254
+ """The newest installed engine directory, or None."""
255
+ root = engine_root()
256
+ if not root.exists():
257
+ return None
258
+ candidates = [
259
+ d for d in root.iterdir()
260
+ if (d / "Simantic.Core.dll").exists() and (d / "sim.runtimeconfig.json").exists()
261
+ ]
262
+ if not candidates:
263
+ return None
264
+ return max(candidates, key=lambda d: _version_key(d.name))
265
+
266
+
267
+ def _version_key(name: str) -> tuple:
268
+ parts = []
269
+ for piece in name.replace("-", ".").split("."):
270
+ parts.append((0, int(piece)) if piece.isdigit() else (1, piece))
271
+ return tuple(parts)
272
+
273
+
274
+ def install_engine(*, force: bool = False, channel: str | None = None) -> Path:
275
+ """Download the engine for this machine into engine_root()/<version>.
276
+
277
+ One zip from the `sim` release manifest, key `engine-<rid>`: the
278
+ Simantic.Core publish directory plus a private .NET runtime under
279
+ `dotnet/`, laid out exactly as published.
280
+ """
281
+ artifact = resolve("sim", rid=f"{ENGINE_KEY}-{current_rid()}", channel=channel)
282
+ target = engine_root() / artifact.version
283
+ if (target / "Simantic.Core.dll").exists() and not force:
284
+ return target
285
+
286
+ payload = download(artifact)
287
+ if not payload.startswith(b"PK\x03\x04"):
288
+ raise InstallError("engine artifact is not a zip archive")
289
+ incoming = target.with_name(f".{artifact.version}.incoming")
290
+ if incoming.exists():
291
+ shutil.rmtree(incoming)
292
+ incoming.mkdir(parents=True)
293
+ try:
294
+ with zipfile.ZipFile(io.BytesIO(payload)) as archive:
295
+ for name in archive.namelist():
296
+ # Refuse anything that would land outside the target.
297
+ dest = (incoming / name).resolve()
298
+ if not str(dest).startswith(str(incoming.resolve())):
299
+ raise InstallError(f"engine archive has an unsafe path: {name}")
300
+ archive.extractall(incoming)
301
+ if target.exists():
302
+ shutil.rmtree(target)
303
+ os.replace(incoming, target)
304
+ except (OSError, zipfile.BadZipFile) as exc:
305
+ shutil.rmtree(incoming, ignore_errors=True)
306
+ raise InstallError(f"cannot unpack the engine into {target}: {exc}") from None
307
+ return target
308
+
309
+
310
+ def installed_version(binary: str) -> str | None:
311
+ """Nothing is recorded locally, so this reports presence, not version."""
312
+ target = bin_dir() / binary
313
+ return str(target) if target.exists() else None
simantic/mcu.py ADDED
@@ -0,0 +1,163 @@
1
+ """Driving the `sim` binary — firmware simulation.
2
+
3
+ Platforms come from one of two places. `mcu=` names a model, which `sim`
4
+ resolves for you and which requires authentication (`sim auth`); models are
5
+ not distributed with this package. `repl=` points at a platform file you
6
+ supply yourself.
7
+
8
+ `sim` emits no structured report: the only observable is UART text, written
9
+ to --output. The verdict therefore comes from substring matching, which is
10
+ the contract `test.yaml` manifests use (`expect` / `expect_absent`) and the
11
+ reason test firmware conventionally prints a `RESULT: PASS` marker.
12
+
13
+ Some installations require a separate simulation server. This module does
14
+ not assume either way — it reads that from what the binary reports, so the
15
+ same code drives both.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import subprocess
22
+ import tempfile
23
+ from collections.abc import Sequence
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ from ._locate import locate
28
+ from . import telemetry
29
+
30
+ ENV_VAR = "SIMANTIC_SIM"
31
+ BINARY = "sim"
32
+
33
+ #: Values accepted by `sim --backend`. Which are available, and which targets
34
+ #: each supports, depends on the installed build — see its --help.
35
+ BACKENDS = ("tlib", "rust")
36
+
37
+
38
+ class SimError(RuntimeError):
39
+ """The simulator could not run: a bad platform, a missing ELF, a failed start."""
40
+
41
+
42
+ class ServerNotConfigured(SimError):
43
+ """This installation needs a simulation server and none was given.
44
+
45
+ Separate from SimError because it is an unconfigured environment, not a
46
+ simulation result: a test runner should skip on it, the way it skips on
47
+ a missing binary, rather than report a firmware failure.
48
+ """
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class SimRun:
53
+ """One firmware run: the UART transcript plus the verdict against it."""
54
+
55
+ output: str
56
+ exit_code: int
57
+ missing: list[str]
58
+ forbidden: list[str]
59
+ #: Which binary produced this, so a failure names the thing that ran.
60
+ runner: str = "sim"
61
+
62
+ @property
63
+ def passed(self) -> bool:
64
+ return self.exit_code == 0 and not self.missing and not self.forbidden
65
+
66
+ def failure_report(self) -> str:
67
+ lines = []
68
+ if self.exit_code != 0:
69
+ lines.append(f"{self.runner} exited {self.exit_code}")
70
+ for text in self.missing:
71
+ lines.append(f" expected but not found: {text!r}")
72
+ for text in self.forbidden:
73
+ lines.append(f" present but forbidden: {text!r}")
74
+ transcript = self.output.strip() or "(no UART output)"
75
+ lines.append("--- UART ---")
76
+ lines.append(transcript)
77
+ return "\n".join(lines)
78
+
79
+
80
+ def sim_binary(explicit: str | os.PathLike[str] | None = None) -> Path:
81
+ """Resolve the sim binary, or raise BinaryNotFound."""
82
+ return locate(BINARY, ENV_VAR, explicit)
83
+
84
+
85
+ def run(
86
+ elf: str | os.PathLike[str],
87
+ *,
88
+ repl: str | os.PathLike[str] | None = None,
89
+ mcu: str | None = None,
90
+ server: str | None = None,
91
+ timeout: int = 15,
92
+ expect: Sequence[str] = (),
93
+ expect_absent: Sequence[str] = (),
94
+ backend: str | None = None,
95
+ use_cached: bool = False,
96
+ ascii_output: bool = True,
97
+ only_messages: bool = True,
98
+ binary: str | os.PathLike[str] | None = None,
99
+ ) -> SimRun:
100
+ """Run one firmware ELF and check its UART against expectations.
101
+
102
+ Give exactly one of `mcu` (a backend-resolved model, needs auth) or
103
+ `repl` (a local platform file). `server` defaults to $SIM_SERVER_URL,
104
+ which `sim` itself reads — it is passed explicitly only when given here.
105
+
106
+ `use_cached` reuses a previously fetched model from ~/.sim_cache, which
107
+ keeps a suite runnable without a round trip per test.
108
+
109
+ Defaults mirror what a fixture wants to read: ASCII rather than hex, and
110
+ message text without the `[t] (LABEL)` prefix.
111
+ """
112
+ if (repl is None) == (mcu is None):
113
+ raise ValueError("give exactly one of repl= or mcu=")
114
+ if backend is not None and backend not in BACKENDS:
115
+ raise ValueError(f"backend must be one of {BACKENDS}, got {backend!r}")
116
+ telemetry.record("sdk.run_firmware")
117
+ with tempfile.TemporaryDirectory() as tmp:
118
+ out_path = Path(tmp) / "uart.txt"
119
+ cmd = [
120
+ str(sim_binary(binary)),
121
+ "--elf", str(elf),
122
+ "--timeout", str(timeout),
123
+ "--output", str(out_path),
124
+ ]
125
+ cmd += ["--repl", str(repl)] if repl is not None else ["--mcu", mcu]
126
+ if server is not None:
127
+ cmd += ["--server", server]
128
+ if use_cached:
129
+ cmd.append("--use-cached")
130
+ if ascii_output:
131
+ cmd.append("--ascii")
132
+ if only_messages:
133
+ cmd.append("--only-messages")
134
+ if backend is not None:
135
+ cmd += ["--backend", backend]
136
+
137
+ # Give the subprocess room past the simulated timeout before treating
138
+ # it as hung: --timeout bounds simulated time, not wall-clock.
139
+ try:
140
+ proc = subprocess.run(
141
+ cmd, capture_output=True, text=True, timeout=timeout * 4 + 30
142
+ )
143
+ except subprocess.TimeoutExpired as exc:
144
+ raise SimError(f"sim did not exit within its wall-clock budget: {exc}") from None
145
+
146
+ output = out_path.read_text(errors="replace") if out_path.exists() else ""
147
+
148
+ if proc.returncode != 0 and not output:
149
+ stderr = proc.stderr.strip()
150
+ # Matched against what sim reports rather than pre-checked: whether a
151
+ # server is needed depends on the installation, not on this package.
152
+ if "sim-server" in stderr:
153
+ raise ServerNotConfigured(
154
+ f"{stderr}\nPass server= or set $SIM_SERVER_URL."
155
+ )
156
+ raise SimError(f"sim exited {proc.returncode} with no output\n{stderr}")
157
+
158
+ return SimRun(
159
+ output=output,
160
+ exit_code=proc.returncode,
161
+ missing=[t for t in expect if t not in output],
162
+ forbidden=[t for t in expect_absent if t in output],
163
+ )
simantic/pyrite.py ADDED
@@ -0,0 +1,72 @@
1
+ """Driving the `pyrite` binary — offline Cortex-M firmware runs.
2
+
3
+ A single self-contained binary: it loads an ELF, runs it for a budget of
4
+ virtual time, and writes the UART transcript to stdout. Nothing else has to
5
+ be installed or started.
6
+
7
+ The platform is either a bundled board (`board=`) or an mcu-lib `.repl` file
8
+ you supply (`repl=`). Like the other runner, the verdict is substring
9
+ matching over the transcript, because UART text is the only observable.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import subprocess
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
18
+
19
+ from ._locate import locate
20
+ from .mcu import SimError, SimRun
21
+ from . import telemetry
22
+
23
+ ENV_VAR = "SIMANTIC_PYRITE"
24
+ BINARY = "pyrite"
25
+
26
+
27
+ def pyrite_binary(explicit: str | os.PathLike[str] | None = None) -> Path:
28
+ """Resolve the pyrite binary, or raise BinaryNotFound."""
29
+ return locate(BINARY, ENV_VAR, explicit)
30
+
31
+
32
+ def run(
33
+ elf: str | os.PathLike[str],
34
+ *,
35
+ board: str | None = None,
36
+ repl: str | os.PathLike[str] | None = None,
37
+ timeout: int = 5,
38
+ expect: Sequence[str] = (),
39
+ expect_absent: Sequence[str] = (),
40
+ binary: str | os.PathLike[str] | None = None,
41
+ ) -> SimRun:
42
+ """Run one firmware ELF and check its UART against expectations.
43
+
44
+ Give exactly one of `board` (bundled) or `repl` (a platform file).
45
+ `timeout` is a budget of simulated time, not wall-clock.
46
+ """
47
+ if (board is None) == (repl is None):
48
+ raise ValueError("give exactly one of board= or repl=")
49
+
50
+ telemetry.record("sdk.run_pyrite")
51
+ cmd = [str(pyrite_binary(binary)), "run", "--elf", str(elf), "--timeout", str(timeout)]
52
+ cmd += ["--board", board] if board is not None else ["--repl", str(repl)]
53
+
54
+ # Wall-clock room beyond the virtual-time budget before calling it hung.
55
+ try:
56
+ proc = subprocess.run(
57
+ cmd, capture_output=True, text=True, timeout=timeout * 4 + 30
58
+ )
59
+ except subprocess.TimeoutExpired as exc:
60
+ raise SimError(f"pyrite did not exit within its wall-clock budget: {exc}") from None
61
+
62
+ output = proc.stdout
63
+ if proc.returncode != 0 and not output.strip():
64
+ raise SimError(f"pyrite exited {proc.returncode}\n{proc.stderr.strip()}")
65
+
66
+ return SimRun(
67
+ output=output,
68
+ exit_code=proc.returncode,
69
+ missing=[t for t in expect if t not in output],
70
+ forbidden=[t for t in expect_absent if t in output],
71
+ runner="pyrite",
72
+ )