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/__init__.py +85 -0
- simantic/__main__.py +10 -0
- simantic/_cli.py +139 -0
- simantic/_locate.py +76 -0
- simantic/agent.py +175 -0
- simantic/analog.py +94 -0
- simantic/auth.py +221 -0
- simantic/engine.py +91 -0
- simantic/fixtures.py +144 -0
- simantic/install.py +313 -0
- simantic/mcu.py +163 -0
- simantic/pyrite.py +72 -0
- simantic/pytest_plugin.py +232 -0
- simantic/report.py +194 -0
- simantic/session.py +409 -0
- simantic/telemetry.py +225 -0
- simantic-0.2.0.dist-info/METADATA +219 -0
- simantic-0.2.0.dist-info/RECORD +21 -0
- simantic-0.2.0.dist-info/WHEEL +4 -0
- simantic-0.2.0.dist-info/entry_points.txt +6 -0
- simantic-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|