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