Files
libretro/scripts/scraper/romfolders/__init__.py
T
Abdessamad Derraz 801af113b7 feat: record every core a rom folder offers
The rom folders files kept the default core of each folder only, so a
core the user picks in the frontend's menu was not known to read its
sets there. Each folder now lists its offers: every <core> of every
<emulator> in AmberELEC's es_systems.cfg (arcade:
packages/ui/emulationstation/config/es_systems.cfg:116-150), every core
of EmuELEC's es_systems.json, every add_emu_core of ROCKNIX's emulators
package.mk with the device case it is written under (arcade: 218-229),
and the emulator of every ES-DE <command>. A core written under every
branch of a case is offered unconditionally. The ES-XML, ES-JSON,
ELEC-conf and ES-DE setups are rescraped at their pins: archr, arkos,
chaos, darkos, jelos, kolos, pan4elec, retrobat and unofficialos gain
the same records and nothing else.

A libretro core offered for a folder reads the sets it loads beside the
game there, as the default one does.

Manifests, roms folders gaining sets read beside the game:
- AmberELEC: +215 (293.1 MB) in fbneo, arcade and mame, for MAME 2010,
  2015, 2016 and 2003-Plus. 1 omission leaves.
- EmuELEC: +291 (232.0 MB) in cps1, cps2, cps3, mame, fbneo and
  arcade, for MAME 2010 and 2003-Plus. 47 omissions leave.
- ROCKNIX: +501 (538.7 MB) in fbneo, neogeo, cps1, cps2, cps3, arcade
  and mame, for MAME 2010, 2015 and 2003-Plus. 120 omissions leave.
- EmuDeck: +306 (251.2 MB) in cps, cps1, cps2, cps3, arcade and mame,
  for MAME 2000, 2003, 2003-Plus and 2010. 92 omissions leave.
Each omission that leaves was a set named as read outside the BIOS
folder. RetroDECK and the other platforms do not change. No file
leaves, and verify --all keeps its severity and status counts.
2026-11-11 12:57:45 +01:00

210 lines
8.6 KiB
Python

