fix: keep a parent config intact under inheritance

This commit is contained in:
Abdessamad Derraz committed 2026-10-16 22:03:16 +02:00
1 parent de08f71c52
commit 1d7b6462e9
2 files changed
+126 -46

No files matched your search

+60 -46
View File
@@ -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.
+66
View File
@@ -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()