From 42bb7f8ab00b30fd3558e234146116a371f7068b Mon Sep 17 00:00:00 2001 From: Ahnaf Shahriar Date: Sun, 23 Aug 2026 00:45:10 -0400 Subject: [PATCH] Ship only the firmware engine: drop analog-cli and pyrite surfaces Neither analog nor pyrite has a public release manifest, so the package advertised commands (simantic install analog-cli / pyrite, the analog and pyrite pytest fixtures, *.sim.toml collection) that cannot work. Removed from the shipped package; the previous surface is kept on branch hold/analog-pyrite for when those products release. Bump to 0.2.1 so the PyPI description is replaced. --- README.md | 82 ++-------- pyproject.toml | 2 +- src/simantic/__init__.py | 49 +----- src/simantic/_cli.py | 14 +- src/simantic/_locate.py | 8 - src/simantic/agent.py | 175 --------------------- src/simantic/analog.py | 94 ------------ src/simantic/install.py | 6 +- src/simantic/pyrite.py | 72 --------- src/simantic/pytest_plugin.py | 80 +--------- src/simantic/report.py | 194 ------------------------ tests/fixtures/divider/divider.sim.toml | 29 ---- tests/test_agent.py | 108 ------------- tests/test_install.py | 21 +-- tests/test_plan.py | 52 ------- tests/test_pyrite.py | 81 ---------- tests/test_report.py | 164 -------------------- tests/test_spool.py | 4 +- 18 files changed, 39 insertions(+), 1196 deletions(-) delete mode 100644 src/simantic/agent.py delete mode 100644 src/simantic/analog.py delete mode 100644 src/simantic/pyrite.py delete mode 100644 src/simantic/report.py delete mode 100644 tests/fixtures/divider/divider.sim.toml delete mode 100644 tests/test_agent.py delete mode 100644 tests/test_plan.py delete mode 100644 tests/test_pyrite.py delete mode 100644 tests/test_report.py diff --git a/README.md b/README.md index 000032a..689a573 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ # simantic -Python control of the [Simantic](https://simantic.dev) simulators — the -firmware engine hosted in your process, circuits via `analog-cli`. Everything -the CLIs can do, as objects and method calls: start a board or a multi-machine scenario, advance virtual +Python control of the [Simantic](https://simantic.dev) firmware simulator, +hosted in your process. Everything the `sim` CLI can do, as objects and method calls: start a board or a multi-machine scenario, advance virtual time by exact amounts, inject UART/GPIO/CAN/radio, read memory and RTOS state, and run as many simulations in parallel as you have cores. pytest is one way to use it, not a requirement. -> **Alpha — not stable.** Version 0.1.x. The API, the CLI surface, and the +> **Alpha — not stable.** Version 0.2.x. The API, the CLI surface, and the > report schema may change without a deprecation period, and any release may -> break the previous one. Pin an exact version (`simantic==0.1.0`) if you +> break the previous one. Pin an exact version (`simantic==0.2.0`) if you > depend on it. Not recommended for production pipelines yet. ```bash @@ -20,8 +19,7 @@ That is the whole setup for Python. The first `Sim(...)` fetches the simulation engine (Simantic.Core plus a private .NET runtime — nothing else to install) into `~/.simantic/engine//`, checksum-verified against the public release manifest. `simantic install` fetches it up front, along -with the `sim` and `analog-cli` binaries if you also want the command-line -tools. +with the `sim` binary if you also want the command-line tool. A Simantic account (`simantic auth`) is needed for one thing: resolving MCU models by name (`mcu="STM32F401RE"`), which are fetched from your account @@ -39,10 +37,9 @@ Every download — engine or binary — is verified against the checksum in the release manifest. The package on PyPI contains only Python; the simulators are never in the wheel. -Already have the binaries? Point `$SIMANTIC_ANALOG_CLI` and `$SIMANTIC_SIM` -at them, or put them on PATH — both take precedence over a managed install. -`simantic status` shows what is authenticated and which binary each name -resolves to. +Already have `sim`? Point `$SIMANTIC_SIM` at it, or put it on PATH — both +take precedence over a managed install. `simantic status` shows what is +authenticated and what resolved. ## Drive a simulation @@ -71,70 +68,27 @@ One-shot runs ("run 5 s, give me the transcript") are `run_firmware(...)`. ## Using it from pytest (optional) `Sim` needs no plugin — construct it inside any test. If you also keep -manifests, installing the package registers two collectors that turn them -into individually addressable pytest items: - -- `*.sim.toml` — one item per `[[test]]` table (analog) -- `test.yaml` — one item per fixture (firmware) +`test.yaml` fixture manifests, installing the package registers a collector +that turns each fixture into an individually addressable pytest item. ```console -$ pytest hardware/ firmware/ -hardware/psu/psu.sim.toml::schematic-erc PASSED -hardware/psu/psu.sim.toml::rails-op PASSED -hardware/psu/psu.sim.toml::startup-settling FAILED -hardware/psu/psu.sim.toml::board-drc SKIPPED (no .kicad_pcb) +$ pytest firmware/ firmware/tests/gpio-loopback/test.yaml::gpio-loopback PASSED +firmware/tests/uart-echo/test.yaml::uart-echo FAILED ``` Because these are ordinary pytest items you get `-k` filtering, `--junitxml` for CI, xdist parallelism, and per-test durations. Failures print the -runner's own explanation rather than a Python traceback: - -``` -startup-settling (tran): fail - FAIL settle-time: V(OUT) measured 0.0082 (expected max 0.006, margin -0.0022) -``` +runner's own explanation — the UART transcript and the expectation it +missed — rather than a Python traceback. Tests that cannot run in the current environment skip rather than fail — a -missing binary, an unconfigured server, an analysis the installed CLI does -not support, a check inapplicable to the project. A red run means a +missing binary or an unconfigured server. A red run means a simulation ran and disagreed with its expectations. ## Library -### Circuits - -```python -import simantic - -report = simantic.run_tests("hardware/psu") -print(f"{report.summary.passed}/{report.summary.total} passed") - -for m in report.test("rails-op").measurements: - print(m.describe()) # out-dc: V(OUT) measured 1.597 (expected eq 1.597 +/- 0.02, margin 0.02) -``` - -A failing test is data, not an exception: it arrives in the report with its -measured value, declared bounds, and margin. Only conditions that prevent a -run at all — bad project, missing `kicad-cli`, invalid testplan — raise -`AnalogCliError`. - -### Firmware - -The shortest path is a pytest fixture — no manifest, no flags: - -```python -def test_firmware_boots(pyrite): - run = pyrite("build/zephyr.elf", board="stm32f401", - expect=["Hello World!"], expect_absent=["FAULT"]) - assert run.passed, run.failure_report() -``` - -`pyrite` runs the ELF offline on the pure-Rust backend and hands back the -UART transcript. The fixture skips when no binary is installed, so a suite -stays green on a machine that has not run `smtc install pyrite`. - -The same runner is available as a plain function, and `sim` has its own: +The one-shot runner: ```python run = simantic.run_firmware( @@ -185,10 +139,6 @@ unaffected and nothing is printed. ## Compatibility -Speaks the `analog-cli.test-report/1` schema. Additive fields within that -revision are tolerated; a breaking revision raises `ReportError` rather than -silently misreading a report. - Multi-machine `test.yaml` fixtures — those with a `machines:` map — need the `--scenario` runner and are not driven yet; they report as skips. diff --git a/pyproject.toml b/pyproject.toml index 043082b..a7076f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "simantic" -version = "0.2.0" +version = "0.2.1" description = "Python SDK and pytest plugin for the Simantic circuit and firmware simulators" # PyYAML reads sim-fixtures test.yaml manifests; pythonnet hosts the # simulation engine (Simantic.Core, .NET) in-process for `simantic.Sim`. diff --git a/src/simantic/__init__.py b/src/simantic/__init__.py index fcec47a..99d6c72 100644 --- a/src/simantic/__init__.py +++ b/src/simantic/__init__.py @@ -1,7 +1,6 @@ """Python control of the Simantic simulators. -One package covers both engines, because co-simulation puts them together: -`analog-cli` for circuits and `sim` for firmware. +The firmware engine, hosted in your process. import simantic @@ -9,21 +8,15 @@ sim.expect("ready"); sim.run_for(0.5) run = simantic.run_firmware("fw.elf", repl="board.repl", expect=["RESULT: PASS"]) # one-shot - report = simantic.run_tests("hardware/psu") # analog -Neither binary is bundled. Point `$SIMANTIC_ANALOG_CLI` and `$SIMANTIC_SIM` -at them, or put them on PATH. +The engine is fetched on first use. The `sim` CLI is optional; point +`$SIMANTIC_SIM` at one, or put it on PATH. -Installing this package also registers a pytest plugin that turns the -manifests a project already keeps — `*.sim.toml` testplans and `test.yaml` -fixture manifests — into individually addressable pytest items. - -The MCP-based agent session lives in `simantic.agent` and is not re-exported -here: `Sim` is the Python surface; MCP is an adapter for chat clients. +Installing this package also registers a pytest plugin that turns `test.yaml` +fixture manifests into individually addressable pytest items. """ -from ._locate import BinaryNotFound, analog_cli -from .analog import AnalogCliError, plan_path, plan_test_names, run_tests +from ._locate import BinaryNotFound from .fixtures import ( Manifest, ModelLibraryUnavailable, @@ -32,36 +25,12 @@ ) from .mcu import ServerNotConfigured, SimError, SimRun, sim_binary from .mcu import run as run_firmware -from .pyrite import pyrite_binary -from .pyrite import run as run_pyrite from .engine import EngineNotFound, engine_dir from .session import ExpectTimeout, Match, Sim -from .report import ( - Expect, - Finding, - Measurement, - ReportError, - Summary, - Test, - TestReport, -) -__version__ = "0.2.0" +__version__ = "0.2.1" __all__ = [ - # analog - "AnalogCliError", - "Expect", - "Finding", - "Measurement", - "ReportError", - "Summary", - "Test", - "TestReport", - "analog_cli", - "plan_path", - "plan_test_names", - "run_tests", # firmware "Manifest", "ModelLibraryUnavailable", @@ -70,11 +39,9 @@ "SimRun", "UnsupportedManifest", "load_manifest", - "pyrite_binary", "run_firmware", - "run_pyrite", "sim_binary", - # scripted sessions (sim --control-stdio) + # scripted sessions "Sim", "Match", "ExpectTimeout", diff --git a/src/simantic/_cli.py b/src/simantic/_cli.py index 0147830..b982832 100644 --- a/src/simantic/_cli.py +++ b/src/simantic/_cli.py @@ -15,16 +15,8 @@ from ._locate import BinaryNotFound, locate from .mcu import BINARY as SIM_BINARY from .mcu import ENV_VAR as SIM_ENV -from ._locate import BINARY as ANALOG_BINARY -from ._locate import ENV_VAR as ANALOG_ENV -from .pyrite import BINARY as PYRITE_BINARY -from .pyrite import ENV_VAR as PYRITE_ENV -BINARIES = ( - (ANALOG_BINARY, ANALOG_ENV), - (SIM_BINARY, SIM_ENV), - (PYRITE_BINARY, PYRITE_ENV), -) +BINARIES = ((SIM_BINARY, SIM_ENV),) def _auth(args) -> int: @@ -59,8 +51,8 @@ def _install(args) -> int: except install.InstallError as exc: print(f"{name}: {exc}", file=sys.stderr) failures += 1 - # Partial success is still useful — one engine may be published and the - # other not — so report it without discarding what did install. + # Partial success is still useful, so report it without discarding what + # did install. return 1 if failures == len(names) else 0 diff --git a/src/simantic/_locate.py b/src/simantic/_locate.py index aa91886..adb4786 100644 --- a/src/simantic/_locate.py +++ b/src/simantic/_locate.py @@ -66,11 +66,3 @@ def locate( f"PATH, or set ${env_var} to its location." ) - -ENV_VAR = "SIMANTIC_ANALOG_CLI" -BINARY = "analog-cli" - - -def analog_cli(explicit: str | os.PathLike[str] | None = None) -> Path: - """Resolve the analog-cli binary, or raise BinaryNotFound.""" - return locate(BINARY, ENV_VAR, explicit) diff --git a/src/simantic/agent.py b/src/simantic/agent.py deleted file mode 100644 index cdd8bac..0000000 --- a/src/simantic/agent.py +++ /dev/null @@ -1,175 +0,0 @@ -"""A session against the pyrite MCP server. - -The CLI runner is one-shot: arguments in, transcript out, no state between -calls. That suits a pytest item and suits nothing that needs to look around -while firmware is stopped. - -This is the other shape. `pyrite-mcp` keeps a machine alive and exposes it -as JSON-RPC tools over stdio, so a caller can break, step, read memory and -registers, and continue — each call structured going in and coming out, -with no argument strings to build or output to scrape. - - with Session() as sim: - sim.call("simulate", elf="fw.elf", board="stm32f401") - print(sim.tools()) - -The process is an implementation detail of the transport; the interface is -the tool surface, which the server owns and this module does not duplicate. -""" - -from __future__ import annotations - -import json -import os -import subprocess -from pathlib import Path -from typing import Any - -from ._locate import locate -from . import telemetry - -ENV_VAR = "SIMANTIC_PYRITE_MCP" -BINARY = "pyrite-mcp" - -PROTOCOL_VERSION = "2024-11-05" - - -class SessionError(RuntimeError): - """The server could not be started, or refused a call.""" - - -class ToolError(SessionError): - """A tool ran and reported failure. Distinct so a caller can react to a - failed operation without treating it as a broken session.""" - - -def mcp_binary(explicit: str | os.PathLike[str] | None = None) -> Path: - """Resolve the pyrite-mcp binary, or raise BinaryNotFound.""" - return locate(BINARY, ENV_VAR, explicit) - - -class Session: - """A live pyrite engine, addressed by tool name.""" - - def __init__(self, binary: str | os.PathLike[str] | None = None) -> None: - self._next_id = 0 - try: - self._proc = subprocess.Popen( - [str(mcp_binary(binary))], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, # diagnostics only; stdout is the protocol - text=True, - bufsize=1, - ) - except OSError as exc: - raise SessionError(f"cannot start {BINARY}: {exc}") from None - self._request("initialize", {"protocolVersion": PROTOCOL_VERSION}) - - # --- protocol --- - - def _request(self, method: str, params: dict[str, Any]) -> Any: - self._next_id += 1 - message = { - "jsonrpc": "2.0", - "id": self._next_id, - "method": method, - "params": params, - } - if self._proc.poll() is not None: - raise SessionError(f"{BINARY} exited with {self._proc.returncode}") - try: - self._proc.stdin.write(json.dumps(message) + "\n") - self._proc.stdin.flush() - except (BrokenPipeError, ValueError): - raise SessionError(f"{BINARY} closed its input") from None - - # One response per request, in order: the server is single-threaded - # over stdio, so the next line is this call's answer. - line = self._proc.stdout.readline() - if not line: - raise SessionError(f"{BINARY} closed its output during {method!r}") - try: - response = json.loads(line) - except json.JSONDecodeError as exc: - raise SessionError(f"{BINARY} sent invalid JSON: {exc}") from None - if "error" in response: - detail = response["error"] - raise SessionError(f"{method} failed: {detail.get('message', detail)}") - return response.get("result") - - # --- surface --- - - def tools(self) -> list[str]: - """Every tool this server exposes, by name.""" - result = self._request("tools/list", {}) - return [t["name"] for t in result.get("tools", [])] - - def call(self, tool: str, **arguments: Any) -> Any: - """Invoke a tool. Returns its parsed result. - - A tool that reports failure raises ToolError rather than returning a - payload the caller has to inspect to notice something went wrong. - """ - telemetry.record(f"mcp.{tool}") - result = self._request("tools/call", {"name": tool, "arguments": arguments}) - payload = _parsed(result) - # The envelope's isError is not trusted: pyrite-mcp sets it on - # successful calls too, so it does not distinguish one from the - # other. The payload's own `error` does, and is what the tools - # themselves report through. Envelope flag only when there is no - # payload to ask. - if isinstance(payload, dict) and "error" in payload: - if payload["error"] is not None: - raise ToolError(f"{tool}: {payload['error']}") - return payload - if isinstance(result, dict) and result.get("isError") and not isinstance(payload, dict): - raise ToolError(f"{tool}: {_text_of(result)}") - return payload - - # --- lifecycle --- - - def close(self) -> None: - if self._proc.poll() is None: - try: - self._proc.stdin.close() - except (BrokenPipeError, ValueError): - pass - try: - self._proc.wait(timeout=5) - except subprocess.TimeoutExpired: - self._proc.kill() - self._proc.wait() - - def __enter__(self) -> Session: - return self - - def __exit__(self, *exc: object) -> None: - self.close() - - -def _text_of(result: dict) -> str: - """The text blocks of an MCP result, joined.""" - parts = [ - block.get("text", "") - for block in result.get("content", []) - if isinstance(block, dict) and block.get("type") == "text" - ] - return "\n".join(p for p in parts if p) - - -def _parsed(result: Any) -> Any: - """A tool result as data where it is data, and as text where it is not. - - Tools return their payload as a JSON string inside a text block, so the - caller would otherwise parse every response by hand. - """ - if not isinstance(result, dict): - return result - text = _text_of(result) - if not text: - return result - try: - return json.loads(text) - except json.JSONDecodeError: - return text diff --git a/src/simantic/analog.py b/src/simantic/analog.py deleted file mode 100644 index d1483d1..0000000 --- a/src/simantic/analog.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Driving `analog-cli` from Python. - -The CLI is the source of truth; this module is a typed subprocess wrapper -around its JSON contract, not a reimplementation of anything it does. -""" - -from __future__ import annotations - -import json -import os -import subprocess -import tomllib -from collections.abc import Sequence -from pathlib import Path - -from ._locate import analog_cli -from . import telemetry -from .report import ReportError, TestReport - -#: Exit codes that still produce a report: everything ran, verdicts inside. -#: 0 = all passed, 1 = at least one test failed or errored. -_REPORT_EXITS = frozenset({0, 1}) - -_EXIT_MEANINGS = { - 2: "bad project", - 3: "kicad-cli not found", - 4: "the runner itself failed", - 6: "testplan invalid or unreadable", -} - - -class AnalogCliError(RuntimeError): - """analog-cli exited with a code that carries no report.""" - - def __init__(self, code: int, stderr: str) -> None: - meaning = _EXIT_MEANINGS.get(code, "unknown failure") - super().__init__(f"analog-cli exited {code} ({meaning})\n{stderr.strip()}") - self.code = code - self.stderr = stderr - - -def run_tests( - project: str | os.PathLike[str], - *, - plan: str | os.PathLike[str] | None = None, - only: Sequence[str] | None = None, - binary: str | os.PathLike[str] | None = None, - timeout: float | None = None, -) -> TestReport: - """Run a project's testplan and return the parsed report. - - `project` is a directory or .kicad_pro. Without `plan`, analog-cli uses - the project's .sim.toml, falling back to its built-in static checks. - A failing test is a normal outcome and comes back in the report; only - conditions that prevent a run at all raise AnalogCliError. - """ - telemetry.record("sdk.run_tests") - cmd = [str(analog_cli(binary)), "test", "-p", str(project), "--format", "json"] - if plan is not None: - cmd += ["--plan", str(plan)] - for name in only or (): - cmd += ["--only", name] - - proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) - if proc.returncode not in _REPORT_EXITS: - raise AnalogCliError(proc.returncode, proc.stderr) - - try: - data = json.loads(proc.stdout) - except json.JSONDecodeError as exc: - raise ReportError( - f"analog-cli exited {proc.returncode} but did not emit JSON: {exc}" - ) from exc - return TestReport.from_json(data) - - -def plan_path(project: str | os.PathLike[str]) -> Path | None: - """The .sim.toml a project directory would use, if it exists.""" - path = Path(project) - directory = path.parent if path.suffix == ".kicad_pro" else path - candidates = sorted(directory.glob("*.sim.toml")) - return candidates[0] if candidates else None - - -def plan_test_names(plan: str | os.PathLike[str]) -> list[str]: - """Names of the [[test]] tables in a .sim.toml testplan, in file order. - - Read directly so a test runner can enumerate cases without invoking the - CLI once per collection. Unnamed tables are skipped: --only matches by - name, so a nameless test is not individually addressable. - """ - with open(plan, "rb") as fh: - data = tomllib.load(fh) - return [t["name"] for t in data.get("test", []) if "name" in t] diff --git a/src/simantic/install.py b/src/simantic/install.py index 2c4a2eb..19d6f05 100644 --- a/src/simantic/install.py +++ b/src/simantic/install.py @@ -104,13 +104,9 @@ def current_rid() -> str: return f"{system}-{arch}" -#: Binary name -> release product prefix. A product that has published no -#: manifest yet fails with a clear message rather than a stray 404. +#: Binary name -> release product prefix. PRODUCTS = { "sim": "cli", - "analog-cli": "analog", - "pyrite": "pyrite", - "pyrite-mcp": "pyrite", } diff --git a/src/simantic/pyrite.py b/src/simantic/pyrite.py deleted file mode 100644 index 6c11b35..0000000 --- a/src/simantic/pyrite.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Driving the `pyrite` binary — offline Cortex-M firmware runs. - -A single self-contained binary: it loads an ELF, runs it for a budget of -virtual time, and writes the UART transcript to stdout. Nothing else has to -be installed or started. - -The platform is either a bundled board (`board=`) or an mcu-lib `.repl` file -you supply (`repl=`). Like the other runner, the verdict is substring -matching over the transcript, because UART text is the only observable. -""" - -from __future__ import annotations - -import os -import subprocess -from collections.abc import Sequence -from pathlib import Path - -from ._locate import locate -from .mcu import SimError, SimRun -from . import telemetry - -ENV_VAR = "SIMANTIC_PYRITE" -BINARY = "pyrite" - - -def pyrite_binary(explicit: str | os.PathLike[str] | None = None) -> Path: - """Resolve the pyrite binary, or raise BinaryNotFound.""" - return locate(BINARY, ENV_VAR, explicit) - - -def run( - elf: str | os.PathLike[str], - *, - board: str | None = None, - repl: str | os.PathLike[str] | None = None, - timeout: int = 5, - expect: Sequence[str] = (), - expect_absent: Sequence[str] = (), - binary: str | os.PathLike[str] | None = None, -) -> SimRun: - """Run one firmware ELF and check its UART against expectations. - - Give exactly one of `board` (bundled) or `repl` (a platform file). - `timeout` is a budget of simulated time, not wall-clock. - """ - if (board is None) == (repl is None): - raise ValueError("give exactly one of board= or repl=") - - telemetry.record("sdk.run_pyrite") - cmd = [str(pyrite_binary(binary)), "run", "--elf", str(elf), "--timeout", str(timeout)] - cmd += ["--board", board] if board is not None else ["--repl", str(repl)] - - # Wall-clock room beyond the virtual-time budget before calling it hung. - try: - proc = subprocess.run( - cmd, capture_output=True, text=True, timeout=timeout * 4 + 30 - ) - except subprocess.TimeoutExpired as exc: - raise SimError(f"pyrite did not exit within its wall-clock budget: {exc}") from None - - output = proc.stdout - if proc.returncode != 0 and not output.strip(): - raise SimError(f"pyrite exited {proc.returncode}\n{proc.stderr.strip()}") - - return SimRun( - output=output, - exit_code=proc.returncode, - missing=[t for t in expect if t not in output], - forbidden=[t for t in expect_absent if t in output], - runner="pyrite", - ) diff --git a/src/simantic/pytest_plugin.py b/src/simantic/pytest_plugin.py index a695caa..447c830 100644 --- a/src/simantic/pytest_plugin.py +++ b/src/simantic/pytest_plugin.py @@ -1,11 +1,10 @@ -"""pytest integration for both simulators. +"""pytest integration for the firmware simulator. -Two collectors, one idea: a manifest the project already maintains becomes +One collector, one idea: a manifest the project already maintains becomes individually addressable pytest items, rather than one opaque pass/fail for a whole suite. That buys `-k` filtering, per-test durations, `--junitxml` rows, and xdist parallelism without any per-project glue. -- `*.sim.toml` — one item per `[[test]]` table (analog-cli) - `test.yaml` — one item per fixture (sim) """ @@ -18,7 +17,6 @@ import pytest from ._locate import BinaryNotFound -from .analog import AnalogCliError, plan_test_names, run_tests from .fixtures import ( MCU_LIB_ENV, ModelLibraryUnavailable, @@ -27,7 +25,6 @@ platform_for, ) from .mcu import ServerNotConfigured, SimError, run as run_firmware -from .report import Test from . import telemetry @@ -58,8 +55,6 @@ def pytest_terminal_summary(terminalreporter): def pytest_collect_file(parent: pytest.Collector, file_path): - if file_path.name.endswith(".sim.toml"): - return SimTomlFile.from_parent(parent, path=file_path) if file_path.name == "test.yaml": return FixtureYamlFile.from_parent(parent, path=file_path) return None @@ -86,43 +81,6 @@ def repr_failure(self, excinfo, style=None): return super().repr_failure(excinfo, style=style) -# --- analog-cli: *.sim.toml ------------------------------------------------ - - -class SimTomlFile(pytest.File): - def collect(self): - for name in plan_test_names(self.path): - yield AnalogTestItem.from_parent(self, name=name) - - -class AnalogTestItem(_ReportingItem): - """One `[[test]]` table, run through `analog-cli test --only `.""" - - def runtest(self) -> None: - try: - report = run_tests(self.path.parent, plan=self.path, only=[self.name]) - except BinaryNotFound as exc: - pytest.skip(str(exc)) - except AnalogCliError as exc: - raise SimulationFailure(str(exc)) from None - - result = report.test(self.name) - if result.status in ("skipped", "not_implemented"): - pytest.skip(result.detail or f"analog-cli reported {result.status}") - if not result.passed: - raise SimulationFailure(result.failure_report()) - self._record_margins(result) - - def _record_margins(self, result: Test) -> None: - """Surface measured values so -rA and --junitxml carry the numbers.""" - for m in result.measurements: - if m.measured is not None: - self.add_report_section("call", m.name, m.describe()) - - def reportinfo(self): - return self.path, 0, f"analog test: {self.name}" - - # --- sim: test.yaml ------------------------------------------------- @@ -181,22 +139,6 @@ def reportinfo(self): # --- fixtures for hand-written tests --------------------------------------- -@pytest.fixture -def analog(): - """The analog-cli runner, skipping when no binary is installed. - - def test_divider(analog): - assert analog("hardware/divider").test("rails-op").passed - """ - from ._locate import analog_cli - - try: - analog_cli() - except BinaryNotFound as exc: - pytest.skip(str(exc)) - return run_tests - - @pytest.fixture def firmware(): """The sim runner, skipping when no binary is installed. @@ -212,21 +154,3 @@ def test_boot(firmware): except BinaryNotFound as exc: pytest.skip(str(exc)) return run_firmware - - -@pytest.fixture -def pyrite(): - """The pyrite runner, skipping when no binary is installed. - - def test_boot(pyrite): - run = pyrite("fw.elf", board="stm32f401", expect=["Hello World!"]) - assert run.passed, run.failure_report() - """ - from .pyrite import pyrite_binary - from .pyrite import run as run_pyrite - - try: - pyrite_binary() - except BinaryNotFound as exc: - pytest.skip(str(exc)) - return run_pyrite diff --git a/src/simantic/report.py b/src/simantic/report.py deleted file mode 100644 index 14d73d8..0000000 --- a/src/simantic/report.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Typed view of the `analog-cli test --format json` report. - -Mirrors schemas/test-report.schema.json ("analog-cli.test-report/1"). Parsing -is tolerant of additive fields — the schema allows those within a revision — -and strict about the shape discriminator. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -SCHEMA = "analog-cli.test-report/1" - -#: Statuses that do not fail a run. `not_implemented` marks a test kind the -#: installed CLI does not support; `skipped` one inapplicable to the project. -#: Neither is an error: a testplan may name more than the CLI can run today. -PASSING_STATUSES = frozenset({"pass", "skipped", "not_implemented"}) - - -class ReportError(ValueError): - """The report was absent, unparseable, or of an unknown shape.""" - - -@dataclass(frozen=True) -class Expect: - min: float | None = None - max: float | None = None - eq: float | None = None - tol: float | None = None - - @property - def informational(self) -> bool: - """An empty expect block never fails.""" - return self.min is None and self.max is None and self.eq is None - - -@dataclass(frozen=True) -class Measurement: - name: str - expect: Expect - passed: bool - signal: str | None = None - measured: float | None = None - #: Signed distance to the nearest bound; >= 0 passes, magnitude is headroom. - margin: float | None = None - - @classmethod - def from_json(cls, data: dict[str, Any]) -> Measurement: - expect = data.get("expect") or {} - return cls( - name=data["name"], - expect=Expect(**{k: v for k, v in expect.items() if k in Expect.__annotations__}), - passed=data["pass"], - signal=data.get("signal"), - measured=data.get("measured"), - margin=data.get("margin"), - ) - - def describe(self) -> str: - """One-line human summary, used in pytest failure output.""" - where = f"{self.signal} " if self.signal else "" - got = "not evaluated" if self.measured is None else f"{self.measured:g}" - bounds = [] - if self.expect.min is not None: - bounds.append(f"min {self.expect.min:g}") - if self.expect.max is not None: - bounds.append(f"max {self.expect.max:g}") - if self.expect.eq is not None: - tol = f" +/- {self.expect.tol:g}" if self.expect.tol is not None else "" - bounds.append(f"eq {self.expect.eq:g}{tol}") - limit = ", ".join(bounds) if bounds else "informational" - margin = "" if self.margin is None else f", margin {self.margin:g}" - return f"{self.name}: {where}measured {got} (expected {limit}{margin})" - - -@dataclass(frozen=True) -class Finding: - kind: str - severity: str - description: str - sheet: str | None = None - - def describe(self) -> str: - where = f" [{self.sheet}]" if self.sheet else "" - return f"{self.severity} {self.kind}{where}: {self.description}" - - -@dataclass(frozen=True) -class Test: - # Not a pytest test class, despite the name. - __test__ = False - - name: str - kind: str - status: str - detail: str | None = None - measurements: list[Measurement] = field(default_factory=list) - findings: list[Finding] = field(default_factory=list) - - @classmethod - def from_json(cls, data: dict[str, Any]) -> Test: - return cls( - name=data["name"], - kind=data["kind"], - status=data["status"], - detail=data.get("detail"), - measurements=[Measurement.from_json(m) for m in data.get("measurements", [])], - findings=[ - Finding( - kind=f["kind"], - severity=f["severity"], - description=f["description"], - sheet=f.get("sheet"), - ) - for f in data.get("findings", []) - ], - ) - - @property - def passed(self) -> bool: - return self.status in PASSING_STATUSES - - def failure_report(self) -> str: - """Multi-line explanation of why this test did not pass.""" - lines = [f"{self.name} ({self.kind}): {self.status}"] - if self.detail: - lines.append(f" {self.detail}") - for m in self.measurements: - if not m.passed: - lines.append(f" FAIL {m.describe()}") - for f in self.findings: - lines.append(f" {f.describe()}") - return "\n".join(lines) - - -@dataclass(frozen=True) -class Summary: - total: int - passed: int - failed: int - errors: int - skipped: int - not_implemented: int - - -@dataclass(frozen=True) -class TestReport: - # Not a pytest test class, despite the name. - __test__ = False - - cli_version: str - project: str - plan: str - started_unix: int - duration_seconds: float - summary: Summary - tests: list[Test] - kicad_cli: str | None = None - - @classmethod - def from_json(cls, data: dict[str, Any]) -> TestReport: - schema = data.get("schema") - if schema != SCHEMA: - raise ReportError(f"expected schema {SCHEMA!r}, got {schema!r}") - s = data["summary"] - return cls( - cli_version=data["cli_version"], - project=data["project"], - plan=data["plan"], - started_unix=data["started_unix"], - duration_seconds=data["duration_seconds"], - summary=Summary( - total=s["total"], - passed=s["passed"], - failed=s["failed"], - errors=s["errors"], - skipped=s["skipped"], - not_implemented=s["not_implemented"], - ), - tests=[Test.from_json(t) for t in data["tests"]], - kicad_cli=data.get("kicad_cli"), - ) - - @property - def passed(self) -> bool: - """True when no test failed or errored (skips do not fail a run).""" - return self.summary.failed == 0 and self.summary.errors == 0 - - def test(self, name: str) -> Test: - for t in self.tests: - if t.name == name: - return t - raise KeyError(f"no test named {name!r} in report for {self.project}") diff --git a/tests/fixtures/divider/divider.sim.toml b/tests/fixtures/divider/divider.sim.toml deleted file mode 100644 index 8d53426..0000000 --- a/tests/fixtures/divider/divider.sim.toml +++ /dev/null @@ -1,29 +0,0 @@ -# A minimal testplan, here only so the enumeration tests have something real -# to read. It is not run: these tests never invoke analog-cli. The shapes that -# matter are a named [[test]], one with a nested [[test.measure]], and one -# without a name (which --only cannot address, so it is skipped). - -[sim] -tstop = "10ms" - -[[test]] -name = "schematic-erc" -kind = "erc" -max_warnings = 20 - -[[test]] -name = "netlist-sane" -kind = "netlist_sanity" - -[[test]] -name = "rails-op" -kind = "op" - - [[test.measure]] - name = "out-dc" - signal = "V(OUT)" - kind = "value_at" - expect = { eq = 1.597, tol = "20m" } - -[[test]] -kind = "drc" diff --git a/tests/test_agent.py b/tests/test_agent.py deleted file mode 100644 index f4a945d..0000000 --- a/tests/test_agent.py +++ /dev/null @@ -1,108 +0,0 @@ -"""The MCP session: protocol handling and what counts as a failure.""" - -import json -import os -import stat -import sys - -import pytest - -from simantic import agent - -# A stand-in server: one JSON-RPC response per request line, canned per method. -FAKE = """#!{python} -import json, sys -for line in sys.stdin: - line = line.strip() - if not line: - continue - req = json.loads(line) - method, rid = req["method"], req.get("id") - if method == "initialize": - out = {{"result": {{"protocolVersion": "2024-11-05"}}}} - elif method == "tools/list": - out = {{"result": {{"tools": [{{"name": "simulate"}}, {{"name": "gdb_break"}}]}}}} - else: - payload = json.dumps({payload}) - out = {{"result": {{"content": [{{"type": "text", "text": payload}}], - "isError": True}}}} - out.update(jsonrpc="2.0", id=rid) - sys.stdout.write(json.dumps(out) + "\\n") - sys.stdout.flush() -""" - - -@pytest.fixture -def server(tmp_path, monkeypatch): - """Install a fake server and return a factory for its canned payload.""" - - def make(payload: str = '{"status": "completed", "error": None}'): - path = tmp_path / "pyrite-mcp" - path.write_text(FAKE.format(python=sys.executable, payload=payload)) - path.chmod(path.stat().st_mode | stat.S_IXUSR) - monkeypatch.setenv("SIMANTIC_PYRITE_MCP", str(path)) - return path - - return make - - -def test_lists_the_tool_surface(server): - server() - with agent.Session() as session: - assert session.tools() == ["simulate", "gdb_break"] - - -def test_a_result_arrives_as_data_not_text(server): - """Tools return JSON inside a text block; parsing it per call is the - boilerplate this exists to remove.""" - server('{"status": "completed", "error": None, "uart": {"text": "hi"}}') - with agent.Session() as session: - result = session.call("simulate", elfPath="fw.elf") - assert result["uart"]["text"] == "hi" - - -def test_a_reported_error_raises(server): - server('{"error": "no such board"}') - with agent.Session() as session: - with pytest.raises(agent.ToolError, match="no such board"): - session.call("simulate", board="nope") - - -def test_success_is_not_mistaken_for_failure(server): - """The server sets isError on successful calls too, so trusting the - envelope would turn every completed run into an exception.""" - server('{"status": "completed", "error": None}') - with agent.Session() as session: - assert session.call("simulate")["status"] == "completed" - - -def test_arguments_reach_the_tool(server): - server('{"error": None, "echo": True}') - with agent.Session() as session: - assert session.call("gdb_break", symbol="main")["echo"] is True - - -def test_a_missing_server_is_reported_clearly(monkeypatch, tmp_path): - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("PATH", "") - monkeypatch.delenv("SIMANTIC_PYRITE_MCP", raising=False) - from simantic._locate import BinaryNotFound - - with pytest.raises(BinaryNotFound, match="pyrite-mcp"): - agent.Session() - - -def test_the_process_is_stopped_on_exit(server): - server() - with agent.Session() as session: - proc = session._proc - assert proc.poll() is None - assert proc.poll() is not None - - -def test_calling_a_closed_session_is_an_error(server): - server() - session = agent.Session() - session.close() - with pytest.raises(agent.SessionError): - session.tools() diff --git a/tests/test_install.py b/tests/test_install.py index ff9d6a8..782d356 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -130,23 +130,14 @@ def test_missing_rid_lists_what_is_available(monkeypatch): install.resolve("sim", rid="win-x64") -def test_pyrite_is_its_own_product(monkeypatch): - """A single self-contained binary, installed under its own name.""" - assert _requested_url(monkeypatch, {}, binary="pyrite").endswith( - "/pyrite/latest.json" - ) - - def test_install_and_status_cover_every_product(): """`PRODUCTS` names what can be fetched; `_cli.BINARIES` is what the bare - `simantic install`/`simantic status` cover — pyrite had a real product - entry but was missing from BINARIES, so it silently sat out both. + `simantic install`/`simantic status` cover. A product missing from + BINARIES silently sits out both. """ from simantic import _cli - assert {name for name, _ in _cli.BINARIES} == set(install.PRODUCTS) - { - "pyrite-mcp" - } + assert {name for name, _ in _cli.BINARIES} == set(install.PRODUCTS) def test_unknown_binary_is_refused(): @@ -214,13 +205,13 @@ def test_extracts_a_lone_entry_under_another_name(): def test_extracts_from_a_gzipped_tarball(): - """pyrite publishes .tar.gz; passing one through would install a tarball.""" - assert install._extract(tarred("pyrite", b"ELF"), "pyrite") == b"ELF" + """Passing a .tar.gz through would install a tarball.""" + assert install._extract(tarred("sim", b"ELF"), "sim") == b"ELF" def test_extracts_a_nested_entry(): """Some archives put the binary under a directory.""" - assert install._extract(tarred("pyrite-osx-arm64/pyrite", b"ELF"), "pyrite") == b"ELF" + assert install._extract(tarred("sim-osx-arm64/sim", b"ELF"), "sim") == b"ELF" def tarred(name: str, body: bytes) -> bytes: diff --git a/tests/test_plan.py b/tests/test_plan.py deleted file mode 100644 index 13c5262..0000000 --- a/tests/test_plan.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Testplan enumeration and binary lookup. No analog-cli binary required.""" - -from pathlib import Path - -import pytest - -from simantic import BinaryNotFound, analog_cli, plan_path, plan_test_names -from simantic._locate import ENV_VAR - -FIXTURE = Path(__file__).resolve().parent / "fixtures" / "divider" -PLAN = FIXTURE / "divider.sim.toml" - - -def test_enumerates_named_tests_in_file_order(): - assert plan_test_names(PLAN) == ["schematic-erc", "netlist-sane", "rails-op"] - - -def test_unnamed_tests_are_skipped(): - """--only matches by name, so a nameless table is not addressable.""" - assert "drc" not in plan_test_names(PLAN) - - -def test_plan_path_finds_the_projects_testplan(): - assert plan_path(FIXTURE) == PLAN - - -def test_plan_path_accepts_a_kicad_pro(): - assert plan_path(FIXTURE / "divider.kicad_pro") == PLAN - - -def test_plan_path_is_none_without_a_testplan(tmp_path): - assert plan_path(tmp_path) is None - - -def test_env_var_overrides_lookup(tmp_path, monkeypatch): - fake = tmp_path / "analog-cli" - fake.touch() - monkeypatch.setenv(ENV_VAR, str(fake)) - assert analog_cli() == fake - - -def test_missing_env_target_is_an_error(tmp_path, monkeypatch): - monkeypatch.setenv(ENV_VAR, str(tmp_path / "absent")) - with pytest.raises(BinaryNotFound, match="does not exist"): - analog_cli() - - -def test_explicit_path_wins(tmp_path, monkeypatch): - monkeypatch.setenv(ENV_VAR, str(tmp_path / "absent")) - explicit = tmp_path / "chosen" - explicit.touch() - assert analog_cli(explicit) == explicit diff --git a/tests/test_pyrite.py b/tests/test_pyrite.py deleted file mode 100644 index 1e2cb98..0000000 --- a/tests/test_pyrite.py +++ /dev/null @@ -1,81 +0,0 @@ -"""The pyrite firmware runner: argument shape and verdict logic.""" - -import subprocess - -import pytest - -from simantic import pyrite - - -@pytest.fixture(autouse=True) -def binary(tmp_path, monkeypatch): - fake = tmp_path / "pyrite" - fake.touch() - monkeypatch.setenv("SIMANTIC_PYRITE", str(fake)) - return fake - - -def fake_run(monkeypatch, stdout="", returncode=0): - seen = [] - - def capture(cmd, **kwargs): - seen.append(cmd) - return subprocess.CompletedProcess(cmd, returncode, stdout, "") - - monkeypatch.setattr(pyrite.subprocess, "run", capture) - return seen - - -def test_board_and_repl_are_mutually_exclusive(): - for kwargs in ({}, {"board": "stm32f401", "repl": "b.repl"}): - with pytest.raises(ValueError, match="exactly one"): - pyrite.run("fw.elf", **kwargs) - - -def test_builds_the_run_subcommand(monkeypatch): - seen = fake_run(monkeypatch) - pyrite.run("fw.elf", board="stm32f401", timeout=9) - assert seen[0][1:] == ["run", "--elf", "fw.elf", "--timeout", "9", - "--board", "stm32f401"] - - -def test_repl_replaces_the_board(monkeypatch): - seen = fake_run(monkeypatch) - pyrite.run("fw.elf", repl="board.repl") - assert "--repl" in seen[0] and "--board" not in seen[0] - - -def test_expectations_are_matched_against_the_transcript(monkeypatch): - fake_run(monkeypatch, stdout="Hello World!\nRESULT: PASS\n") - run = pyrite.run("fw.elf", board="stm32f401", expect=["RESULT: PASS"]) - assert run.passed - assert run.runner == "pyrite" - - -def test_a_missing_expectation_fails_and_is_named(monkeypatch): - fake_run(monkeypatch, stdout="Hello World!\n") - run = pyrite.run("fw.elf", board="stm32f401", expect=["RESULT: PASS"]) - assert not run.passed - assert run.missing == ["RESULT: PASS"] - assert "RESULT: PASS" in run.failure_report() - - -def test_a_forbidden_string_fails(monkeypatch): - fake_run(monkeypatch, stdout="RESULT: FAIL\n") - run = pyrite.run("fw.elf", board="stm32f401", expect_absent=["RESULT: FAIL"]) - assert not run.passed - assert run.forbidden == ["RESULT: FAIL"] - - -def test_a_failure_report_names_pyrite_not_sim(monkeypatch): - """Two runners exist; a report that named the wrong one would misdirect.""" - fake_run(monkeypatch, stdout="boot\n", returncode=3) - run = pyrite.run("fw.elf", board="stm32f401") - assert "pyrite exited 3" in run.failure_report() - - -def test_a_crash_with_no_output_raises(monkeypatch): - """No transcript means nothing ran, which is an error rather than a verdict.""" - fake_run(monkeypatch, stdout="", returncode=2) - with pytest.raises(pyrite.SimError, match="pyrite exited 2"): - pyrite.run("fw.elf", board="stm32f401") diff --git a/tests/test_report.py b/tests/test_report.py deleted file mode 100644 index 6cd4d7c..0000000 --- a/tests/test_report.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Report parsing, checked against the shapes schemas/test-report.schema.json -allows. No analog-cli binary required. -""" - -import pytest - -from simantic import ReportError, TestReport - -BASE = { - "schema": "analog-cli.test-report/1", - "cli_version": "0.1.0", - "project": "rc_divider", - "plan": "rc_divider.sim.toml", - "started_unix": 1770000000, - "duration_seconds": 1.5, - "summary": { - "total": 3, - "passed": 1, - "failed": 1, - "errors": 0, - "skipped": 1, - "not_implemented": 0, - }, - "tests": [ - { - "name": "rails-op", - "kind": "op", - "status": "pass", - "measurements": [ - { - "name": "out-dc", - "signal": "V(OUT)", - "measured": 1.597, - "expect": {"eq": 1.597, "tol": 0.02}, - "margin": 0.02, - "pass": True, - } - ], - }, - { - "name": "startup-settling", - "kind": "tran", - "status": "fail", - "detail": "1 of 1 measurement failed", - "measurements": [ - { - "name": "settle-time", - "signal": "V(OUT)", - "measured": 0.0082, - "expect": {"max": 0.006}, - "margin": -0.0022, - "pass": False, - } - ], - }, - { - "name": "board-drc", - "kind": "drc", - "status": "skipped", - "detail": "no .kicad_pcb", - }, - ], -} - - -def test_parses_summary_and_tests(): - report = TestReport.from_json(BASE) - assert report.project == "rc_divider" - assert report.summary.total == 3 - assert len(report.tests) == 3 - - -def test_failed_run_is_not_passed(): - assert not TestReport.from_json(BASE).passed - - -def test_skips_alone_do_not_fail_a_run(): - data = BASE | { - "summary": BASE["summary"] | {"failed": 0, "passed": 2}, - "tests": [t for t in BASE["tests"] if t["status"] != "fail"], - } - report = TestReport.from_json(data) - assert report.passed - assert report.test("board-drc").passed - - -def test_not_implemented_never_fails(): - data = BASE | { - "summary": BASE["summary"] | {"failed": 0, "not_implemented": 1, "passed": 1}, - "tests": [{"name": "noise-floor", "kind": "noise", "status": "not_implemented"}], - } - assert TestReport.from_json(data).passed - - -def test_measurement_describe_carries_the_numbers(): - report = TestReport.from_json(BASE) - text = report.test("startup-settling").measurements[0].describe() - assert "V(OUT)" in text - assert "0.0082" in text - assert "max 0.006" in text - - -def test_failure_report_explains_the_failing_measurement(): - text = TestReport.from_json(BASE).test("startup-settling").failure_report() - assert "startup-settling (tran): fail" in text - assert "FAIL settle-time" in text - - -def test_findings_are_parsed(): - data = BASE | { - "tests": [ - { - "name": "schematic-erc", - "kind": "erc", - "status": "fail", - "findings": [ - { - "kind": "pin_not_connected", - "severity": "error", - "description": "U1 pin 3 unconnected", - "sheet": "/power", - } - ], - } - ] - } - finding = TestReport.from_json(data).test("schematic-erc").findings[0] - assert finding.sheet == "/power" - assert "pin_not_connected" in finding.describe() - - -def test_additive_fields_are_tolerated(): - """The schema allows additive fields within a revision.""" - data = BASE | {"future_field": 1} - data["tests"] = [BASE["tests"][0] | {"future_field": 2}] - assert TestReport.from_json(data).test("rails-op").passed - - -def test_unknown_schema_revision_is_refused(): - with pytest.raises(ReportError, match="analog-cli.test-report/1"): - TestReport.from_json(BASE | {"schema": "analog-cli.test-report/2"}) - - -def test_informational_measurement_has_no_bounds(): - data = BASE | { - "tests": [ - { - "name": "counts", - "kind": "erc", - "status": "pass", - "measurements": [ - {"name": "warnings", "measured": 2.0, "expect": {}, "pass": True} - ], - } - ] - } - m = TestReport.from_json(data).test("counts").measurements[0] - assert m.expect.informational - assert "informational" in m.describe() - - -def test_missing_test_name_raises(): - with pytest.raises(KeyError): - TestReport.from_json(BASE).test("nope") diff --git a/tests/test_spool.py b/tests/test_spool.py index be3e5c1..363eede 100644 --- a/tests/test_spool.py +++ b/tests/test_spool.py @@ -50,9 +50,9 @@ def test_calls_are_buffered_not_sent(uploads): def test_repeated_calls_become_counts(uploads): for _ in range(3): telemetry.record("mcp.gdb_step") - telemetry.record("sdk.run_pyrite") + telemetry.record("sdk.run_firmware") telemetry.flush(force=True) - assert uploads[0]["calls"] == {"mcp.gdb_step": 3, "sdk.run_pyrite": 1} + assert uploads[0]["calls"] == {"mcp.gdb_step": 3, "sdk.run_firmware": 1} def test_opting_out_records_nothing(monkeypatch, uploads):