mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 04:39:06 -05:00
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.
This commit is contained in:
1 parent
ff2137de9d
commit
65362e628f
5 files changed
+420
-4
No files matched your search
+1
-1
@@ -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 }
|
||||
|
||||
+218
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user