"""Scrape each setup's rom folders from its own source, at a pinned revision.
`platforms/romfolders/_sources/<setup>.yml` names the repositories and
the commits to read, the format of the system list, and the facts a parser
cannot read from a table (roms root, case rule, folders the OS never scans),
each with its `source_ref`. A format parser reads the system list and returns
one row per folder. The setup file it writes carries the OS's own names only:
`scripts/romfolders.py` resolves them to repository systems and profiles.
Every fetch goes through `upstream.fetch_file`, cached by revision, so a
second run reads nothing from the network and writes the same bytes.
"""
from __future__ import annotations
import importlib
import pkgutil
import sys
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
_SCRIPTS_DIR = str(Path(__file__).resolve().parents[2])
if _SCRIPTS_DIR not in sys.path:
sys.path.insert(0, _SCRIPTS_DIR)
import upstream # noqa: E402
from safeparse import yaml_load # noqa: E402
CACHE_DIR = upstream.CACHE_DIR
# Keys a setup file carries, in the order they are written.
SETUP_KEYS = (
"setup", "name", "platform", "sources", "roms_root", "case_sensitive",
"case_ref", "skip", "hidden", "media", "notes", "folders",
)
ROW_KEYS = (
"folder", "name", "aliases", "extensions", "files", "core", "emulator", "source_ref",
"board_defaults", "offers", "arcade", "searched_by", "note",
)
class SourceError(RuntimeError):
"""A pinned file is absent, or its content no longer has the shape read."""
@dataclass
class Fetcher:
"""Reads pinned files of one setup's declared sources."""
sources: dict[str, dict]
cache_dir: str = CACHE_DIR
offline: bool = False
_cached_dirs: dict[str, dict[str, set[str]]] = field(default_factory=dict, init=False, repr=False)
def _repo(self, key: str) -> tuple[upstream.Repo, str]:
if key not in self.sources:
raise SourceError(f"source {key!r} is not declared")
spec = self.sources[key]
repo = upstream.parse_repo(spec["repo"])
if repo is None:
raise SourceError(f"source {key!r}: unsupported forge {spec['repo']}")
return repo, spec["commit"]
def text(self, key: str, path: str) -> str:
"""One file at the pinned commit, or SourceError when absent."""
repo, sha = self._repo(key)
lines = upstream.fetch_file(repo, sha, path, self.cache_dir, self.offline)
if lines is None:
raise SourceError(f"{repo.slug}@{sha[:8]}:{path} does not exist")
return "\n".join(lines) + "\n"
def optional_text(self, key: str, path: str) -> str | None:
repo, sha = self._repo(key)
for directory, held in self._cached_dirs.get(key, {}).items():
if path.startswith(directory + "/") and path not in held:
return None
lines = upstream.fetch_file(repo, sha, path, self.cache_dir, self.offline)
return None if lines is None else "\n".join(lines) + "\n"
def tree(self, key: str, prefix: str = "") -> list[str]:
"""Every blob path under a prefix at the pinned commit."""
repo, sha = self._repo(key)
if repo.family == "gitlab" and prefix.strip("/"):
return self._gitlab_subtree(repo, sha, prefix)
paths, truncated = upstream.list_tree(repo, sha, self.cache_dir, self.offline)
if truncated:
raise SourceError(f"{repo.slug}@{sha[:8]}: the forge truncated the tree")
return sorted(p for p in paths if p.startswith(prefix))
def cache_directory(self, key: str, directory: str) -> None:
"""Fetch a whole GitLab directory in one archive request before its
files are read one by one. A no-op on other forges."""
repo, sha = self._repo(key)
held = upstream.cache_gitlab_directory(repo, sha, directory, self.cache_dir, self.offline)
if held is not None:
self._cached_dirs.setdefault(key, {})[directory.strip("/")] = held
def entries(self, key: str, directory: str) -> list[str]:
"""Paths of the files and folders directly under one directory."""
repo, sha = self._repo(key)
held = self._cached_dirs.get(key, {}).get(directory.strip("/"))
if held is not None:
prefix = directory.strip("/") + "/"
return sorted({prefix + p[len(prefix):].split("/", 1)[0] for p in held})
if repo.family != "gitlab":
prefix = directory.strip("/") + "/"
return sorted({prefix + p[len(prefix):].split("/", 1)[0] for p in self.tree(key, prefix)})
return self._gitlab_listing(repo, sha, directory, recursive=False, blobs=False)
def _gitlab_subtree(self, repo: upstream.Repo, sha: str, prefix: str) -> list[str]:
"""GitLab pages its tree 100 entries at a time and Recalbox's whole
tree outruns the page cap: list only the directory asked for."""
paths = self._gitlab_listing(repo, sha, prefix, recursive=True, blobs=True)
return sorted(p for p in paths if p.startswith(prefix))
def _gitlab_listing(
self, repo: upstream.Repo, sha: str, directory: str, *, recursive: bool, blobs: bool
) -> list[str]:
directory = directory.strip("/")
paths: list[str] = []
for page in range(1, upstream.MAX_TREE_PAGES + 1):
url = (
f"{repo.api_base}/projects/{upstream._project(repo)}/repository/tree"
f"?ref={sha}&path={directory}&recursive={'true' if recursive else 'false'}"
f"&per_page={upstream.TREE_PAGE_SIZE}&page={page}"
)
payload = upstream._api(url, self.cache_dir, self.offline)
if not isinstance(payload, list):
raise SourceError(f"{repo.slug}@{sha[:8]}: {directory} could not be listed")
paths.extend(
entry["path"] for entry in payload
if isinstance(entry, dict) and entry.get("path")
and (entry.get("type") == "blob" or not blobs)
)
if len(payload) < upstream.TREE_PAGE_SIZE:
return sorted(paths)
raise SourceError(f"{repo.slug}@{sha[:8]}: {directory} outruns the page cap")
Parser = Callable[[dict, Fetcher], list[dict]]
FORMATS: dict[str, Parser] = {}
def register(name: str) -> Callable[[Parser], Parser]:
def wrap(func: Parser) -> Parser:
if name in FORMATS:
raise ValueError(f"format {name!r} registered twice")
FORMATS[name] = func
return func
return wrap
def load_formats() -> dict[str, Parser]:
"""Import every parser module of this package once."""
if not FORMATS:
for info in pkgutil.iter_modules([str(Path(__file__).parent)]):
if not info.name.startswith("_"):
importlib.import_module(f"{__name__}.{info.name}")
return FORMATS
def load_sources(directory: Path) -> dict[str, dict]:
"""Each setup's declared sources and facts, keyed by its file name."""
out: dict[str, dict] = {}
for path in sorted(Path(directory).glob("*.yml")):
with path.open(encoding="utf-8") as handle:
out[path.stem] = yaml_load(handle) or {}
return out
def _clean_row(row: dict) -> dict:
unknown = set(row) - set(ROW_KEYS)
if unknown:
raise ValueError(f"folder {row.get('folder')!r}: unknown keys {sorted(unknown)}")
out = {key: row[key] for key in ROW_KEYS if row.get(key) not in (None, [], {}, "")}
if out.get("name") == out["folder"]:
del out["name"]
if "extensions" in out:
out["extensions"] = sorted({e.lower() for e in out["extensions"]})
if "aliases" in out:
out["aliases"] = sorted(set(out["aliases"]) - {out["folder"]})
if not out["aliases"]:
del out["aliases"]
return out
def scrape_setup(setup: str, spec: dict, fetcher: Fetcher) -> dict:
"""The setup document: declared facts, then the parsed folders."""
formats = load_formats()
fmt = spec.get("format")
rows: list[dict] = []
if fmt:
if fmt not in formats:
raise SourceError(f"{setup}: no parser for format {fmt!r}")
rows = formats[fmt](spec, fetcher)
if not rows and not spec.get("allow_empty"):
raise SourceError(f"{setup}: format {fmt} read no folder")
folders: dict[str, dict] = {}
for row in rows:
clean = _clean_row(row)
key = clean["folder"]
if key in folders:
raise SourceError(f"{setup}: folder {key!r} read twice")
folders[key] = clean
doc = {"setup": setup, **{k: spec[k] for k in SETUP_KEYS[1:-1] if k in spec}}
doc["folders"] = [folders[k] for k in sorted(folders, key=lambda f: (f.lower(), f))]
return doc