feat: add exporter plugin architecture

This commit is contained in:
Abdessamad Derraz
2026-03-29 13:19:38 +02:00
parent 2ce8db1754
commit e86d8d68af
4 changed files with 226 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
"""Exporter plugin discovery module.
Auto-detects *_exporter.py files and exposes their exporters.
Each exporter module must define an Exporter class inheriting BaseExporter.
"""
from __future__ import annotations
import importlib
import pkgutil
from pathlib import Path
from .base_exporter import BaseExporter
_exporters: dict[str, type] = {}
def discover_exporters() -> dict[str, type]:
"""Auto-discover *_exporter.py modules, return {platform: ExporterClass}."""
if _exporters:
return _exporters
package_dir = Path(__file__).parent
for _finder, name, _ispkg in pkgutil.iter_modules([str(package_dir)]):
if not name.endswith("_exporter") or name == "base_exporter":
continue
module = importlib.import_module(f".{name}", package=__package__)
exporter_class = getattr(module, "Exporter", None)
if exporter_class and issubclass(exporter_class, BaseExporter):
_exporters[exporter_class.platform_name()] = exporter_class
return _exporters

View File

@@ -0,0 +1,27 @@
"""Abstract base class for platform exporters."""
from __future__ import annotations
from abc import ABC, abstractmethod
class BaseExporter(ABC):
"""Base class for exporting truth data to native platform formats."""
@staticmethod
@abstractmethod
def platform_name() -> str:
"""Return the platform identifier this exporter targets."""
@abstractmethod
def export(
self,
truth_data: dict,
output_path: str,
scraped_data: dict | None = None,
) -> None:
"""Export truth data to the native platform format."""
@abstractmethod
def validate(self, truth_data: dict, output_path: str) -> list[str]:
"""Validate exported file against truth data, return list of issues."""

View File

@@ -0,0 +1,104 @@
"""Exporter for libretro System.dat (clrmamepro DAT format)."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scraper.dat_parser import parse_dat
from .base_exporter import BaseExporter
def _slug_to_native(slug: str) -> str:
"""Convert a system slug to a native 'Manufacturer - Console' name."""
parts = slug.split("-", 1)
if len(parts) == 1:
return parts[0].title()
manufacturer = parts[0].replace("-", " ").title()
console = parts[1].replace("-", " ").title()
return f"{manufacturer} - {console}"
class Exporter(BaseExporter):
"""Export truth data to libretro System.dat format."""
@staticmethod
def platform_name() -> str:
return "retroarch"
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] = []
lines.append('clrmamepro (')
lines.append('\tname "System.dat"')
lines.append(')')
systems = truth_data.get("systems", {})
for sys_id in sorted(systems):
sys_data = systems[sys_id]
native_name = native_map.get(sys_id, _slug_to_native(sys_id))
for fe in sys_data.get("files", []):
name = fe.get("name", "")
if name.startswith("_"):
continue
dest = fe.get("path", name)
size = fe.get("size", 0)
crc = fe.get("crc32", "")
md5 = fe.get("md5", "")
sha1 = fe.get("sha1", "")
rom_parts = [f'name "{name}"']
rom_parts.append(f"size {size}")
if crc:
rom_parts.append(f"crc {crc}")
if md5:
rom_parts.append(f"md5 {md5}")
if sha1:
rom_parts.append(f"sha1 {sha1}")
rom_str = " ".join(rom_parts)
game_name = f"{native_name}/{dest}"
lines.append("")
lines.append("game (")
lines.append(f'\tname "{game_name}"')
lines.append(f'\tdescription "{name}"')
lines.append(f"\trom ( {rom_str} )")
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")
parsed = parse_dat(content)
exported_names: set[str] = set()
for rom in parsed:
exported_names.add(rom.name)
issues: list[str] = []
for sys_id, sys_data in truth_data.get("systems", {}).items():
for fe in sys_data.get("files", []):
name = fe.get("name", "")
if name.startswith("_"):
continue
if name not in exported_names:
issues.append(f"missing: {name} (system {sys_id})")
return issues

View File

@@ -3048,5 +3048,65 @@ class TestE2E(unittest.TestCase):
self.assertEqual(snes["native_id"], "snes")
# ---------------------------------------------------------------
# Exporter: System.dat round-trip
# ---------------------------------------------------------------
def test_systemdat_exporter_round_trip(self):
"""Export truth data to System.dat and validate round-trip."""
from exporter import discover_exporters
from exporter.systemdat_exporter import Exporter as SystemDatExporter
truth = {
"platform": "retroarch",
"systems": {
"sony-playstation": {
"files": [
{
"name": "scph5501.bin",
"path": "scph5501.bin",
"size": 524288,
"md5": "490f666e1afb15ed6c63b88fc7571f2f",
"sha1": "b056ee5a4d65937e1a3a17e1e78f3258ea49c38e",
"crc32": "71af80b4",
"required": True,
"_cores": ["beetle_psx"],
"_source_refs": ["libretro.c:50"],
},
],
},
},
}
scraped = {
"systems": {
"sony-playstation": {
"native_id": "Sony - PlayStation",
"files": [
{"name": "scph5501.bin", "destination": "scph5501.bin"},
],
},
},
}
out_path = os.path.join(self.root, "System.dat")
exporter = SystemDatExporter()
exporter.export(truth, out_path, scraped_data=scraped)
with open(out_path) as fh:
content = fh.read()
self.assertIn("Sony - PlayStation", content)
self.assertIn("scph5501.bin", content)
self.assertIn("b056ee5a4d65937e1a3a17e1e78f3258ea49c38e", content)
self.assertIn('name "System.dat"', content)
issues = exporter.validate(truth, out_path)
self.assertEqual(issues, [])
# Discovery finds the systemdat exporter
exporters = discover_exporters()
self.assertIn("retroarch", exporters)
self.assertIs(exporters["retroarch"], SystemDatExporter)
if __name__ == "__main__":
unittest.main()