fix: patch Batocera's values, not its entries

An entry with one md5 to correct was written again whole: atarist lost
its eight blank lines, every '"x"},' became '"x" },', and most of the
819 lines Batocera's export changed were layout. Each item now keeps
its own text and only the md5 inside it changes; an addition follows
the last item in its indentation and spacing. The patched entry must
read as the one that would have been written, or that one is written.
Batocera's diff to the original drops to 273 lines, ROCKNIX's to 3.
This commit is contained in:
Abdessamad Derraz committed 2026-10-20 19:22:58 +02:00
1 parent 2faf971aa3
commit aaffcdcb63
2 files changed
+139 -1

No files matched your search

+90 -1
View File
@@ -21,6 +21,9 @@ SOURCE_URL = (
_ENTRY_START = re.compile(r'^(\s{4})"([^"]+)":\s*\{')
_INDENT = " " * 4
# One BIOS item: a dict literal with no nested braces.
_ITEM = re.compile(r"\{[^{}\n]*\}")
_MD5_VALUE = re.compile(r'("md5"\s*:\s*")[^"]*(")')
def _note_key(text: str) -> tuple[str, str]:
@@ -283,6 +286,89 @@ class Exporter(BaseExporter):
lines.append(f"{_INDENT}] }},")
return lines
def _patched_entry(
self,
system: NativeSystem,
files: list[NativeFile],
original: list[str],
) -> list[str] | None:
"""The entry with only its changed values replaced, None if it cannot be.
Written again whole, an entry with one md5 to correct lost atarist's
eight blank lines and turned every '"x"},' into '"x" },': 941 lines
of Batocera's diff were layout. Each item keeps its own text and
only the md5 inside it changes, and an addition follows the last
item in its form. The result must read as the entry that would have
been written, or the caller writes that one.
"""
before = self._parse_entry(original)
own = self._own_entry(system, before)
wanted: dict[tuple, list[dict]] = {}
order: list[tuple[tuple, dict]] = []
for text in self._bios_items(files, own):
item = ast.literal_eval(text)
key = tuple(item.get(k) for k in ("file", "zippedFile", "emulator", "core"))
wanted.setdefault(key, []).append(item)
order.append((key, text))
joined = "\n".join(original)
edits: list[tuple[int, int, str]] = []
last: re.Match[str] | None = None
consumed: dict[tuple, int] = {}
for match in _ITEM.finditer(joined):
try:
theirs = ast.literal_eval(match.group(0))
except (SyntaxError, ValueError):
continue
if not isinstance(theirs, dict) or "file" not in theirs:
continue
key = tuple(theirs.get(k) for k in ("file", "zippedFile", "emulator", "core"))
queue = wanted.get(key, [])
used = consumed.get(key, 0)
if used >= len(queue):
return None
ours = queue[used]
consumed[key] = used + 1
last = match
if ours == theirs:
continue
if {k for k in {*ours, *theirs} if ours.get(k) != theirs.get(k)} - {"md5"}:
return None
patched = _MD5_VALUE.sub(
lambda m: m.group(1) + str(ours["md5"]) + m.group(2), match.group(0), count=1
)
edits.append((match.start(), match.end(), patched))
if last is None:
return None
added = []
seen: dict[tuple, int] = {}
for key, text in order:
seen[key] = seen.get(key, 0) + 1
if seen[key] > consumed.get(key, 0):
added.append(text)
if added:
# Their spacing before the closing brace: '"x"}' or '"x" }'.
tight = not last.group(0).endswith(" }")
added = [a[:-2] + "}" if tight and a.endswith(" }") else a for a in added]
if len(original) == 1:
insert = ", " + ", ".join(added)
else:
line_start = joined.rfind("\n", 0, last.start()) + 1
indent = joined[line_start:last.start()]
insert = "," + "".join(
f"\n{indent}{a}" + ("," if i < len(added) - 1 else "")
for i, a in enumerate(added)
)
edits.append((last.end(), last.end(), insert))
for start, end, text in sorted(edits, reverse=True):
joined = joined[:start] + text + joined[end:]
lines = joined.split("\n")
if self._parse_entry(lines) != self._parse_entry([self._entry_line(system, files, before)]):
return None
return lines
@staticmethod
def _split_original(original: str) -> tuple[list[str], list[str], list[str]]:
"""Cut the script into what precedes the dict, the dict, what follows."""
@@ -393,7 +479,10 @@ class Exporter(BaseExporter):
# and alignment included, so the diff shows only corrections.
rebuilt.extend(original_lines)
else:
rebuilt.extend(self._entry_block(*pair, original_lines))
patched = self._patched_entry(*pair, original_lines)
rebuilt.extend(
patched if patched is not None else self._entry_block(*pair, original_lines)
)
rebuilt.extend(body[index:])
added = [
+49
View File
@@ -1569,6 +1569,55 @@ class RetrodeckExport(unittest.TestCase):
self.assertEqual(set(written), {self.A, self.B, self.C})
class BatoceraPatchesValuesNotEntries(unittest.TestCase):
"""An entry with one md5 to correct was written again whole: atarist's
eight blank lines went, each '"x"},' became '"x" },', and 941 lines of
Batocera's diff were layout."""
ORIGINAL = (
"#!/usr/bin/env python\n"
"systems = {\n"
' "atarist": { "name": "Atari ST", "biosFiles": [\n'
' # ST\n'
' { "md5": "' + "1" * 32 + '", "file": "bios/tos102.img"},\n'
"\n"
' { "md5": "' + "2" * 32 + '", "file": "bios/tos104.img"}\n'
" ] },\n"
"}\n"
"def checkBios(systems, prefix, filterROMs):\n"
" return {}\n"
)
def render(self, truth_files):
scraped = {"cores": ["hatari"], "systems": {"atari-st": {"native_id": "atarist", "files": [
{"name": "tos102.img", "destination": "tos102.img", "native_system": "atarist",
"native_name": "Atari ST", "native_path": "bios/tos102.img", "md5": "1" * 32},
{"name": "tos104.img", "destination": "tos104.img", "native_system": "atarist",
"native_name": "Atari ST", "native_path": "bios/tos104.img", "md5": "2" * 32},
]}}}
systems, report = build_native_model({"systems": {"atari-st": {"files": truth_files}}},
scraped)
exporter = discover_exporters()["batocera"]()
return exporter.render(systems, report, {"batocera-systems": self.ORIGINAL}, scraped)[
"batocera-systems"]
def test_a_corrected_md5_changes_that_value_only(self):
produced = self.render([{"name": "tos104.img", "path": "tos104.img",
"md5": "3" * 32, "_cores": ["hatari"]}])
expected = self.ORIGINAL.replace("2" * 32, "3" * 32)
self.assertEqual(produced, expected)
def test_an_addition_follows_the_last_item_in_its_form(self):
produced = self.render([{"name": "tos206.img", "path": "tos206.img",
"md5": "4" * 32, "_cores": ["hatari"]}])
self.assertIn('"file": "bios/tos104.img"},\n'
' { "md5": "' + "4" * 32 + '", "file": "bios/tos206.img",'
' "emulator": "libretro", "core": "hatari"}\n'
" ] },", produced)
self.assertIn("# ST\n", produced)
self.assertIn('"file": "bios/tos102.img"},\n\n', produced)
class BatoceraExport(unittest.TestCase):
def _render(self, originals):
systems, report = model()