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/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Python control of the Simantic simulators.
|
|
2
|
+
|
|
3
|
+
One package covers both engines, because co-simulation puts them together:
|
|
4
|
+
`analog-cli` for circuits and `sim` for firmware.
|
|
5
|
+
|
|
6
|
+
import simantic
|
|
7
|
+
|
|
8
|
+
with simantic.Sim(elf="fw.elf", repl="board.repl") as sim: # live control
|
|
9
|
+
sim.expect("ready"); sim.run_for(0.5)
|
|
10
|
+
run = simantic.run_firmware("fw.elf", repl="board.repl",
|
|
11
|
+
expect=["RESULT: PASS"]) # one-shot
|
|
12
|
+
report = simantic.run_tests("hardware/psu") # analog
|
|
13
|
+
|
|
14
|
+
Neither binary is bundled. Point `$SIMANTIC_ANALOG_CLI` and `$SIMANTIC_SIM`
|
|
15
|
+
at them, or put them on PATH.
|
|
16
|
+
|
|
17
|
+
Installing this package also registers a pytest plugin that turns the
|
|
18
|
+
manifests a project already keeps — `*.sim.toml` testplans and `test.yaml`
|
|
19
|
+
fixture manifests — into individually addressable pytest items.
|
|
20
|
+
|
|
21
|
+
The MCP-based agent session lives in `simantic.agent` and is not re-exported
|
|
22
|
+
here: `Sim` is the Python surface; MCP is an adapter for chat clients.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from ._locate import BinaryNotFound, analog_cli
|
|
26
|
+
from .analog import AnalogCliError, plan_path, plan_test_names, run_tests
|
|
27
|
+
from .fixtures import (
|
|
28
|
+
Manifest,
|
|
29
|
+
ModelLibraryUnavailable,
|
|
30
|
+
UnsupportedManifest,
|
|
31
|
+
load_manifest,
|
|
32
|
+
)
|
|
33
|
+
from .mcu import ServerNotConfigured, SimError, SimRun, sim_binary
|
|
34
|
+
from .mcu import run as run_firmware
|
|
35
|
+
from .pyrite import pyrite_binary
|
|
36
|
+
from .pyrite import run as run_pyrite
|
|
37
|
+
from .engine import EngineNotFound, engine_dir
|
|
38
|
+
from .session import ExpectTimeout, Match, Sim
|
|
39
|
+
from .report import (
|
|
40
|
+
Expect,
|
|
41
|
+
Finding,
|
|
42
|
+
Measurement,
|
|
43
|
+
ReportError,
|
|
44
|
+
Summary,
|
|
45
|
+
Test,
|
|
46
|
+
TestReport,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__version__ = "0.2.0"
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
# analog
|
|
53
|
+
"AnalogCliError",
|
|
54
|
+
"Expect",
|
|
55
|
+
"Finding",
|
|
56
|
+
"Measurement",
|
|
57
|
+
"ReportError",
|
|
58
|
+
"Summary",
|
|
59
|
+
"Test",
|
|
60
|
+
"TestReport",
|
|
61
|
+
"analog_cli",
|
|
62
|
+
"plan_path",
|
|
63
|
+
"plan_test_names",
|
|
64
|
+
"run_tests",
|
|
65
|
+
# firmware
|
|
66
|
+
"Manifest",
|
|
67
|
+
"ModelLibraryUnavailable",
|
|
68
|
+
"ServerNotConfigured",
|
|
69
|
+
"SimError",
|
|
70
|
+
"SimRun",
|
|
71
|
+
"UnsupportedManifest",
|
|
72
|
+
"load_manifest",
|
|
73
|
+
"pyrite_binary",
|
|
74
|
+
"run_firmware",
|
|
75
|
+
"run_pyrite",
|
|
76
|
+
"sim_binary",
|
|
77
|
+
# scripted sessions (sim --control-stdio)
|
|
78
|
+
"Sim",
|
|
79
|
+
"Match",
|
|
80
|
+
"ExpectTimeout",
|
|
81
|
+
"EngineNotFound",
|
|
82
|
+
"engine_dir",
|
|
83
|
+
# shared
|
|
84
|
+
"BinaryNotFound",
|
|
85
|
+
]
|
simantic/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""`python -m simantic`, equivalent to the `simantic` command.
|
|
2
|
+
|
|
3
|
+
The generated launcher lands in the environment's bin/ (Scripts\\ on Windows),
|
|
4
|
+
which is not always on PATH. This entry point needs only an interpreter that
|
|
5
|
+
can import the package, so it works wherever the install did.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._cli import main
|
|
9
|
+
|
|
10
|
+
raise SystemExit(main())
|
simantic/_cli.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""The `simantic` command: authenticate, install binaries, report status.
|
|
2
|
+
|
|
3
|
+
Thin by design. It exists so `pip install simantic` is followed by two
|
|
4
|
+
obvious commands rather than a documentation hunt, not to become a third
|
|
5
|
+
CLI alongside the simulators.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import getpass
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from . import auth, install, telemetry
|
|
15
|
+
from ._locate import BinaryNotFound, locate
|
|
16
|
+
from .mcu import BINARY as SIM_BINARY
|
|
17
|
+
from .mcu import ENV_VAR as SIM_ENV
|
|
18
|
+
from ._locate import BINARY as ANALOG_BINARY
|
|
19
|
+
from ._locate import ENV_VAR as ANALOG_ENV
|
|
20
|
+
from .pyrite import BINARY as PYRITE_BINARY
|
|
21
|
+
from .pyrite import ENV_VAR as PYRITE_ENV
|
|
22
|
+
|
|
23
|
+
BINARIES = (
|
|
24
|
+
(ANALOG_BINARY, ANALOG_ENV),
|
|
25
|
+
(SIM_BINARY, SIM_ENV),
|
|
26
|
+
(PYRITE_BINARY, PYRITE_ENV),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _auth(args) -> int:
|
|
31
|
+
token = args.token
|
|
32
|
+
if token is None and not sys.stdin.isatty():
|
|
33
|
+
token = sys.stdin.read().strip()
|
|
34
|
+
if token is not None:
|
|
35
|
+
credentials = auth.login(token)
|
|
36
|
+
elif sys.stdin.isatty() and not args.no_browser:
|
|
37
|
+
credentials = auth.browser_login()
|
|
38
|
+
else:
|
|
39
|
+
# Echo off: argv is visible to `ps`, and so is a shell history entry.
|
|
40
|
+
token = getpass.getpass("Personal access token (smtc_...): ").strip()
|
|
41
|
+
credentials = auth.login(token)
|
|
42
|
+
|
|
43
|
+
where = auth.sim_id_path()
|
|
44
|
+
who = f" for {credentials.email}" if credentials.email else ""
|
|
45
|
+
print(f"Credentials saved to {where}{who}")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _install(args) -> int:
|
|
50
|
+
names = args.binary or [name for name, _ in BINARIES] + [install.ENGINE_KEY]
|
|
51
|
+
failures = 0
|
|
52
|
+
for name in names:
|
|
53
|
+
try:
|
|
54
|
+
if name == install.ENGINE_KEY:
|
|
55
|
+
path = install.install_engine(force=args.force, channel=args.channel)
|
|
56
|
+
else:
|
|
57
|
+
path = install.install(name, force=args.force, channel=args.channel)
|
|
58
|
+
print(f"{name}: {path}")
|
|
59
|
+
except install.InstallError as exc:
|
|
60
|
+
print(f"{name}: {exc}", file=sys.stderr)
|
|
61
|
+
failures += 1
|
|
62
|
+
# Partial success is still useful — one engine may be published and the
|
|
63
|
+
# other not — so report it without discarding what did install.
|
|
64
|
+
return 1 if failures == len(names) else 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _status(args) -> int:
|
|
68
|
+
try:
|
|
69
|
+
credentials = auth.load()
|
|
70
|
+
who = credentials.email or "(no email recorded)"
|
|
71
|
+
print(f"authenticated: {who}")
|
|
72
|
+
except auth.NotAuthenticated as exc:
|
|
73
|
+
print(f"not authenticated: {exc}")
|
|
74
|
+
|
|
75
|
+
print(f"binaries in: {install.bin_dir()}")
|
|
76
|
+
for name, env in BINARIES:
|
|
77
|
+
try:
|
|
78
|
+
print(f" {name}: {locate(name, env)}")
|
|
79
|
+
except BinaryNotFound:
|
|
80
|
+
print(f" {name}: not found (run `simantic install {name}`)")
|
|
81
|
+
engine = install.installed_engine()
|
|
82
|
+
print(f" engine: {engine if engine else 'not found (fetched on first use, or `simantic install engine`)'}")
|
|
83
|
+
print(telemetry.describe())
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: list[str] | None = None) -> int:
|
|
88
|
+
# prog is left to argparse so usage reflects however it was invoked:
|
|
89
|
+
# `simantic`, the short `smtc`, or `python -m simantic`.
|
|
90
|
+
parser = argparse.ArgumentParser(
|
|
91
|
+
description="Simantic SDK: authentication and binaries."
|
|
92
|
+
)
|
|
93
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
94
|
+
|
|
95
|
+
p_auth = sub.add_parser("auth", help="store backend credentials in ~/.sim_id")
|
|
96
|
+
p_auth.add_argument(
|
|
97
|
+
"--token",
|
|
98
|
+
help="personal access token; opens a browser sign-in, or reads stdin "
|
|
99
|
+
"when piped, when omitted",
|
|
100
|
+
)
|
|
101
|
+
p_auth.add_argument(
|
|
102
|
+
"--no-browser",
|
|
103
|
+
action="store_true",
|
|
104
|
+
help="prompt for a token instead of opening a browser",
|
|
105
|
+
)
|
|
106
|
+
p_auth.set_defaults(func=_auth)
|
|
107
|
+
|
|
108
|
+
p_install = sub.add_parser("install", help="download simulator binaries")
|
|
109
|
+
p_install.add_argument("binary", nargs="*", help="defaults to all known binaries and the engine")
|
|
110
|
+
p_install.add_argument(
|
|
111
|
+
"--force", action="store_true", help="re-download even if already present"
|
|
112
|
+
)
|
|
113
|
+
p_install.add_argument(
|
|
114
|
+
"--channel",
|
|
115
|
+
help="release channel to install from (default: latest, or $SIMANTIC_CHANNEL)",
|
|
116
|
+
)
|
|
117
|
+
p_install.set_defaults(func=_install)
|
|
118
|
+
|
|
119
|
+
sub.add_parser("status", help="show credentials and resolved binaries").set_defaults(
|
|
120
|
+
func=_status
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
args = parser.parse_args(argv)
|
|
124
|
+
telemetry.record(f"cli.{args.command}")
|
|
125
|
+
try:
|
|
126
|
+
result = args.func(args)
|
|
127
|
+
# A CLI invocation is a natural moment to upload: the user is not
|
|
128
|
+
# waiting on a simulation, and the spool is due at most hourly.
|
|
129
|
+
telemetry.flush()
|
|
130
|
+
return result
|
|
131
|
+
except (auth.AuthError, install.InstallError) as exc:
|
|
132
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
133
|
+
return 1
|
|
134
|
+
except KeyboardInterrupt:
|
|
135
|
+
return 130
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
raise SystemExit(main())
|
simantic/_locate.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Finding the simulator binaries.
|
|
2
|
+
|
|
3
|
+
The SDK never bundles a binary: it drives whichever one the caller points at.
|
|
4
|
+
Resolution order, most explicit first:
|
|
5
|
+
|
|
6
|
+
1. an explicit argument
|
|
7
|
+
2. the binary's environment variable
|
|
8
|
+
3. ~/.simantic/bin, where `simantic install` puts things
|
|
9
|
+
4. a `_bin/` directory inside this package
|
|
10
|
+
5. PATH
|
|
11
|
+
|
|
12
|
+
A deliberate `simantic install` outranks a bundled `_bin/` copy, so fetching
|
|
13
|
+
a newer binary actually takes effect. The `_bin/` step is what would let a
|
|
14
|
+
platform-specific wheel ship a binary and be found with no change here.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BinaryNotFound(RuntimeError):
|
|
23
|
+
"""Raised when a simulator binary cannot be located."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def locate(
|
|
27
|
+
binary: str,
|
|
28
|
+
env_var: str,
|
|
29
|
+
explicit: str | os.PathLike[str] | None = None,
|
|
30
|
+
) -> Path:
|
|
31
|
+
"""Resolve `binary`, or raise BinaryNotFound explaining how to supply it."""
|
|
32
|
+
if explicit is not None:
|
|
33
|
+
path = Path(explicit)
|
|
34
|
+
if not path.exists():
|
|
35
|
+
raise BinaryNotFound(f"{path} does not exist")
|
|
36
|
+
return path
|
|
37
|
+
|
|
38
|
+
env = os.environ.get(env_var)
|
|
39
|
+
if env:
|
|
40
|
+
path = Path(env)
|
|
41
|
+
if not path.exists():
|
|
42
|
+
raise BinaryNotFound(f"${env_var} points at {path}, which does not exist")
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
# Imported here: install imports nothing from this module, but keeping the
|
|
46
|
+
# dependency one-way makes that impossible to get wrong later.
|
|
47
|
+
from .install import bin_dir
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
managed = bin_dir() / binary
|
|
51
|
+
except Exception: # no HOME and no $SIMANTIC_HOME — just skip this step
|
|
52
|
+
managed = None
|
|
53
|
+
if managed is not None and managed.exists():
|
|
54
|
+
return managed
|
|
55
|
+
|
|
56
|
+
bundled = Path(__file__).parent / "_bin" / binary
|
|
57
|
+
if bundled.exists():
|
|
58
|
+
return bundled
|
|
59
|
+
|
|
60
|
+
found = shutil.which(binary)
|
|
61
|
+
if found:
|
|
62
|
+
return Path(found)
|
|
63
|
+
|
|
64
|
+
raise BinaryNotFound(
|
|
65
|
+
f"no {binary!r} binary found. Run `simantic install {binary}`, put it on "
|
|
66
|
+
f"PATH, or set ${env_var} to its location."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
ENV_VAR = "SIMANTIC_ANALOG_CLI"
|
|
71
|
+
BINARY = "analog-cli"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def analog_cli(explicit: str | os.PathLike[str] | None = None) -> Path:
|
|
75
|
+
"""Resolve the analog-cli binary, or raise BinaryNotFound."""
|
|
76
|
+
return locate(BINARY, ENV_VAR, explicit)
|
simantic/agent.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""A session against the pyrite MCP server.
|
|
2
|
+
|
|
3
|
+
The CLI runner is one-shot: arguments in, transcript out, no state between
|
|
4
|
+
calls. That suits a pytest item and suits nothing that needs to look around
|
|
5
|
+
while firmware is stopped.
|
|
6
|
+
|
|
7
|
+
This is the other shape. `pyrite-mcp` keeps a machine alive and exposes it
|
|
8
|
+
as JSON-RPC tools over stdio, so a caller can break, step, read memory and
|
|
9
|
+
registers, and continue — each call structured going in and coming out,
|
|
10
|
+
with no argument strings to build or output to scrape.
|
|
11
|
+
|
|
12
|
+
with Session() as sim:
|
|
13
|
+
sim.call("simulate", elf="fw.elf", board="stm32f401")
|
|
14
|
+
print(sim.tools())
|
|
15
|
+
|
|
16
|
+
The process is an implementation detail of the transport; the interface is
|
|
17
|
+
the tool surface, which the server owns and this module does not duplicate.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ._locate import locate
|
|
29
|
+
from . import telemetry
|
|
30
|
+
|
|
31
|
+
ENV_VAR = "SIMANTIC_PYRITE_MCP"
|
|
32
|
+
BINARY = "pyrite-mcp"
|
|
33
|
+
|
|
34
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SessionError(RuntimeError):
|
|
38
|
+
"""The server could not be started, or refused a call."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ToolError(SessionError):
|
|
42
|
+
"""A tool ran and reported failure. Distinct so a caller can react to a
|
|
43
|
+
failed operation without treating it as a broken session."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def mcp_binary(explicit: str | os.PathLike[str] | None = None) -> Path:
|
|
47
|
+
"""Resolve the pyrite-mcp binary, or raise BinaryNotFound."""
|
|
48
|
+
return locate(BINARY, ENV_VAR, explicit)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Session:
|
|
52
|
+
"""A live pyrite engine, addressed by tool name."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, binary: str | os.PathLike[str] | None = None) -> None:
|
|
55
|
+
self._next_id = 0
|
|
56
|
+
try:
|
|
57
|
+
self._proc = subprocess.Popen(
|
|
58
|
+
[str(mcp_binary(binary))],
|
|
59
|
+
stdin=subprocess.PIPE,
|
|
60
|
+
stdout=subprocess.PIPE,
|
|
61
|
+
stderr=subprocess.DEVNULL, # diagnostics only; stdout is the protocol
|
|
62
|
+
text=True,
|
|
63
|
+
bufsize=1,
|
|
64
|
+
)
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
raise SessionError(f"cannot start {BINARY}: {exc}") from None
|
|
67
|
+
self._request("initialize", {"protocolVersion": PROTOCOL_VERSION})
|
|
68
|
+
|
|
69
|
+
# --- protocol ---
|
|
70
|
+
|
|
71
|
+
def _request(self, method: str, params: dict[str, Any]) -> Any:
|
|
72
|
+
self._next_id += 1
|
|
73
|
+
message = {
|
|
74
|
+
"jsonrpc": "2.0",
|
|
75
|
+
"id": self._next_id,
|
|
76
|
+
"method": method,
|
|
77
|
+
"params": params,
|
|
78
|
+
}
|
|
79
|
+
if self._proc.poll() is not None:
|
|
80
|
+
raise SessionError(f"{BINARY} exited with {self._proc.returncode}")
|
|
81
|
+
try:
|
|
82
|
+
self._proc.stdin.write(json.dumps(message) + "\n")
|
|
83
|
+
self._proc.stdin.flush()
|
|
84
|
+
except (BrokenPipeError, ValueError):
|
|
85
|
+
raise SessionError(f"{BINARY} closed its input") from None
|
|
86
|
+
|
|
87
|
+
# One response per request, in order: the server is single-threaded
|
|
88
|
+
# over stdio, so the next line is this call's answer.
|
|
89
|
+
line = self._proc.stdout.readline()
|
|
90
|
+
if not line:
|
|
91
|
+
raise SessionError(f"{BINARY} closed its output during {method!r}")
|
|
92
|
+
try:
|
|
93
|
+
response = json.loads(line)
|
|
94
|
+
except json.JSONDecodeError as exc:
|
|
95
|
+
raise SessionError(f"{BINARY} sent invalid JSON: {exc}") from None
|
|
96
|
+
if "error" in response:
|
|
97
|
+
detail = response["error"]
|
|
98
|
+
raise SessionError(f"{method} failed: {detail.get('message', detail)}")
|
|
99
|
+
return response.get("result")
|
|
100
|
+
|
|
101
|
+
# --- surface ---
|
|
102
|
+
|
|
103
|
+
def tools(self) -> list[str]:
|
|
104
|
+
"""Every tool this server exposes, by name."""
|
|
105
|
+
result = self._request("tools/list", {})
|
|
106
|
+
return [t["name"] for t in result.get("tools", [])]
|
|
107
|
+
|
|
108
|
+
def call(self, tool: str, **arguments: Any) -> Any:
|
|
109
|
+
"""Invoke a tool. Returns its parsed result.
|
|
110
|
+
|
|
111
|
+
A tool that reports failure raises ToolError rather than returning a
|
|
112
|
+
payload the caller has to inspect to notice something went wrong.
|
|
113
|
+
"""
|
|
114
|
+
telemetry.record(f"mcp.{tool}")
|
|
115
|
+
result = self._request("tools/call", {"name": tool, "arguments": arguments})
|
|
116
|
+
payload = _parsed(result)
|
|
117
|
+
# The envelope's isError is not trusted: pyrite-mcp sets it on
|
|
118
|
+
# successful calls too, so it does not distinguish one from the
|
|
119
|
+
# other. The payload's own `error` does, and is what the tools
|
|
120
|
+
# themselves report through. Envelope flag only when there is no
|
|
121
|
+
# payload to ask.
|
|
122
|
+
if isinstance(payload, dict) and "error" in payload:
|
|
123
|
+
if payload["error"] is not None:
|
|
124
|
+
raise ToolError(f"{tool}: {payload['error']}")
|
|
125
|
+
return payload
|
|
126
|
+
if isinstance(result, dict) and result.get("isError") and not isinstance(payload, dict):
|
|
127
|
+
raise ToolError(f"{tool}: {_text_of(result)}")
|
|
128
|
+
return payload
|
|
129
|
+
|
|
130
|
+
# --- lifecycle ---
|
|
131
|
+
|
|
132
|
+
def close(self) -> None:
|
|
133
|
+
if self._proc.poll() is None:
|
|
134
|
+
try:
|
|
135
|
+
self._proc.stdin.close()
|
|
136
|
+
except (BrokenPipeError, ValueError):
|
|
137
|
+
pass
|
|
138
|
+
try:
|
|
139
|
+
self._proc.wait(timeout=5)
|
|
140
|
+
except subprocess.TimeoutExpired:
|
|
141
|
+
self._proc.kill()
|
|
142
|
+
self._proc.wait()
|
|
143
|
+
|
|
144
|
+
def __enter__(self) -> Session:
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def __exit__(self, *exc: object) -> None:
|
|
148
|
+
self.close()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _text_of(result: dict) -> str:
|
|
152
|
+
"""The text blocks of an MCP result, joined."""
|
|
153
|
+
parts = [
|
|
154
|
+
block.get("text", "")
|
|
155
|
+
for block in result.get("content", [])
|
|
156
|
+
if isinstance(block, dict) and block.get("type") == "text"
|
|
157
|
+
]
|
|
158
|
+
return "\n".join(p for p in parts if p)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parsed(result: Any) -> Any:
|
|
162
|
+
"""A tool result as data where it is data, and as text where it is not.
|
|
163
|
+
|
|
164
|
+
Tools return their payload as a JSON string inside a text block, so the
|
|
165
|
+
caller would otherwise parse every response by hand.
|
|
166
|
+
"""
|
|
167
|
+
if not isinstance(result, dict):
|
|
168
|
+
return result
|
|
169
|
+
text = _text_of(result)
|
|
170
|
+
if not text:
|
|
171
|
+
return result
|
|
172
|
+
try:
|
|
173
|
+
return json.loads(text)
|
|
174
|
+
except json.JSONDecodeError:
|
|
175
|
+
return text
|
simantic/analog.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Driving `analog-cli` from Python.
|
|
2
|
+
|
|
3
|
+
The CLI is the source of truth; this module is a typed subprocess wrapper
|
|
4
|
+
around its JSON contract, not a reimplementation of anything it does.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import tomllib
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ._locate import analog_cli
|
|
17
|
+
from . import telemetry
|
|
18
|
+
from .report import ReportError, TestReport
|
|
19
|
+
|
|
20
|
+
#: Exit codes that still produce a report: everything ran, verdicts inside.
|
|
21
|
+
#: 0 = all passed, 1 = at least one test failed or errored.
|
|
22
|
+
_REPORT_EXITS = frozenset({0, 1})
|
|
23
|
+
|
|
24
|
+
_EXIT_MEANINGS = {
|
|
25
|
+
2: "bad project",
|
|
26
|
+
3: "kicad-cli not found",
|
|
27
|
+
4: "the runner itself failed",
|
|
28
|
+
6: "testplan invalid or unreadable",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AnalogCliError(RuntimeError):
|
|
33
|
+
"""analog-cli exited with a code that carries no report."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, code: int, stderr: str) -> None:
|
|
36
|
+
meaning = _EXIT_MEANINGS.get(code, "unknown failure")
|
|
37
|
+
super().__init__(f"analog-cli exited {code} ({meaning})\n{stderr.strip()}")
|
|
38
|
+
self.code = code
|
|
39
|
+
self.stderr = stderr
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_tests(
|
|
43
|
+
project: str | os.PathLike[str],
|
|
44
|
+
*,
|
|
45
|
+
plan: str | os.PathLike[str] | None = None,
|
|
46
|
+
only: Sequence[str] | None = None,
|
|
47
|
+
binary: str | os.PathLike[str] | None = None,
|
|
48
|
+
timeout: float | None = None,
|
|
49
|
+
) -> TestReport:
|
|
50
|
+
"""Run a project's testplan and return the parsed report.
|
|
51
|
+
|
|
52
|
+
`project` is a directory or .kicad_pro. Without `plan`, analog-cli uses
|
|
53
|
+
the project's <name>.sim.toml, falling back to its built-in static checks.
|
|
54
|
+
A failing test is a normal outcome and comes back in the report; only
|
|
55
|
+
conditions that prevent a run at all raise AnalogCliError.
|
|
56
|
+
"""
|
|
57
|
+
telemetry.record("sdk.run_tests")
|
|
58
|
+
cmd = [str(analog_cli(binary)), "test", "-p", str(project), "--format", "json"]
|
|
59
|
+
if plan is not None:
|
|
60
|
+
cmd += ["--plan", str(plan)]
|
|
61
|
+
for name in only or ():
|
|
62
|
+
cmd += ["--only", name]
|
|
63
|
+
|
|
64
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
65
|
+
if proc.returncode not in _REPORT_EXITS:
|
|
66
|
+
raise AnalogCliError(proc.returncode, proc.stderr)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
data = json.loads(proc.stdout)
|
|
70
|
+
except json.JSONDecodeError as exc:
|
|
71
|
+
raise ReportError(
|
|
72
|
+
f"analog-cli exited {proc.returncode} but did not emit JSON: {exc}"
|
|
73
|
+
) from exc
|
|
74
|
+
return TestReport.from_json(data)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def plan_path(project: str | os.PathLike[str]) -> Path | None:
|
|
78
|
+
"""The <name>.sim.toml a project directory would use, if it exists."""
|
|
79
|
+
path = Path(project)
|
|
80
|
+
directory = path.parent if path.suffix == ".kicad_pro" else path
|
|
81
|
+
candidates = sorted(directory.glob("*.sim.toml"))
|
|
82
|
+
return candidates[0] if candidates else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def plan_test_names(plan: str | os.PathLike[str]) -> list[str]:
|
|
86
|
+
"""Names of the [[test]] tables in a .sim.toml testplan, in file order.
|
|
87
|
+
|
|
88
|
+
Read directly so a test runner can enumerate cases without invoking the
|
|
89
|
+
CLI once per collection. Unnamed tables are skipped: --only matches by
|
|
90
|
+
name, so a nameless test is not individually addressable.
|
|
91
|
+
"""
|
|
92
|
+
with open(plan, "rb") as fh:
|
|
93
|
+
data = tomllib.load(fh)
|
|
94
|
+
return [t["name"] for t in data.get("test", []) if "name" in t]
|