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/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
+ )