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/session.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Drive a live simulation from Python, step by step.
|
|
2
|
+
|
|
3
|
+
`Sim` controls one emulation hosted in this process: it advances virtual
|
|
4
|
+
time only on request and otherwise observes without perturbing, so a script
|
|
5
|
+
replays the same firmware behaviour every run and Python think-time costs
|
|
6
|
+
nothing. It is the programmatic face of everything `sim` can do; pytest is
|
|
7
|
+
one place to use it, a plain script or a process pool is another.
|
|
8
|
+
|
|
9
|
+
from simantic import Sim
|
|
10
|
+
|
|
11
|
+
with Sim(elf="fw.elf", repl="board.repl", uart="uart0") as sim:
|
|
12
|
+
sim.expect(">>> ") # run until the prompt, then hold
|
|
13
|
+
sim.send("print(6*7)") # delivered when time next advances
|
|
14
|
+
sim.expect(r"42\\r?\\n>>> ") # run until answered
|
|
15
|
+
sim.run_for(0.5) # advance exactly 500 virtual ms
|
|
16
|
+
assert "Traceback" not in sim.read_uart()
|
|
17
|
+
|
|
18
|
+
scenario = {
|
|
19
|
+
"machines": {"c6": {"mcu": "ESP32-C6", "elf": "image.elf"}},
|
|
20
|
+
"networkServices": [{"name": "broker", "host": "192.0.2.1", "port": 1883,
|
|
21
|
+
"type": "Antmicro.Renode.Peripherals.Network.ScriptedNetworkService",
|
|
22
|
+
"args": "mqtt_broker.py"}],
|
|
23
|
+
"quantum": 0.00001,
|
|
24
|
+
}
|
|
25
|
+
with Sim(scenario=scenario, machine="c6", uart="uart0") as sim:
|
|
26
|
+
assert sim.expect("CONNACK verified", timeout=60).virtual_seconds < 5
|
|
27
|
+
|
|
28
|
+
Platforms: `repl=` is a platform file you supply (.replx templates are
|
|
29
|
+
rendered for you); `mcu=` names a model, resolved exactly like `sim --mcu` —
|
|
30
|
+
from `~/.sim_cache`, else fetched with your stored credentials and cached —
|
|
31
|
+
optionally with an `overlay=` fragment. Scenario machines accept the same
|
|
32
|
+
keys. (`$SIMANTIC_MCU_LIB` switches `mcu=` to a local model library for
|
|
33
|
+
model development.)
|
|
34
|
+
|
|
35
|
+
The engine is `Simantic.Core`, hosted in-process (see `engine.py`); this
|
|
36
|
+
class adds vocabulary, not semantics.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import tempfile
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any
|
|
46
|
+
|
|
47
|
+
from . import telemetry
|
|
48
|
+
from .engine import load
|
|
49
|
+
from .fixtures import MCU_LIB_ENV, platform_path
|
|
50
|
+
from .mcu import SimError
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ExpectTimeout(AssertionError):
|
|
54
|
+
"""expect() did not match; carries the text collected while waiting."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, pattern: str, text: str, virtual_seconds: float):
|
|
57
|
+
super().__init__(
|
|
58
|
+
f"expected /{pattern}/ did not appear by virtual t={virtual_seconds:.6f}s; "
|
|
59
|
+
f"collected: {text!r}"
|
|
60
|
+
)
|
|
61
|
+
self.pattern = pattern
|
|
62
|
+
self.text = text
|
|
63
|
+
self.virtual_seconds = virtual_seconds
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Match:
|
|
67
|
+
"""A successful expect: the matched text and the virtual time of the match."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, text: str, virtual_seconds: float):
|
|
70
|
+
self.text = text
|
|
71
|
+
self.virtual_seconds = virtual_seconds
|
|
72
|
+
|
|
73
|
+
def __contains__(self, needle: str) -> bool:
|
|
74
|
+
return needle in self.text
|
|
75
|
+
|
|
76
|
+
def __repr__(self) -> str:
|
|
77
|
+
return f"Match(t={self.virtual_seconds:.6f}, text={self.text!r})"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _bytes(net_bytes) -> bytes:
|
|
81
|
+
return bytes(bytearray(net_bytes)) if net_bytes is not None else b""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _uart(r) -> dict:
|
|
85
|
+
return {"t": r.T, "machine": r.Machine, "label": r.Label, "text": r.Text}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _frame(r) -> dict:
|
|
89
|
+
return {"t": r.T, "machine": r.Machine, "label": r.Label, "protocol": r.Protocol,
|
|
90
|
+
"direction": r.Direction, "summary": r.Summary, "id": r.Id,
|
|
91
|
+
"data": _bytes(r.Data) if r.Data is not None else None}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _log(r) -> dict:
|
|
95
|
+
return {"t": r.T, "level": r.Level, "source": r.Source, "message": r.Message}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _interrupt(r) -> dict:
|
|
99
|
+
return {"t": r.T, "machine": r.Machine, "direction": r.Direction,
|
|
100
|
+
"exception": int(r.ExceptionIndex), "name": r.Name}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _as_dict(net_obj) -> dict | None:
|
|
104
|
+
"""An engine record as plain Python (camelCase keys), via the engine's own JSON."""
|
|
105
|
+
if net_obj is None:
|
|
106
|
+
return None
|
|
107
|
+
import json
|
|
108
|
+
|
|
109
|
+
import clr # type: ignore[import-not-found]
|
|
110
|
+
|
|
111
|
+
clr.AddReference("System.Text.Json")
|
|
112
|
+
from System.Text.Json import JsonNamingPolicy, JsonSerializer, JsonSerializerOptions # type: ignore[import-not-found]
|
|
113
|
+
|
|
114
|
+
opts = JsonSerializerOptions()
|
|
115
|
+
opts.PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
116
|
+
return json.loads(JsonSerializer.Serialize(net_obj, net_obj.GetType(), opts))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _symbol_trace(r) -> dict:
|
|
120
|
+
return {"t": r.T, "machine": r.Machine, "symbol": r.Symbol, "address": int(r.Address),
|
|
121
|
+
"args": [{"register": a.Register, "value": int(a.Value), "symbol": a.Symbol} for a in r.Args]}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Sim:
|
|
125
|
+
"""One live simulation, driven from Python. Use as a context manager."""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
*,
|
|
130
|
+
elf: str | os.PathLike[str] | None = None,
|
|
131
|
+
repl: str | os.PathLike[str] | None = None,
|
|
132
|
+
mcu: str | None = None,
|
|
133
|
+
overlay: str | os.PathLike[str] | None = None,
|
|
134
|
+
scenario: dict[str, Any] | None = None,
|
|
135
|
+
machine: str | None = None,
|
|
136
|
+
uart: str = "uart0",
|
|
137
|
+
trace_symbols: list[str] = (),
|
|
138
|
+
trace_interrupts: bool = False,
|
|
139
|
+
show_logs: bool = False,
|
|
140
|
+
cwd: str | os.PathLike[str] | None = None,
|
|
141
|
+
engine_dir: str | os.PathLike[str] | None = None,
|
|
142
|
+
):
|
|
143
|
+
self.machine = machine
|
|
144
|
+
self.uart = uart
|
|
145
|
+
self._cursors = {"uart": 0, "frames": 0, "logs": 0, "interrupts": 0, "symbol_trace": 0}
|
|
146
|
+
# pexpect-style stream: text the firmware printed but no expect() has
|
|
147
|
+
# consumed yet, so sequential expects never miss output that arrived
|
|
148
|
+
# in a previous call's overshoot.
|
|
149
|
+
self._pending: list[tuple[float, str]] = []
|
|
150
|
+
self._work = Path(tempfile.mkdtemp(prefix="simantic-session-"))
|
|
151
|
+
self._base = Path(cwd) if cwd else Path.cwd()
|
|
152
|
+
|
|
153
|
+
# Argument errors are the caller's and must not depend on an engine
|
|
154
|
+
# being present.
|
|
155
|
+
if scenario is not None:
|
|
156
|
+
if elf is not None or repl is not None or mcu is not None:
|
|
157
|
+
raise ValueError("scenario= is exclusive with elf=/repl=/mcu=")
|
|
158
|
+
if not (scenario.get("machines") or {}):
|
|
159
|
+
raise ValueError("scenario needs at least one machine")
|
|
160
|
+
elif elf is None or (repl is None) == (mcu is None):
|
|
161
|
+
raise ValueError("give elf= and exactly one of repl= or mcu= (or scenario=)")
|
|
162
|
+
|
|
163
|
+
ns = load(engine_dir)
|
|
164
|
+
spec = ns.SessionSpec()
|
|
165
|
+
spec.TraceInterrupts = trace_interrupts
|
|
166
|
+
spec.ShowBackendLogs = show_logs
|
|
167
|
+
for s in trace_symbols:
|
|
168
|
+
spec.TraceSymbols.Add(s)
|
|
169
|
+
|
|
170
|
+
if scenario is not None:
|
|
171
|
+
self._fill_scenario(spec, scenario)
|
|
172
|
+
else:
|
|
173
|
+
self._add_machine(spec, "machine", repl, mcu, overlay, elf)
|
|
174
|
+
|
|
175
|
+
telemetry.record("sdk.session")
|
|
176
|
+
try:
|
|
177
|
+
self._session = ns.Session.Start(spec)
|
|
178
|
+
except Exception as exc: # .NET exceptions surface as Python exceptions
|
|
179
|
+
raise SimError(f"could not start the simulation: {exc}") from None
|
|
180
|
+
self.machines: list[str] = list(self._session.Machines)
|
|
181
|
+
|
|
182
|
+
# -- platform / scenario preparation -----------------------------------
|
|
183
|
+
|
|
184
|
+
def _add_machine(self, spec, name: str, repl, mcu, overlay, elf) -> None:
|
|
185
|
+
"""Platform file → AddMachine; model name → the local model library when
|
|
186
|
+
$SIMANTIC_MCU_LIB is set (development), else the engine's own resolver
|
|
187
|
+
(~/.sim_cache, then the backend with stored credentials — like `sim --mcu`)."""
|
|
188
|
+
elf_path = str(self._base / elf)
|
|
189
|
+
if repl is not None:
|
|
190
|
+
if overlay is not None:
|
|
191
|
+
raise ValueError("overlay= applies to mcu=, not repl=")
|
|
192
|
+
spec.AddMachine(name, str(self._base / repl), elf_path)
|
|
193
|
+
return
|
|
194
|
+
if os.environ.get(MCU_LIB_ENV):
|
|
195
|
+
platform = platform_path(mcu, self._base / overlay if overlay else None, self._work)
|
|
196
|
+
spec.AddMachine(name, str(platform), elf_path)
|
|
197
|
+
return
|
|
198
|
+
fragment = (self._base / overlay).read_text() if overlay else None
|
|
199
|
+
spec.AddModel(name, mcu, elf_path, fragment)
|
|
200
|
+
|
|
201
|
+
def _fill_scenario(self, spec, scenario: dict[str, Any]) -> None:
|
|
202
|
+
machines = scenario.get("machines") or {}
|
|
203
|
+
if not machines:
|
|
204
|
+
raise ValueError("scenario needs at least one machine")
|
|
205
|
+
for name, m in machines.items():
|
|
206
|
+
if "elf" not in m or ("repl" in m) == ("mcu" in m):
|
|
207
|
+
raise ValueError(f"machine {name!r} needs elf and exactly one of repl/mcu")
|
|
208
|
+
self._add_machine(spec, name, m.get("repl"), m.get("mcu"), m.get("overlay"), m["elf"])
|
|
209
|
+
for med in scenario.get("media") or []:
|
|
210
|
+
sm = spec.AddMedium(med["type"], list(med.get("connect") or []))
|
|
211
|
+
sm.Strict = bool(med.get("strict", False))
|
|
212
|
+
if med.get("hostBridge"):
|
|
213
|
+
sm.HostBridge = med["hostBridge"]
|
|
214
|
+
for svc in scenario.get("networkServices") or []:
|
|
215
|
+
spec.AddService(svc["name"], svc["host"], int(svc.get("port", 0)),
|
|
216
|
+
svc.get("type", "Antmicro.Renode.Peripherals.Network.EchoService"),
|
|
217
|
+
self._service_args(svc.get("args", "")))
|
|
218
|
+
if scenario.get("quantum") is not None:
|
|
219
|
+
spec.QuantumSeconds = float(scenario["quantum"])
|
|
220
|
+
|
|
221
|
+
def _service_args(self, args: str) -> str:
|
|
222
|
+
# A script path is the common case; make it absolute against cwd=.
|
|
223
|
+
p = self._base / args
|
|
224
|
+
return str(p) if args and p.exists() else args
|
|
225
|
+
|
|
226
|
+
# -- stimulus -----------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def send(self, text: str, line_ending: str = "\r", uart: str | None = None,
|
|
229
|
+
machine: str | None = None) -> None:
|
|
230
|
+
"""Type into the UART. While paused (the normal state between calls)
|
|
231
|
+
the bytes are delivered at the start of the next expect/run_for."""
|
|
232
|
+
self.send_bytes((text + line_ending).encode("latin-1"), uart, machine)
|
|
233
|
+
|
|
234
|
+
def send_bytes(self, data: bytes, uart: str | None = None, machine: str | None = None) -> None:
|
|
235
|
+
self._session.Send(bytes(data), uart or self.uart, machine or self.machine)
|
|
236
|
+
|
|
237
|
+
def inject_gpio(self, peripheral: str, pin: int, state: bool, machine: str | None = None) -> None:
|
|
238
|
+
"""Drive an external GPIO input line (a button press/release)."""
|
|
239
|
+
self._session.InjectGpio(peripheral, pin, state, machine or self.machine)
|
|
240
|
+
|
|
241
|
+
def inject_can(self, peripheral: str, can_id: int, data: bytes, *, extended: bool = False,
|
|
242
|
+
remote: bool = False, fd: bool = False, brs: bool = False,
|
|
243
|
+
machine: str | None = None) -> None:
|
|
244
|
+
"""Put a CAN frame on the bus as seen by `peripheral`."""
|
|
245
|
+
self._session.InjectCan(peripheral, can_id, bytes(data), extended, remote, fd, brs,
|
|
246
|
+
machine or self.machine)
|
|
247
|
+
|
|
248
|
+
def inject_radio(self, peripheral: str, frame: bytes, machine: str | None = None) -> None:
|
|
249
|
+
"""Deliver a raw radio frame to a radio peripheral."""
|
|
250
|
+
self._session.InjectRadio(peripheral, bytes(frame), machine or self.machine)
|
|
251
|
+
|
|
252
|
+
# -- time control -------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def run_for(self, virtual_seconds: float) -> float:
|
|
255
|
+
"""Advance exactly this much virtual time, then hold. Returns elapsed virtual time."""
|
|
256
|
+
return self._await(self._session.RunForAsync(float(virtual_seconds)))
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def time(self) -> float:
|
|
260
|
+
"""Elapsed virtual time in seconds."""
|
|
261
|
+
return self._session.VirtualTime
|
|
262
|
+
|
|
263
|
+
def expect(self, pattern: str, timeout: float = 30, uart: str | None = None,
|
|
264
|
+
machine: str | None = None) -> Match:
|
|
265
|
+
"""Run until the UART output matches the regex, then hold.
|
|
266
|
+
|
|
267
|
+
Output already printed but not consumed by a previous expect() is
|
|
268
|
+
matched first, without advancing time. `timeout` is wall-clock
|
|
269
|
+
seconds of simulation effort, not virtual time. Raises ExpectTimeout
|
|
270
|
+
(an AssertionError) if the pattern never appears.
|
|
271
|
+
"""
|
|
272
|
+
rx = re.compile(pattern)
|
|
273
|
+
self._drain_pending()
|
|
274
|
+
text = "".join(s for _, s in self._pending)
|
|
275
|
+
m = rx.search(text)
|
|
276
|
+
if m:
|
|
277
|
+
t = self._time_at_offset(m.end())
|
|
278
|
+
self._consume(m.end())
|
|
279
|
+
return Match(m.group(0), t)
|
|
280
|
+
|
|
281
|
+
r = self._await(self._session.ExpectAsync(pattern, uart or self.uart, machine or self.machine, float(timeout)))
|
|
282
|
+
if not r.Matched:
|
|
283
|
+
raise ExpectTimeout(pattern, text + r.Text, r.VirtualSeconds)
|
|
284
|
+
live = rx.search(r.Text)
|
|
285
|
+
matched_text = live.group(0) if live else r.Text
|
|
286
|
+
# Consume the stream through the live match and no further, so lines
|
|
287
|
+
# printed in the overshoot stay buffered for the next expect.
|
|
288
|
+
self._drain_pending()
|
|
289
|
+
text = "".join(s for _, s in self._pending)
|
|
290
|
+
m = rx.search(text)
|
|
291
|
+
if m:
|
|
292
|
+
self._consume(m.end())
|
|
293
|
+
else:
|
|
294
|
+
idx = text.rfind(matched_text)
|
|
295
|
+
if idx >= 0:
|
|
296
|
+
self._consume(idx + len(matched_text))
|
|
297
|
+
return Match(matched_text, r.VirtualSeconds)
|
|
298
|
+
|
|
299
|
+
# -- observation (never advances time) ----------------------------------
|
|
300
|
+
|
|
301
|
+
def read_uart(self, from_start: bool = False) -> str:
|
|
302
|
+
"""Everything the firmware printed since the last read (or ever)."""
|
|
303
|
+
recs = self.uart_records(from_start)
|
|
304
|
+
self._pending.clear()
|
|
305
|
+
return "".join(r["text"] for r in recs)
|
|
306
|
+
|
|
307
|
+
def uart_records(self, from_start: bool = False) -> list[dict]:
|
|
308
|
+
"""Timestamped UART records: {t, machine, label, text}."""
|
|
309
|
+
return self._records("uart", self._session.ReadUart, _uart, from_start)
|
|
310
|
+
|
|
311
|
+
def frames(self, from_start: bool = False) -> list[dict]:
|
|
312
|
+
"""Captured bus frames (CAN/SPI/I2C/BLE/Ethernet) since the last call."""
|
|
313
|
+
return self._records("frames", self._session.ReadFrames, _frame, from_start)
|
|
314
|
+
|
|
315
|
+
def logs(self, from_start: bool = False) -> list[dict]:
|
|
316
|
+
"""Simulator-side logs — unhandled registers, model warnings."""
|
|
317
|
+
return self._records("logs", self._session.ReadLogs, _log, from_start)
|
|
318
|
+
|
|
319
|
+
def interrupts(self, from_start: bool = False) -> list[dict]:
|
|
320
|
+
"""Interrupt entry/exit records (needs trace_interrupts=True)."""
|
|
321
|
+
return self._records("interrupts", self._session.ReadInterrupts, _interrupt, from_start)
|
|
322
|
+
|
|
323
|
+
def symbol_trace(self, from_start: bool = False) -> list[dict]:
|
|
324
|
+
"""Hits on trace_symbols= with their argument registers (non-halting)."""
|
|
325
|
+
return self._records("symbol_trace", self._session.ReadSymbolTrace, _symbol_trace, from_start)
|
|
326
|
+
|
|
327
|
+
def read_memory(self, address: int | str, count: int = 4, machine: str | None = None) -> bytes:
|
|
328
|
+
"""Read bytes from the system bus; `address` is an int or a symbol name."""
|
|
329
|
+
if isinstance(address, str):
|
|
330
|
+
address = self.symbol(address, machine)
|
|
331
|
+
return _bytes(self._session.ReadMemory(int(address), int(count), machine or self.machine))
|
|
332
|
+
|
|
333
|
+
def read_u32(self, address: int | str, machine: str | None = None) -> int:
|
|
334
|
+
return int.from_bytes(self.read_memory(address, 4, machine), "little")
|
|
335
|
+
|
|
336
|
+
def symbol(self, name: str, machine: str | None = None) -> int:
|
|
337
|
+
"""Address of an ELF symbol."""
|
|
338
|
+
return int(self._session.ResolveSymbol(name, machine or self.machine))
|
|
339
|
+
|
|
340
|
+
def threads(self, machine: str | None = None) -> dict | None:
|
|
341
|
+
"""RTOS thread snapshot, e.g. {"rtos": "Zephyr", "threads": [{"name", "state",
|
|
342
|
+
"priority", ...}], "truncated": False}; None when no RTOS is recognised."""
|
|
343
|
+
return _as_dict(self._session.Threads(machine or self.machine))
|
|
344
|
+
|
|
345
|
+
def heap(self, machine: str | None = None) -> dict | None:
|
|
346
|
+
"""Heap report, e.g. {"arenaStart", "arenaSizeBytes", "usedBytes", "freeBytes",
|
|
347
|
+
"largestFreeBlockBytes", "fragmentationRatio", ...}; None when not recognised."""
|
|
348
|
+
return _as_dict(self._session.Heap(machine or self.machine))
|
|
349
|
+
|
|
350
|
+
# -- lifecycle ----------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
def close(self) -> None:
|
|
353
|
+
if getattr(self, "_session", None) is not None:
|
|
354
|
+
self._session.Dispose()
|
|
355
|
+
self._session = None
|
|
356
|
+
|
|
357
|
+
def __enter__(self) -> "Sim":
|
|
358
|
+
return self
|
|
359
|
+
|
|
360
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
361
|
+
self.close()
|
|
362
|
+
|
|
363
|
+
# -- internals ----------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
@staticmethod
|
|
366
|
+
def _await(task):
|
|
367
|
+
"""Wait for an engine task while releasing the GIL: scripted peers run
|
|
368
|
+
Python on the emulation thread and need it while the clock is running."""
|
|
369
|
+
import time
|
|
370
|
+
|
|
371
|
+
while not task.IsCompleted:
|
|
372
|
+
time.sleep(0.0005)
|
|
373
|
+
if task.IsFaulted:
|
|
374
|
+
raise SimError(str(task.Exception.GetBaseException().Message))
|
|
375
|
+
return task.Result
|
|
376
|
+
|
|
377
|
+
def _records(self, key: str, reader, convert, from_start: bool) -> list[dict]:
|
|
378
|
+
cursor = 0 if from_start else self._cursors[key]
|
|
379
|
+
out: list[dict] = []
|
|
380
|
+
while True:
|
|
381
|
+
page = reader(cursor, 2000)
|
|
382
|
+
out.extend(convert(r) for r in page.Records)
|
|
383
|
+
cursor = page.Next
|
|
384
|
+
if not page.Truncated:
|
|
385
|
+
break
|
|
386
|
+
self._cursors[key] = cursor
|
|
387
|
+
return out
|
|
388
|
+
|
|
389
|
+
def _drain_pending(self) -> None:
|
|
390
|
+
for rec in self.uart_records(False):
|
|
391
|
+
self._pending.append((rec["t"], rec["text"]))
|
|
392
|
+
|
|
393
|
+
def _time_at_offset(self, offset: int) -> float:
|
|
394
|
+
seen = 0
|
|
395
|
+
for t, s in self._pending:
|
|
396
|
+
seen += len(s)
|
|
397
|
+
if seen >= offset:
|
|
398
|
+
return t
|
|
399
|
+
return self._pending[-1][0] if self._pending else 0.0
|
|
400
|
+
|
|
401
|
+
def _consume(self, offset: int) -> None:
|
|
402
|
+
while offset > 0 and self._pending:
|
|
403
|
+
t, s = self._pending[0]
|
|
404
|
+
if len(s) <= offset:
|
|
405
|
+
offset -= len(s)
|
|
406
|
+
self._pending.pop(0)
|
|
407
|
+
else:
|
|
408
|
+
self._pending[0] = (t, s[offset:])
|
|
409
|
+
offset = 0
|
simantic/telemetry.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Reporting that a run happened, to the account that ran it.
|
|
2
|
+
|
|
3
|
+
Deliberately narrow. What goes up is the *shape* of a run — how many tests,
|
|
4
|
+
how many passed, which runner, how long — and never its content. Paths,
|
|
5
|
+
project names, ELF filenames, testplan names, and UART transcripts are the
|
|
6
|
+
customer's intellectual property and are the reason a package like this gets
|
|
7
|
+
uninstalled; none of them leave the machine.
|
|
8
|
+
|
|
9
|
+
Sending is best-effort and silent: telemetry that breaks a test run, slows
|
|
10
|
+
it down, or prints a warning is worse than no telemetry. Every failure path
|
|
11
|
+
here ends in `return`.
|
|
12
|
+
|
|
13
|
+
Off unless the user is authenticated, and off whenever $SIMANTIC_TELEMETRY=0
|
|
14
|
+
or $DO_NOT_TRACK=1.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import platform
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
|
|
26
|
+
from . import auth
|
|
27
|
+
|
|
28
|
+
#: Deliberately not `report-usage`. That endpoint feeds the run statistics,
|
|
29
|
+
#: which count every row as a simulation and treat a missing exit code as a
|
|
30
|
+
#: failure — a call-count report there would corrupt a live metric.
|
|
31
|
+
REPORT_URL = "https://drjdhqfvrttolueolzif.supabase.co/functions/v1/report-sdk-usage"
|
|
32
|
+
|
|
33
|
+
#: The server caps a report at 16 KB. Nothing here approaches it, and the
|
|
34
|
+
#: cap is enforced locally so an oversized report is dropped rather than
|
|
35
|
+
#: rejected with an error nobody sees.
|
|
36
|
+
MAX_BYTES = 16 * 1024
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def enabled() -> bool:
|
|
40
|
+
"""Whether to report at all.
|
|
41
|
+
|
|
42
|
+
DO_NOT_TRACK is honoured because it is the cross-tool convention, and a
|
|
43
|
+
user who has set it should not have to learn ours as well.
|
|
44
|
+
"""
|
|
45
|
+
if os.environ.get("SIMANTIC_TELEMETRY", "").strip() in {"0", "false", "off", "no"}:
|
|
46
|
+
return False
|
|
47
|
+
if os.environ.get("DO_NOT_TRACK", "").strip() in {"1", "true", "yes"}:
|
|
48
|
+
return False
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def environment() -> dict[str, str]:
|
|
53
|
+
"""The environment a run happened in. No hostname, no user, no paths."""
|
|
54
|
+
from . import __version__
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
"cli_version": __version__,
|
|
58
|
+
"client": "simantic-py",
|
|
59
|
+
"python": platform.python_version(),
|
|
60
|
+
"os": platform.system().lower(),
|
|
61
|
+
"arch": platform.machine().lower(),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def report(event: str, **fields: object) -> bool:
|
|
66
|
+
"""Send one usage report. Returns whether it was sent.
|
|
67
|
+
|
|
68
|
+
The return value is for tests; callers ignore it, because there is
|
|
69
|
+
nothing useful for them to do when telemetry fails.
|
|
70
|
+
"""
|
|
71
|
+
if not enabled():
|
|
72
|
+
return False
|
|
73
|
+
try:
|
|
74
|
+
credentials = auth.load()
|
|
75
|
+
except auth.AuthError:
|
|
76
|
+
return False # unauthenticated: nothing to attribute a report to
|
|
77
|
+
|
|
78
|
+
payload = {"event": event, **environment(), **fields}
|
|
79
|
+
body = json.dumps(payload).encode()
|
|
80
|
+
if len(body) > MAX_BYTES:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
request = urllib.request.Request(
|
|
84
|
+
REPORT_URL,
|
|
85
|
+
data=body,
|
|
86
|
+
method="POST",
|
|
87
|
+
headers={
|
|
88
|
+
"Authorization": f"Bearer {credentials.api_key}",
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
with urllib.request.urlopen(request, timeout=5):
|
|
94
|
+
return True
|
|
95
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
96
|
+
# Offline, blocked, slow, or refused — all of which are fine.
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --- the call spool ---
|
|
101
|
+
#
|
|
102
|
+
# Which calls get made, buffered locally and uploaded on an interval rather
|
|
103
|
+
# than per call. A round trip inside `run_tests` would put the network on the
|
|
104
|
+
# critical path of a simulation; a line appended to a file does not.
|
|
105
|
+
|
|
106
|
+
UPLOAD_INTERVAL = 3600 # seconds
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def spool_path():
|
|
110
|
+
from .install import simantic_home
|
|
111
|
+
|
|
112
|
+
return simantic_home() / "usage.jsonl"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def stamp_path():
|
|
116
|
+
from .install import simantic_home
|
|
117
|
+
|
|
118
|
+
return simantic_home() / "usage.last"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def record(call: str) -> None:
|
|
122
|
+
"""Note that `call` happened. Never raises, never blocks on the network.
|
|
123
|
+
|
|
124
|
+
Only the name of the call — an identifier from this package's own API —
|
|
125
|
+
is written. Its arguments are the caller's data and are not ours.
|
|
126
|
+
"""
|
|
127
|
+
if not enabled():
|
|
128
|
+
return
|
|
129
|
+
try:
|
|
130
|
+
path = spool_path()
|
|
131
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
# One short line, opened append-only per write: concurrent pytest
|
|
133
|
+
# workers appending to the same spool must not interleave, and an
|
|
134
|
+
# O_APPEND write below the pipe buffer is atomic on POSIX.
|
|
135
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
136
|
+
handle.write(json.dumps({"call": call}) + "\n")
|
|
137
|
+
except OSError:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def due(interval: float = UPLOAD_INTERVAL) -> bool:
|
|
142
|
+
"""Whether the spool is old enough to upload."""
|
|
143
|
+
import time
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
return (time.time() - stamp_path().stat().st_mtime) >= interval
|
|
147
|
+
except OSError:
|
|
148
|
+
return True # never uploaded: the first flush is due
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def flush(*, force: bool = False, interval: float = UPLOAD_INTERVAL) -> bool:
|
|
152
|
+
"""Upload the spool as counts per call, if it is due. Best-effort.
|
|
153
|
+
|
|
154
|
+
The spool is renamed before it is read, so calls recorded while an upload
|
|
155
|
+
is in flight land in a fresh file and are not lost. A failed upload keeps
|
|
156
|
+
the claimed file and folds it into the next attempt, so an offline week
|
|
157
|
+
reports once rather than not at all.
|
|
158
|
+
"""
|
|
159
|
+
if not enabled() or not (force or due(interval)):
|
|
160
|
+
return False
|
|
161
|
+
try:
|
|
162
|
+
spool = spool_path()
|
|
163
|
+
claimed = spool.with_suffix(".sending")
|
|
164
|
+
if spool.exists():
|
|
165
|
+
# Fold any previously failed upload in rather than overwrite it.
|
|
166
|
+
if claimed.exists():
|
|
167
|
+
with open(claimed, "a", encoding="utf-8") as dst:
|
|
168
|
+
dst.write(spool.read_text(encoding="utf-8"))
|
|
169
|
+
spool.unlink(missing_ok=True)
|
|
170
|
+
else:
|
|
171
|
+
os.replace(spool, claimed)
|
|
172
|
+
if not claimed.exists():
|
|
173
|
+
return False
|
|
174
|
+
counts: dict[str, int] = {}
|
|
175
|
+
for line in claimed.read_text(encoding="utf-8").splitlines():
|
|
176
|
+
try:
|
|
177
|
+
name = json.loads(line).get("call")
|
|
178
|
+
except json.JSONDecodeError:
|
|
179
|
+
continue
|
|
180
|
+
if isinstance(name, str):
|
|
181
|
+
counts[name] = counts.get(name, 0) + 1
|
|
182
|
+
except OSError:
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
if not counts:
|
|
186
|
+
_touch(claimed)
|
|
187
|
+
return False
|
|
188
|
+
if report("usage", calls=counts):
|
|
189
|
+
try:
|
|
190
|
+
claimed.unlink(missing_ok=True)
|
|
191
|
+
except OSError:
|
|
192
|
+
pass
|
|
193
|
+
_touch()
|
|
194
|
+
return True
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _touch(claimed=None) -> None:
|
|
199
|
+
"""Mark an upload attempt, so a failure does not retry on every call."""
|
|
200
|
+
try:
|
|
201
|
+
stamp_path().parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
stamp_path().write_text("")
|
|
203
|
+
if claimed is not None:
|
|
204
|
+
claimed.unlink(missing_ok=True)
|
|
205
|
+
except OSError:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def describe() -> str:
|
|
210
|
+
"""What this package reports, in the words a user would want to read."""
|
|
211
|
+
if not enabled():
|
|
212
|
+
return "telemetry: disabled"
|
|
213
|
+
fields = ", ".join(sorted(environment()))
|
|
214
|
+
try:
|
|
215
|
+
pending = len(spool_path().read_text(encoding="utf-8").splitlines())
|
|
216
|
+
except OSError:
|
|
217
|
+
pending = 0
|
|
218
|
+
return (
|
|
219
|
+
f"telemetry: enabled when authenticated\n"
|
|
220
|
+
f" sends: {fields}, plus test counts and which calls were made\n"
|
|
221
|
+
f" never sends: file paths, project or test names, firmware, output\n"
|
|
222
|
+
f" buffered at: {spool_path()} ({pending} calls pending, "
|
|
223
|
+
f"uploaded hourly)\n"
|
|
224
|
+
f" disable with: SIMANTIC_TELEMETRY=0 (or DO_NOT_TRACK=1)"
|
|
225
|
+
)
|