diff --git a/install.ps1 b/install.ps1 index fbb39ad50..19267c967 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213" + $defaultInstallSha256 = "ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 48dfd738d..6e003ed10 100644 --- a/install.py +++ b/install.py @@ -51,7 +51,7 @@ import xml.etree.ElementTree as ET import zipfile from collections.abc import Callable, Collection, Iterable, Iterator, Mapping from pathlib import Path, PurePosixPath -from typing import Any, BinaryIO, ContextManager, NamedTuple, TextIO +from typing import Any, BinaryIO, ContextManager, NamedTuple, NoReturn, TextIO # Built as C extensions that a Python compiled without their libraries # lacks: the run stopped on a ModuleNotFoundError traceback. @@ -662,7 +662,9 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # reason: not_found, not_a_folder, unreadable, or system for the root # of the system and /proc, /sys, /dev) or dest (reason: unreadable, a # folder above it this user cannot open, or not_writable, the BIOS - # folder cannot be created, a summary following). + # folder cannot be created, a summary following) or option (reason: + # unknown, a platform, target, system, core or emulator the run cannot + # use, refused before any download). "error": ("stage", "reason", "platform", "message"), # A folder named with --from, once read: the files it holds (counted # up to MAX_SCANNED_FILES, truncated past them), and the folders below @@ -3058,6 +3060,23 @@ def did_you_mean( return f"{listing} lists all {len(known)}." +def refuse_name(platform: str, *lines: str) -> NoReturn: + """End the run on a name it cannot use (a platform, target, system, core + or emulator), refused before any download. + + The reason went to stderr only: a reader of --events got start, then + end with code 1, and could not tell a typo from a failed download. + """ + message = " ".join(line.strip() for line in lines) + if message.startswith("Error: "): + message = message[len("Error: "):] + EVENTS.emit("error", stage="option", reason="unknown", platform=platform, + message=message) + for line in lines: + print(line, file=sys.stderr) + sys.exit(1) + + def normalize_platform(name: str, say: bool = True) -> str: """Return the canonical platform id for user-supplied input. @@ -3077,10 +3096,8 @@ def normalize_platform(name: str, say: bool = True) -> str: print(f" {rule.name} gets the {display_name(rule.manifest)} file list: " f"--platform {rule.manifest}.") return rule.manifest - print(f"Unknown platform '{name.strip()}'. " - + did_you_mean([name], list(AVAILABLE_PLATFORMS), "--list-platforms"), - file=sys.stderr) - sys.exit(1) + refuse_name(name.strip(), f"Unknown platform '{name.strip()}'. " + + did_you_mean([name], list(AVAILABLE_PLATFORMS), "--list-platforms")) def _read_limited_json( @@ -9061,8 +9078,7 @@ def _narrow_to_emulators( try: chosen = resolve_choices(names, known, "emulator") except ValueError as exc: - print(f"Error: {plat}: {exc}", file=sys.stderr) - sys.exit(1) + refuse_name(plat, f"Error: {plat}: {exc}") before = len(files) files, omitted = narrow_to_cores(manifest, chosen, files, omitted) # A whole-platform install keeps the platform's files of a system no @@ -9118,8 +9134,7 @@ def _requested_narrowing( cores = resolve_choices(args.core or [], choices["cores"], "core") regions = resolve_regions(args.region or []) except ValueError as exc: - print(f"Error: {plat}: {exc}", file=sys.stderr) - sys.exit(1) + refuse_name(plat, f"Error: {plat}: {exc}") return systems, cores, regions @@ -14393,14 +14408,13 @@ def _several_found(platforms: list[tuple[str, Path]], setups: list) -> None: sys.exit(1) -def _unknown_target(target: str, plat: str, targets: dict) -> None: +def _unknown_target(target: str, plat: str, targets: dict, *after: str) -> NoReturn: if not targets: # No value can work: "unknown target" read as a typo of the board. - print(f"Error: {display_name(plat)} has no hardware targets: run again " - "without --target.", file=sys.stderr) - return - print(f"Error: unknown target '{target}' for {display_name(plat)}.", file=sys.stderr) - print(" " + did_you_mean([target], sorted(targets), "--list-targets"), file=sys.stderr) + refuse_name(plat, f"Error: {display_name(plat)} has no hardware targets: run again " + "without --target.", *after) + refuse_name(plat, f"Error: unknown target '{target}' for {display_name(plat)}.", + " " + did_you_mean([target], sorted(targets), "--list-targets"), *after) def _is_terminal(stream: TextIO) -> bool: @@ -14743,11 +14757,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: sys.exit(1) target_lists[plat_name] = targets if args.target not in targets: - _unknown_target(args.target, plat_name, targets) - if len(platforms) > 1: - print(" Nothing was installed. Name one platform with " - "--platform to install it alone.", file=sys.stderr) - sys.exit(1) + _unknown_target(args.target, plat_name, targets, *( + [" Nothing was installed. Name one platform with " + "--platform to install it alone."] if len(platforms) > 1 else [])) if args.romm_firmware is not None: # Applied or refused, never ignored. @@ -14869,7 +14881,6 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # of what a filter was asked for: a 4 GB download instead of # the subset the target needs. Refuse rather than ignore. _unknown_target(target, plat_name, targets) - sys.exit(1) elif target_info.get("cores") is None: print( f" Target '{target}' publishes no core list: " diff --git a/install.sh b/install.sh index b3bd625c5..27da4735a 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213" +DEFAULT_INSTALL_SHA256="ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 1672607f9..c68543061 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1081583, - "sha256": "9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213", + "size": 1082235, + "sha256": "ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1135210, - "sha256": "59914e1d36e1169d6a9b24314a8e64833a434fa8c6df05916257aa5007855b0f" + "size": 1135862, + "sha256": "a1d165f2bcbc60eec9cc8654e92bf4f1d1790d93e2c33dcd467594a5c85ad9ea" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1081583, - "sha256": "9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213", + "size": 1082235, + "sha256": "ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1081583, - "sha256": "9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213", + "size": 1082235, + "sha256": "ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index 8556425fa..98c6d74c7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -3952,6 +3952,44 @@ def _install_run(env: dict, *argv: str, **kwargs) -> subprocess.CompletedProcess ) +class AnUnknownNameIsAnEvent(unittest.TestCase): + """--events carried start then end with code 1 for an unknown platform, + target or system: the reason was on stderr only, which readers ignore.""" + + def _events(self, *argv: str) -> tuple[int, list[dict]]: + env = {key: value for key, value in os.environ.items() + if key != "RETROBIOS_BASE_URL"} + env[install.OFFLINE_VARIABLE] = "1" + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as dest: + proc = subprocess.run( + [sys.executable, str(REPO_ROOT / "install.py"), "--dest", dest, + "--check", "--events", "--no-input", *argv], + env=env, capture_output=True, text=True, timeout=120, + stdin=subprocess.DEVNULL) + return proc.returncode, [json.loads(line) for line in proc.stdout.splitlines()] + + def test_each_refusal_says_why(self): + manifest = str(REPO_ROOT / "install" / "retroarch.json") + for argv, words in ( + (["--platform", "nope"], "Unknown platform 'nope'"), + (["--platform", "retroarch", "--manifest", manifest, "--system", "nope"], + "unknown system: nope"), + (["--platform", "retroarch", "--manifest", manifest, "--core", "nope"], + "unknown core: nope"), + (["--platform", "retroarch", "--manifest", manifest, "--emulator", "nope"], + "unknown emulator: nope")): + with self.subTest(argv=argv): + code, events = self._events(*argv) + self.assertNotEqual(code, 0) + errors = [event for event in events if event["event"] == "error"] + self.assertEqual(len(errors), 1, events) + self.assertEqual((errors[0]["stage"], errors[0]["reason"]), + ("option", "unknown")) + self.assertIn(words, errors[0]["message"]) + self.assertFalse(errors[0]["message"].startswith("Error")) + self.assertEqual(events[-1]["event"], "end") + + class ATypoGetsTheClosestName(unittest.TestCase): """--platform batocra, --system playstation or --core swanstaton were answered with every name the platform has, up to 400 on one line, and @@ -4013,7 +4051,7 @@ class ATypoGetsTheClosestName(unittest.TestCase): def test_a_target_typo(self): err = io.StringIO() - with contextlib.redirect_stderr(err): + with contextlib.redirect_stderr(err), self.assertRaises(SystemExit): install._unknown_target("swich", "retroarch", {"switch": {}, "desktop": {}}) self.assertIn("Did you mean: switch?", err.getvalue()) self.assertIn("--list-targets", err.getvalue()) diff --git a/tests/test_install_cores.py b/tests/test_install_cores.py index 26a71a6cb..c0c057470 100644 --- a/tests/test_install_cores.py +++ b/tests/test_install_cores.py @@ -426,7 +426,8 @@ class APlatformWithoutTargetsSaysSo(unittest.TestCase): user, when no value can work there.""" def test_the_way_forward_is_named(self): - with contextlib.redirect_stderr(io.StringIO()) as err: + with contextlib.redirect_stderr(io.StringIO()) as err, \ + self.assertRaises(SystemExit): install._unknown_target("switch", "misterfpga", {}) said = err.getvalue() self.assertIn("MiSTer FPGA has no hardware targets: run again without --target.", said) diff --git a/wiki/installer.md b/wiki/installer.md index 44815a33e..67da7754c 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1761,7 +1761,7 @@ other schemas under `api/v1/schemas/`. | `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` | | `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it. What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence | | `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`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | +| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. A platform, target, system, core or emulator named on the command line is unknown or cannot be used here (`stage` is `option`, `reason` `unknown`, `platform` the platform it was checked against or the name typed), refused before any download. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | | `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered | | `version` | `platform`, `list_version`, `installed_version`, `message` | Before `choices`, when the system here runs another version than the one the list was built for: `list_version` is the list's, `installed_version` the one the system's version file states, and `message` says it and what it means. Not written when either version is unknown. The run goes on | | `choices` | `platform`, `systems` | Before `checking`: the systems the run's list lets one choose among, sorted by id, `other` last for the files no system names. The run's other options apply (`--core`, `--region`, `--no-game-data`, the files left out for an unknown save directory), `--system` does not: these are the systems a screen lists to choose from, and the count a `--system` is out of |