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/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
|