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/_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/src/simantic/demo.py b/src/simantic/demo.py new file mode 100644 index 0000000..b88a8e5 --- /dev/null +++ b/src/simantic/demo.py @@ -0,0 +1,182 @@ +"""`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 time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path + +from . import install, telemetry +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 [])] + started = time.monotonic() + try: + 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 new file mode 100644 index 0000000..6a1f2e0 --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,126 @@ +"""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, telemetry + + +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_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) + + 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 + + +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