mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
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.
210 lines
8.6 KiB
Python
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
|