Files
libretro/scripts/exporter/recalbox_exporter.py
T
Abdessamad Derraz 4231902a6c docs: write prose without dashes or semicolons
Profiles, the wiki, comments, docstrings, command output and schema descriptions carried em-dashes, clause-joining semicolons, unicode arrows, box-drawing banners and check-mark glyphs. Each sentence was rewritten rather than having its character swapped. Hashes, paths, source_ref values and every cited file:line are unchanged, which a comparison of each parsed profile against the previous revision confirms.

validate_pr comments now say PASS and FAIL in words, and source_ref values in a profile's structured blocks render as code. test_prose_style fails if any of these comes back, in tracked files or in the generated site.
2026-10-19 06:47:31 +02:00

254 lines
10 KiB
Python

"""Exporter for Recalbox's es_bios.xml.
The file is validated by es_bios.xsd, which makes path, md5 and core
required on every bios element. An entry we cannot give all three to is not
written: Recalbox would reject the file whole.
mandatory and hashMatchMandatory are separate axes. Recalbox reads a missing
attribute as true for both, so each is written only when it is false, or
when the platform stated it explicitly.
"""
from __future__ import annotations
import sys
from pathlib import Path
from xml.etree.ElementTree import Comment, ParseError
from xml.sax.saxutils import quoteattr
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from common import parse_untrusted_xml
from .base_exporter import BaseExporter
from .baseline import NativeFile, NativeSystem, Report
SOURCE_URL = (
"https://gitlab.com/recalbox/recalbox/-/raw/master/board/recalbox/fsoverlay"
"/recalbox/share_init/system/.emulationstation/es_bios.xml"
)
SCHEMA_URL = (
"https://gitlab.com/recalbox/recalbox/-/raw/master/board/recalbox/fsoverlay"
"/recalbox/share_init/system/.emulationstation/es_bios.xsd"
)
class Exporter(BaseExporter):
"""Write Recalbox's es_bios.xml, corrected."""
@staticmethod
def platform_name() -> str:
return "recalbox"
@staticmethod
def native_filename() -> str:
return "es_bios.xml"
@staticmethod
def keys_by_system() -> bool:
return True
@staticmethod
def carries() -> frozenset[str]:
return frozenset({"md5", "required"})
@staticmethod
def native_sources() -> dict[str, str]:
return {"es_bios.xml": SOURCE_URL, "es_bios.xsd": SCHEMA_URL}
def _path(self, fe: NativeFile) -> str:
"""The path Recalbox reads, pipe-joined when it accepts several.
A path Recalbox already states is reproduced exactly: several of its
entries sit at the BIOS root with no directory at all, and prefixing
them with the system would point the frontend somewhere else. An
addition goes where the core reads it, relative to the BIOS directory
Recalbox gives its cores: DuckStation reads scph5000.bin at the root,
not under psx/.
"""
if fe.platform is not None:
path = str(fe.platform.get("destination") or fe.name)
alternatives = fe.platform.get("alt_paths") or []
if alternatives:
return "|".join([path, *[str(a) for a in alternatives]])
return path
return fe.destination or fe.name
def _bios_element(self, fe: NativeFile, native_id: str) -> str:
attrs = [f"path={quoteattr(self._path(fe))}"]
attrs.append(f'md5={quoteattr(",".join(fe.hashes("md5")))}')
attrs.append(f'core={quoteattr(",".join(fe.cores()))}')
if not fe.required:
attrs.append('mandatory="false"')
elif fe.native("mandatory_declared", None) is True:
attrs.append('mandatory="true"')
hash_match = fe.native("hash_match_mandatory", None)
if hash_match is False:
attrs.append('hashMatchMandatory="false"')
elif hash_match is True:
attrs.append('hashMatchMandatory="true"')
# The platform's own note only: ours is prose about our profiles, with
# file:line citations, and the format does not carry note corrections.
note = " ".join(str((fe.platform or {}).get("note") or "").split())
if note:
attrs.append(f"note={quoteattr(note)}")
return f" <bios {' '.join(attrs)} />"
def states(self, fe: NativeFile, field_name: str) -> bool:
"""An element it cannot rewrite is written back as Recalbox wrote it.
es_bios.xsd makes md5 required: an entry whose md5 the truth
contradicts without naming another keeps its element whole, and
counting the md5 cleared announced a change absent from the file.
"""
if fe.platform is not None and not self.writable(fe):
return False
return super().states(fe, field_name)
@classmethod
def writable(cls, fe: NativeFile, require: str = "") -> bool:
"""es_bios.xsd makes md5 and core required: without them, no element.
An entry Recalbox already ships has both, so this only ever gates
what we would be adding.
"""
if fe.platform is None and fe.foreign_system:
return False
return bool(fe.hashes("md5")) and bool(fe.cores())
def render(
self,
systems: dict[str, NativeSystem],
report: Report,
originals: dict[str, str],
scraped: dict | None = None,
) -> dict[str, str]:
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<biosList xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
' xsi:noNamespaceSchemaLocation="es_bios.xsd">',
]
# Recalbox's own file is the base, so an element our transcription
# does not carry (added or renamed after a scrape) stays as written,
# and its system order with it. A platform can hold several blocks
# (Pentagon and Scorpion are both zxspectrum), and an entry the
# maintainer commented out stays commented out.
theirs: list[tuple[str, str, list[dict[str, str] | str]]] = []
original = originals.get(self.native_filename(), "")
if original:
try:
root = parse_untrusted_xml(original, self.native_filename(), comments=True)
except (ParseError, ValueError) as exc:
# Rebuilt from our model alone, the export would drop every
# system we do not transcribe and the maintainer's comments.
raise ValueError(
f"Recalbox's {self.native_filename()} does not parse ({exc}); "
"fetch it again with --refresh-cache"
) from exc
if root is not None:
theirs = [
(s.get("platform", ""), s.get("fullname", ""), [
f"<!--{child.text}-->" if child.tag is Comment else dict(child.attrib)
for child in s
if child.tag is Comment or child.tag == "bios"
])
for s in root.iter("system")
]
ours: dict[str, tuple[NativeSystem, dict[str, tuple[NativeFile, str]]]] = {}
for system in systems.values():
writable = [fe for fe in system.files if self.writable(fe)]
if writable:
ours[system.native_id] = (system, {
self._path(fe): (fe, self._bios_element(fe, system.native_id))
for fe in writable
})
def element(attrs: dict[str, str]) -> str:
rendered = " ".join(f"{k}={quoteattr(v)}" for k, v in attrs.items())
return f" <bios {rendered} />"
def emit(platform: str, fullname: str, body: list[str]) -> None:
lines.append(
f" <system fullname={quoteattr(fullname)} platform={quoteattr(platform)}>"
)
lines.extend(body)
lines.append(" </system>")
# Each block of theirs corrects the entries it holds.
blocks: list[tuple[str, str, list[str]]] = []
for platform, fullname, children in theirs:
elements = ours.get(platform, (None, {}))[1]
body: list[str] = []
for child in children:
if isinstance(child, str):
body.append(f" {child}")
continue
mine = elements.pop(child.get("path", ""), None)
body.append(mine[1] if mine else element(child))
blocks.append((platform, fullname, body))
# An addition joins the block named like its file, else the first
# block of its platform.
first: dict[str, int] = {}
named: dict[tuple[str, str], int] = {}
for index, (platform, fullname, _) in enumerate(blocks):
first.setdefault(platform, index)
named.setdefault((platform, fullname), index)
for platform in [p for p in ours if p in first]:
_, elements = ours.pop(platform)
for fe, rendered in elements.values():
key = (platform, str(fe.native("native_name", "")))
blocks[named.get(key, first[platform])][2].append(rendered)
for platform, fullname, body in blocks:
emit(platform, fullname, body)
for platform in sorted(ours):
system, elements = ours[platform]
emit(platform, self.display_name(system), [r for _, r in elements.values()])
lines.append("</biosList>")
lines.append("")
return {self.native_filename(): "\n".join(lines)}
def validate(
self,
systems: dict[str, NativeSystem],
produced: dict[str, str],
) -> list[str]:
content = produced[self.native_filename()]
issues: list[str] = []
try:
root = parse_untrusted_xml(content, self.native_filename())
except (ParseError, ValueError) as exc:
return [f"the XML does not parse: {exc}"]
for element in root.iter("bios"):
for attribute in ("path", "md5", "core"):
if not element.get(attribute):
issues.append(
f"es_bios.xsd requires {attribute}: "
f"{element.get('path', '?')}"
)
for element in root.iter("system"):
if not list(element):
issues.append(f"empty system: {element.get('platform', '?')}")
for attribute in ("fullname", "platform"):
if not element.get(attribute):
issues.append(f"es_bios.xsd requires {attribute} on system")
exported = {
element.get("path", "").casefold() for element in root.iter("bios")
}
for system in systems.values():
for fe in system.files:
if not self.writable(fe):
continue
if self._path(fe).casefold() not in exported:
issues.append(f"absent: {system.native_id}/{fe.name}")
return issues