From 65362e628fcba7902ad37129ac129f95bd3b4fb9 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:57:59 +0200 Subject: [PATCH] feat: add --events output to the installer One JSON object per line on stdout (detection, per-file and per-system check, download progress, summary, exit code) and the usual text on stderr, so a program driving install.py never parses prose. EVENT_KEYS is the one schema. The installer page documents it, and a test holds the two equal. The handheld port reads it. --- install.ps1 | 2 +- install.py | 220 +++++++++++++++++++++++++++++++++++++++++- install.sh | 2 +- tests/test_install.py | 174 +++++++++++++++++++++++++++++++++ wiki/installer.md | 26 +++++ 5 files changed, 420 insertions(+), 4 deletions(-) diff --git a/install.ps1 b/install.ps1 index 16e613e2..6dcd7a1a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "fe8de4eda0919c268d4d2c0d571c7d0dc1cf675d7604263766f5f99a35aa3724" + $defaultInstallSha256 = "3bdaabc9f88e20e09afb99805df5481527611bac796906b98ae6579c3dfcd4a4" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 9cd3e52e..517feaff 100644 --- a/install.py +++ b/install.py @@ -342,6 +342,7 @@ class Progress: def __init__(self, total_files: int, total_bytes: int, term: Terminal) -> None: self.total_files = total_files + self.expected_bytes = total_bytes self.total_bytes = max(total_bytes, 1) self.term = term self.files = 0 @@ -349,6 +350,7 @@ class Progress: self.resumed = 0 self.start = time.monotonic() self._drawn = 0.0 + self._evented = self.start self._lock = threading.Lock() def add(self, count: int, resumed: bool = False) -> None: @@ -360,6 +362,10 @@ class Progress: if self.term.live and not self.term.quiet and now - self._drawn >= 0.1: self._drawn = now self._draw() + if EVENTS.on and now - self._evented >= EVENT_PROGRESS_INTERVAL: + self._evented = now + EVENTS.emit("progress", files_done=self.files, files_total=self.total_files, + bytes_done=self.bytes, bytes_total=self.expected_bytes) def forget_resumed(self, count: int) -> None: with self._lock: @@ -429,6 +435,141 @@ class Progress: print(f"\r\033[K{text[:width]}", end="", file=self.term.stream, flush=True) +# --events: one JSON object per line on stdout, for a program driving the +# installer (the handheld port shows its screens from them). Each kind has +# exactly these keys besides "event", in this order, and nothing else. +EVENT_SCHEMA = 1 +EVENT_KEYS: dict[str, tuple[str, ...]] = { + # mode: install, check, dry-run or list. + "start": ("schema", "installer", "mode"), + # platforms: [{"platform", "name", "bios_dir"}], empty when none is found. + "detect": ("os", "os_name", "platforms"), + # stage: manifest. reason: network, not_found or invalid. + "error": ("stage", "reason", "platform", "message"), + # state: present (hash verified), wrong_hash or missing. + "file": ("platform", "dest", "systems", "size", "state"), + "system": ("platform", "system", "name", "files", "present", "missing", + "wrong_hash", "bytes_needed"), + "check": ("platform", "name", "bios_dir", "files", "present", "missing", + "wrong_hash", "bytes_needed", "data_missing", "not_installable"), + "download": ("platform", "files", "bytes"), + "progress": ("files_done", "files_total", "bytes_done", "bytes_total"), + "downloaded": ("dest", "ok", "reason", "size", "files_done", "files_total", + "bytes_done", "bytes_total"), + # failed: [{"dest", "reason"}]. skipped: [{"reason", "label", "files", + # "required"}], the files the collection cannot supply. + "summary": ("mode", "installed", "up_to_date", "pending", "failed", "skipped", + "interrupted"), + "end": ("code",), +} +# Seconds between two progress events while bytes arrive. +EVENT_PROGRESS_INTERVAL = 0.5 + + +class EventStream: + """The --events output: closed (nothing written) until opened.""" + + def __init__(self) -> None: + self.stream: TextIO | None = None + self._lock = threading.Lock() + + def open(self, stream: TextIO) -> None: + self.stream = stream + + @property + def on(self) -> bool: + return self.stream is not None + + def emit(self, kind: str, **fields: object) -> None: + if self.stream is None: + return + keys = EVENT_KEYS[kind] + if set(fields) != set(keys): + raise ValueError(f"event {kind}: keys {sorted(fields)} != {sorted(keys)}") + line = json.dumps({"event": kind, **{key: fields[key] for key in keys}}) + with self._lock: + self.stream.write(line + "\n") + self.stream.flush() + + +EVENTS = EventStream() + + +def _event_mode(args: argparse.Namespace) -> str: + if args.check: + return "check" + if args.dry_run: + return "dry-run" + if (args.list_platforms or args.list_targets or args.list_systems + or args.list_cores or args.list_emulators): + return "list" + return "install" + + +def _event_exit_code(code: object) -> int: + """The process status sys.exit(code) gives.""" + if code is None: + return 0 + return code if isinstance(code, int) else 1 + + +def _os_name() -> str: + """The OS's own name, from os-release, else the kernel's.""" + release = _parse_os_release() + return release.get("PRETTY_NAME") or release.get("NAME") or platform.system() + + +def _emit_check( + plat: str, manifest: dict, bios_path: Path, files: list[dict], up_to_date: list[dict], + mismatched: list[dict], data_missing: int, not_installable: int, +) -> None: + """One event per file, per system and per platform after a check.""" + present = {id(entry) for entry in up_to_date} + wrong = {id(entry) for entry in mismatched} + systems: dict[str, dict[str, int]] = {} + for entry in files: + state = ("present" if id(entry) in present + else "wrong_hash" if id(entry) in wrong else "missing") + size = entry.get("size", 0) + EVENTS.emit("file", platform=plat, dest=entry["dest"], + systems=entry_systems(entry), size=size, state=state) + for system in entry_systems(entry) or [OTHER_SYSTEMS]: + counts = systems.setdefault(system, { + "files": 0, "present": 0, "missing": 0, "wrong_hash": 0, + "bytes_needed": 0, + }) + counts["files"] += 1 + counts[state] += 1 + if state != "present": + counts["bytes_needed"] += size + names = manifest.get("system_names") or {} + for system in sorted(systems): + EVENTS.emit("system", platform=plat, system=system, + name=", ".join(names.get(system) or [system]), **systems[system]) + needed = [entry for entry in files if id(entry) not in present] + EVENTS.emit( + "check", platform=plat, name=display_name(plat, manifest), bios_dir=str(bios_path), + files=len(files), present=len(up_to_date), missing=len(needed) - len(mismatched), + wrong_hash=len(mismatched), + bytes_needed=sum(entry.get("size", 0) for entry in needed), + data_missing=data_missing, not_installable=not_installable, + ) + + +def _skipped(omitted: list[dict]) -> list[dict]: + """The files the collection cannot supply, grouped by reason.""" + groups: dict[str, dict] = {} + for entry in omitted: + reason = entry.get("reason", "unknown") + group = groups.setdefault(reason, { + "reason": reason, "label": OMISSION_REASONS.get(reason, reason), + "files": 0, "required": 0, + }) + group["files"] += 1 + group["required"] += bool(entry.get("required", True)) + return [groups[reason] for reason in sorted(groups)] + + # The three RetroArch packages published for Android, newest ABI first. ANDROID_RETROARCH_PACKAGES = ( "com.retroarch.aarch64", @@ -2164,9 +2305,15 @@ def fetch_manifest(plat: str) -> dict: try: return _validate_manifest(_fetch_json(url, MAX_MANIFEST_BYTES, "manifest"), plat) except ValueError as exc: + EVENTS.emit("error", stage="manifest", reason="invalid", platform=plat, + message=f"the file list is not valid: {exc}") print(f" The file list for {plat} is not valid: {exc}", file=sys.stderr) sys.exit(1) except urllib.error.HTTPError as exc: + EVENTS.emit( + "error", stage="manifest", reason="not_found" if exc.code == 404 else "network", + platform=plat, message=f"the file list could not be fetched: HTTP {exc.code}", + ) if exc.code == 404 and os.environ.get("RETROBIOS_BASE_URL"): print(f" No file list for {plat} there.", file=sys.stderr) for line in _address_lines(url): @@ -2190,6 +2337,9 @@ def fetch_manifest(plat: str) -> dict: print(NETWORK_ADVICE, file=sys.stderr) sys.exit(1) except (urllib.error.URLError, OSError) as exc: + EVENTS.emit("error", stage="manifest", reason="network", platform=plat, + message=f"the file list could not be fetched: " + f"{getattr(exc, 'reason', None) or exc}") print(f" Could not fetch the file list for {plat} after {MAX_RETRIES} " f"attempts: {getattr(exc, 'reason', None) or exc}", file=sys.stderr) for line in _address_lines(url): @@ -3612,6 +3762,12 @@ def download_files( if _stop.is_set(): continue progress.done(dest, success, reason) + EVENTS.emit( + "downloaded", dest=dest, ok=success, reason=reason, + size=future_map[future].get("size", 0), files_done=progress.files, + files_total=progress.total_files, bytes_done=progress.bytes, + bytes_total=progress.expected_bytes, + ) if not success: failed.append((dest, reason)) if reason == NOT_WRITABLE and not _folder_writable(bios_path): @@ -4733,6 +4889,12 @@ def main() -> None: action="store_true", help="print only errors and the final summary (implies --no-input)", ) + parser.add_argument( + "--events", + action="store_true", + help="write the run as JSON events on stdout, one per line, and the " + "usual text on stderr (implies --no-input)", + ) parser.add_argument( "--version", action="version", @@ -4763,6 +4925,29 @@ def main() -> None: parser.error("--quiet applies to an install, not to --check, --dry-run or a listing") if args.every_board and args.target: parser.error("--every-board installs every board; --target names one") + if args.events: + # The events own stdout. The text a person reads goes to stderr, so a + # program never has to tell one from the other. + args.no_input = True + EVENTS.open(sys.stdout) + sys.stdout = sys.stderr + EVENTS.emit("start", schema=EVENT_SCHEMA, installer=installer_id(), + mode=_event_mode(args)) + try: + _run(args) + except SystemExit as exc: + EVENTS.emit("end", code=_event_exit_code(exc.code)) + raise + except KeyboardInterrupt: + EVENTS.emit("end", code=130) + raise + EVENTS.emit("end", code=0) + return + _run(args) + + +def _run(args: argparse.Namespace) -> None: + """The run, its text quiet or not.""" if not args.quiet: _install(args, sys.stdout) return @@ -4948,6 +5133,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: platforms = detect_platforms(os_type) if not platforms: print(" No supported platform detected.") + EVENTS.emit("detect", os=os_type, os_name=_os_name(), platforms=[]) # Standalone emulators read their BIOS from folders of their own, # which the file lists name: an ES-DE user with DuckStation and # PCSX2 was sent to RetroArch's folder, which nothing here reads. @@ -4970,6 +5156,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: for name, path in platforms: print(f" Found {display_name(name)} at {path}") + if platforms: + EVENTS.emit("detect", os=os_type, os_name=_os_name(), platforms=[ + {"platform": name, "name": display_name(name), "bios_dir": str(path)} + for name, path in platforms + ]) + if len(platforms) > 1 and not listing and interactive: platforms = _prompt_platform_choice(platforms) @@ -4986,6 +5178,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: total_seconds = 0.0 total_declined = 0 check_pending = 0 + total_verified = 0 + files_pending = 0 failures: list[tuple[str, str]] = [] installed_paths: list[Path] = [] @@ -5253,6 +5447,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) ) data_dirs = select_data_directories(manifest, files, narrowed) + data_states = [ + (entry, data_directory_state(entry, bios_path)) for entry in data_dirs + ] if args.check or EVENTS.on else [] + if EVENTS.on: + _emit_check( + plat_name, manifest, bios_path, files, up_to_date, mismatched, + sum(len(state[1]) for _entry, state in data_states), len(omitted_files), + ) + total_verified += len(up_to_date) if args.check: if to_download: @@ -5285,8 +5488,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: every=args.verbose, ): print(line) - for entry in data_dirs: - _root, needed, present = data_directory_state(entry, bios_path) + for entry, (_root, needed, present) in data_states: if needed: print(f" {entry['ref']}: {present}/{len(entry['files'])} data files " f"present, {len(needed)} to fetch") @@ -5294,6 +5496,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: continue if args.dry_run: + files_pending += len(to_download) _print_plan(to_download, bios_path, term, [ (entry, data_directory_state(entry, bios_path)[1]) for entry in data_dirs ]) @@ -5313,7 +5516,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: "install less (see --help).") total_up_to_date += len(up_to_date) total_declined += len(to_download) + files_pending += len(to_download) continue + EVENTS.emit("download", platform=plat_name, files=len(to_download), bytes=dl_size) print(f"\nDownloading {len(to_download)} files ({format_size(dl_size)})...") print(" Press Ctrl+C to stop. Running the same command again " "carries on from here.") @@ -5341,6 +5546,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: term=term, progress=progress, ) total_downloaded += len(to_download) - len(failed) + files_pending += len(failed) + if interrupted: + files_pending += max(len(to_download) - progress.files, 0) total_errors += len(failed) failures.extend(failed) total_bytes += progress.transferred() @@ -5407,6 +5615,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: "they read into them.") sys.stdout = real_stdout + if not listing: + EVENTS.emit( + "summary", mode=_event_mode(args), installed=total_downloaded, + up_to_date=total_verified if args.check or args.dry_run else total_up_to_date, + pending=check_pending if args.check else files_pending, + failed=[{"dest": dest, "reason": str(reason)} for dest, reason in failures], + skipped=_skipped(omitted_seen), interrupted=total_interrupted, + ) # A check scripted as `--check && launch` reads the exit code only. if args.check and check_pending: sys.exit(1) diff --git a/install.sh b/install.sh index 6f563d44..2cd908b2 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="fe8de4eda0919c268d4d2c0d571c7d0dc1cf675d7604263766f5f99a35aa3724" +DEFAULT_INSTALL_SHA256="3bdaabc9f88e20e09afb99805df5481527611bac796906b98ae6579c3dfcd4a4" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install.py b/tests/test_install.py index c16ca60e..08376eb2 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -12,6 +12,7 @@ import os import shlex import re import shutil +import socket import subprocess import sys import tempfile @@ -4379,5 +4380,178 @@ class RomMFirmwareInstallsUnderItsTemplate(unittest.TestCase, _LoopbackRun): self.assertIn("{platform}/bios", proc.stdout) +class _SyntheticTree: + """A served tree of four BIOS files over loopback, one with no system, + and a BIOS folder holding one of them, another with other bytes, and + neither of the last two.""" + + FILES = { + "bios/A/a.bin": b"alpha" * 20, + "bios/A/b.bin": b"bravo" * 30, + "bios/B/c.bin": b"charlie" * 10, + "bios/X/d.bin": b"delta" * 5, + } + + def serve_tree(self, test: unittest.TestCase) -> dict: + root = Path(_tempdir(test)) + files = [] + for path, data in self.FILES.items(): + (root / path).parent.mkdir(parents=True, exist_ok=True) + (root / path).write_bytes(data) + name = Path(path).name + systems = {"a.bin": ["sys-a"], "b.bin": ["sys-a"], "c.bin": ["sys-b"]}.get(name) + entry = {"dest": name, "repo_path": path, "size": len(data), + "sha1": hashlib.sha1(data).hexdigest(), "cores": None} + if systems: + entry["systems"] = systems + files.append(entry) + manifest = { + "manifest_version": 2, "platform": "retroarch", "display_name": "RetroArch", + "files": files, "system_names": {"sys-a": ["alpha"]}, + "omitted_files": [{"dest": "e.bin", "name": "e.bin", "system": "sys-b", + "required": True, "reason": "not_found", + "systems": ["sys-b"]}], + } + (root / "install").mkdir() + (root / "install" / "retroarch.json").write_text(json.dumps(manifest)) + + class Quiet(http.server.SimpleHTTPRequestHandler): + def log_message(self, *args) -> None: + return + + handler = functools.partial(Quiet, directory=str(root)) + httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + test.addCleanup(httpd.server_close) + test.addCleanup(httpd.shutdown) + self.home = Path(_tempdir(test)) + self.bios = self.home / "bios" + self.bios.mkdir() + (self.bios / "a.bin").write_bytes(self.FILES["bios/A/a.bin"]) + (self.bios / "b.bin").write_bytes(b"other bytes") + return { + **os.environ, "HOME": str(self.home), "RETROBIOS_OS": "linux", + "RETROBIOS_BASE_URL": f"http://127.0.0.1:{httpd.server_address[1]}", + } + + +def _events(proc: subprocess.CompletedProcess) -> list[dict]: + """The --events stream, every stdout line a JSON object.""" + return [json.loads(line) for line in proc.stdout.splitlines()] + + +class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree): + """--events: the handheld port reads the run as JSON lines on stdout and + never parses the text, which goes to stderr.""" + + def setUp(self): + self.env = self.serve_tree(self) + + def _run(self, *argv: str) -> tuple[subprocess.CompletedProcess, list[dict]]: + proc = _install_run(self.env, "--platform", "retroarch", "--dest", str(self.bios), + "--events", "--jobs", "1", *argv) + self.assertNotIn("Traceback", proc.stderr) + return proc, _events(proc) + + def _head(self, mode: str, installer: str) -> list[dict]: + return [ + {"event": "start", "schema": install.EVENT_SCHEMA, "installer": installer, + "mode": mode}, + {"event": "detect", "os": "linux", "os_name": install._os_name(), + "platforms": [{"platform": "retroarch", "name": "RetroArch", + "bios_dir": str(self.bios)}]}, + {"event": "file", "platform": "retroarch", "dest": "a.bin", + "systems": ["sys-a"], "size": 100, "state": "present"}, + {"event": "file", "platform": "retroarch", "dest": "b.bin", + "systems": ["sys-a"], "size": 150, "state": "wrong_hash"}, + {"event": "file", "platform": "retroarch", "dest": "c.bin", + "systems": ["sys-b"], "size": 70, "state": "missing"}, + {"event": "file", "platform": "retroarch", "dest": "d.bin", + "systems": [], "size": 25, "state": "missing"}, + {"event": "system", "platform": "retroarch", "system": "other", "name": "other", + "files": 1, "present": 0, "missing": 1, "wrong_hash": 0, "bytes_needed": 25}, + {"event": "system", "platform": "retroarch", "system": "sys-a", "name": "alpha", + "files": 2, "present": 1, "missing": 0, "wrong_hash": 1, "bytes_needed": 150}, + {"event": "system", "platform": "retroarch", "system": "sys-b", "name": "sys-b", + "files": 1, "present": 0, "missing": 1, "wrong_hash": 0, "bytes_needed": 70}, + {"event": "check", "platform": "retroarch", "name": "RetroArch", + "bios_dir": str(self.bios), "files": 4, "present": 1, "missing": 2, + "wrong_hash": 1, "bytes_needed": 245, "data_missing": 0, + "not_installable": 1}, + ] + + SKIPPED = [{"reason": "not_found", "label": "not found", "files": 1, "required": 1}] + + def test_a_check_run(self): + proc, events = self._run("--check") + self.assertEqual(proc.returncode, 1) + installer = events[0]["installer"] + self.assertEqual(events, self._head("check", installer) + [ + {"event": "summary", "mode": "check", "installed": 0, "up_to_date": 1, + "pending": 3, "failed": [], "skipped": self.SKIPPED, "interrupted": False}, + {"event": "end", "code": 1}, + ]) + self.assertIn("2/4 present (1 verified, 1 wrong hash)", proc.stderr) + self.assertFalse((self.bios / "c.bin").exists()) + + def test_an_install_run(self): + proc, events = self._run() + self.assertEqual(proc.returncode, 0, proc.stderr) + installer = events[0]["installer"] + progress = [event for event in events if event["event"] == "progress"] + for event in progress: + self.assertEqual(list(event), ["event", *install.EVENT_KEYS["progress"]]) + done = [{"event": "downloaded", "dest": dest, "ok": True, "reason": "", + "size": size, "files_done": n, "files_total": 3, "bytes_done": total, + "bytes_total": 245} + for n, (dest, size, total) in enumerate( + (("c.bin", 70, 70), ("d.bin", 25, 95), ("b.bin", 150, 245)), 1)] + self.assertEqual([e for e in events if e["event"] != "progress"], + self._head("install", installer) + [ + {"event": "download", "platform": "retroarch", "files": 3, "bytes": 245}, + *done, + {"event": "summary", "mode": "install", "installed": 3, "up_to_date": 1, + "pending": 0, "failed": [], "skipped": self.SKIPPED, "interrupted": False}, + {"event": "end", "code": 0}, + ]) + self.assertEqual((self.bios / "b.bin").read_bytes(), self.FILES["bios/A/b.bin"]) + self.assertIn("Done. 3 files installed", proc.stderr) + + def test_every_event_has_its_schema_keys(self): + _proc, events = self._run() + for event in events: + self.assertEqual(list(event), ["event", *install.EVENT_KEYS[event["event"]]]) + + def test_a_file_list_out_of_reach_is_a_network_error(self): + # A port nothing listens on: the connection is refused at once. + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + closed = probe.getsockname()[1] + env = {**self.env, "RETROBIOS_BASE_URL": f"http://127.0.0.1:{closed}"} + proc = _install_run(env, "--platform", "retroarch", "--dest", str(self.bios), + "--events", "--check") + events = _events(proc) + self.assertEqual([e["event"] for e in events], ["start", "detect", "error", "end"]) + self.assertEqual(events[2]["reason"], "network") + self.assertEqual(events[2]["stage"], "manifest") + self.assertEqual(events[3], {"event": "end", "code": 1}) + + def test_nothing_detected_is_an_empty_platform_list(self): + proc = _install_run(self.env, "--events", "--check") + events = _events(proc) + self.assertEqual(proc.returncode, 1) + self.assertEqual(events[1]["platforms"], []) + self.assertEqual(events[-1], {"event": "end", "code": 1}) + + def test_the_schema_is_documented(self): + page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + for kind, keys in install.EVENT_KEYS.items(): + row = next((line for line in page.splitlines() + if line.startswith(f"| `{kind}` |")), None) + self.assertIsNotNone(row, kind) + for key in keys: + self.assertIn(f"`{key}`", row, f"{kind}.{key}") + + if __name__ == "__main__": unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index 57dae073..83409744 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -251,6 +251,7 @@ what the options name, everything by default. | `--dry-run` | Show what would be downloaded, by system and with sizes, without writing anything | | `--yes`, `-y` | Do not ask before a download of 1 GB or more | | `--quiet`, `-q` | Print only failures and the final summary. Implies `--no-input` | +| `--events` | Write the run as JSON events on standard output, one object per line, and the usual text on standard error. Implies `--no-input`. See [Machine-readable events](#machine-readable-events) | | `--version` | Print the installer's identity, the first 12 hex digits of the SHA-256 the bootstraps pin | | `--list-platforms` | Print the supported platforms and what was detected here | | `--list-targets` | Print the hardware targets a platform publishes, with the core count each carries | @@ -267,6 +268,31 @@ it, a file already present with the expected hash is left untouched, and one whose contents do not match is re-downloaded and replaced by the verified copy. Files the list does not name are never read, moved or deleted. +## Machine-readable events + +With `--events`, standard output carries only JSON objects, one per line, and +the text a person reads goes to standard error. A program driving the installer reads +the objects and never parses the text. Every object has an `event` key naming its +kind, and each kind has exactly the keys below, in this order. The schema is +`EVENT_KEYS` in `install.py`, and a test holds this table to it. + +| Kind | Keys | When | +|------|------|------| +| `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` | +| `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found | +| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`). `reason` is `network`, `not_found` or `invalid` | +| `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` | +| `system` | `platform`, `system`, `name`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed` | Each system, sorted by id. Files with no system count under `other`. `name` is the platform's own name for it | +| `check` | `platform`, `name`, `bios_dir`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `data_missing`, `not_installable` | The platform's totals. `data_missing` counts data-tree files to fetch, `not_installable` the files the collection cannot supply | +| `download` | `platform`, `files`, `bytes` | Before the downloads start | +| `progress` | `files_done`, `files_total`, `bytes_done`, `bytes_total` | At most every half second while bytes arrive | +| `downloaded` | `dest`, `ok`, `reason`, `size`, `files_done`, `files_total`, `bytes_done`, `bytes_total` | Each finished file. `reason` says why one failed, and is empty otherwise | +| `summary` | `mode`, `installed`, `up_to_date`, `pending`, `failed`, `skipped`, `interrupted` | The outcome. `pending` counts what a next run would still fetch, `failed` lists `{dest, reason}`, `skipped` lists `{reason, label, files, required}` for the files the collection cannot supply. Not written for a listing | +| `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) | + +An option refused before the run starts (`install.py: error:`, code 2) writes +no event at all. A program reads a missing `end` as that case. + ## Environment overrides | Variable | Effect |