From 4a30cbf882c3670b5657186559b381ea8bd58030 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:57:15 +0200 Subject: [PATCH] fix: count a refused standalone copy as an error --- install.ps1 | 2 +- install.py | 29 ++++++++++------ install.sh | 2 +- tests/test_install.py | 78 +++++++++++++++++++++++++++++++++++++++---- 4 files changed, 92 insertions(+), 19 deletions(-) diff --git a/install.ps1 b/install.ps1 index 9630c90f..8f1026f0 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,7 +8,7 @@ param( $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -$defaultInstallSha256 = "60e18da18b5a9a43b18d37020b676ad47babd6d12c5314e8fcc1370e4864c4e5" +$defaultInstallSha256 = "df0adf1fd5088ef9509429fb1d5720e5ca5e7bf46aff9ad0021851dce7349107" $maximumInstallerBytes = 2MB $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } $temporary = $null diff --git a/install.py b/install.py index eeef4695..e1322451 100644 --- a/install.py +++ b/install.py @@ -120,7 +120,8 @@ def _os_default_dests(os_type: str) -> dict[str, Path]: appdata = Path(os.environ.get("APPDATA", str(home / "AppData" / "Roaming"))) return { "retroarch": appdata / "RetroArch" / "system", - "retrobat": profile / "RetroBat" / "bios", + # RetroBat's installer defaults to C:\RetroBat (installer.iss). + "retrobat": Path("C:/RetroBat/bios"), "bizhawk": profile / "BizHawk" / "Firmware", } if os_type == "darwin": @@ -1294,7 +1295,7 @@ def download_files( def do_standalone_copies( manifest: dict, bios_path: Path, os_type: str, extra_dirs: dict[str, Path] | None = None, -) -> tuple[int, int]: +) -> tuple[int, int, int]: """Copy BIOS files to standalone emulator directories. Supports: @@ -1307,16 +1308,19 @@ def do_standalone_copies( default per-OS locations, for setups such as LaunchBox that keep their emulators outside them. The layout below the root is the same. - Returns (copied_count, skipped_count). + Returns (copied, skipped, failed): skipped is a target directory that + does not exist, failed a copy the system refused (permission, disk + full, read-only mount), which used to be counted as "dir not found". """ from fnmatch import fnmatch copies = manifest.get("standalone_copies", []) if not copies: - return 0, 0 + return 0, 0, 0 copied = 0 skipped = 0 + failed = 0 for entry in copies: # Note entries: print message if emulator detected @@ -1375,10 +1379,11 @@ def do_standalone_copies( try: shutil.copy2(src, dest) copied += 1 - except OSError: - skipped += 1 + except OSError as exc: + print(f" ERROR: {dest}: {exc.strerror or exc}") + failed += 1 - return copied, skipped + return copied, skipped, failed def format_size(n: int) -> str: @@ -1738,11 +1743,15 @@ def main() -> None: print("\nStandalone emulators:") lb_root = launchbox_root(os_type) extra_dirs = launchbox_bios_dirs(lb_root) if lb_root else None - copied, skipped = do_standalone_copies( + copied, skipped, copy_failed = do_standalone_copies( manifest, bios_path, os_type, extra_dirs ) - if copied or skipped: - print(f" {copied} copied, {skipped} skipped (dir not found)") + if copied or skipped or copy_failed: + print( + f" {copied} copied, {skipped} skipped (dir not found), " + f"{copy_failed} failed" + ) + total_errors += copy_failed elif manifest.get("standalone_copies") and not args.check: print( "\nStandalone copies skipped " diff --git a/install.sh b/install.sh index af80de0a..00ad754d 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="60e18da18b5a9a43b18d37020b676ad47babd6d12c5314e8fcc1370e4864c4e5" +DEFAULT_INSTALL_SHA256="df0adf1fd5088ef9509429fb1d5720e5ca5e7bf46aff9ad0021851dce7349107" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install.py b/tests/test_install.py index 5ce43617..c5b44d0c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -218,6 +218,15 @@ class TestDefaultsFollowTheRegistry(unittest.TestCase): with self.subTest(platform=name, os=os_type): self.assertIn(install._default_dest(os_type, name), candidates) checked += 1 + # The installer's own default comes first in the registry and + # is the one the pack guide and the installer name. + first = next( + (self._expand(rule["path"]) for rule in (entry.get("install") or {}).get("detect", []) + if rule.get("os") == "windows" and rule.get("method") == "path_exists"), + None, + ) + if name == "retrobat": + self.assertEqual(install._default_dest("windows", name), first) self.assertGreater(checked, 10) @@ -789,7 +798,7 @@ class TestStandaloneCopiesExtraDirs(unittest.TestCase): (bios / "ps2-0230a.bin").write_text("rom") extra = Path(tempfile.mkdtemp()) / "bios" extra.mkdir() - copied, _ = install.do_standalone_copies( + copied, _, _ = install.do_standalone_copies( self._manifest(), bios, "windows", {"pcsx2": extra} ) self.assertEqual(copied, 1) @@ -801,7 +810,7 @@ class TestStandaloneCopiesExtraDirs(unittest.TestCase): (bios / "GC" / "USA" / "IPL.bin").write_text("ipl") user = Path(tempfile.mkdtemp()) / "User" (user / "GC" / "USA").mkdir(parents=True) - copied, _ = install.do_standalone_copies( + copied, _, _ = install.do_standalone_copies( self._manifest(), bios, "windows", {"dolphin": user} ) self.assertEqual(copied, 1) @@ -811,10 +820,64 @@ class TestStandaloneCopiesExtraDirs(unittest.TestCase): bios = Path(tempfile.mkdtemp()) (bios / "ps2-0230a.bin").write_text("rom") self.assertEqual( - install.do_standalone_copies(self._manifest(), bios, "windows"), (0, 0) + install.do_standalone_copies(self._manifest(), bios, "windows"), (0, 0, 0) ) +class InstallerStaysOnPython38AndTheStdlib(unittest.TestCase): + """install.py runs where the bootstraps let it run: Python 3.8, stdlib only. + + The suite runs on 3.12 with pyyaml installed, so a removeprefix() or an + import of yaml kept every test green and died at the user's first run, + after the bootstrap had told them 3.8 was enough. + """ + + SOURCE = (REPO_ROOT / "install.py").read_text(encoding="utf-8") + + def test_the_grammar_is_python_38(self): + import ast # noqa: PLC0415 + + ast.parse(self.SOURCE, filename="install.py", feature_version=(3, 8)) + + def test_every_import_is_standard_library(self): + import ast # noqa: PLC0415 + import sys # noqa: PLC0415 + + tree = ast.parse(self.SOURCE) + imported = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + imported.add(node.module.split(".")[0]) + self.assertEqual(sorted(imported - set(sys.stdlib_module_names)), []) + + def test_no_api_newer_than_38(self): + newer = (".removeprefix(", ".removesuffix(", "zoneinfo", "graphlib", + "functools.cache(", ".bit_count(", "math.lcm(", "math.nextafter(") + used = [api for api in newer if api in self.SOURCE] + self.assertEqual(used, []) + + +class ARefusedCopyIsAnError(unittest.TestCase): + """A PermissionError on a standalone copy was counted as "dir not found" + and the run ended on Done with exit code 0.""" + + def test_a_refused_copy_is_counted_failed_not_skipped(self): + with tempfile.TemporaryDirectory() as tmp: + bios = Path(tmp, "bios") + bios.mkdir() + (bios / "prod.keys").write_bytes(b"k") + target = Path(tmp, "keys") + target.mkdir() + manifest = {"standalone_copies": [ + {"file": "prod.keys", "targets": {"linux": [str(target)]}} + ]} + with unittest.mock.patch("shutil.copy2", side_effect=PermissionError(13, "denied")): + result = install.do_standalone_copies(manifest, bios, "linux") + self.assertEqual(result, (0, 0, 1)) + + class TargetFlagIsAppliedOrRefused(unittest.TestCase): """A filter the installer cannot apply must stop it, not be ignored. @@ -844,11 +907,12 @@ class TargetFlagIsAppliedOrRefused(unittest.TestCase): for name in ("install/targets/retroarch.json",): path = REPO_ROOT / name if not path.is_file(): - continue + self.skipTest(f"{name} not generated yet") targets = json.loads(path.read_text()) for alias in ("switch", "ps2", "psp"): - if alias not in targets: - continue + # An alias the builder accepts and the manifest lacks is a + # documented word that fails on the installer's side. + self.assertIn(alias, targets, f"builder accepts --target {alias}") with self.subTest(alias=alias): resolved = load_target_config( "retroarch", alias, str(REPO_ROOT / "platforms") @@ -1007,7 +1071,7 @@ class TestStandaloneCopyTargetsAreUntrusted(unittest.TestCase): outside.write_text("untouched") (target / "boot.bin").symlink_to(outside) - copied, skipped = install.do_standalone_copies( + copied, skipped, _ = install.do_standalone_copies( self._manifest(str(target)), bios, "linux" ) self.assertEqual(copied, 0)