Files
libretro/tests/test_scraper_contract.py
T
Abdessamad Derraz 6280f4c370 feat: read recalbox's cores from its system files
es_bios.xml names only the cores that need a BIOS, so a hand-kept
registry list stood in, with mame and mamemess that Recalbox does not
ship and without vice, ymir, vircon32 and others it does. The scraper
now reads each system.ini of recalbox-romfs2 at the scraped tag, and
the registry list is gone.
2026-10-17 03:31:11 +02:00

468 lines
18 KiB
Python

#!/usr/bin/env python3
"""The BaseScraper contract the wiki asks contributors to implement.
wiki/adding-a-scraper.md and wiki/adding-a-platform.md document
compare_with_config, ChangeSet.has_changes and test_connection as the
interface a new scraper plugs into. No scraper in the repository calls them,
so nothing proved they still worked; a documented contract that is never
exercised is a promise to contributors backed by nothing.
"""
from __future__ import annotations
import importlib
import subprocess
import sys
import tempfile
import unittest
import urllib.error
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from scraper import emudeck_scraper, libretro_scraper # noqa: E402
from scraper.base_scraper import ( # noqa: E402
BaseScraper,
BiosRequirement,
ChangeSet,
fetch_github_latest_version,
github_headers,
)
class _StubScraper(BaseScraper):
"""Minimal scraper: returns what it was handed, or raises."""
def __init__(self, requirements=None, error: Exception | None = None):
super().__init__(url="https://example.test/bios.xml")
self._requirements = requirements or []
self._error = error
def fetch_requirements(self) -> list[BiosRequirement]:
if self._error is not None:
raise self._error
return self._requirements
def validate_format(self, raw_data: str) -> bool:
return True
def _config(*files: dict) -> dict:
return {"systems": {"test-sys": {"files": list(files)}}}
class ChangeSetSummary(unittest.TestCase):
def test_empty_changeset_has_no_changes(self):
changes = ChangeSet()
self.assertFalse(changes.has_changes)
self.assertEqual(changes.summary(), "no changes")
def test_summary_counts_each_kind(self):
req = BiosRequirement(name="a.bin", system="test-sys")
changes = ChangeSet(added=[req], removed=[req], modified=[(req, req)])
self.assertTrue(changes.has_changes)
self.assertEqual(changes.summary(), "+1 added, -1 removed, ~1 modified")
class CompareWithConfig(unittest.TestCase):
def test_new_upstream_file_is_reported_as_added(self):
scraper = _StubScraper([BiosRequirement(name="new.bin", system="test-sys")])
changes = scraper.compare_with_config(_config())
self.assertEqual([r.name for r in changes.added], ["new.bin"])
self.assertTrue(changes.has_changes)
def test_file_dropped_upstream_is_reported_as_removed(self):
scraper = _StubScraper([])
changes = scraper.compare_with_config(_config({"name": "old.bin"}))
self.assertEqual([r.name for r in changes.removed], ["old.bin"])
def test_changed_sha1_is_reported_as_modified(self):
scraper = _StubScraper(
[BiosRequirement(name="a.bin", system="test-sys", sha1="b" * 40)]
)
changes = scraper.compare_with_config(_config({"name": "a.bin", "sha1": "a" * 40}))
self.assertEqual(len(changes.modified), 1)
before, after = changes.modified[0]
self.assertEqual((before.sha1, after.sha1), ("a" * 40, "b" * 40))
def test_changed_md5_is_reported_when_no_sha1_is_declared(self):
scraper = _StubScraper(
[BiosRequirement(name="a.bin", system="test-sys", md5="b" * 32)]
)
changes = scraper.compare_with_config(_config({"name": "a.bin", "md5": "a" * 32}))
self.assertEqual(len(changes.modified), 1)
def test_identical_hashes_are_not_a_change(self):
scraper = _StubScraper(
[BiosRequirement(name="a.bin", system="test-sys", sha1="a" * 40)]
)
changes = scraper.compare_with_config(_config({"name": "a.bin", "sha1": "a" * 40}))
self.assertFalse(changes.has_changes)
def test_a_file_in_another_system_is_not_the_same_file(self):
scraper = _StubScraper([BiosRequirement(name="a.bin", system="other-sys")])
changes = scraper.compare_with_config(_config({"name": "a.bin"}))
self.assertEqual([r.name for r in changes.added], ["a.bin"])
self.assertEqual([r.name for r in changes.removed], ["a.bin"])
class TestConnection(unittest.TestCase):
def test_reachable_source_reports_true(self):
self.assertTrue(_StubScraper([]).test_connection())
def test_network_failure_reports_false(self):
self.assertFalse(_StubScraper(error=OSError("unreachable")).test_connection())
def test_bad_payload_reports_false(self):
self.assertFalse(_StubScraper(error=ValueError("bad format")).test_connection())
def test_an_unexpected_error_is_not_swallowed(self):
"""Only reachability failures are absorbed; a bug must surface."""
with self.assertRaises(KeyError):
_StubScraper(error=KeyError("bug")).test_connection()
class RetroPiePackageList(unittest.TestCase):
"""RetroPie states its packages, and nothing else states them for it."""
@staticmethod
def _archive(modules: dict[str, str]) -> bytes:
import io
import tarfile
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
for path, body in modules.items():
payload = body.encode()
info = tarfile.TarInfo(f"RetroPie-Setup-master/{path}")
info.size = len(payload)
archive.addfile(info, io.BytesIO(payload))
return buffer.getvalue()
def _scraper(self, modules: dict[str, str]):
from scraper.retropie_scraper import Scraper
scraper = Scraper()
scraper.module_ids(self._archive(modules))
return scraper
def test_a_package_is_read_from_the_id_its_script_declares(self):
scraper = self._scraper(
{
"scriptmodules/libretrocores/lr-mgba.sh": 'rp_module_id="lr-mgba"\n',
"scriptmodules/emulators/openmsx.sh": 'rp_module_id="openmsx"\n',
}
)
self.assertEqual(scraper.module_ids(), ["lr-mgba", "openmsx"])
def test_a_script_outside_the_package_directories_is_not_a_package(self):
"""Setup helpers, themes and drivers carry no core."""
scraper = self._scraper(
{
"scriptmodules/libretrocores/lr-mgba.sh": 'rp_module_id="lr-mgba"\n',
"scriptmodules/supplementary/autostart.sh": (
'rp_module_id="autostart"\n'
),
"scriptmodules/admin/setup.sh": 'rp_module_id="setup"\n',
}
)
self.assertEqual(scraper.module_ids(), ["lr-mgba"])
def test_the_core_list_drops_the_libretro_prefix(self):
scraper = self._scraper(
{
"retropie_packages.sh": '__version="4.8.12"\n',
"scriptmodules/libretrocores/lr-mgba.sh": 'rp_module_id="lr-mgba"\n',
"scriptmodules/emulators/openmsx.sh": 'rp_module_id="openmsx"\n',
}
)
config = scraper.generate_platform_yaml()
self.assertEqual(config["cores"], ["mgba", "openmsx"])
self.assertEqual(config["inherits"], "retroarch")
self.assertEqual(config["base_destination"], "BIOS")
def test_the_version_is_the_one_the_setup_script_declares(self):
scraper = self._scraper(
{
"retropie_packages.sh": '#!/bin/bash\n__version="4.8.12"\n',
"scriptmodules/libretrocores/lr-mgba.sh": 'rp_module_id="lr-mgba"\n',
}
)
self.assertEqual(scraper.generate_platform_yaml()["version"], "4.8.12")
def test_a_version_assigned_elsewhere_is_not_the_setup_version(self):
"""Only the top-level retropie_packages.sh speaks for the release."""
scraper = self._scraper(
{
"scriptmodules/emulators/openmsx.sh": (
'rp_module_id="openmsx"\n__version="1.0"\n'
),
}
)
with self.assertRaises(ValueError):
scraper.generate_platform_yaml()
def test_no_requirement_is_invented_from_prose(self):
"""RetroPie names BIOS in prose, without a hash to transcribe."""
scraper = self._scraper(
{
"scriptmodules/libretrocores/lr-mgba.sh": (
'rp_module_id="lr-mgba"\n'
'rp_module_help="Copy the required BIOS file gba_bios.bin'
' to $biosdir"\n'
),
}
)
self.assertEqual(scraper.fetch_requirements(), [])
class RecalboxBuildMode(unittest.TestCase):
"""Recalbox's cores come from each system.ini of recalbox-romfs2: a
`[core.N]` section names `emulator = "libretro"` with the core, or a
standalone emulator. es_bios.xml only names the cores that need a BIOS,
so read alone it left most of Recalbox's cores, mame0258 and mame0278
among them, outside the pack."""
SYSTEM_INI = """\
[system]
name = "mame"
fullname = "MAME"
extra line the parser must survive.
[core.0]
package = BR2_PACKAGE_LIBRETRO_MAME0278
emulator = "libretro"
core = "mame0278"
[core.1]
emulator = "advancemame"
core = "advancemame"
[core.2.rpi4]
emulator = "libretro"
core = "mame2003_plus"
"""
def test_libretro_cores_and_standalone_emulators(self):
from scraper.recalbox_scraper import system_ini_cores
cores, standalone = system_ini_cores(self.SYSTEM_INI)
self.assertEqual(cores, {"mame0278", "advancemame", "mame2003_plus"})
self.assertEqual(standalone, {"advancemame"})
def test_the_registry_leaves_the_list_to_the_scraper(self):
import yaml
root = Path(__file__).resolve().parents[1]
registry = yaml.safe_load((root / "platforms" / "_registry.yml").read_text())
self.assertNotIn("cores", registry["platforms"]["recalbox"])
class UnreadableReleaseStopsTheScrape(unittest.TestCase):
"""A failed tag lookup fell back to master, or to an empty or invented
version. BizHawk, RomM, Batocera and Recalbox then wrote a YAML read from
master under the previous release's version (Recalbox under "10.0"), and
the exporter patched a revision the data never described."""
@staticmethod
def _refuse(*_args, **_kwargs):
raise urllib.error.HTTPError("https://api.github.com/x", 403, "rate limited", {}, None)
def test_every_pinned_scraper_raises(self):
for name in (
"scraper.bizhawk_scraper",
"scraper.romm_scraper",
"scraper.batocera_scraper",
"scraper.recalbox_scraper",
):
module = importlib.import_module(name)
with self.subTest(scraper=name), mock.patch(
"urllib.request.urlopen", self._refuse
), self.assertRaises(RuntimeError):
module.Scraper()
def test_a_release_lookup_raises(self):
with mock.patch("urllib.request.urlopen", self._refuse), self.assertRaises(
RuntimeError
):
fetch_github_latest_version("libretro/RetroArch")
def test_the_token_is_sent(self):
with mock.patch.dict("os.environ", {"GITHUB_TOKEN": "t0k"}):
self.assertEqual(github_headers()["Authorization"], "Bearer t0k")
def test_emudeck_cores_raise(self):
"""EmuDeck wrote cores: [] when the GitHub listing was refused."""
with mock.patch("urllib.request.urlopen", self._refuse), self.assertRaises(
RuntimeError
):
emudeck_scraper.Scraper()._fetch_installed_emulators()
def test_libretro_core_metadata_raises(self):
"""libretro dropped every system name and manufacturer when the
core-info listing was refused."""
with mock.patch("urllib.request.urlopen", self._refuse), self.assertRaises(
RuntimeError
):
libretro_scraper.Scraper()._fetch_core_metadata()
def test_a_core_info_file_the_forge_refuses_raises(self):
"""Read as empty, the core left the result and --compare-db counted
none of its firmware missing."""
from scraper import coreinfo_scraper # noqa: PLC0415
scraper = coreinfo_scraper.Scraper()
with mock.patch.object(
scraper, "_fetch_info_list", return_value=["mednafen_psx_libretro.info"]
), mock.patch("urllib.request.urlopen", self._refuse), self.assertRaises(
ConnectionError
):
scraper.fetch_requirements()
class EveryGitHubApiCallerSendsTheToken(unittest.TestCase):
"""romset_dat_importer and coreinfo_scraper called api.github.com with no
token: past the anonymous quota, --fetch failed with 403 while
refresh_stale had put a token in their environment."""
def test_each_module_naming_the_api_reads_the_token(self):
scripts = Path(__file__).resolve().parent.parent / "scripts"
silent = sorted(
str(path.relative_to(scripts))
for path in scripts.rglob("*.py")
if "api.github.com" in (source := path.read_text(encoding="utf-8"))
and "GITHUB_TOKEN" not in source
and "github_headers(" not in source
)
self.assertEqual(silent, [])
def test_each_function_building_an_api_request_reads_the_token(self):
"""Per module, one call of github_headers anywhere covered them all:
fetch_github_latest_tag sent no token beside a helper that did."""
import ast # noqa: PLC0415
scripts = Path(__file__).resolve().parent.parent / "scripts"
silent = []
for path in sorted(scripts.rglob("*.py")):
source = path.read_text(encoding="utf-8")
for node in ast.walk(ast.parse(source)):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
body = ast.get_source_segment(source, node) or ""
requests = any(
isinstance(call, ast.Call)
and getattr(call.func, "attr", getattr(call.func, "id", ""))
in ("Request", "urlopen")
for call in ast.walk(node)
)
if (
requests
and "api.github.com" in body
and "github_headers(" not in body
and "GITHUB_TOKEN" not in body
):
silent.append(f"{path.relative_to(scripts)}:{node.name}")
self.assertEqual(silent, [])
def test_the_tag_helper_sends_the_token_and_raises(self):
from scraper import base_scraper # noqa: PLC0415
seen = {}
def answer(request, timeout=None):
seen.update(request.headers)
raise urllib.error.HTTPError(request.full_url, 403, "rate limited", {}, None)
with mock.patch.dict("os.environ", {"GITHUB_TOKEN": "t0k"}), mock.patch(
"urllib.request.urlopen", answer
), self.assertRaises(RuntimeError):
base_scraper.fetch_github_latest_tag("o/r")
self.assertEqual(seen.get("Authorization"), "Bearer t0k")
_IMPORT_WITHOUT_NETWORK = """
import importlib, socket, sys
from pathlib import Path
root, package = Path(sys.argv[1]), sys.argv[2]
sys.path[:0] = [str(root), str(root / "scripts")]
attempts = []
def refuse(*_args, **_kwargs):
attempts.append(1)
raise OSError("network refused at import")
socket.socket.connect = refuse
socket.create_connection = refuse
socket.getaddrinfo = refuse
for path in sorted((root / package.replace(".", "/")).rglob("*.py")):
if path.name == "__init__.py":
continue
name = ".".join(path.relative_to(root).with_suffix("").parts)
before = len(attempts)
try:
importlib.import_module(name)
except Exception as exc:
print(f"{name}: {type(exc).__name__}: {exc}")
continue
# A fetch whose error the module swallows ("or 'master'") still tried.
if len(attempts) > before:
print(f"{name}: reached for the network")
"""
class ImportingAScraperFetchesNothing(unittest.TestCase):
"""A tag resolved at module load made a refused API call fall back to
master before any scraper ran; the rule since is no network at import.
Each module is imported in a fresh interpreter with sockets refused, so
nothing already cached hides an import-time fetch."""
@staticmethod
def _offenders(root: Path, package: str) -> str:
return subprocess.run(
[sys.executable, "-I", "-c", _IMPORT_WITHOUT_NETWORK, str(root), package],
capture_output=True,
text=True,
check=True,
timeout=300,
).stdout.strip()
def test_every_scraper_imports_offline(self):
self.assertEqual(self._offenders(REPO_ROOT, "scripts.scraper"), "")
def test_a_swallowed_import_time_fetch_is_caught(self):
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as directory:
package = Path(directory) / "planted"
package.mkdir()
(package / "__init__.py").write_text("")
(package / "quiet_scraper.py").write_text(
"import urllib.request\n"
"def _tag():\n"
" try:\n"
" return urllib.request.urlopen('https://api.github.com/x').read()\n"
" except OSError:\n"
" return None\n"
"TAG = _tag() or 'master'\n"
)
self.assertIn(
"planted.quiet_scraper: reached for the network",
self._offenders(Path(directory), "planted"),
)
def test_an_import_time_fetch_is_caught(self):
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as directory:
package = Path(directory) / "planted"
package.mkdir()
(package / "__init__.py").write_text("")
(package / "eager_scraper.py").write_text(
"import urllib.request\n"
"urllib.request.urlopen('https://api.github.com/repos/o/r/tags')\n"
)
self.assertIn(
"planted.eager_scraper", self._offenders(Path(directory), "planted")
)
if __name__ == "__main__":
unittest.main()