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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""pytest integration for both simulators.
|
|
2
|
+
|
|
3
|
+
Two collectors, one idea: a manifest the project already maintains becomes
|
|
4
|
+
individually addressable pytest items, rather than one opaque pass/fail for a
|
|
5
|
+
whole suite. That buys `-k` filtering, per-test durations, `--junitxml` rows,
|
|
6
|
+
and xdist parallelism without any per-project glue.
|
|
7
|
+
|
|
8
|
+
- `*.sim.toml` — one item per `[[test]]` table (analog-cli)
|
|
9
|
+
- `test.yaml` — one item per fixture (sim)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from ._locate import BinaryNotFound
|
|
21
|
+
from .analog import AnalogCliError, plan_test_names, run_tests
|
|
22
|
+
from .fixtures import (
|
|
23
|
+
MCU_LIB_ENV,
|
|
24
|
+
ModelLibraryUnavailable,
|
|
25
|
+
UnsupportedManifest,
|
|
26
|
+
load_manifest,
|
|
27
|
+
platform_for,
|
|
28
|
+
)
|
|
29
|
+
from .mcu import ServerNotConfigured, SimError, run as run_firmware
|
|
30
|
+
from .report import Test
|
|
31
|
+
from . import telemetry
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
#: Node ids this plugin collected, so a project's own unit tests are not
|
|
35
|
+
#: counted: what they do is not this package's business to measure.
|
|
36
|
+
_OURS: set[str] = set()
|
|
37
|
+
_COUNTS = {"passed": 0, "failed": 0, "skipped": 0}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def pytest_runtest_logreport(report):
|
|
41
|
+
if report.when == "call" and report.nodeid in _OURS:
|
|
42
|
+
if report.outcome in _COUNTS:
|
|
43
|
+
_COUNTS[report.outcome] += 1
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pytest_terminal_summary(terminalreporter):
|
|
47
|
+
"""Report the shape of the session once, after the results are known.
|
|
48
|
+
|
|
49
|
+
Session-level rather than per-test: one request per `pytest` invocation
|
|
50
|
+
keeps this off the critical path, where per-test reporting would turn a
|
|
51
|
+
200-test suite into 200 round trips.
|
|
52
|
+
"""
|
|
53
|
+
if any(_COUNTS.values()):
|
|
54
|
+
telemetry.report("pytest-session", **_COUNTS)
|
|
55
|
+
# Session end is the only point in a test run where a round trip costs
|
|
56
|
+
# nobody anything; the spool itself is due at most hourly.
|
|
57
|
+
telemetry.flush()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def pytest_collect_file(parent: pytest.Collector, file_path):
|
|
61
|
+
if file_path.name.endswith(".sim.toml"):
|
|
62
|
+
return SimTomlFile.from_parent(parent, path=file_path)
|
|
63
|
+
if file_path.name == "test.yaml":
|
|
64
|
+
return FixtureYamlFile.from_parent(parent, path=file_path)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SimulationFailure(Exception):
|
|
69
|
+
"""A simulation did not meet its declared expectations.
|
|
70
|
+
|
|
71
|
+
Carries the runner's own explanation, which is already the most useful
|
|
72
|
+
thing to print: measured values and margins, or the UART transcript.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _ReportingItem(pytest.Item):
|
|
77
|
+
"""Shared failure rendering: show the report, not a Python traceback."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, *args, **kwargs):
|
|
80
|
+
super().__init__(*args, **kwargs)
|
|
81
|
+
_OURS.add(self.nodeid)
|
|
82
|
+
|
|
83
|
+
def repr_failure(self, excinfo, style=None):
|
|
84
|
+
if isinstance(excinfo.value, SimulationFailure):
|
|
85
|
+
return str(excinfo.value)
|
|
86
|
+
return super().repr_failure(excinfo, style=style)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- analog-cli: *.sim.toml ------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SimTomlFile(pytest.File):
|
|
93
|
+
def collect(self):
|
|
94
|
+
for name in plan_test_names(self.path):
|
|
95
|
+
yield AnalogTestItem.from_parent(self, name=name)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AnalogTestItem(_ReportingItem):
|
|
99
|
+
"""One `[[test]]` table, run through `analog-cli test --only <name>`."""
|
|
100
|
+
|
|
101
|
+
def runtest(self) -> None:
|
|
102
|
+
try:
|
|
103
|
+
report = run_tests(self.path.parent, plan=self.path, only=[self.name])
|
|
104
|
+
except BinaryNotFound as exc:
|
|
105
|
+
pytest.skip(str(exc))
|
|
106
|
+
except AnalogCliError as exc:
|
|
107
|
+
raise SimulationFailure(str(exc)) from None
|
|
108
|
+
|
|
109
|
+
result = report.test(self.name)
|
|
110
|
+
if result.status in ("skipped", "not_implemented"):
|
|
111
|
+
pytest.skip(result.detail or f"analog-cli reported {result.status}")
|
|
112
|
+
if not result.passed:
|
|
113
|
+
raise SimulationFailure(result.failure_report())
|
|
114
|
+
self._record_margins(result)
|
|
115
|
+
|
|
116
|
+
def _record_margins(self, result: Test) -> None:
|
|
117
|
+
"""Surface measured values so -rA and --junitxml carry the numbers."""
|
|
118
|
+
for m in result.measurements:
|
|
119
|
+
if m.measured is not None:
|
|
120
|
+
self.add_report_section("call", m.name, m.describe())
|
|
121
|
+
|
|
122
|
+
def reportinfo(self):
|
|
123
|
+
return self.path, 0, f"analog test: {self.name}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- sim: test.yaml -------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class FixtureYamlFile(pytest.File):
|
|
130
|
+
def collect(self):
|
|
131
|
+
yield FirmwareItem.from_parent(self, name=self.path.parent.name)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class FirmwareItem(_ReportingItem):
|
|
135
|
+
"""One fixture manifest: boot the ELF, check the UART transcript.
|
|
136
|
+
|
|
137
|
+
The platform is resolved by model name, which needs `sim auth`. A local
|
|
138
|
+
model library ($SIMANTIC_MCU_LIB) overrides that and resolves without a
|
|
139
|
+
round trip, which is also what a fixture's `overlay` fragment requires,
|
|
140
|
+
since an overlay edits platform text before the simulator sees it.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def runtest(self) -> None:
|
|
144
|
+
try:
|
|
145
|
+
manifest = load_manifest(self.path)
|
|
146
|
+
except UnsupportedManifest as exc:
|
|
147
|
+
pytest.skip(str(exc))
|
|
148
|
+
|
|
149
|
+
local_models = os.environ.get(MCU_LIB_ENV)
|
|
150
|
+
if manifest.overlay and not local_models:
|
|
151
|
+
pytest.skip(
|
|
152
|
+
f"fixture applies an overlay fragment, which needs a local model: "
|
|
153
|
+
f"set ${MCU_LIB_ENV} to a local model library"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
157
|
+
try:
|
|
158
|
+
if local_models:
|
|
159
|
+
target = {"repl": platform_for(manifest, Path(tmp))}
|
|
160
|
+
else:
|
|
161
|
+
target = {"mcu": manifest.mcu, "use_cached": True}
|
|
162
|
+
result = run_firmware(
|
|
163
|
+
manifest.elf_path,
|
|
164
|
+
timeout=manifest.timeout,
|
|
165
|
+
expect=manifest.expect,
|
|
166
|
+
expect_absent=manifest.expect_absent,
|
|
167
|
+
**target,
|
|
168
|
+
)
|
|
169
|
+
except (BinaryNotFound, ModelLibraryUnavailable, ServerNotConfigured) as exc:
|
|
170
|
+
pytest.skip(str(exc))
|
|
171
|
+
except SimError as exc:
|
|
172
|
+
raise SimulationFailure(str(exc)) from None
|
|
173
|
+
|
|
174
|
+
if not result.passed:
|
|
175
|
+
raise SimulationFailure(result.failure_report())
|
|
176
|
+
|
|
177
|
+
def reportinfo(self):
|
|
178
|
+
return self.path, 0, f"firmware: {self.name}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# --- fixtures for hand-written tests ---------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.fixture
|
|
185
|
+
def analog():
|
|
186
|
+
"""The analog-cli runner, skipping when no binary is installed.
|
|
187
|
+
|
|
188
|
+
def test_divider(analog):
|
|
189
|
+
assert analog("hardware/divider").test("rails-op").passed
|
|
190
|
+
"""
|
|
191
|
+
from ._locate import analog_cli
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
analog_cli()
|
|
195
|
+
except BinaryNotFound as exc:
|
|
196
|
+
pytest.skip(str(exc))
|
|
197
|
+
return run_tests
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@pytest.fixture
|
|
201
|
+
def firmware():
|
|
202
|
+
"""The sim runner, skipping when no binary is installed.
|
|
203
|
+
|
|
204
|
+
def test_boot(firmware):
|
|
205
|
+
run = firmware("fw.elf", repl="board.repl", expect=["RESULT: PASS"])
|
|
206
|
+
assert run.passed, run.failure_report()
|
|
207
|
+
"""
|
|
208
|
+
from .mcu import sim_binary
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
sim_binary()
|
|
212
|
+
except BinaryNotFound as exc:
|
|
213
|
+
pytest.skip(str(exc))
|
|
214
|
+
return run_firmware
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@pytest.fixture
|
|
218
|
+
def pyrite():
|
|
219
|
+
"""The pyrite runner, skipping when no binary is installed.
|
|
220
|
+
|
|
221
|
+
def test_boot(pyrite):
|
|
222
|
+
run = pyrite("fw.elf", board="stm32f401", expect=["Hello World!"])
|
|
223
|
+
assert run.passed, run.failure_report()
|
|
224
|
+
"""
|
|
225
|
+
from .pyrite import pyrite_binary
|
|
226
|
+
from .pyrite import run as run_pyrite
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
pyrite_binary()
|
|
230
|
+
except BinaryNotFound as exc:
|
|
231
|
+
pytest.skip(str(exc))
|
|
232
|
+
return run_pyrite
|
simantic/report.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Typed view of the `analog-cli test --format json` report.
|
|
2
|
+
|
|
3
|
+
Mirrors schemas/test-report.schema.json ("analog-cli.test-report/1"). Parsing
|
|
4
|
+
is tolerant of additive fields — the schema allows those within a revision —
|
|
5
|
+
and strict about the shape discriminator.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
SCHEMA = "analog-cli.test-report/1"
|
|
14
|
+
|
|
15
|
+
#: Statuses that do not fail a run. `not_implemented` marks a test kind the
|
|
16
|
+
#: installed CLI does not support; `skipped` one inapplicable to the project.
|
|
17
|
+
#: Neither is an error: a testplan may name more than the CLI can run today.
|
|
18
|
+
PASSING_STATUSES = frozenset({"pass", "skipped", "not_implemented"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ReportError(ValueError):
|
|
22
|
+
"""The report was absent, unparseable, or of an unknown shape."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Expect:
|
|
27
|
+
min: float | None = None
|
|
28
|
+
max: float | None = None
|
|
29
|
+
eq: float | None = None
|
|
30
|
+
tol: float | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def informational(self) -> bool:
|
|
34
|
+
"""An empty expect block never fails."""
|
|
35
|
+
return self.min is None and self.max is None and self.eq is None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Measurement:
|
|
40
|
+
name: str
|
|
41
|
+
expect: Expect
|
|
42
|
+
passed: bool
|
|
43
|
+
signal: str | None = None
|
|
44
|
+
measured: float | None = None
|
|
45
|
+
#: Signed distance to the nearest bound; >= 0 passes, magnitude is headroom.
|
|
46
|
+
margin: float | None = None
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_json(cls, data: dict[str, Any]) -> Measurement:
|
|
50
|
+
expect = data.get("expect") or {}
|
|
51
|
+
return cls(
|
|
52
|
+
name=data["name"],
|
|
53
|
+
expect=Expect(**{k: v for k, v in expect.items() if k in Expect.__annotations__}),
|
|
54
|
+
passed=data["pass"],
|
|
55
|
+
signal=data.get("signal"),
|
|
56
|
+
measured=data.get("measured"),
|
|
57
|
+
margin=data.get("margin"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def describe(self) -> str:
|
|
61
|
+
"""One-line human summary, used in pytest failure output."""
|
|
62
|
+
where = f"{self.signal} " if self.signal else ""
|
|
63
|
+
got = "not evaluated" if self.measured is None else f"{self.measured:g}"
|
|
64
|
+
bounds = []
|
|
65
|
+
if self.expect.min is not None:
|
|
66
|
+
bounds.append(f"min {self.expect.min:g}")
|
|
67
|
+
if self.expect.max is not None:
|
|
68
|
+
bounds.append(f"max {self.expect.max:g}")
|
|
69
|
+
if self.expect.eq is not None:
|
|
70
|
+
tol = f" +/- {self.expect.tol:g}" if self.expect.tol is not None else ""
|
|
71
|
+
bounds.append(f"eq {self.expect.eq:g}{tol}")
|
|
72
|
+
limit = ", ".join(bounds) if bounds else "informational"
|
|
73
|
+
margin = "" if self.margin is None else f", margin {self.margin:g}"
|
|
74
|
+
return f"{self.name}: {where}measured {got} (expected {limit}{margin})"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Finding:
|
|
79
|
+
kind: str
|
|
80
|
+
severity: str
|
|
81
|
+
description: str
|
|
82
|
+
sheet: str | None = None
|
|
83
|
+
|
|
84
|
+
def describe(self) -> str:
|
|
85
|
+
where = f" [{self.sheet}]" if self.sheet else ""
|
|
86
|
+
return f"{self.severity} {self.kind}{where}: {self.description}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Test:
|
|
91
|
+
# Not a pytest test class, despite the name.
|
|
92
|
+
__test__ = False
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
kind: str
|
|
96
|
+
status: str
|
|
97
|
+
detail: str | None = None
|
|
98
|
+
measurements: list[Measurement] = field(default_factory=list)
|
|
99
|
+
findings: list[Finding] = field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_json(cls, data: dict[str, Any]) -> Test:
|
|
103
|
+
return cls(
|
|
104
|
+
name=data["name"],
|
|
105
|
+
kind=data["kind"],
|
|
106
|
+
status=data["status"],
|
|
107
|
+
detail=data.get("detail"),
|
|
108
|
+
measurements=[Measurement.from_json(m) for m in data.get("measurements", [])],
|
|
109
|
+
findings=[
|
|
110
|
+
Finding(
|
|
111
|
+
kind=f["kind"],
|
|
112
|
+
severity=f["severity"],
|
|
113
|
+
description=f["description"],
|
|
114
|
+
sheet=f.get("sheet"),
|
|
115
|
+
)
|
|
116
|
+
for f in data.get("findings", [])
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def passed(self) -> bool:
|
|
122
|
+
return self.status in PASSING_STATUSES
|
|
123
|
+
|
|
124
|
+
def failure_report(self) -> str:
|
|
125
|
+
"""Multi-line explanation of why this test did not pass."""
|
|
126
|
+
lines = [f"{self.name} ({self.kind}): {self.status}"]
|
|
127
|
+
if self.detail:
|
|
128
|
+
lines.append(f" {self.detail}")
|
|
129
|
+
for m in self.measurements:
|
|
130
|
+
if not m.passed:
|
|
131
|
+
lines.append(f" FAIL {m.describe()}")
|
|
132
|
+
for f in self.findings:
|
|
133
|
+
lines.append(f" {f.describe()}")
|
|
134
|
+
return "\n".join(lines)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class Summary:
|
|
139
|
+
total: int
|
|
140
|
+
passed: int
|
|
141
|
+
failed: int
|
|
142
|
+
errors: int
|
|
143
|
+
skipped: int
|
|
144
|
+
not_implemented: int
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class TestReport:
|
|
149
|
+
# Not a pytest test class, despite the name.
|
|
150
|
+
__test__ = False
|
|
151
|
+
|
|
152
|
+
cli_version: str
|
|
153
|
+
project: str
|
|
154
|
+
plan: str
|
|
155
|
+
started_unix: int
|
|
156
|
+
duration_seconds: float
|
|
157
|
+
summary: Summary
|
|
158
|
+
tests: list[Test]
|
|
159
|
+
kicad_cli: str | None = None
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def from_json(cls, data: dict[str, Any]) -> TestReport:
|
|
163
|
+
schema = data.get("schema")
|
|
164
|
+
if schema != SCHEMA:
|
|
165
|
+
raise ReportError(f"expected schema {SCHEMA!r}, got {schema!r}")
|
|
166
|
+
s = data["summary"]
|
|
167
|
+
return cls(
|
|
168
|
+
cli_version=data["cli_version"],
|
|
169
|
+
project=data["project"],
|
|
170
|
+
plan=data["plan"],
|
|
171
|
+
started_unix=data["started_unix"],
|
|
172
|
+
duration_seconds=data["duration_seconds"],
|
|
173
|
+
summary=Summary(
|
|
174
|
+
total=s["total"],
|
|
175
|
+
passed=s["passed"],
|
|
176
|
+
failed=s["failed"],
|
|
177
|
+
errors=s["errors"],
|
|
178
|
+
skipped=s["skipped"],
|
|
179
|
+
not_implemented=s["not_implemented"],
|
|
180
|
+
),
|
|
181
|
+
tests=[Test.from_json(t) for t in data["tests"]],
|
|
182
|
+
kicad_cli=data.get("kicad_cli"),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def passed(self) -> bool:
|
|
187
|
+
"""True when no test failed or errored (skips do not fail a run)."""
|
|
188
|
+
return self.summary.failed == 0 and self.summary.errors == 0
|
|
189
|
+
|
|
190
|
+
def test(self, name: str) -> Test:
|
|
191
|
+
for t in self.tests:
|
|
192
|
+
if t.name == name:
|
|
193
|
+
return t
|
|
194
|
+
raise KeyError(f"no test named {name!r} in report for {self.project}")
|