diff --git a/scripts/common.py b/scripts/common.py index fd215746..7b4c9a2b 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -96,6 +96,42 @@ _shared_yml_cache: dict[str, dict] = {} _platform_config_cache: dict[tuple[str, str], dict] = {} +def _inherited_yaml(platform_name: str, platforms_dir: str) -> tuple[dict, list[str]]: + """The platform's YAML merged over its parents', and the chain of names. + + Built from fresh copies: merging over the parent's cached config shared + its systems, so a child's overrides rewrote the parent for the rest of + the run. + """ + if yaml is None: + raise ImportError("PyYAML required: pip install pyyaml") + + config_file = os.path.join(platforms_dir, f"{platform_name}.yml") + if not os.path.exists(config_file): + raise FileNotFoundError(f"Platform config not found: {config_file}") + + with open(config_file) as f: + config = yaml_load(f) or {} + if "inherits" not in config: + return config, [platform_name] + + merged, chain = _inherited_yaml(config["inherits"], platforms_dir) + merged.update( + {k: v for k, v in config.items() if k not in ("inherits", "overrides")} + ) + if "overrides" in config and "systems" in config["overrides"]: + merged.setdefault("systems", {}) + for sys_id, override in config["overrides"]["systems"].items(): + if sys_id in merged["systems"]: + merged["systems"][sys_id] = { + **merged["systems"][sys_id], + **override, + } + else: + merged["systems"][sys_id] = override + return merged, [platform_name, *chain] + + def load_platform_config(platform_name: str, platforms_dir: str = "platforms") -> dict: """Load a platform config with inheritance and shared group resolution. @@ -106,34 +142,7 @@ def load_platform_config(platform_name: str, platforms_dir: str = "platforms") - if cache_key in _platform_config_cache: return _platform_config_cache[cache_key] - if yaml is None: - raise ImportError("PyYAML required: pip install pyyaml") - - config_file = os.path.join(platforms_dir, f"{platform_name}.yml") - if not os.path.exists(config_file): - raise FileNotFoundError(f"Platform config not found: {config_file}") - - with open(config_file) as f: - config = yaml_load(f) or {} - - # Resolve inheritance - if "inherits" in config: - parent = load_platform_config(config["inherits"], platforms_dir) - merged = {**parent} - merged.update( - {k: v for k, v in config.items() if k not in ("inherits", "overrides")} - ) - if "overrides" in config and "systems" in config["overrides"]: - merged.setdefault("systems", {}) - for sys_id, override in config["overrides"]["systems"].items(): - if sys_id in merged["systems"]: - merged["systems"][sys_id] = { - **merged["systems"][sys_id], - **override, - } - else: - merged["systems"][sys_id] = override - config = merged + config, chain = _inherited_yaml(platform_name, platforms_dir) # Resolve shared group includes shared_path = os.path.join(platforms_dir, "_shared.yml") @@ -176,26 +185,31 @@ def load_platform_config(platform_name: str, platforms_dir: str = "platforms") - with open(registry_path) as f: _shared_yml_cache[reg_real] = yaml_load(f) or {} reg = _shared_yml_cache[reg_real] - reg_entry = reg.get("platforms", {}).get(platform_name, {}) + # The platform's own entry first, then its parents' for what it does + # not say: RetroPie's status, scraper and install are its own, its + # verification mode is RetroArch's. + for depth, name in enumerate(chain): + reg_entry = reg.get("platforms", {}).get(name, {}) - # Merge cores (union for lists, override for all_libretro) - reg_cores = reg_entry.get("cores") - if reg_cores is not None: - cfg_cores = config.get("cores") - if reg_cores == "all_libretro": - config["cores"] = "all_libretro" - elif isinstance(reg_cores, list) and isinstance(cfg_cores, list): - merged_set = {str(c) for c in cfg_cores} | {str(c) for c in reg_cores} - config["cores"] = sorted(merged_set) - elif isinstance(reg_cores, list) and cfg_cores is None: - config["cores"] = reg_cores + # Merge cores (union for lists, override for all_libretro); a + # parent's only where the platform names none. + reg_cores = reg_entry.get("cores") + if reg_cores is not None and (depth == 0 or "cores" not in config): + cfg_cores = config.get("cores") + if reg_cores == "all_libretro": + config["cores"] = "all_libretro" + elif isinstance(reg_cores, list) and isinstance(cfg_cores, list): + merged_set = {str(c) for c in cfg_cores} | {str(c) for c in reg_cores} + config["cores"] = sorted(merged_set) + elif isinstance(reg_cores, list) and cfg_cores is None: + config["cores"] = reg_cores - # Merge all registry fields absent from config (except cores, - # handled above with union logic). No hardcoded list: any field - # added to the registry is automatically available in the config. - for key, val in reg_entry.items(): - if key != "cores" and key not in config: - config[key] = val + # Merge all registry fields absent from config (except cores, + # handled above with union logic). No hardcoded list: any field + # added to the registry is automatically available in the config. + for key, val in reg_entry.items(): + if key != "cores" and key not in config: + config[key] = val # Recalbox hashes a .zip by its members, so a plain md5 never answers one # of its archive declarations: the resolver reads the mark per entry. diff --git a/tests/test_platform_inheritance.py b/tests/test_platform_inheritance.py new file mode 100644 index 00000000..1e4c6a0d --- /dev/null +++ b/tests/test_platform_inheritance.py @@ -0,0 +1,66 @@ +"""A platform that inherits another one. + +The child was merged over the parent's cached config: its overrides rewrote +the parent's systems for the rest of the run, and the parent's registry +fields hid the child's own, so RetroPie loaded as active with RetroArch's +scraper and install block. +""" + +from __future__ import annotations + +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import common # noqa: E402 + + +class AChildLeavesItsParentAlone(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.dir, True) + self.addCleanup(common._platform_config_cache.clear) + self.addCleanup(common._shared_yml_cache.clear) + files = { + "parent.yml": {"platform": "Parent", "verification_mode": "existence", + "systems": {"sys-a": {"files": [{"name": "a.bin"}]}}}, + "child.yml": {"platform": "Child", "inherits": "parent", "overrides": { + "systems": {"sys-a": {"files": [{"name": "child.bin"}]}, + "sys-new": {"files": [{"name": "new.bin"}]}}}}, + "_registry.yml": {"platforms": { + "parent": {"status": "active", "scraper": "parent", "hash_type": "md5", + "cores": "all_libretro"}, + "child": {"status": "archived", "scraper": "child"}, + }}, + } + for name, data in files.items(): + (self.dir / name).write_text(yaml.safe_dump(data)) + + def load(self, name): + return common.load_platform_config(name, str(self.dir)) + + def test_the_parent_keeps_its_systems(self): + self.load("parent") + self.load("child") + parent = self.load("parent") + self.assertEqual(list(parent["systems"]), ["sys-a"]) + self.assertEqual(parent["systems"]["sys-a"]["files"], [{"name": "a.bin"}]) + + def test_the_child_reads_its_own_registry_entry_first(self): + self.load("parent") + child = self.load("child") + self.assertEqual((child["status"], child["scraper"]), ("archived", "child")) + # What it does not say comes from its parent's entry. + self.assertEqual((child["hash_type"], child["cores"]), ("md5", "all_libretro")) + self.assertEqual(child["systems"]["sys-a"]["files"], [{"name": "child.bin"}]) + + +if __name__ == "__main__": + unittest.main()