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.
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 09:59:25 +02:00
1 parent 10c94369df
commit 5e4d9199c2
7 files changed
+145 -16

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "86c4e8b2a67b5721020dd3629212c741a67a3b3180e8f62fb61bba333303a5b6" $defaultInstallSha256 = "c462ff5c1c755b269b680f6e1c4c22ef58b7ae9b92679444634c35d248cc4267"
$maximumInstallerBytes = 2MB $maximumInstallerBytes = 2MB
$installerExit = 0 $installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+49 -12
View File
@@ -1559,17 +1559,23 @@ def _name_key(name: str) -> str:
SHORT_LIST = 12 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. """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 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 import difflib # noqa: PLC0415
by_key: dict[str, str] = {} by_key: dict[str, str] = {}
for name in known: for name in known:
by_key.setdefault(_name_key(name), name) by_key.setdefault(_name_key(name), name)
for alias, name in (aliases or {}).items():
by_key.setdefault(_name_key(alias), name)
close: list[str] = [] close: list[str] = []
for word in typed: for word in typed:
key = _name_key(word) 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") 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) mapping = data.get(field)
if mapping is None: if mapping is None:
continue 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. """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 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 itself a name wins over splitting it: Recalbox names one system
`msx1,msx2,msxturbor`. An unknown name is refused, never ignored, since `msx1,msx2,msxturbor`. An unknown name is refused, never ignored, since
carrying on would install something other than what was asked. 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} 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 # 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. # name when no other one reads the same once case and spacing go.
by_key: dict[str, str | None] = {} by_key: dict[str, str | None] = {}
for name in known: for name in known:
key = _name_key(name) key = _name_key(name)
by_key[key] = None if key in by_key else 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] = [] chosen: list[str] = []
unknown: list[str] = [] unknown: list[str] = []
for value in values: 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( listing = {"system": "--list-systems", "emulator": "--list-emulators"}.get(
label, "--list-cores") label, "--list-cores")
raise ValueError( 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: if values and not chosen:
raise ValueError(f"--{label} needs at least one name") 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) 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( 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: ) -> None:
"""What --system or --core accepts for a platform, with what each holds.""" """What --system or --core accepts for a platform, with what each holds."""
choices = available_choices(files, omitted) choices = available_choices(files, omitted)
@@ -4129,14 +4162,15 @@ def _print_choices(
if not names: if not names:
print(f" {plat} records no {'systems' if systems else 'cores'}") print(f" {plat} records no {'systems' if systems else 'cores'}")
return 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: for name in names:
held = ( held = (
[f for f in files if _chosen(f, {name})] [f for f in files if _chosen(f, {name})]
if systems if systems
else _filter_by_target([f for f in files if f.get("cores") is not None], [name]) 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]: 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( 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: ) -> tuple[list[str], list[str], list[str]] | None:
"""The systems, cores and regions named on the command line, checked.""" """The systems, cores and regions named on the command line, checked."""
if not (args.system or args.core or args.region): if not (args.system or args.core or args.region):
@@ -4343,7 +4378,9 @@ def _requested_narrowing(
sys.exit(1) sys.exit(1)
choices = available_choices(files, omitted) choices = available_choices(files, omitted)
try: 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") cores = resolve_choices(args.core or [], choices["cores"], "core")
regions = resolve_regions(args.region or []) regions = resolve_regions(args.region or [])
except ValueError as exc: 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 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 ( if (
selection is None selection is None
and interactive and interactive
@@ -4924,7 +4961,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
print(f" Narrowed {before} -> {len(files)} files") print(f" Narrowed {before} -> {len(files)} files")
if args.list_systems or args.list_cores: 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 continue
total_size = sum(f.get("size", 0) for f in files) total_size = sum(f.get("size", 0) for f in files)
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER="" TEMP_INSTALLER=""
TEMP_DIRECTORY="" TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="86c4e8b2a67b5721020dd3629212c741a67a3b3180e8f62fb61bba333303a5b6" DEFAULT_INSTALL_SHA256="c462ff5c1c755b269b680f6e1c4c22ef58b7ae9b92679444634c35d248cc4267"
MAX_INSTALLER_BYTES=2097152 MAX_INSTALLER_BYTES=2097152
cleanup() { cleanup() {
+12
View File
@@ -120,6 +120,18 @@
"uniqueItems": true "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": { "omitted_files": {
"type": "array", "type": "array",
"items": { "items": {
+4 -1
View File
@@ -74,7 +74,7 @@ from common import (
import packresolve import packresolve
from refresh_data_dirs import cache_lock, hold_data_dirs from refresh_data_dirs import cache_lock, hold_data_dirs
import region as region_mod import region as region_mod
from manifestselect import SelectionIndex from manifestselect import SelectionIndex, native_system_names
import slot as slot_mod import slot as slot_mod
import slots import slots
import split_pack import split_pack
@@ -4465,6 +4465,9 @@ def generate_manifest(
), ),
"core_names": core_names, "core_names": core_names,
"system_cores": system_cores, "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, "files": manifest_files,
} }
if data_directories: if data_directories:
+36 -1
View File
@@ -17,8 +17,10 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import re
import region as region_mod 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 from packextras import _extra_system_ids, _kept, extra_region_groups, system_spellings
@@ -118,3 +120,36 @@ class SelectionIndex:
if priority is not None: if priority is not None:
out["priority"] = priority out["priority"] = priority
return out 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())}
+42
View File
@@ -746,5 +746,47 @@ class AListingNeedsNoInstall(unittest.TestCase):
self.assertIn("nintendo-gba", proc.stdout) 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__": if __name__ == "__main__":
unittest.main() unittest.main()