From 8a769921dd35bbddab5d594b805d9426f4744a73 Mon Sep 17 00:00:00 2001 From: Ahnaf Shahriar Date: Sat, 19 Sep 2026 06:45:53 -0700 Subject: [PATCH 1/5] Add `simantic demo`: account-free packaged scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first thing a reader does after `pip install simantic` — or after `uvx simantic demo`, which installs nothing permanent. Nothing in this path reads ~/.sim_id or calls get-mcu-details, so it runs before anyone has decided whether to sign up. The first demo is `ble-pair`: two ESP32-C3s on stock ESP-IDF bleprph and blecent, pairing over a shared BLE medium. A single chip printing to a UART shows nothing a reader cannot get elsewhere; two of them finding each other over a simulated radio does. Running is delegated to the `sim` binary's `--scenario`, which already wires N machines onto one medium and one virtual timeline, rather than reimplemented against the single-machine `simantic_rust.Session`. Assets come from the public releases bucket, keyed by sha256 through the existing install.download path. Also fixes the PyPI Source and Issues links, which both pointed at simantic-dev/simantic-py and 404'd. Closes simantic-dev/pippy#18 Refs simantic-dev/pippy#17 Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 4 +- src/simantic/_cli.py | 33 +++++++- src/simantic/demo.py | 175 +++++++++++++++++++++++++++++++++++++++++++ tests/test_demo.py | 61 +++++++++++++++ 4 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 src/simantic/demo.py create mode 100644 tests/test_demo.py diff --git a/pyproject.toml b/pyproject.toml index dbe8aff..4377260 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,8 @@ dependencies = ["pyyaml>=6", "pythonnet>=3.0.5"] [project.urls] Homepage = "https://simantic.dev" -Source = "https://github.com/simantic-dev/simantic-py" -Issues = "https://github.com/simantic-dev/simantic-py/issues" +Source = "https://github.com/simantic-dev/pippy" +Issues = "https://github.com/simantic-dev/pippy/issues" [project.entry-points.pytest11] simantic = "simantic.pytest_plugin" diff --git a/src/simantic/_cli.py b/src/simantic/_cli.py index d7a8768..d248025 100644 --- a/src/simantic/_cli.py +++ b/src/simantic/_cli.py @@ -12,7 +12,7 @@ import getpass import sys -from . import auth, esp_image, install, telemetry +from . import auth, demo, esp_image, install, telemetry from ._locate import BinaryNotFound, locate from .mcu import BINARY as SIM_BINARY from .mcu import ENV_VAR as SIM_ENV @@ -116,6 +116,16 @@ def _esp_rom(args) -> int: return 0 +def _demo(args) -> int: + if args.name is None: + print("Demos run without an account. Pick one:\n") + for name, entry in sorted(demo.DEMOS.items()): + print(f" {name:<12} {entry.summary}") + print(f"\nRun one with: simantic demo {sorted(demo.DEMOS)[0]}") + return 0 + return demo.run(args.name, force=args.force, extra=args.sim_args) + + def main(argv: list[str] | None = None) -> int: # prog is left to argparse so usage reflects however it was invoked: # `simantic`, the short `smtc`, or `python -m simantic`. @@ -124,6 +134,20 @@ def main(argv: list[str] | None = None) -> int: ) sub = parser.add_subparsers(dest="command", required=True) + # First, because it is the first thing a new reader should run: it needs + # no account and no prior `install`. + p_demo = sub.add_parser("demo", help="run a packaged scenario; no account needed") + p_demo.add_argument("name", nargs="?", help="demo to run; omit to list them") + p_demo.add_argument( + "--force", action="store_true", help="re-download the demo's assets" + ) + p_demo.add_argument( + "sim_args", + nargs=argparse.REMAINDER, + help="arguments passed through to the simulator", + ) + p_demo.set_defaults(func=_demo) + p_auth = sub.add_parser("auth", help="store backend credentials in ~/.sim_id") p_auth.add_argument( "--token", @@ -188,7 +212,12 @@ def main(argv: list[str] | None = None) -> int: # waiting on a simulation, and the spool is due at most hourly. telemetry.flush() return result - except (auth.AuthError, install.InstallError, esp_image.EspImageError) as exc: + except ( + auth.AuthError, + install.InstallError, + esp_image.EspImageError, + demo.DemoError, + ) as exc: print(f"error: {exc}", file=sys.stderr) return 1 except KeyboardInterrupt: diff --git a/src/simantic/demo.py b/src/simantic/demo.py new file mode 100644 index 0000000..773d693 --- /dev/null +++ b/src/simantic/demo.py @@ -0,0 +1,175 @@ +"""`simantic demo`: run a packaged scenario without an account. + +The first thing someone does after `pip install simantic` — or after +`uvx simantic demo`, which installs nothing permanent. Nothing here reads +`~/.sim_id` or touches `get-mcu-details`: a demo's platform and firmware come +from the public releases bucket, so it runs before a reader has decided +whether to sign up. + +Scenarios are multi-machine, which is the point. A single chip printing to a +UART does not show anything a reader cannot already get elsewhere; two of them +finding each other over a simulated radio does. The run is delegated to the +`sim` binary's `--scenario`, which already wires N machines onto one shared +medium and one virtual timeline, rather than reimplemented against the +single-machine `simantic_rust.Session`. +""" + +from __future__ import annotations + +import json +import subprocess +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path + +from . import install +from ._locate import BinaryNotFound, locate +from .mcu import BINARY as SIM_BINARY +from .mcu import ENV_VAR as SIM_ENV + + +class DemoError(RuntimeError): + """A demo could not be prepared or run.""" + + +@dataclass(frozen=True) +class Demo: + """One packaged scenario: its assets, and the scenario file to write.""" + + name: str + summary: str + #: Asset file name -> path under the demo prefix in the releases bucket. + assets: dict[str, str] + #: `scenario.yaml` contents, less the paths, which are filled from assets. + machines: dict[str, dict[str, str]] + media: list[dict[str, object]] = field(default_factory=list) + quantum: float | None = None + timeout: int = 30 + + +#: BLE's inter-frame spacing is 150 us, so the two nodes have to interleave +#: well inside that or one misses the other's response window entirely. +BLE_QUANTUM_S = 0.00001 + +DEMOS: dict[str, Demo] = { + "ble-pair": Demo( + name="ble-pair", + summary=( + "two ESP32-C3s running stock ESP-IDF bleprph and blecent, " + "pairing over a simulated BLE medium" + ), + assets={ + "esp32c3.repl": "ble-pair/esp32c3.repl", + "bleprph.bin": "ble-pair/bleprph-flash.bin", + "blecent.bin": "ble-pair/blecent-flash.bin", + }, + machines={ + "peripheral": {"repl": "esp32c3.repl", "elf": "bleprph.bin"}, + "central": {"repl": "esp32c3.repl", "elf": "blecent.bin"}, + }, + media=[{"type": "ble", "connect": ["peripheral.radio", "central.radio"]}], + quantum=BLE_QUANTUM_S, + timeout=30, + ), +} + +#: Demo assets are published beside the release manifests, under their own +#: prefix so a demo can be re-cut without a new engine release. +DEMO_PRODUCT = "demos" + + +def demo_root() -> Path: + """Where fetched demo assets live: ~/.simantic/demos//.""" + return install.simantic_home() / "demos" + + +def _manifest(timeout: float = 30) -> dict: + """The demo asset manifest: {"assets": {path: {"sha256": ...}}}.""" + url = f"{install.releases_url()}/{DEMO_PRODUCT}/manifest.json" + try: + with urllib.request.urlopen(urllib.request.Request(url), timeout=timeout) as response: + return json.loads(response.read()) + except urllib.error.HTTPError as exc: + raise DemoError( + f"no demo assets published (HTTP {exc.code} from {url})" + ) from None + except urllib.error.URLError as exc: + raise DemoError(f"cannot reach the release server: {exc.reason}") from None + except json.JSONDecodeError as exc: + raise DemoError(f"demo manifest is not valid JSON: {exc}") from None + + +def fetch_assets(demo: Demo, *, force: bool = False) -> Path: + """Download the demo's assets into its cache directory, and return it.""" + target = demo_root() / demo.name + missing = [n for n in demo.assets if force or not (target / n).exists()] + if not missing: + return target + + entries = _manifest().get("assets") or {} + target.mkdir(parents=True, exist_ok=True) + for name in missing: + remote = demo.assets[name] + entry = entries.get(remote) + if entry is None: + raise DemoError(f"{remote!r} is not in the demo manifest") + artifact = install.Artifact( + version=demo.name, + url=f"{install.releases_url()}/{DEMO_PRODUCT}/{remote}", + sha256=entry.get("sha256"), + ) + try: + payload = install.download(artifact) + except install.InstallError as exc: + raise DemoError(f"{name}: {exc}") from None + (target / name).write_bytes(payload) + return target + + +def write_scenario(demo: Demo, directory: Path) -> Path: + """Write the scenario file `sim --scenario` reads, beside the assets. + + Paths stay bare names because `sim` resolves `repl` and `elf` relative to + the scenario file's own directory. + """ + scenario: dict[str, object] = { + "machines": demo.machines, + "timeout": demo.timeout, + } + if demo.media: + scenario["media"] = demo.media + if demo.quantum is not None: + scenario["quantum"] = demo.quantum + path = directory / "scenario.yaml" + # The reader is a serde_yaml Deserialize, and JSON is a subset of YAML, so + # this avoids a PyYAML dump and its tag/anchor surprises. + path.write_text(json.dumps(scenario, indent=2) + "\n") + return path + + +def run(name: str, *, force: bool = False, extra: list[str] | None = None) -> int: + """Prepare and run a demo, streaming the simulators' output.""" + demo = DEMOS.get(name) + if demo is None: + known = ", ".join(sorted(DEMOS)) + raise DemoError(f"unknown demo {name!r}; available: {known}") + + try: + binary = locate(SIM_BINARY, SIM_ENV) + except BinaryNotFound: + try: + binary = install.install(SIM_BINARY) + except install.InstallError as exc: + raise DemoError( + f"the {SIM_BINARY!r} binary is needed to run a scenario and " + f"could not be installed: {exc}" + ) from None + + directory = fetch_assets(demo, force=force) + scenario = write_scenario(demo, directory) + command = [str(binary), "--scenario", str(scenario), *(extra or [])] + try: + return subprocess.call(command) + except OSError as exc: + raise DemoError(f"could not run {binary}: {exc}") from None diff --git a/tests/test_demo.py b/tests/test_demo.py new file mode 100644 index 0000000..87a1beb --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,61 @@ +"""The scenario a demo writes, and the promise that it needs no account.""" + +from __future__ import annotations + +import json + +import pytest + +from simantic import demo + + +def test_ble_pair_scenario_is_two_nodes_on_one_medium(tmp_path): + entry = demo.DEMOS["ble-pair"] + scenario = json.loads(demo.write_scenario(entry, tmp_path).read_text()) + + assert sorted(scenario["machines"]) == ["central", "peripheral"] + assert len(scenario["media"]) == 1 + medium = scenario["media"][0] + assert medium["type"] == "ble" + # Both radios on the same medium, or the two nodes never hear each other. + assert sorted(medium["connect"]) == ["central.radio", "peripheral.radio"] + + +def test_ble_pair_quantum_resolves_ifs(): + """BLE's T_IFS is 150 us; a coarser quantum loses the response window.""" + assert demo.DEMOS["ble-pair"].quantum is not None + assert demo.DEMOS["ble-pair"].quantum <= 150e-6 / 2 + + +def test_scenario_paths_are_bare_names(tmp_path): + """`sim` resolves repl/elf against the scenario file's own directory.""" + entry = demo.DEMOS["ble-pair"] + scenario = json.loads(demo.write_scenario(entry, tmp_path).read_text()) + for machine in scenario["machines"].values(): + for key in ("repl", "elf"): + assert "/" not in machine[key] + assert machine[key] in entry.assets + + +def test_unknown_demo_lists_the_known_ones(): + with pytest.raises(demo.DemoError, match="available: ble-pair"): + demo.run("nope") + + +def test_running_a_demo_never_loads_credentials(tmp_path, monkeypatch): + """The whole point: a demo runs before anyone has signed up.""" + monkeypatch.setattr(demo.install, "simantic_home", lambda: tmp_path) + + def fail(*_args, **_kwargs): + raise AssertionError("a demo must not read credentials") + + monkeypatch.setattr("simantic.auth.load", fail) + entry = demo.DEMOS["ble-pair"] + target = demo.demo_root() / entry.name + target.mkdir(parents=True) + for name in entry.assets: + (target / name).write_bytes(b"") + + # Assets already cached, so this must not reach the network either. + monkeypatch.setattr(demo, "_manifest", fail) + assert demo.fetch_assets(entry) == target From b804495afe088e35fb781016bd794ec4ea085f67 Mon Sep 17 00:00:00 2001 From: Ahnaf Shahriar Date: Sat, 19 Sep 2026 06:49:46 -0700 Subject: [PATCH 2/5] Report demo runs anonymously, without an account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A demo is the one run we most need to see and the one we currently cannot: `report()` returns early when unauthenticated, so every launch-day run would spool locally and never upload. Sent immediately rather than spooled. A demo is usually a one-shot `uvx` process, so the reader never issues a second command and the hourly upload would never fire. Strictly less goes up than for an authenticated run: which demo, whether it worked, how long it took, and the existing environment fields. No identifier of any kind, so two runs cannot be linked — these are people who have not signed up to anything. A separate endpoint from `report-sdk-usage` so an anonymous, internet-facing route can be rate-limited on its own and can never reach the billing path. `status` now discloses this; claiming "enabled when authenticated" while reporting anonymously would have been a false promise. Co-Authored-By: Claude Opus 5 (1M context) --- src/simantic/demo.py | 11 +++++++-- src/simantic/telemetry.py | 48 ++++++++++++++++++++++++++++++++++++++ tests/test_demo.py | 49 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/simantic/demo.py b/src/simantic/demo.py index 773d693..b88a8e5 100644 --- a/src/simantic/demo.py +++ b/src/simantic/demo.py @@ -18,12 +18,13 @@ import json import subprocess +import time import urllib.error import urllib.request from dataclasses import dataclass, field from pathlib import Path -from . import install +from . import install, telemetry from ._locate import BinaryNotFound, locate from .mcu import BINARY as SIM_BINARY from .mcu import ENV_VAR as SIM_ENV @@ -169,7 +170,13 @@ def run(name: str, *, force: bool = False, extra: list[str] | None = None) -> in directory = fetch_assets(demo, force=force) scenario = write_scenario(demo, directory) command = [str(binary), "--scenario", str(scenario), *(extra or [])] + started = time.monotonic() try: - return subprocess.call(command) + code = subprocess.call(command) except OSError as exc: raise DemoError(f"could not run {binary}: {exc}") from None + # Reported here rather than spooled: a demo is usually a one-shot `uvx` + # process, so an hourly upload would never happen. Best-effort, and it + # must not change what the command returns. + telemetry.report_demo(demo.name, ok=code == 0, seconds=time.monotonic() - started) + return code diff --git a/src/simantic/telemetry.py b/src/simantic/telemetry.py index c2f05cb..e093c02 100644 --- a/src/simantic/telemetry.py +++ b/src/simantic/telemetry.py @@ -30,6 +30,14 @@ #: failure — a call-count report there would corrupt a live metric. REPORT_URL = "https://drjdhqfvrttolueolzif.supabase.co/functions/v1/report-sdk-usage" +#: Demo runs report here instead, unauthenticated. A separate function so an +#: anonymous, internet-facing endpoint can be rate-limited on its own and can +#: never reach the billing path that `report-usage` feeds. +ANON_REPORT_URL = os.environ.get( + "SIMANTIC_DEMO_REPORT_URL", + "https://drjdhqfvrttolueolzif.supabase.co/functions/v1/report-demo-usage", +) + #: The server caps a report at 16 KB. Nothing here approaches it, and the #: cap is enforced locally so an oversized report is dropped rather than #: rejected with an error nobody sees. @@ -97,6 +105,43 @@ def report(event: str, **fields: object) -> bool: return False +def report_demo(demo: str, *, ok: bool, seconds: float) -> bool: + """Report one `simantic demo` run, with no account and no identifier. + + Sent immediately rather than spooled. A demo is usually run through + `uvx`, so the process is ephemeral and the reader is unlikely to run a + second command: an hourly spool would never be uploaded, and the one run + that mattered would be the one we never heard about. + + Strictly less is sent than for an authenticated run — the same + environment fields, which demo, whether it worked and how long it took. + Nothing here distinguishes two runs by the same person from two people, + and that is deliberate: these are strangers who have not signed up to + anything. + """ + if not enabled(): + return False + + payload = { + "event": "demo", + "demo": demo, + "ok": ok, + "seconds": round(seconds, 1), + **environment(), + } + request = urllib.request.Request( + ANON_REPORT_URL, + data=json.dumps(payload).encode(), + method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=3): + return True + except (urllib.error.URLError, OSError, ValueError): + return False + + # --- the call spool --- # # Which calls get made, buffered locally and uploaded on an interval rather @@ -219,6 +264,9 @@ def describe() -> str: f"telemetry: enabled when authenticated\n" f" sends: {fields}, plus test counts and which calls were made\n" f" never sends: file paths, project or test names, firmware, output\n" + f" `simantic demo` also reports anonymously without an account:\n" + f" which demo, whether it worked, how long it took, and {fields}\n" + f" with no identifier, so runs cannot be linked to each other\n" f" buffered at: {spool_path()} ({pending} calls pending, " f"uploaded hourly)\n" f" disable with: SIMANTIC_TELEMETRY=0 (or DO_NOT_TRACK=1)" diff --git a/tests/test_demo.py b/tests/test_demo.py index 87a1beb..1c31e66 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -6,7 +6,7 @@ import pytest -from simantic import demo +from simantic import demo, telemetry def test_ble_pair_scenario_is_two_nodes_on_one_medium(tmp_path): @@ -42,6 +42,53 @@ def test_unknown_demo_lists_the_known_ones(): demo.run("nope") +def test_demo_report_needs_no_credentials_and_no_identifier(monkeypatch): + """Anonymous by construction: no Authorization, nothing that links runs.""" + sent = {} + + class Response: + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def capture(request, timeout=None): + sent["headers"] = {k.lower(): v for k, v in request.header_items()} + sent["payload"] = json.loads(request.data) + return Response() + + monkeypatch.setattr("simantic.auth.load", lambda: pytest.fail("no credentials")) + monkeypatch.setattr("urllib.request.urlopen", capture) + assert telemetry.report_demo("ble-pair", ok=True, seconds=4.2) + + assert "authorization" not in sent["headers"] + payload = sent["payload"] + assert payload["demo"] == "ble-pair" and payload["ok"] is True + # No stable identifier of any kind, or these stop being anonymous. + for key in ("user", "email", "token", "id", "install_id", "machine_id"): + assert key not in payload + + +def test_demo_report_honours_opt_out(monkeypatch): + monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: pytest.fail("sent")) + monkeypatch.setenv("DO_NOT_TRACK", "1") + assert not telemetry.report_demo("ble-pair", ok=True, seconds=1.0) + monkeypatch.delenv("DO_NOT_TRACK") + monkeypatch.setenv("SIMANTIC_TELEMETRY", "0") + assert not telemetry.report_demo("ble-pair", ok=True, seconds=1.0) + + +def test_demo_report_never_breaks_the_run(monkeypatch): + """A telemetry failure must not change what the demo returns.""" + + def boom(*_a, **_k): + raise OSError("network down") + + monkeypatch.setattr("urllib.request.urlopen", boom) + assert telemetry.report_demo("ble-pair", ok=True, seconds=1.0) is False + + def test_running_a_demo_never_loads_credentials(tmp_path, monkeypatch): """The whole point: a demo runs before anyone has signed up.""" monkeypatch.setattr(demo.install, "simantic_home", lambda: tmp_path) From 0de67e8f26362ed77c4812b6d15dbf410dd75a21 Mon Sep 17 00:00:00 2001 From: Ahnaf Shahriar Date: Sat, 19 Sep 2026 06:56:44 -0700 Subject: [PATCH 3/5] Revert "Report demo runs anonymously, without an account" This reverts commit b804495afe088e35fb781016bd794ec4ea085f67. --- src/simantic/demo.py | 11 ++------- src/simantic/telemetry.py | 48 -------------------------------------- tests/test_demo.py | 49 +-------------------------------------- 3 files changed, 3 insertions(+), 105 deletions(-) diff --git a/src/simantic/demo.py b/src/simantic/demo.py index b88a8e5..773d693 100644 --- a/src/simantic/demo.py +++ b/src/simantic/demo.py @@ -18,13 +18,12 @@ import json import subprocess -import time import urllib.error import urllib.request from dataclasses import dataclass, field from pathlib import Path -from . import install, telemetry +from . import install from ._locate import BinaryNotFound, locate from .mcu import BINARY as SIM_BINARY from .mcu import ENV_VAR as SIM_ENV @@ -170,13 +169,7 @@ def run(name: str, *, force: bool = False, extra: list[str] | None = None) -> in directory = fetch_assets(demo, force=force) scenario = write_scenario(demo, directory) command = [str(binary), "--scenario", str(scenario), *(extra or [])] - started = time.monotonic() try: - code = subprocess.call(command) + return subprocess.call(command) except OSError as exc: raise DemoError(f"could not run {binary}: {exc}") from None - # Reported here rather than spooled: a demo is usually a one-shot `uvx` - # process, so an hourly upload would never happen. Best-effort, and it - # must not change what the command returns. - telemetry.report_demo(demo.name, ok=code == 0, seconds=time.monotonic() - started) - return code diff --git a/src/simantic/telemetry.py b/src/simantic/telemetry.py index e093c02..c2f05cb 100644 --- a/src/simantic/telemetry.py +++ b/src/simantic/telemetry.py @@ -30,14 +30,6 @@ #: failure — a call-count report there would corrupt a live metric. REPORT_URL = "https://drjdhqfvrttolueolzif.supabase.co/functions/v1/report-sdk-usage" -#: Demo runs report here instead, unauthenticated. A separate function so an -#: anonymous, internet-facing endpoint can be rate-limited on its own and can -#: never reach the billing path that `report-usage` feeds. -ANON_REPORT_URL = os.environ.get( - "SIMANTIC_DEMO_REPORT_URL", - "https://drjdhqfvrttolueolzif.supabase.co/functions/v1/report-demo-usage", -) - #: The server caps a report at 16 KB. Nothing here approaches it, and the #: cap is enforced locally so an oversized report is dropped rather than #: rejected with an error nobody sees. @@ -105,43 +97,6 @@ def report(event: str, **fields: object) -> bool: return False -def report_demo(demo: str, *, ok: bool, seconds: float) -> bool: - """Report one `simantic demo` run, with no account and no identifier. - - Sent immediately rather than spooled. A demo is usually run through - `uvx`, so the process is ephemeral and the reader is unlikely to run a - second command: an hourly spool would never be uploaded, and the one run - that mattered would be the one we never heard about. - - Strictly less is sent than for an authenticated run — the same - environment fields, which demo, whether it worked and how long it took. - Nothing here distinguishes two runs by the same person from two people, - and that is deliberate: these are strangers who have not signed up to - anything. - """ - if not enabled(): - return False - - payload = { - "event": "demo", - "demo": demo, - "ok": ok, - "seconds": round(seconds, 1), - **environment(), - } - request = urllib.request.Request( - ANON_REPORT_URL, - data=json.dumps(payload).encode(), - method="POST", - headers={"Content-Type": "application/json"}, - ) - try: - with urllib.request.urlopen(request, timeout=3): - return True - except (urllib.error.URLError, OSError, ValueError): - return False - - # --- the call spool --- # # Which calls get made, buffered locally and uploaded on an interval rather @@ -264,9 +219,6 @@ def describe() -> str: f"telemetry: enabled when authenticated\n" f" sends: {fields}, plus test counts and which calls were made\n" f" never sends: file paths, project or test names, firmware, output\n" - f" `simantic demo` also reports anonymously without an account:\n" - f" which demo, whether it worked, how long it took, and {fields}\n" - f" with no identifier, so runs cannot be linked to each other\n" f" buffered at: {spool_path()} ({pending} calls pending, " f"uploaded hourly)\n" f" disable with: SIMANTIC_TELEMETRY=0 (or DO_NOT_TRACK=1)" diff --git a/tests/test_demo.py b/tests/test_demo.py index 1c31e66..87a1beb 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -6,7 +6,7 @@ import pytest -from simantic import demo, telemetry +from simantic import demo def test_ble_pair_scenario_is_two_nodes_on_one_medium(tmp_path): @@ -42,53 +42,6 @@ def test_unknown_demo_lists_the_known_ones(): demo.run("nope") -def test_demo_report_needs_no_credentials_and_no_identifier(monkeypatch): - """Anonymous by construction: no Authorization, nothing that links runs.""" - sent = {} - - class Response: - def __enter__(self): - return self - - def __exit__(self, *_): - return False - - def capture(request, timeout=None): - sent["headers"] = {k.lower(): v for k, v in request.header_items()} - sent["payload"] = json.loads(request.data) - return Response() - - monkeypatch.setattr("simantic.auth.load", lambda: pytest.fail("no credentials")) - monkeypatch.setattr("urllib.request.urlopen", capture) - assert telemetry.report_demo("ble-pair", ok=True, seconds=4.2) - - assert "authorization" not in sent["headers"] - payload = sent["payload"] - assert payload["demo"] == "ble-pair" and payload["ok"] is True - # No stable identifier of any kind, or these stop being anonymous. - for key in ("user", "email", "token", "id", "install_id", "machine_id"): - assert key not in payload - - -def test_demo_report_honours_opt_out(monkeypatch): - monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: pytest.fail("sent")) - monkeypatch.setenv("DO_NOT_TRACK", "1") - assert not telemetry.report_demo("ble-pair", ok=True, seconds=1.0) - monkeypatch.delenv("DO_NOT_TRACK") - monkeypatch.setenv("SIMANTIC_TELEMETRY", "0") - assert not telemetry.report_demo("ble-pair", ok=True, seconds=1.0) - - -def test_demo_report_never_breaks_the_run(monkeypatch): - """A telemetry failure must not change what the demo returns.""" - - def boom(*_a, **_k): - raise OSError("network down") - - monkeypatch.setattr("urllib.request.urlopen", boom) - assert telemetry.report_demo("ble-pair", ok=True, seconds=1.0) is False - - def test_running_a_demo_never_loads_credentials(tmp_path, monkeypatch): """The whole point: a demo runs before anyone has signed up.""" monkeypatch.setattr(demo.install, "simantic_home", lambda: tmp_path) From 0513b34ab692bf8e1ff8f7e98507ba9d0c48ce12 Mon Sep 17 00:00:00 2001 From: Ahnaf Shahriar Date: Sat, 19 Sep 2026 07:00:15 -0700 Subject: [PATCH 4/5] Reapply "Report demo runs anonymously, without an account" This reverts commit 0de67e8f26362ed77c4812b6d15dbf410dd75a21. --- src/simantic/demo.py | 11 +++++++-- src/simantic/telemetry.py | 48 ++++++++++++++++++++++++++++++++++++++ tests/test_demo.py | 49 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/simantic/demo.py b/src/simantic/demo.py index 773d693..b88a8e5 100644 --- a/src/simantic/demo.py +++ b/src/simantic/demo.py @@ -18,12 +18,13 @@ import json import subprocess +import time import urllib.error import urllib.request from dataclasses import dataclass, field from pathlib import Path -from . import install +from . import install, telemetry from ._locate import BinaryNotFound, locate from .mcu import BINARY as SIM_BINARY from .mcu import ENV_VAR as SIM_ENV @@ -169,7 +170,13 @@ def run(name: str, *, force: bool = False, extra: list[str] | None = None) -> in directory = fetch_assets(demo, force=force) scenario = write_scenario(demo, directory) command = [str(binary), "--scenario", str(scenario), *(extra or [])] + started = time.monotonic() try: - return subprocess.call(command) + code = subprocess.call(command) except OSError as exc: raise DemoError(f"could not run {binary}: {exc}") from None + # Reported here rather than spooled: a demo is usually a one-shot `uvx` + # process, so an hourly upload would never happen. Best-effort, and it + # must not change what the command returns. + telemetry.report_demo(demo.name, ok=code == 0, seconds=time.monotonic() - started) + return code diff --git a/src/simantic/telemetry.py b/src/simantic/telemetry.py index c2f05cb..e093c02 100644 --- a/src/simantic/telemetry.py +++ b/src/simantic/telemetry.py @@ -30,6 +30,14 @@ #: failure — a call-count report there would corrupt a live metric. REPORT_URL = "https://drjdhqfvrttolueolzif.supabase.co/functions/v1/report-sdk-usage" +#: Demo runs report here instead, unauthenticated. A separate function so an +#: anonymous, internet-facing endpoint can be rate-limited on its own and can +#: never reach the billing path that `report-usage` feeds. +ANON_REPORT_URL = os.environ.get( + "SIMANTIC_DEMO_REPORT_URL", + "https://drjdhqfvrttolueolzif.supabase.co/functions/v1/report-demo-usage", +) + #: The server caps a report at 16 KB. Nothing here approaches it, and the #: cap is enforced locally so an oversized report is dropped rather than #: rejected with an error nobody sees. @@ -97,6 +105,43 @@ def report(event: str, **fields: object) -> bool: return False +def report_demo(demo: str, *, ok: bool, seconds: float) -> bool: + """Report one `simantic demo` run, with no account and no identifier. + + Sent immediately rather than spooled. A demo is usually run through + `uvx`, so the process is ephemeral and the reader is unlikely to run a + second command: an hourly spool would never be uploaded, and the one run + that mattered would be the one we never heard about. + + Strictly less is sent than for an authenticated run — the same + environment fields, which demo, whether it worked and how long it took. + Nothing here distinguishes two runs by the same person from two people, + and that is deliberate: these are strangers who have not signed up to + anything. + """ + if not enabled(): + return False + + payload = { + "event": "demo", + "demo": demo, + "ok": ok, + "seconds": round(seconds, 1), + **environment(), + } + request = urllib.request.Request( + ANON_REPORT_URL, + data=json.dumps(payload).encode(), + method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=3): + return True + except (urllib.error.URLError, OSError, ValueError): + return False + + # --- the call spool --- # # Which calls get made, buffered locally and uploaded on an interval rather @@ -219,6 +264,9 @@ def describe() -> str: f"telemetry: enabled when authenticated\n" f" sends: {fields}, plus test counts and which calls were made\n" f" never sends: file paths, project or test names, firmware, output\n" + f" `simantic demo` also reports anonymously without an account:\n" + f" which demo, whether it worked, how long it took, and {fields}\n" + f" with no identifier, so runs cannot be linked to each other\n" f" buffered at: {spool_path()} ({pending} calls pending, " f"uploaded hourly)\n" f" disable with: SIMANTIC_TELEMETRY=0 (or DO_NOT_TRACK=1)" diff --git a/tests/test_demo.py b/tests/test_demo.py index 87a1beb..1c31e66 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -6,7 +6,7 @@ import pytest -from simantic import demo +from simantic import demo, telemetry def test_ble_pair_scenario_is_two_nodes_on_one_medium(tmp_path): @@ -42,6 +42,53 @@ def test_unknown_demo_lists_the_known_ones(): demo.run("nope") +def test_demo_report_needs_no_credentials_and_no_identifier(monkeypatch): + """Anonymous by construction: no Authorization, nothing that links runs.""" + sent = {} + + class Response: + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def capture(request, timeout=None): + sent["headers"] = {k.lower(): v for k, v in request.header_items()} + sent["payload"] = json.loads(request.data) + return Response() + + monkeypatch.setattr("simantic.auth.load", lambda: pytest.fail("no credentials")) + monkeypatch.setattr("urllib.request.urlopen", capture) + assert telemetry.report_demo("ble-pair", ok=True, seconds=4.2) + + assert "authorization" not in sent["headers"] + payload = sent["payload"] + assert payload["demo"] == "ble-pair" and payload["ok"] is True + # No stable identifier of any kind, or these stop being anonymous. + for key in ("user", "email", "token", "id", "install_id", "machine_id"): + assert key not in payload + + +def test_demo_report_honours_opt_out(monkeypatch): + monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: pytest.fail("sent")) + monkeypatch.setenv("DO_NOT_TRACK", "1") + assert not telemetry.report_demo("ble-pair", ok=True, seconds=1.0) + monkeypatch.delenv("DO_NOT_TRACK") + monkeypatch.setenv("SIMANTIC_TELEMETRY", "0") + assert not telemetry.report_demo("ble-pair", ok=True, seconds=1.0) + + +def test_demo_report_never_breaks_the_run(monkeypatch): + """A telemetry failure must not change what the demo returns.""" + + def boom(*_a, **_k): + raise OSError("network down") + + monkeypatch.setattr("urllib.request.urlopen", boom) + assert telemetry.report_demo("ble-pair", ok=True, seconds=1.0) is False + + def test_running_a_demo_never_loads_credentials(tmp_path, monkeypatch): """The whole point: a demo runs before anyone has signed up.""" monkeypatch.setattr(demo.install, "simantic_home", lambda: tmp_path) From 9cc3f70216621023580109c851c825c1413e8c17 Mon Sep 17 00:00:00 2001 From: Ahnaf Shahriar Date: Sat, 19 Sep 2026 07:07:25 -0700 Subject: [PATCH 5/5] Say what to do when a model needs an account The credentials error is the first wall a new user hits, and it read like a stack frame: it named ~/.sim_id, which the reader did not ask about and cannot act on, and buried the one useful instruction at the end. It now names the model, says it needs an account, lists the two commands that resolve it, and points at `simantic demo` for anyone who would rather try something before signing up. Also splits 401/403 out of the HTTP path. "not a supported model" is wrong when the name is fine and the account simply lacks access; it sends people hunting for a typo that is not there. That becomes the common case once model access is enforced. Co-Authored-By: Claude Opus 5 (1M context) --- src/simantic/_replx.py | 33 ++++++++++++++++++++++++++++++--- tests/test_demo.py | 18 ++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/simantic/_replx.py b/src/simantic/_replx.py index d88e60b..db0b360 100644 --- a/src/simantic/_replx.py +++ b/src/simantic/_replx.py @@ -73,8 +73,20 @@ def model_replx(mcu: str, *, use_cache: bool = True) -> str: return replx try: credentials = auth.load() - except auth.NotAuthenticated as exc: - raise SimError(f"mcu={mcu!r} needs credentials to fetch the model: {exc}") from None + except auth.NotAuthenticated: + # The first wall a new user hits, so it says what to do rather than + # what went wrong. The path to ~/.sim_id is a detail they did not ask + # about and cannot act on. + raise SimError( + f"{mcu} needs an account to download its model.\n" + f"\n" + f" simantic auth sign in (opens a browser)\n" + f" simantic status check who you are signed in as\n" + f"\n" + f"Or run a demo first — those need no account:\n" + f"\n" + f" simantic demo" + ) from None request = urllib.request.Request( f"{MCU_DETAILS_URL}?model={urllib.parse.quote(mcu)}", headers={"Authorization": f"Bearer {credentials.api_key}"}, @@ -83,7 +95,22 @@ def model_replx(mcu: str, *, use_cache: bool = True) -> str: with urllib.request.urlopen(request, timeout=30) as response: details = json.loads(response.read()) except urllib.error.HTTPError as exc: - raise SimError(f"mcu={mcu!r} is not a supported model (HTTP {exc.code})") from None + if exc.code in (401, 403): + # Distinct from "no such model": the name is fine, this account + # cannot have it. Saying "not supported" here sends people off + # hunting for a typo that is not there. + raise SimError( + f"your account does not have access to {mcu}.\n" + f"\n" + f" simantic status check who you are signed in as\n" + f"\n" + f"If that is the wrong account, `simantic auth` again. " + f"Otherwise ask us to enable it." + ) from None + raise SimError( + f"{mcu} is not a model we publish (HTTP {exc.code}). " + f"`simantic status` lists what you can run." + ) from None except urllib.error.URLError as exc: raise SimError(f"cannot reach the model backend: {exc.reason}") from None replx = details.get("replx") diff --git a/tests/test_demo.py b/tests/test_demo.py index 1c31e66..6a1f2e0 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -106,3 +106,21 @@ def fail(*_args, **_kwargs): # Assets already cached, so this must not reach the network either. monkeypatch.setattr(demo, "_manifest", fail) assert demo.fetch_assets(entry) == target + + +def test_unauthenticated_model_error_says_what_to_do(tmp_path, monkeypatch): + """The first wall a new user hits: actions, not a file path.""" + from simantic import _replx + from simantic.mcu import SimError + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("SIMANTIC_HOME", str(tmp_path)) + monkeypatch.delenv("SIMANTIC_MCU_LIB", raising=False) + with pytest.raises(SimError) as caught: + _replx.model_replx("ESP32-C3") + + message = str(caught.value) + assert "ESP32-C3 needs an account" in message + assert "simantic auth" in message and "simantic demo" in message + # The path to ~/.sim_id is a detail the reader cannot act on. + assert ".sim_id" not in message