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"
$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 }
+49 -12
View File
@@ -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)
+1 -1
View File
@@ -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() {
+12
View File
@@ -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": {
+4 -1
View File
@@ -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:
+36 -1
View File
@@ -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())}
+42
View File
@@ -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()