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.
@@ -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}")