From 5e4d9199c2aff014fb4e47338f2ea1f113da8208 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:59:25 +0200 Subject: [PATCH] feat: accept platform system names in --system The manifest now carries system_names, each system's native_system names from the platform's own file, and --system, its suggestions and --list-systems read them: --platform batocera --system psx installs sony-playstation. --- install.ps1 | 2 +- install.py | 61 ++++++++++++++++++++++------ install.sh | 2 +- schemas/install-manifest.schema.json | 12 ++++++ scripts/generate_pack.py | 5 ++- scripts/manifestselect.py | 37 ++++++++++++++++- tests/test_install_selection.py | 42 +++++++++++++++++++ 7 files changed, 145 insertions(+), 16 deletions(-) diff --git a/install.ps1 b/install.ps1 index 33733458..a8f7e43f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "86c4e8b2a67b5721020dd3629212c741a67a3b3180e8f62fb61bba333303a5b6" + $defaultInstallSha256 = "c462ff5c1c755b269b680f6e1c4c22ef58b7ae9b92679444634c35d248cc4267" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 21e945c3..11053847 100644 --- a/install.py +++ b/install.py @@ -1559,17 +1559,23 @@ def _name_key(name: str) -> str: SHORT_LIST = 12 -def did_you_mean(typed: list[str], known: list[str], listing: str) -> str: +def did_you_mean( + typed: list[str], known: list[str], listing: str, + aliases: dict[str, str] | None = None, +) -> str: """The closest known names to what was typed, and the option listing them all. A typo was answered with every name the platform has, up to 400 on one - line, with no hint that playstation is sony-playstation. + line, with no hint that playstation is sony-playstation. An alias close + to what was typed suggests the name it stands for. """ import difflib # noqa: PLC0415 by_key: dict[str, str] = {} for name in known: by_key.setdefault(_name_key(name), name) + for alias, name in (aliases or {}).items(): + by_key.setdefault(_name_key(alias), name) close: list[str] = [] for word in typed: key = _name_key(word) @@ -1983,7 +1989,7 @@ def _validate_manifest(data: object, plat: str) -> dict: ): raise ValueError("manifest total_omitted does not match omitted_files") - for field in ("core_names", "system_cores"): + for field in ("core_names", "system_cores", "system_names"): mapping = data.get(field) if mapping is None: continue @@ -2532,21 +2538,31 @@ def available_choices(files: list[dict], omitted: list[dict]) -> dict[str, list[ } -def resolve_choices(values: list[str], known: list[str], label: str) -> list[str]: +def resolve_choices( + values: list[str], known: list[str], label: str, + aliases: dict[str, str] | None = None, +) -> list[str]: """Names given on the command line, matched without regard to case. Each value is one name or a comma-separated list. A whole value that is itself a name wins over splitting it: Recalbox names one system `msx1,msx2,msxturbor`. An unknown name is refused, never ignored, since carrying on would install something other than what was asked. + aliases maps another name of a known one to it, as Batocera's `psx` + stands for sony-playstation. A known name always wins over an alias. """ + aliases = {alias: name for alias, name in (aliases or {}).items() if name in known} by_lower = {name.lower(): name for name in known} + for alias, name in aliases.items(): + by_lower.setdefault(alias.lower(), name) # Spelled as the site writes it (Sony PlayStation), a name is still one # name when no other one reads the same once case and spacing go. by_key: dict[str, str | None] = {} for name in known: key = _name_key(name) by_key[key] = None if key in by_key else name + for alias, name in aliases.items(): + by_key.setdefault(_name_key(alias), name) chosen: list[str] = [] unknown: list[str] = [] for value in values: @@ -2566,7 +2582,8 @@ def resolve_choices(values: list[str], known: list[str], label: str) -> list[str listing = {"system": "--list-systems", "emulator": "--list-emulators"}.get( label, "--list-cores") raise ValueError( - f"unknown {label}: {', '.join(unknown)}. {did_you_mean(unknown, known, listing)}" + f"unknown {label}: {', '.join(unknown)}. " + f"{did_you_mean(unknown, known, listing, aliases)}" ) if values and not chosen: raise ValueError(f"--{label} needs at least one name") @@ -4120,8 +4137,24 @@ def _same_choice_hint(plat: str, systems: list[str], cores: list[str], regions: return " ".join(parts) +def system_aliases(manifest: dict | None) -> dict[str, str]: + """The platform's own names for its systems, each with the system id.""" + return { + alias: system + for system, names in ((manifest or {}).get("system_names") or {}).items() + for alias in names + } + + +def _system_label(name: str, manifest: dict | None) -> str: + """A system id with the names the platform gives it: nintendo-gba (gba).""" + names = ((manifest or {}).get("system_names") or {}).get(name) + return f"{name} ({', '.join(names)})" if names else name + + def _print_choices( - plat: str, files: list[dict], omitted: list[dict], systems: bool + plat: str, files: list[dict], omitted: list[dict], systems: bool, + manifest: dict | None = None, ) -> None: """What --system or --core accepts for a platform, with what each holds.""" choices = available_choices(files, omitted) @@ -4129,14 +4162,15 @@ def _print_choices( if not names: print(f" {plat} records no {'systems' if systems else 'cores'}") return - width = min(max(len(name) for name in names), 40) + labels = {name: _system_label(name, manifest) if systems else name for name in names} + width = min(max(len(label) for label in labels.values()), 40) for name in names: held = ( [f for f in files if _chosen(f, {name})] if systems else _filter_by_target([f for f in files if f.get("cores") is not None], [name]) ) - print(f" {name:<{width}} {_sized(held)}") + print(f" {labels[name]:<{width}} {_sized(held)}") def emulator_names(manifest: dict, files: list[dict], omitted: list[dict]) -> dict[str, str]: @@ -4326,7 +4360,8 @@ def _narrow_to_emulators( def _requested_narrowing( - args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict] + args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict], + manifest: dict | None = None, ) -> tuple[list[str], list[str], list[str]] | None: """The systems, cores and regions named on the command line, checked.""" if not (args.system or args.core or args.region): @@ -4343,7 +4378,9 @@ def _requested_narrowing( sys.exit(1) choices = available_choices(files, omitted) try: - systems = resolve_choices(args.system or [], choices["systems"], "system") + systems = resolve_choices( + args.system or [], choices["systems"], "system", system_aliases(manifest) + ) cores = resolve_choices(args.core or [], choices["cores"], "core") regions = resolve_regions(args.region or []) except ValueError as exc: @@ -4887,7 +4924,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: args.emulator, manifest, plat_name, files, omitted_files ) - selection = _requested_narrowing(args, plat_name, files, omitted_files) + selection = _requested_narrowing(args, plat_name, files, omitted_files, manifest) if ( selection is None and interactive @@ -4924,7 +4961,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: print(f" Narrowed {before} -> {len(files)} files") if args.list_systems or args.list_cores: - _print_choices(plat_name, files, omitted_files, args.list_systems) + _print_choices(plat_name, files, omitted_files, args.list_systems, manifest) continue total_size = sum(f.get("size", 0) for f in files) diff --git a/install.sh b/install.sh index b61ac077..1b2e1670 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="86c4e8b2a67b5721020dd3629212c741a67a3b3180e8f62fb61bba333303a5b6" +DEFAULT_INSTALL_SHA256="c462ff5c1c755b269b680f6e1c4c22ef58b7ae9b92679444634c35d248cc4267" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json index 542555b1..0174cc1b 100644 --- a/schemas/install-manifest.schema.json +++ b/schemas/install-manifest.schema.json @@ -120,6 +120,18 @@ "uniqueItems": true } }, + "system_names": { + "description": "Each platform system with the names the platform's own file gives it (native_system), which the installer accepts for --system.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + }, "omitted_files": { "type": "array", "items": { diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index fa7b434a..abe20a00 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -74,7 +74,7 @@ from common import ( import packresolve from refresh_data_dirs import cache_lock, hold_data_dirs import region as region_mod -from manifestselect import SelectionIndex +from manifestselect import SelectionIndex, native_system_names import slot as slot_mod import slots import split_pack @@ -4465,6 +4465,9 @@ def generate_manifest( ), "core_names": core_names, "system_cores": system_cores, + # The names the platform itself gives its systems (Batocera's psx), + # which --system accepts beside the ids. + "system_names": native_system_names(config, list(pack_systems)), "files": manifest_files, } if data_directories: diff --git a/scripts/manifestselect.py b/scripts/manifestselect.py index fad640c7..1784efe9 100644 --- a/scripts/manifestselect.py +++ b/scripts/manifestselect.py @@ -17,8 +17,10 @@ from __future__ import annotations from dataclasses import dataclass +import re + import region as region_mod -from common import _norm_system_id, sanitize_pack_path +from common import _norm_system_id, native_layer, sanitize_pack_path from packextras import _extra_system_ids, _kept, extra_region_groups, system_spellings @@ -118,3 +120,36 @@ class SelectionIndex: if priority is not None: out["priority"] = priority return out + + +def _name_key(name: str) -> str: + """Letters and digits only, as the installer compares names.""" + return re.sub(r"[^a-z0-9]", "", name.lower()) + + +def native_system_names(config: dict, systems: list[str]) -> dict[str, list[str]]: + """Per platform system, the names the platform's own file gives it. + + A Batocera user knows PlayStation as `psx`, the folder under roms/, and + the platform YAML records that name as `native_system` on each file it + transcribes. Only the platform's own entries count (`native_layer`), a + name spelled like the system id adds nothing, and a name the platform + gives to two systems, or that reads as another system's id, names + neither. + """ + native = native_layer(config) or {} + by_name: dict[str, set[str]] = {} + for sys_id, system in native.get("systems", {}).items(): + if sys_id not in systems: + continue + for entry in system.get("files", []): + name = entry.get("native_system") + if isinstance(name, str) and name.strip(): + by_name.setdefault(name.strip(), set()).add(sys_id) + ids = {_name_key(sys_id) for sys_id in systems} + out: dict[str, list[str]] = {} + for name, owners in by_name.items(): + if len(owners) != 1 or _name_key(name) in ids: + continue + out.setdefault(next(iter(owners)), []).append(name) + return {sys_id: sorted(names) for sys_id, names in sorted(out.items())} diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 22b806a2..2dcfb9d5 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -746,5 +746,47 @@ class AListingNeedsNoInstall(unittest.TestCase): self.assertIn("nintendo-gba", proc.stdout) +class ThePlatformsOwnSystemNamesAreAccepted(unittest.TestCase): + """A Batocera user who typed `--system psx`, the folder under roms/, + was told the name is unknown with no suggestion, while the platform + file records psx as the native_system of sony-playstation.""" + + def test_the_names_come_from_the_platform_file(self): + from common import load_platform_config # noqa: PLC0415 + from manifestselect import native_system_names # noqa: PLC0415 + + config = load_platform_config("batocera", str(REPO_ROOT / "platforms")) + names = native_system_names(config, list(config["systems"])) + self.assertEqual(names["sony-playstation"], ["psx"]) + self.assertEqual(names["nintendo-gba"], ["gba"]) + # A name spelled like the system id adds nothing. + self.assertTrue(all( + install._name_key(alias) != install._name_key(system) + for system, aliases in names.items() for alias in aliases + )) + + def test_the_builder_writes_them_into_the_manifest(self): + source = (REPO_ROOT / "scripts" / "generate_pack.py").read_text(encoding="utf-8") + self.assertIn('"system_names": native_system_names(config, list(pack_systems))', + source) + + def test_a_native_name_narrows_and_lists(self): + with FakeRepository() as repo: + path = repo.root / "install" / "retroarch.json" + manifest = json.loads(path.read_text()) + manifest["system_names"] = {"sony-playstation": ["psx"], + "nintendo-gba": ["gba"]} + path.write_text(json.dumps(manifest)) + proc = repo.run("--system", "psx", "--no-input") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual(repo.installed(), + ["pcsx/extra.bin", "scph5500.bin", "scph5501.bin"]) + listing = repo.run("--list-systems", "--no-input") + self.assertIn("nintendo-gba (gba)", listing.stdout) + typo = repo.run("--system", "psxx", "--no-input") + self.assertEqual(typo.returncode, 1) + self.assertIn("Did you mean: sony-playstation", typo.stderr) + + if __name__ == "__main__": unittest.main()