From 1e6b499602c128a2a892275788aa071221b91c53 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:31:44 +0200 Subject: [PATCH] feat: add batocera, recalbox, retrobat native exporters --- scripts/exporter/batocera_exporter.py | 80 ++++++++++++++++++++ scripts/exporter/recalbox_exporter.py | 90 ++++++++++++++++++++++ scripts/exporter/retrobat_exporter.py | 87 +++++++++++++++++++++ tests/test_e2e.py | 104 ++++++++++++++++++++++++++ 4 files changed, 361 insertions(+) create mode 100644 scripts/exporter/batocera_exporter.py create mode 100644 scripts/exporter/recalbox_exporter.py create mode 100644 scripts/exporter/retrobat_exporter.py diff --git a/scripts/exporter/batocera_exporter.py b/scripts/exporter/batocera_exporter.py new file mode 100644 index 00000000..4208a20a --- /dev/null +++ b/scripts/exporter/batocera_exporter.py @@ -0,0 +1,80 @@ +"""Exporter for Batocera batocera-systems format (Python dict).""" + +from __future__ import annotations + +from pathlib import Path + +from .base_exporter import BaseExporter + + +class Exporter(BaseExporter): + """Export truth data to Batocera batocera-systems format.""" + + @staticmethod + def platform_name() -> str: + return "batocera" + + def export( + self, + truth_data: dict, + output_path: str, + scraped_data: dict | None = None, + ) -> None: + native_map: dict[str, str] = {} + if scraped_data: + for sys_id, sys_data in scraped_data.get("systems", {}).items(): + nid = sys_data.get("native_id") + if nid: + native_map[sys_id] = nid + + lines: list[str] = [ + "#!/usr/bin/env python3", + "# Generated batocera-systems BIOS declarations", + "from collections import OrderedDict", + "", + "systems = {", + ] + + systems = truth_data.get("systems", {}) + for sys_id in sorted(systems): + sys_data = systems[sys_id] + files = sys_data.get("files", []) + if not files: + continue + + native_id = native_map.get(sys_id, sys_id) + lines.append(f' "{native_id}": {{') + lines.append(' "biosFiles": [') + + for fe in files: + name = fe.get("name", "") + if name.startswith("_"): + continue + dest = fe.get("destination", name) + md5 = fe.get("md5", "") + if isinstance(md5, list): + md5 = md5[0] if md5 else "" + + lines.append(" {") + lines.append(f' "file": "bios/{dest}",') + lines.append(f' "md5": "{md5}",') + lines.append(" },") + + lines.append(" ],") + lines.append(" },") + + lines.append("}") + lines.append("") + Path(output_path).write_text("\n".join(lines), encoding="utf-8") + + def validate(self, truth_data: dict, output_path: str) -> list[str]: + content = Path(output_path).read_text(encoding="utf-8") + issues: list[str] = [] + for sys_data in truth_data.get("systems", {}).values(): + for fe in sys_data.get("files", []): + name = fe.get("name", "") + if name.startswith("_"): + continue + if name not in content: + issues.append(f"missing: {name}") + return issues diff --git a/scripts/exporter/recalbox_exporter.py b/scripts/exporter/recalbox_exporter.py new file mode 100644 index 00000000..a7c06606 --- /dev/null +++ b/scripts/exporter/recalbox_exporter.py @@ -0,0 +1,90 @@ +"""Exporter for Recalbox es_bios.xml format.""" + +from __future__ import annotations + +from pathlib import Path +from xml.etree.ElementTree import Element, SubElement, ElementTree, indent + +from .base_exporter import BaseExporter + + +class Exporter(BaseExporter): + """Export truth data to Recalbox es_bios.xml format.""" + + @staticmethod + def platform_name() -> str: + return "recalbox" + + def export( + self, + truth_data: dict, + output_path: str, + scraped_data: dict | None = None, + ) -> None: + native_map: dict[str, str] = {} + if scraped_data: + for sys_id, sys_data in scraped_data.get("systems", {}).items(): + nid = sys_data.get("native_id") + if nid: + native_map[sys_id] = nid + + root = Element("biosList") + + systems = truth_data.get("systems", {}) + for sys_id in sorted(systems): + sys_data = systems[sys_id] + files = sys_data.get("files", []) + if not files: + continue + + native_id = native_map.get(sys_id, sys_id) + system_el = SubElement(root, "system", platform=native_id) + + for fe in files: + name = fe.get("name", "") + if name.startswith("_"): + continue + + dest = fe.get("destination", name) + md5 = fe.get("md5", "") + if isinstance(md5, list): + md5 = ",".join(md5) + required = fe.get("required", False) + + attrs = { + "path": dest, + "md5": md5, + "mandatory": "true" if required else "false", + "hashMatchMandatory": "true" if required else "false", + } + SubElement(system_el, "bios", **attrs) + + indent(root, space=" ") + tree = ElementTree(root) + tree.write(output_path, encoding="unicode", xml_declaration=True) + # Add trailing newline + with open(output_path, "a") as f: + f.write("\n") + + def validate(self, truth_data: dict, output_path: str) -> list[str]: + from xml.etree.ElementTree import parse as xml_parse + + tree = xml_parse(output_path) + root = tree.getroot() + + exported_paths: set[str] = set() + for bios_el in root.iter("bios"): + path = bios_el.get("path", "") + if path: + exported_paths.add(path) + + issues: list[str] = [] + for sys_data in truth_data.get("systems", {}).values(): + for fe in sys_data.get("files", []): + name = fe.get("name", "") + if name.startswith("_"): + continue + dest = fe.get("destination", name) + if dest not in exported_paths: + issues.append(f"missing: {dest}") + return issues diff --git a/scripts/exporter/retrobat_exporter.py b/scripts/exporter/retrobat_exporter.py new file mode 100644 index 00000000..e4e5b457 --- /dev/null +++ b/scripts/exporter/retrobat_exporter.py @@ -0,0 +1,87 @@ +"""Exporter for RetroBat batocera-systems.json format.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .base_exporter import BaseExporter + + +class Exporter(BaseExporter): + """Export truth data to RetroBat batocera-systems.json format.""" + + @staticmethod + def platform_name() -> str: + return "retrobat" + + def export( + self, + truth_data: dict, + output_path: str, + scraped_data: dict | None = None, + ) -> None: + native_map: dict[str, str] = {} + if scraped_data: + for sys_id, sys_data in scraped_data.get("systems", {}).items(): + nid = sys_data.get("native_id") + if nid: + native_map[sys_id] = nid + + output: dict[str, dict] = {} + + systems = truth_data.get("systems", {}) + for sys_id in sorted(systems): + sys_data = systems[sys_id] + files = sys_data.get("files", []) + if not files: + continue + + native_id = native_map.get(sys_id, sys_id) + bios_files: list[dict] = [] + + for fe in files: + name = fe.get("name", "") + if name.startswith("_"): + continue + dest = fe.get("destination", name) + md5 = fe.get("md5", "") + if isinstance(md5, list): + md5 = md5[0] if md5 else "" + + entry = {"file": f"bios/{dest}"} + if md5: + entry["md5"] = md5 + bios_files.append(entry) + + if bios_files: + output[native_id] = {"biosFiles": bios_files} + + Path(output_path).write_text( + json.dumps(output, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + def validate(self, truth_data: dict, output_path: str) -> list[str]: + data = json.loads(Path(output_path).read_text(encoding="utf-8")) + + exported_files: set[str] = set() + for sys_data in data.values(): + for bf in sys_data.get("biosFiles", []): + path = bf.get("file", "") + # Strip bios/ prefix, index both full path and basename + stripped = path.removeprefix("bios/") + exported_files.add(stripped) + basename = path.split("/")[-1] if "/" in path else path + exported_files.add(basename) + + issues: list[str] = [] + for sys_data in truth_data.get("systems", {}).values(): + for fe in sys_data.get("files", []): + name = fe.get("name", "") + if name.startswith("_"): + continue + dest = fe.get("destination", name) + if name not in exported_files and dest not in exported_files: + issues.append(f"missing: {name}") + return issues diff --git a/tests/test_e2e.py b/tests/test_e2e.py index d8f09a8c..352b0be4 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -3500,5 +3500,109 @@ class TestE2E(unittest.TestCase): self.assertEqual(status, "agnostic_fallback") + def test_179_batocera_exporter_round_trip(self): + """Batocera exporter produces valid Python dict format.""" + from exporter.batocera_exporter import Exporter + + truth = { + "systems": { + "sony-playstation": { + "_coverage": {"cores_profiled": ["c"]}, + "files": [ + {"name": "scph5501.bin", "destination": "scph5501.bin", + "required": True, "md5": "b" * 32, + "_cores": ["c"], "_source_refs": []}, + ], + } + } + } + scraped = { + "systems": { + "sony-playstation": {"native_id": "psx", "files": []}, + } + } + out = os.path.join(self.root, "batocera-systems") + exp = Exporter() + exp.export(truth, out, scraped_data=scraped) + + content = open(out).read() + self.assertIn('"psx"', content) + self.assertIn("scph5501.bin", content) + self.assertIn("b" * 32, content) + self.assertEqual(exp.validate(truth, out), []) + + def test_180_recalbox_exporter_round_trip(self): + """Recalbox exporter produces valid es_bios.xml.""" + from exporter.recalbox_exporter import Exporter + + truth = { + "systems": { + "sony-playstation": { + "_coverage": {"cores_profiled": ["c"]}, + "files": [ + {"name": "scph5501.bin", "destination": "scph5501.bin", + "required": True, "md5": "b" * 32, + "_cores": ["c"], "_source_refs": []}, + ], + } + } + } + scraped = { + "systems": { + "sony-playstation": {"native_id": "psx", "files": []}, + } + } + out = os.path.join(self.root, "es_bios.xml") + exp = Exporter() + exp.export(truth, out, scraped_data=scraped) + + content = open(out).read() + self.assertIn("", content) + self.assertIn('platform="psx"', content) + self.assertIn("scph5501.bin", content) + self.assertIn('mandatory="true"', content) + self.assertEqual(exp.validate(truth, out), []) + + def test_181_retrobat_exporter_round_trip(self): + """RetroBat exporter produces valid JSON.""" + import json as _json + from exporter.retrobat_exporter import Exporter + + truth = { + "systems": { + "sony-playstation": { + "_coverage": {"cores_profiled": ["c"]}, + "files": [ + {"name": "scph5501.bin", "destination": "scph5501.bin", + "required": True, "md5": "b" * 32, + "_cores": ["c"], "_source_refs": []}, + ], + } + } + } + scraped = { + "systems": { + "sony-playstation": {"native_id": "psx", "files": []}, + } + } + out = os.path.join(self.root, "batocera-systems.json") + exp = Exporter() + exp.export(truth, out, scraped_data=scraped) + + data = _json.loads(open(out).read()) + self.assertIn("psx", data) + self.assertTrue(any("scph5501" in bf["file"] for bf in data["psx"]["biosFiles"])) + self.assertEqual(exp.validate(truth, out), []) + + def test_182_exporter_discovery(self): + """All exporters are discovered by the plugin system.""" + from exporter import discover_exporters + exporters = discover_exporters() + self.assertIn("retroarch", exporters) + self.assertIn("batocera", exporters) + self.assertIn("recalbox", exporters) + self.assertIn("retrobat", exporters) + + if __name__ == "__main__": unittest.main()