mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-13 21:43:55 -06:00
chore: merge site fixes
This commit is contained in:
commit
5b6feef785
286 files changed
+2851
-285
No files matched your search
@@ -116,6 +116,9 @@ def main() -> int:
|
||||
parser.add_argument("--json", action="store_true", help="print the list as JSON")
|
||||
parser.add_argument("--strict", action="store_true", help="exit 1 while a file is listed")
|
||||
args = parser.parse_args()
|
||||
from common import work_from_repo_root # noqa: PLC0415
|
||||
|
||||
work_from_repo_root(args, parser, ["db", "emulators_dir", "platforms_dir", "install_dir"])
|
||||
|
||||
rows = bundled_only(
|
||||
load_database(args.db), load_emulator_profiles(args.emulators_dir),
|
||||
|
||||
+6
-3
@@ -83,14 +83,17 @@ from collections.abc import Callable, Iterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
# First: core_probe stops an interpreter older than 3.9 before anything else
|
||||
# fails on it.
|
||||
import core_probe
|
||||
|
||||
import yaml
|
||||
|
||||
import autorun
|
||||
import campaign_profiles
|
||||
import campaign_rules as rules
|
||||
import core_probe
|
||||
import measurement_records as mr
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
+38
-17
@@ -54,11 +54,13 @@ import pin_installer # noqa: E402
|
||||
import refresh_data_dirs # noqa: E402
|
||||
import upstream # noqa: E402
|
||||
from common import ( # noqa: E402
|
||||
InputMissing,
|
||||
apply_target_overrides,
|
||||
list_registered_platforms,
|
||||
load_emulator_profiles,
|
||||
load_platform_registry,
|
||||
require_directory,
|
||||
suggest_names,
|
||||
upstream_profile_index,
|
||||
write_text_atomic,
|
||||
yaml_load,
|
||||
@@ -73,9 +75,13 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CORE_INFO_REPO = "https://github.com/libretro/libretro-core-info"
|
||||
# The git blob of every .info as last reviewed: `--record-coreinfo` writes it.
|
||||
CORE_INFO_RECORD = REPO_ROOT / "platforms" / "_core_info.json"
|
||||
NO_INTRO_MIRROR = "https://github.com/hugo19941994/auto-datfile-generator"
|
||||
NO_INTRO_RELEASE = "Daily_Rebuild"
|
||||
NO_INTRO_ASSET = "no-intro.zip"
|
||||
NO_INTRO_MIRROR = game_dat_importer.NOINTRO_MIRROR
|
||||
NO_INTRO_RELEASE = game_dat_importer.NOINTRO_RELEASE
|
||||
NO_INTRO_ASSET = game_dat_importer.NOINTRO_ASSET
|
||||
# The mirror rebuilds daily. A pack older than this is a mirror that stopped:
|
||||
# hugo19941994's failed every day from 2026-07-08, and comparing our import
|
||||
# date with its asset read OK for four months.
|
||||
NO_INTRO_MAX_AGE_DAYS = 7
|
||||
TOSEC_DOWNLOADS = "https://www.tosecdev.org/downloads"
|
||||
MAME_REPO = "https://github.com/mamedev/mame"
|
||||
FBNEO_REPO = "https://github.com/libretro/FBNeo"
|
||||
@@ -893,7 +899,10 @@ def record_core_info(
|
||||
"""
|
||||
unknown = sorted(set(names) - set(blobs))
|
||||
if unknown:
|
||||
raise ValueError(f"no such .info in core-info: {', '.join(unknown)}")
|
||||
raise InputMissing(
|
||||
f"no such .info in core-info: {', '.join(unknown)}. "
|
||||
+ suggest_names(unknown, list(blobs), "libretro-core-info")
|
||||
)
|
||||
recorded = load_core_info_record(path) or {}
|
||||
recorded.update({n: blobs[n] for n in (names or blobs)})
|
||||
payload = {"repository": CORE_INFO_REPO, "blobs": dict(sorted(recorded.items()))}
|
||||
@@ -1083,6 +1092,25 @@ def _no_intro_asset_date(cache_dir: str, offline: bool) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _no_intro_row(subject: str, local: str, updated: str) -> Finding:
|
||||
"""Our import against the mirror's pack, and the pack against today."""
|
||||
age = (datetime.fromisoformat(_today()) - datetime.fromisoformat(updated)).days
|
||||
if age > NO_INTRO_MAX_AGE_DAYS:
|
||||
return Finding(
|
||||
"catalogs", subject, STALE, local=local, remote=updated,
|
||||
detail=f"the mirror last rebuilt {NO_INTRO_ASSET} on {updated}, {age} days ago, "
|
||||
f"though it rebuilds daily: {NO_INTRO_MIRROR}/actions",
|
||||
)
|
||||
return Finding(
|
||||
"catalogs", subject, STALE if updated > local else OK,
|
||||
local=local, remote=updated, detail=f"{NO_INTRO_ASSET} rebuilt {updated}",
|
||||
)
|
||||
|
||||
|
||||
def _check_nointro_games(cache_dir: str, offline: bool) -> Finding:
|
||||
"""games/catalogs/no-intro/ against the pack it was read from. The pack
|
||||
holds every DAT: rebuilt after the catalogs were read, it may carry
|
||||
@@ -1094,10 +1122,7 @@ def _check_nointro_games(cache_dir: str, offline: bool) -> Finding:
|
||||
updated = _no_intro_asset_date(cache_dir, offline)
|
||||
if not updated:
|
||||
return Finding("catalogs", subject, UNKNOWN, local=fetched, detail="asset not listed")
|
||||
return Finding(
|
||||
"catalogs", subject, STALE if updated > fetched else OK,
|
||||
local=fetched, remote=updated, detail=f"{NO_INTRO_ASSET} rebuilt {updated}",
|
||||
)
|
||||
return _no_intro_row(subject, fetched, updated)
|
||||
|
||||
|
||||
def redump_datfile_version(code: str) -> str:
|
||||
@@ -1171,14 +1196,7 @@ def _check_no_intro(cache_dir: str, offline: bool) -> Finding:
|
||||
updated = _no_intro_asset_date(cache_dir, offline)
|
||||
if not updated:
|
||||
return Finding("catalogs", "no-intro", UNKNOWN, local=imported, detail="asset not listed")
|
||||
return Finding(
|
||||
"catalogs",
|
||||
"no-intro",
|
||||
STALE if updated > imported else OK,
|
||||
local=imported,
|
||||
remote=updated,
|
||||
detail=f"{NO_INTRO_ASSET} rebuilt {updated}",
|
||||
)
|
||||
return _no_intro_row("no-intro", imported, updated)
|
||||
|
||||
|
||||
def tosec_latest_pack(html: str) -> str | None:
|
||||
@@ -1839,7 +1857,10 @@ def main() -> int:
|
||||
if args.record_coreinfo is not None:
|
||||
blobs = core_info_blobs(args.cache_dir, args.offline)
|
||||
if not blobs:
|
||||
raise RuntimeError("core-info listing unavailable: nothing recorded")
|
||||
raise InputMissing(
|
||||
f"core-info listing not in {args.cache_dir}: run without --offline"
|
||||
if args.offline else "core-info listing unavailable: the GitHub API did not answer"
|
||||
)
|
||||
done = record_core_info(blobs, args.record_coreinfo)
|
||||
print(f"recorded {len(done)} .info file(s) as reviewed")
|
||||
return 0
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Check a BIOS folder against one platform's list, from the published data only:
|
||||
python check_platform_folder.py batocera path/to/bios. Exit 1 on a file wrong or missing."""
|
||||
"""Usage: python check_platform_folder.py batocera path/to/bios. Exit 1 on a file wrong or missing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -42,14 +40,12 @@ def check(platform: str, root: Path, api: str = API) -> list[tuple[str, str]]:
|
||||
"""(destination, ok | wrong | missing) per entry. With no destination (EmuDeck), any file with its hash passes."""
|
||||
with urllib.request.urlopen(f"{api}{platform}.json") as response:
|
||||
contract = json.load(response)["items"][0]["contract"]
|
||||
mode, results = contract["verification_mode"], []
|
||||
loose = [path for path in root.iterdir() if path.is_file()] if root.is_dir() else []
|
||||
mode, results, loose = contract["verification_mode"], [], [p for p in root.iterdir() if p.is_file()]
|
||||
for system_id, system in contract["systems"].items():
|
||||
listed = [e for e in system.get("files") or [] if not e.get("destination")]
|
||||
for entry in (e for e in system.get("files") or [] if e.get("destination")):
|
||||
path = root / entry["destination"]
|
||||
verdict = "ok" if accepts(entry, path, mode) else "wrong" if path.is_file() else "missing"
|
||||
results.append((entry["destination"], verdict))
|
||||
results.append((entry["destination"], "ok" if accepts(entry, path, mode) else "wrong" if path.is_file() else "missing"))
|
||||
if listed:
|
||||
held = any(accepts(e, path, mode) for e in listed for path in loose)
|
||||
results.append((f"{system_id}: any file", "ok" if held else "missing"))
|
||||
@@ -57,12 +53,16 @@ def check(platform: str, root: Path, api: str = API) -> list[tuple[str, str]]:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit(__doc__)
|
||||
try:
|
||||
found = check(sys.argv[1], Path(sys.argv[2]))
|
||||
except urllib.error.HTTPError as error: # a 404: no platform has that name
|
||||
except urllib.request.HTTPError as error: # a 404: no platform has that name
|
||||
with urllib.request.urlopen(API.replace("platforms/", "catalog.json")) as response:
|
||||
known = sorted(i["url"][10:-5] for i in json.load(response)["items"] if i["url"].startswith("manifests/"))
|
||||
sys.exit(f"unknown platform: {sys.argv[1]}. Known: {', '.join(known)}" if error.code == 404 else error)
|
||||
except OSError as error: # no such folder, or no network (URLError)
|
||||
sys.exit(f"error: {error}")
|
||||
for verdict in ("ok", "wrong", "missing"):
|
||||
names = sorted({dest for dest, seen in found if seen == verdict})
|
||||
print(f"{verdict}: {len(names)}" + "".join(f"\n {name}" for name in names if verdict != "ok"))
|
||||
|
||||
+101
-15
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections import Counter
|
||||
|
||||
import argparse
|
||||
import filecmp
|
||||
import hashlib
|
||||
import functools
|
||||
@@ -19,6 +20,16 @@ import signal
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, TextIO
|
||||
|
||||
# Every build script imports this module. verify.py, slots.py, validate_pr.py
|
||||
# and cross_reference.py started under 3.8 and stopped halfway on a
|
||||
# str.removeprefix AttributeError, which named no version.
|
||||
if sys.version_info < (3, 9):
|
||||
sys.exit(
|
||||
f"{Path(sys.argv[0]).name} needs Python 3.9 or newer, "
|
||||
f"this is {sys.version.split()[0]}"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
@@ -53,6 +64,33 @@ class DatabaseUnreadable(InputMissing):
|
||||
"""The database a command was pointed at does not exist or is not JSON."""
|
||||
|
||||
|
||||
class SourceUnreadable(ValueError):
|
||||
"""A profile or platform file that is not valid YAML. Not an InputMissing:
|
||||
the callers that skip a platform whose file is absent must not skip one
|
||||
whose file is damaged. run_cli prints the message on one line."""
|
||||
|
||||
|
||||
def source_yaml(stream: TextIO) -> Any:
|
||||
"""Parse one of our YAML sources, naming the file a syntax error is in.
|
||||
|
||||
Every profile is loaded whatever the command, so one misindented key in
|
||||
an unrelated profile stopped `verify.py --emulator handy` on a 37-line
|
||||
traceback, the faulty file named only near its end.
|
||||
"""
|
||||
if yaml is None:
|
||||
return yaml_load(stream)
|
||||
try:
|
||||
return yaml_load(stream)
|
||||
except yaml.YAMLError as exc:
|
||||
mark = getattr(exc, "problem_mark", None)
|
||||
where = f"{stream.name}:{mark.line + 1}" if mark else stream.name
|
||||
problem = getattr(exc, "problem", None) or str(exc).splitlines()[0]
|
||||
raise SourceUnreadable(
|
||||
f"{where}: {problem}. Check it with: "
|
||||
"python scripts/validate_schemas.py --source-only"
|
||||
) from None
|
||||
|
||||
|
||||
def load_database(db_path: str) -> dict:
|
||||
"""Load database.json and return parsed dict.
|
||||
|
||||
@@ -64,6 +102,18 @@ def load_database(db_path: str) -> dict:
|
||||
with open(db_path) as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
# A mistyped --db beside the real database was told to rescan the
|
||||
# whole collection into a second file under the typo.
|
||||
import difflib # noqa: PLC0415
|
||||
|
||||
typed = Path(db_path)
|
||||
close = difflib.get_close_matches(
|
||||
typed.name, [p.name for p in typed.parent.glob("*.json")], n=1, cutoff=0.8
|
||||
)
|
||||
if close:
|
||||
raise DatabaseUnreadable(
|
||||
f"{db_path} not found. Did you mean {typed.with_name(close[0])}?"
|
||||
) from None
|
||||
raise DatabaseUnreadable(
|
||||
f"{db_path} not found. Build it with: python scripts/generate_db.py "
|
||||
f"--force --bios-dir bios --output {db_path}"
|
||||
@@ -302,7 +352,7 @@ def _inherited_yaml(platform_name: str, platforms_dir: str) -> tuple[dict, list[
|
||||
raise FileNotFoundError(f"Platform config not found: {config_file}")
|
||||
|
||||
with open(config_file) as f:
|
||||
config = yaml_load(f) or {}
|
||||
config = source_yaml(f) or {}
|
||||
if "inherits" not in config:
|
||||
return config, [platform_name]
|
||||
|
||||
@@ -348,7 +398,7 @@ def load_platform_config(platform_name: str, platforms_dir: str = "platforms") -
|
||||
reg_real = os.path.realpath(registry_path)
|
||||
if reg_real not in _shared_yml_cache:
|
||||
with open(registry_path) as f:
|
||||
_shared_yml_cache[reg_real] = yaml_load(f) or {}
|
||||
_shared_yml_cache[reg_real] = source_yaml(f) or {}
|
||||
reg = _shared_yml_cache[reg_real]
|
||||
# The platform's own entry first, then its parents' for what it does
|
||||
# not say: RetroPie's status, scraper and install are its own, its
|
||||
@@ -387,7 +437,7 @@ def load_platform_config(platform_name: str, platforms_dir: str = "platforms") -
|
||||
shared_real = os.path.realpath(shared_path)
|
||||
if shared_real not in _shared_yml_cache:
|
||||
with open(shared_path) as f:
|
||||
_shared_yml_cache[shared_real] = yaml_load(f) or {}
|
||||
_shared_yml_cache[shared_real] = source_yaml(f) or {}
|
||||
shared = _shared_yml_cache[shared_real]
|
||||
shared_groups = shared.get("shared_groups", {})
|
||||
for system in config.get("systems", {}).values():
|
||||
@@ -494,7 +544,7 @@ def load_data_dir_registry(platforms_dir: str = "platforms") -> dict:
|
||||
if not os.path.exists(registry_path):
|
||||
return {}
|
||||
with open(registry_path) as f:
|
||||
data = yaml_load(f) or {}
|
||||
data = source_yaml(f) or {}
|
||||
return data.get("data_directories", {})
|
||||
|
||||
|
||||
@@ -504,7 +554,7 @@ def load_platform_registry(platforms_dir: str = "platforms") -> dict:
|
||||
if not os.path.exists(registry_path):
|
||||
return {}
|
||||
with open(registry_path) as f:
|
||||
return (yaml_load(f) or {}).get("platforms", {})
|
||||
return (source_yaml(f) or {}).get("platforms", {})
|
||||
|
||||
|
||||
def list_registered_platforms(platforms_dir: str = "platforms") -> list[str]:
|
||||
@@ -521,7 +571,7 @@ def list_registered_platforms(platforms_dir: str = "platforms") -> list[str]:
|
||||
if not os.path.exists(registry_path):
|
||||
raise InputMissing(f"no platform registry at {registry_path}")
|
||||
with open(registry_path) as f:
|
||||
registry = yaml_load(f) or {}
|
||||
registry = source_yaml(f) or {}
|
||||
platforms = []
|
||||
for name, meta in sorted(registry.get("platforms", {}).items()):
|
||||
config_path = os.path.join(platforms_dir, meta.get("config", f"{name}.yml"))
|
||||
@@ -621,6 +671,31 @@ def suggest_names(
|
||||
return f"{listing_flag} lists all {total}."
|
||||
|
||||
|
||||
def work_from_repo_root(
|
||||
args: argparse.Namespace, parser: argparse.ArgumentParser, path_flags: list[str]
|
||||
) -> None:
|
||||
"""Run from the repository root, whichever directory the command started in.
|
||||
|
||||
database.json records every file from the root and the data registry
|
||||
names its caches the same way, so a run from scripts/ ended on a
|
||||
FileNotFoundError traceback. A path the caller typed is read from where
|
||||
they typed it, a default from the root.
|
||||
"""
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
if Path.cwd().resolve() == root:
|
||||
return
|
||||
for flag in path_flags:
|
||||
value = getattr(args, flag, None)
|
||||
if value and value != parser.get_default(flag):
|
||||
# A Path stays a Path for the code that joins onto it.
|
||||
setattr(args, flag, type(value)(os.path.abspath(value)))
|
||||
os.chdir(root)
|
||||
if getattr(args, "platform", None):
|
||||
# Read before the move, the registry was not there to map
|
||||
# "RetroArch" to its slug.
|
||||
args.platform = platform_arg(args.platform)
|
||||
|
||||
|
||||
def platform_arg(value: str) -> str:
|
||||
"""--platform as typed, mapped to the registry's slug when it names one.
|
||||
|
||||
@@ -669,7 +744,7 @@ def _target_overrides(platform_name: str, targets_dir: str) -> dict:
|
||||
if not os.path.exists(overrides_file):
|
||||
return {}
|
||||
with open(overrides_file) as f:
|
||||
all_overrides = yaml_load(f) or {}
|
||||
all_overrides = source_yaml(f) or {}
|
||||
return all_overrides.get(platform_name, {}).get("targets", {})
|
||||
|
||||
|
||||
@@ -690,7 +765,7 @@ def platform_files_target(platform: str, target: str, platforms_dir: str = "plat
|
||||
if not os.path.exists(target_file):
|
||||
return False
|
||||
with open(target_file) as f:
|
||||
targets = (yaml_load(f) or {}).get("targets", {})
|
||||
targets = (source_yaml(f) or {}).get("targets", {})
|
||||
return target in _target_alias_index(targets, _target_overrides(platform, targets_dir))
|
||||
|
||||
|
||||
@@ -710,7 +785,7 @@ def canonical_target_name(
|
||||
if not os.path.exists(target_file):
|
||||
continue
|
||||
with open(target_file) as f:
|
||||
targets = (yaml_load(f) or {}).get("targets", {})
|
||||
targets = (source_yaml(f) or {}).get("targets", {})
|
||||
canonical = _target_alias_index(
|
||||
targets, _target_overrides(platform_name, targets_dir)
|
||||
).get(target)
|
||||
@@ -764,7 +839,7 @@ def load_target_config(
|
||||
f"(platforms with targets: {', '.join(with_targets)})"
|
||||
)
|
||||
with open(target_file) as f:
|
||||
data = yaml_load(f) or {}
|
||||
data = source_yaml(f) or {}
|
||||
|
||||
targets = data.get("targets", {})
|
||||
overrides = _target_overrides(platform_name, targets_dir)
|
||||
@@ -822,7 +897,7 @@ def list_available_targets(
|
||||
if not os.path.exists(target_file):
|
||||
return []
|
||||
with open(target_file) as f:
|
||||
data = yaml_load(f) or {}
|
||||
data = source_yaml(f) or {}
|
||||
|
||||
# The count --target filters on: overrides applied, as load_target_config
|
||||
# applies them. The scraped list counted Batocera's machine variants and
|
||||
@@ -2229,7 +2304,7 @@ def load_emulator_profiles(
|
||||
raise InputMissing(f"no emulator profile directory at {emulators_dir}")
|
||||
for f in source_yaml_paths(emu_path):
|
||||
with open(f) as fh:
|
||||
profile = yaml_load(fh) or {}
|
||||
profile = source_yaml(fh) or {}
|
||||
if "emulator" not in profile:
|
||||
continue
|
||||
_normalize_hash_fields(profile)
|
||||
@@ -2359,7 +2434,7 @@ def group_identical_platforms(
|
||||
try:
|
||||
raw_path = os.path.join(platforms_dir, f"{platform}.yml")
|
||||
with open(raw_path) as f:
|
||||
raw = yaml_load(f) or {}
|
||||
raw = source_yaml(f) or {}
|
||||
inherits[platform] = "inherits" in raw
|
||||
config = load_platform_config(platform, platforms_dir)
|
||||
except FileNotFoundError:
|
||||
@@ -3503,6 +3578,9 @@ def build_target_cores_cache(
|
||||
raise
|
||||
notes.append(f"INFO: Skipping {p}: no hardware targets")
|
||||
skip.append(p)
|
||||
except SourceUnreadable:
|
||||
# A damaged targets file is not a platform without the target.
|
||||
raise
|
||||
except ValueError as e:
|
||||
if is_all:
|
||||
notes.append(f"INFO: Skipping {p}: {e}")
|
||||
@@ -3530,7 +3608,7 @@ def _unknown_target_message(target: str, platforms: list[str], platforms_dir: st
|
||||
if not os.path.exists(target_file):
|
||||
continue
|
||||
with open(target_file) as f:
|
||||
targets = (yaml_load(f) or {}).get("targets", {})
|
||||
targets = (source_yaml(f) or {}).get("targets", {})
|
||||
overrides = _target_overrides(platform, targets_dir)
|
||||
for name in _target_alias_index(targets, overrides):
|
||||
owners.setdefault(name, []).append(platform)
|
||||
@@ -3624,10 +3702,18 @@ def run_cli(main: Callable[[], int | None]) -> None:
|
||||
for name in ("SIGTERM", "SIGHUP"):
|
||||
if hasattr(signal, name):
|
||||
signal.signal(getattr(signal, name), _exit_on_signal)
|
||||
# Redirected, stdout is block-buffered and stderr is not: the --strict
|
||||
# verdict landed on the first line of the log, before the report it
|
||||
# closes. Line buffering keeps the two in the order they were written.
|
||||
# A stream a caller swapped in (a test's StringIO) has nothing to set.
|
||||
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
reconfigure(line_buffering=True)
|
||||
try:
|
||||
code = main()
|
||||
sys.stdout.flush()
|
||||
except (InputMissing, ArtifactLockBusy, ArtifactDirectoryMissing) as exc:
|
||||
except (InputMissing, SourceUnreadable, ArtifactLockBusy,
|
||||
ArtifactDirectoryMissing) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except BrokenPipeError:
|
||||
|
||||
@@ -40,6 +40,14 @@ from collections.abc import Iterable, Iterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
# The probe tools do not import common.py, which states the 3.9 floor for the
|
||||
# other scripts. Under 3.8 they stopped on a str.removesuffix AttributeError.
|
||||
if sys.version_info < (3, 9):
|
||||
sys.exit(
|
||||
f"{Path(sys.argv[0]).name} needs Python 3.9 or newer, "
|
||||
f"this is {sys.version.split()[0]}"
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MIN_BENCH_REPETITIONS = 5
|
||||
DEFAULT_WORK_DIR = ROOT / "tmp" / "core_probe"
|
||||
|
||||
@@ -1191,6 +1191,9 @@ def main() -> None:
|
||||
"--list-emulators", action="store_true", help="List available emulators"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
from common import work_from_repo_root # noqa: PLC0415
|
||||
|
||||
work_from_repo_root(args, parser, ["emulators_dir", "platforms_dir", "db"])
|
||||
if args.list_emulators:
|
||||
ignored = [flag for flag, on in (
|
||||
("--emulator", args.emulator), ("--platform", args.platform),
|
||||
|
||||
@@ -221,12 +221,19 @@ class Exporter(BaseExporter):
|
||||
ours = str(entry.get("path") or entry.get("name", "")).split("/")[:-1]
|
||||
return not {d.casefold() for d in theirs} & {d.casefold() for d in ours}
|
||||
|
||||
def _bios_element(self, fe: NativeFile, native_id: str) -> str:
|
||||
def _bios_element(
|
||||
self, fe: NativeFile, native_id: str, also: tuple[NativeFile, ...] = ()
|
||||
) -> str:
|
||||
"""``also`` are further additions at fe's path: Bios.cpp reads one
|
||||
element per path, and its md5 and core lists take every one of them."""
|
||||
group = (fe, *also)
|
||||
md5 = dict.fromkeys(h for f in group for h in f.hashes("md5"))
|
||||
cores = dict.fromkeys(c for f in group for c in f.cores())
|
||||
attrs = [f"path={quoteattr(self._path(fe))}"]
|
||||
attrs.append(f'md5={quoteattr(",".join(fe.hashes("md5")))}')
|
||||
attrs.append(f'core={quoteattr(",".join(fe.cores()))}')
|
||||
attrs.append(f'md5={quoteattr(",".join(md5))}')
|
||||
attrs.append(f'core={quoteattr(",".join(cores))}')
|
||||
|
||||
if not self.required_value(fe):
|
||||
if not any(self.required_value(f) for f in group):
|
||||
attrs.append('mandatory="false"')
|
||||
elif fe.native("mandatory_declared", None) is True:
|
||||
attrs.append('mandatory="true"')
|
||||
@@ -343,11 +350,17 @@ class Exporter(BaseExporter):
|
||||
}
|
||||
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:
|
||||
# Two additions at one path (two cores, two dumps) are one
|
||||
# element: keyed by path, the second replaced the first and its
|
||||
# md5 left the file while both were counted added.
|
||||
at_path: dict[str, list[NativeFile]] = {}
|
||||
for fe in system.files:
|
||||
if self.writable(fe):
|
||||
at_path.setdefault(self._path(fe), []).append(fe)
|
||||
if at_path:
|
||||
ours[system.native_id] = (system, {
|
||||
self._path(fe): (fe, self._bios_element(fe, system.native_id))
|
||||
for fe in writable
|
||||
path: (group[0], self._bios_element(group[0], system.native_id, tuple(group[1:])))
|
||||
for path, group in at_path.items()
|
||||
})
|
||||
|
||||
def element(attrs: dict[str, str]) -> str:
|
||||
|
||||
@@ -215,6 +215,7 @@ class Exporter(BaseExporter):
|
||||
ours: list[OrderedDict],
|
||||
additions: list[OrderedDict] | None = None,
|
||||
appended: set[tuple[str, str]] | None = None,
|
||||
held_back: list[int] | None = None,
|
||||
) -> list[OrderedDict]:
|
||||
"""Correct the component's own list, never replace it.
|
||||
|
||||
@@ -301,13 +302,19 @@ class Exporter(BaseExporter):
|
||||
for e in merged
|
||||
for system in (_systems_of(e) or [""])
|
||||
}
|
||||
for entry in additions or []:
|
||||
for index, entry in enumerate(additions or []):
|
||||
key = (str(entry.get("filename", "")), str(entry.get("system", "")), _paths_key(entry))
|
||||
if key[0] and key not in held:
|
||||
held.add(key)
|
||||
merged.append(entry)
|
||||
elif held_back is not None:
|
||||
held_back.append(index)
|
||||
return merged
|
||||
|
||||
def lands(self, fe: NativeFile) -> bool:
|
||||
"""An addition the list already held is not written a second time."""
|
||||
return self.writable(fe) and id(fe) not in getattr(self, "_held_back", set())
|
||||
|
||||
def render(
|
||||
self,
|
||||
systems: dict[str, NativeSystem],
|
||||
@@ -317,6 +324,7 @@ class Exporter(BaseExporter):
|
||||
) -> dict[str, str]:
|
||||
self._place_additions(systems)
|
||||
self._appended: set[int] = set()
|
||||
self._held_back: set[int] = set()
|
||||
grouped = self._by_component(systems)
|
||||
produced: dict[str, str] = {}
|
||||
|
||||
@@ -331,25 +339,28 @@ class Exporter(BaseExporter):
|
||||
continue
|
||||
|
||||
entries = [self._entry(fe) for fe in files if fe.platform is not None]
|
||||
additions = [self._entry(fe) for fe in files if fe.platform is None]
|
||||
added = [fe for fe in files if fe.platform is None]
|
||||
additions = [self._entry(fe) for fe in added]
|
||||
appended: set[tuple[str, str]] = set()
|
||||
held_back: list[int] = []
|
||||
for component_value in manifest.values():
|
||||
if not isinstance(component_value, dict):
|
||||
continue
|
||||
holder = self._bios_holder(component_value)
|
||||
if holder is None:
|
||||
component_value["bios"] = self._merge(
|
||||
None, entries, additions, appended
|
||||
None, entries, additions, appended, held_back
|
||||
)
|
||||
else:
|
||||
container, key = holder
|
||||
written = container.get(key)
|
||||
merged = self._merge(written, entries, additions, appended)
|
||||
merged = self._merge(written, entries, additions, appended, held_back)
|
||||
# A single object stays one while nothing is added to it.
|
||||
container[key] = (
|
||||
merged[0] if isinstance(written, dict) and len(merged) == 1 else merged
|
||||
)
|
||||
break
|
||||
self._held_back.update(id(added[index]) for index in held_back)
|
||||
# A declared entry whose revision the platform does not hold is
|
||||
# written beside theirs: a new line, not a corrected value.
|
||||
self._appended.update(
|
||||
|
||||
@@ -336,6 +336,8 @@ class Exporter(BaseExporter):
|
||||
systems_of.setdefault(str(name).lower(), set()).add(fe.native_system)
|
||||
known = set(systems_of)
|
||||
self._skipped: dict[str, list[str]] = {}
|
||||
# Rewritten, with a file of a system no clause lists left out.
|
||||
self._partial: list[str] = []
|
||||
self._named: set[int] = set()
|
||||
produced: dict[str, str] = {}
|
||||
|
||||
@@ -400,12 +402,11 @@ class Exporter(BaseExporter):
|
||||
homes.setdefault(owners[0], []).append(fe)
|
||||
else:
|
||||
homeless = True
|
||||
if homeless:
|
||||
# No enumeration of that system to extend, and a sentence we
|
||||
# would have to write ourselves is a documentation change,
|
||||
# not a correction.
|
||||
skip("whose help text lists no files of that system to add to", module_id)
|
||||
# No enumeration of that system to extend, and a sentence we
|
||||
# would have to write ourselves is a documentation change, not a
|
||||
# correction. With nothing placed the package is left as is.
|
||||
if not homes:
|
||||
skip("whose help text lists no files of that system to add to", module_id)
|
||||
continue
|
||||
|
||||
added = sum(len(self._in_search_order(fes)) for fes in homes.values())
|
||||
@@ -428,6 +429,8 @@ class Exporter(BaseExporter):
|
||||
f'rp_module_help="{new_help}"',
|
||||
1,
|
||||
)
|
||||
if homeless:
|
||||
self._partial.append(module_id)
|
||||
|
||||
return produced
|
||||
|
||||
@@ -454,12 +457,20 @@ class Exporter(BaseExporter):
|
||||
) -> str:
|
||||
"""Packages are the unit here, not file entries."""
|
||||
skipped: dict[str, list[str]] = getattr(self, "_skipped", {})
|
||||
|
||||
def shown(modules: list[str]) -> str:
|
||||
names = ", ".join(sorted(modules)[:4])
|
||||
return names + (f" and {len(modules) - 4} more" if len(modules) > 4 else "")
|
||||
|
||||
parts = [f"{len(produced)} packages updated"]
|
||||
partial = getattr(self, "_partial", [])
|
||||
if partial:
|
||||
parts.append(
|
||||
f"Updated in part: {shown(partial)}, whose help text lists no files "
|
||||
"of one of their systems to add to"
|
||||
)
|
||||
for reason, modules in sorted(skipped.items()):
|
||||
shown = ", ".join(sorted(modules)[:4])
|
||||
if len(modules) > 4:
|
||||
shown += f" and {len(modules) - 4} more"
|
||||
parts.append(f"Left as is: {shown}, {reason}")
|
||||
parts.append(f"Left as is: {shown(modules)}, {reason}")
|
||||
return ". ".join(parts)
|
||||
|
||||
def validate(
|
||||
|
||||
@@ -175,6 +175,9 @@ class Exporter(BaseExporter):
|
||||
|
||||
ours: dict[str, dict[str, str]] = {}
|
||||
added: dict[str, set[str]] = {}
|
||||
# A rom name is one line: an addition whose name an earlier entry
|
||||
# took is not written, and was counted added.
|
||||
self._shadowed: set[int] = set()
|
||||
for system, files in self.exportable(systems):
|
||||
rendered: dict[str, str] = {}
|
||||
for fe in files:
|
||||
@@ -194,7 +197,11 @@ class Exporter(BaseExporter):
|
||||
sha1 = fe.record_hash("sha1")
|
||||
if sha1:
|
||||
parts.append(f"sha1 {sha1}")
|
||||
rendered.setdefault(name, f"\trom ( {' '.join(parts)} )")
|
||||
if name in rendered:
|
||||
if fe.platform is None:
|
||||
self._shadowed.add(id(fe))
|
||||
continue
|
||||
rendered[name] = f"\trom ( {' '.join(parts)} )"
|
||||
if fe.platform is None:
|
||||
added.setdefault(system.native_id, set()).add(name)
|
||||
if rendered:
|
||||
@@ -241,7 +248,11 @@ class Exporter(BaseExporter):
|
||||
return {self.native_filename(): "\n".join(lines)}
|
||||
|
||||
def lands(self, fe: NativeFile) -> bool:
|
||||
return self.writable(fe) and id(fe) not in getattr(self, "_untitled", set())
|
||||
return (
|
||||
self.writable(fe)
|
||||
and id(fe) not in getattr(self, "_untitled", set())
|
||||
and id(fe) not in getattr(self, "_shadowed", set())
|
||||
)
|
||||
|
||||
def validate(
|
||||
self,
|
||||
|
||||
@@ -247,6 +247,9 @@ def main() -> int:
|
||||
help="profiles the rules are checked against (default emulators)")
|
||||
parser.add_argument("--json", action="store_true", help="print the summary as JSON")
|
||||
args = parser.parse_args()
|
||||
from common import work_from_repo_root # noqa: PLC0415
|
||||
|
||||
work_from_repo_root(args, parser, ["regions", "extras", "headers", "emulators_dir"])
|
||||
|
||||
regions = load_yaml(args.regions)
|
||||
extras = load_yaml(args.extras)
|
||||
|
||||
@@ -43,6 +43,7 @@ from common import (
|
||||
require_directory,
|
||||
target_listing_line,
|
||||
suggest_names,
|
||||
system_index,
|
||||
require_platform,
|
||||
load_target_config,
|
||||
archive_entry,
|
||||
@@ -164,7 +165,9 @@ class PackRecord:
|
||||
self.sources: dict[str, str] = {}
|
||||
|
||||
|
||||
def _add_pack_member(zf: zipfile.ZipFile, source_path: str, arcname: str) -> None:
|
||||
def _add_pack_member(
|
||||
zf: zipfile.ZipFile | PackRecord, source_path: str, arcname: str
|
||||
) -> None:
|
||||
"""Add a file to the pack with metadata that does not depend on the build.
|
||||
|
||||
ZipFile.write copies the source file's mtime into the member. For a file
|
||||
@@ -197,7 +200,9 @@ def _add_pack_member(zf: zipfile.ZipFile, source_path: str, arcname: str) -> Non
|
||||
shutil.copyfileobj(source, target, 1024 * 1024)
|
||||
|
||||
|
||||
def _write_generated_member(zf: zipfile.ZipFile, arcname: str, text: str) -> None:
|
||||
def _write_generated_member(
|
||||
zf: zipfile.ZipFile | PackRecord, arcname: str, text: str
|
||||
) -> None:
|
||||
"""Write a pack member the builder composes rather than copies.
|
||||
|
||||
READMEs, instruction notes and the manifest are produced at build time,
|
||||
@@ -581,7 +586,7 @@ def _data_directory_members(
|
||||
|
||||
|
||||
def _pack_data_directories(
|
||||
zf: zipfile.ZipFile,
|
||||
zf: zipfile.ZipFile | PackRecord,
|
||||
pack_systems: dict,
|
||||
data_registry: dict | None,
|
||||
platform_name: str,
|
||||
@@ -1476,7 +1481,9 @@ def generate_pack(
|
||||
|
||||
|
||||
def _extract_zip_to_archive(
|
||||
source_zip: str, dest_prefix: str, target_zf: zipfile.ZipFile
|
||||
source_zip: str,
|
||||
dest_prefix: str,
|
||||
target_zf: zipfile.ZipFile | PackRecord,
|
||||
) -> None:
|
||||
"""Extract contents of a source ZIP into target ZIP under dest_prefix."""
|
||||
with zipfile.ZipFile(source_zip, "r") as src:
|
||||
@@ -1507,7 +1514,7 @@ def _extract_zip_to_archive(
|
||||
def _add_zip_to_pack(
|
||||
source_zip: str,
|
||||
dest_path: str,
|
||||
target_zf: zipfile.ZipFile,
|
||||
target_zf: zipfile.ZipFile | PackRecord,
|
||||
file_entry: dict | None = None,
|
||||
) -> None:
|
||||
"""Write a source ZIP into the pack, byte for byte.
|
||||
@@ -1555,7 +1562,7 @@ class DamagedArchiveError(OSError):
|
||||
|
||||
|
||||
def _pack_data_tree(
|
||||
zf: zipfile.ZipFile,
|
||||
zf: zipfile.ZipFile | PackRecord,
|
||||
local_cache: str,
|
||||
dd_dest: str,
|
||||
seen_destinations: set[str],
|
||||
@@ -2053,7 +2060,10 @@ def generate_system_pack(
|
||||
f"No emulators found for system(s): {', '.join(system_ids)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(suggest_names(system_ids, sorted(all_systems), "--list-systems"),
|
||||
# --list-systems merges spellings that differ in case or separator:
|
||||
# the hint counted 579 spellings for the 532 lines it prints.
|
||||
print(suggest_names(system_ids, sorted(all_systems), "--list-systems",
|
||||
listed=len(system_index(profiles))),
|
||||
file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from siterender import SECTION_LABELS, WIKI_SRC_DIR
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
@@ -56,6 +55,7 @@ from common import (
|
||||
load_platform_config,
|
||||
load_provenance_snapshots,
|
||||
native_layer,
|
||||
require_directory,
|
||||
require_yaml,
|
||||
resolution_is_hash_exact,
|
||||
resolve_local_file,
|
||||
@@ -64,6 +64,7 @@ from common import (
|
||||
unique_emulator_profiles,
|
||||
yaml_load,
|
||||
)
|
||||
from siterender import SECTION_LABELS, WIKI_SRC_DIR
|
||||
from artifacts import ArtifactLockBusy, artifact_lock
|
||||
from artifacts import json_text as _json_text
|
||||
from cross_reference import FileConsumers, entry_target, held_copy
|
||||
@@ -2330,7 +2331,7 @@ def generate_data_page(exports: list[dict], measurements_sqlite: Path | None = N
|
||||
f"({REPO_URL}/blob/main/scripts/check_platform_folder.py). A test runs it "
|
||||
"against the verifier on a sample folder, so the two give the same answer. "
|
||||
"It exits 1 when a file is wrong or missing. For an unknown platform it "
|
||||
"prints the known ones.",
|
||||
"prints the known ones, and it names a folder that does not exist.",
|
||||
"",
|
||||
"`install.py --check` answers another question by default: whether the folder "
|
||||
"holds what the pack installs, the manifest above. With `--declared-only`, or "
|
||||
@@ -12393,6 +12394,10 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
if args.docs_dir is None:
|
||||
args.docs_dir = str(Path(args.site_dir) / STARLIGHT_CONTENT)
|
||||
# Before any write: the public assets and the bootstraps went out first,
|
||||
# and a mistyped folder stopped the run only after them.
|
||||
require_directory(args.emulators_dir, "--emulators-dir")
|
||||
require_directory(args.platforms_dir, "--platforms-dir")
|
||||
try:
|
||||
# Two runs over one docs/ delete each other's pages and scratch
|
||||
# files in the sweep, so the second is refused instead.
|
||||
|
||||
@@ -146,6 +146,9 @@ def main() -> int:
|
||||
"--strict", action="store_true", help="exit non-zero when any is found"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
from common import work_from_repo_root # noqa: PLC0415
|
||||
|
||||
work_from_repo_root(args, parser, ["bios_dir"])
|
||||
from common import require_directory # noqa: PLC0415
|
||||
|
||||
require_directory(args.bios_dir)
|
||||
|
||||
@@ -26,7 +26,10 @@ def list_platforms() -> list[str]:
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False, description="List available platforms")
|
||||
parser.parse_args()
|
||||
args = parser.parse_args()
|
||||
from common import work_from_repo_root # noqa: PLC0415
|
||||
|
||||
work_from_repo_root(args, parser, [])
|
||||
|
||||
platforms = list_platforms()
|
||||
|
||||
|
||||
+123
-54
@@ -20,6 +20,7 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections import Counter
|
||||
from collections.abc import Callable, Iterable, Iterator, Sequence
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
@@ -80,6 +81,9 @@ from upstream import CompareResult, find_renamed
|
||||
DEFAULT_CACHE = upstream.CACHE_DIR
|
||||
ANON_QUOTA = 60
|
||||
TRIAGE_PATH_SAMPLE = 5
|
||||
# A pass that asks the forges says where it is at this interval: an online
|
||||
# --all --triage ran 15 minutes without a word and read as hung.
|
||||
PROGRESS_SECONDS = 30
|
||||
|
||||
STATUS_ORDER = (
|
||||
"ANCHORED", "UNCHECKED", "EXTERNAL", "BINARY", "SHIFTED", "RENAMED", "MOVED",
|
||||
@@ -4991,35 +4995,40 @@ def _print_extras_from(
|
||||
print(f" {line}")
|
||||
|
||||
|
||||
class _Progress:
|
||||
"""A stderr line every PROGRESS_SECONDS while a pass asks the forges."""
|
||||
|
||||
def __init__(self, total: int, doing: str, quiet: bool) -> None:
|
||||
self.total, self.doing, self.quiet = total, doing, quiet
|
||||
self.last = time.monotonic()
|
||||
|
||||
def step(self, done: int, name: str) -> None:
|
||||
now = time.monotonic()
|
||||
if not self.quiet and now - self.last >= PROGRESS_SECONDS:
|
||||
print(f"{self.doing}: {done} of {self.total}, at {name}", file=sys.stderr, flush=True)
|
||||
self.last = now
|
||||
|
||||
|
||||
def _interrupted(done: int, total: int, what: str) -> None:
|
||||
print(f"interrupted after {done} of {total} profiles: {what}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _print_triage(
|
||||
args: argparse.Namespace, selected: dict, reports: list[ProfileReport]
|
||||
) -> None:
|
||||
"""Profiles ranked by drift, worst first."""
|
||||
) -> bool:
|
||||
"""Profiles ranked by drift, worst first. False when Ctrl-C cut the
|
||||
scoring short: the ranking then covers the profiles scored."""
|
||||
ranked = []
|
||||
sampled = 0
|
||||
for report in reports:
|
||||
profile = selected[report.name]
|
||||
version = None
|
||||
commits = 0
|
||||
refused = ""
|
||||
repo = select_repo(profile)
|
||||
if report.head and repo is not None:
|
||||
paths = _cited_paths(report)
|
||||
try:
|
||||
version = check_version(profile, repo, args.cache_dir, args.offline)
|
||||
for path in paths[:TRIAGE_PATH_SAMPLE]:
|
||||
commits += upstream.commits_touching(
|
||||
repo, path, report.pin, args.cache_dir, args.offline,
|
||||
head=report.head,
|
||||
)
|
||||
except upstream.UpstreamError as exc:
|
||||
# One forge refusing a request (Eden's answers 403 to commit
|
||||
# lookups) leaves this profile unscored, not the whole ranking.
|
||||
refused = "commit history unreachable"
|
||||
print(f"{report.name}: {exc}", file=sys.stderr)
|
||||
if len(paths) > TRIAGE_PATH_SAMPLE and not refused:
|
||||
sampled += 1
|
||||
ranked.append((drift_score(report, version, commits), report, refused))
|
||||
progress = _Progress(len(reports), "scored", args.offline or len(reports) < 2)
|
||||
complete = True
|
||||
try:
|
||||
for done, report in enumerate(reports):
|
||||
progress.step(done, report.name)
|
||||
sampled += _score(args, selected, report, ranked)
|
||||
except KeyboardInterrupt:
|
||||
_interrupted(len(ranked), len(reports), "the ranking covers those scored")
|
||||
complete = False
|
||||
if sampled:
|
||||
# One line for the run: a notice per profile buried the ranking
|
||||
# under 254 lines of stderr.
|
||||
@@ -5037,6 +5046,37 @@ def _print_triage(
|
||||
pending += bool(report.needs_review() or report.uncheckable())
|
||||
print(f"{score:5d} {report.name:30s} {state}")
|
||||
print(f"{len(ranked)} profiles, {pending} with something to review")
|
||||
return complete
|
||||
|
||||
|
||||
def _score(
|
||||
args: argparse.Namespace, selected: dict, report: ProfileReport, ranked: list
|
||||
) -> bool:
|
||||
"""Append one profile's drift score to ``ranked``. True when its commit
|
||||
count was read on a sample of the paths it cites."""
|
||||
profile = selected[report.name]
|
||||
version = None
|
||||
commits = 0
|
||||
refused = ""
|
||||
sampled = False
|
||||
repo = select_repo(profile)
|
||||
if report.head and repo is not None:
|
||||
paths = _cited_paths(report)
|
||||
try:
|
||||
version = check_version(profile, repo, args.cache_dir, args.offline)
|
||||
for path in paths[:TRIAGE_PATH_SAMPLE]:
|
||||
commits += upstream.commits_touching(
|
||||
repo, path, report.pin, args.cache_dir, args.offline,
|
||||
head=report.head,
|
||||
)
|
||||
except upstream.UpstreamError as exc:
|
||||
# One forge refusing a request (Eden's answers 403 to commit
|
||||
# lookups) leaves this profile unscored, not the whole ranking.
|
||||
refused = "commit history unreachable"
|
||||
print(f"{report.name}: {exc}", file=sys.stderr)
|
||||
sampled = len(paths) > TRIAGE_PATH_SAMPLE and not refused
|
||||
ranked.append((drift_score(report, version, commits), report, refused))
|
||||
return sampled
|
||||
|
||||
|
||||
def _refuse_flag_combinations(args: argparse.Namespace) -> None:
|
||||
@@ -5260,30 +5300,24 @@ def main() -> None:
|
||||
return
|
||||
|
||||
reports = []
|
||||
for name, profile in selected.items():
|
||||
profile_path = Path(args.emulators_dir) / f"{name}.yml"
|
||||
try:
|
||||
report = _build_full_report(args, name, profile, profile_path, failed)
|
||||
except upstream.RateLimitError as exc:
|
||||
# A quota is one forge's: git.libretro.com answering 429 said
|
||||
# nothing about the 600 profiles on GitHub. upstream stops asking
|
||||
# that host, so the rest of the pass costs it nothing.
|
||||
print(f"{name}: {exc}", file=sys.stderr)
|
||||
report = ProfileReport(
|
||||
name=name, entries=[], counts={},
|
||||
skipped=f"rate limited: {exc.host or exc}",
|
||||
)
|
||||
failed.add(name)
|
||||
reports.append(report)
|
||||
continue
|
||||
reports.append(report)
|
||||
if writes:
|
||||
try:
|
||||
_apply_writes(args, name, profile, report)
|
||||
except YamlWriteError as exc:
|
||||
# The guard already left the file untouched, so keep going.
|
||||
print(f"{name}: write refused: {exc}", file=sys.stderr)
|
||||
failed.add(name)
|
||||
quiet = args.offline or len(selected) < 2
|
||||
if not quiet:
|
||||
print(
|
||||
f"reading {len(selected)} profiles against their forges, a line every "
|
||||
f"{PROGRESS_SECONDS} s. --offline answers from the cache alone.",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
progress = _Progress(len(selected), "read", quiet)
|
||||
complete = True
|
||||
try:
|
||||
for name, profile in selected.items():
|
||||
progress.step(len(reports), name)
|
||||
_read_one(args, name, profile, reports, failed, writes)
|
||||
except KeyboardInterrupt:
|
||||
# What was read is still worth printing: a ranking of 200 profiles
|
||||
# beats none after 15 minutes.
|
||||
_interrupted(len(reports), len(selected), "the output covers those read")
|
||||
complete = False
|
||||
|
||||
# --changed-only narrows every output, not the text report alone: the
|
||||
# documented `--all --changed-only --json` listed every profile.
|
||||
@@ -5291,14 +5325,49 @@ def main() -> None:
|
||||
report for report in reports
|
||||
if not args.changed_only or report.needs_review() or report.name in failed
|
||||
]
|
||||
_emit(args, selected, reports, shown)
|
||||
complete = _emit(args, selected, reports, shown) is not False and complete
|
||||
_refresh_source_paths(args)
|
||||
if not complete:
|
||||
raise SystemExit(130)
|
||||
if failed or (args.strict and any(
|
||||
report.needs_review() or report.uncheckable() for report in reports
|
||||
)):
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _read_one(
|
||||
args: argparse.Namespace,
|
||||
name: str,
|
||||
profile: dict,
|
||||
reports: list[ProfileReport],
|
||||
failed: set[str],
|
||||
writes: bool,
|
||||
) -> None:
|
||||
"""Build one profile's report into ``reports``, then apply the writes."""
|
||||
profile_path = Path(args.emulators_dir) / f"{name}.yml"
|
||||
try:
|
||||
report = _build_full_report(args, name, profile, profile_path, failed)
|
||||
except upstream.RateLimitError as exc:
|
||||
# A quota is one forge's: git.libretro.com answering 429 said
|
||||
# nothing about the 600 profiles on GitHub. upstream stops asking
|
||||
# that host, so the rest of the pass costs it nothing.
|
||||
print(f"{name}: {exc}", file=sys.stderr)
|
||||
reports.append(ProfileReport(
|
||||
name=name, entries=[], counts={},
|
||||
skipped=f"rate limited: {exc.host or exc}",
|
||||
))
|
||||
failed.add(name)
|
||||
return
|
||||
reports.append(report)
|
||||
if writes:
|
||||
try:
|
||||
_apply_writes(args, name, profile, report)
|
||||
except YamlWriteError as exc:
|
||||
# The guard already left the file untouched, so keep going.
|
||||
print(f"{name}: write refused: {exc}", file=sys.stderr)
|
||||
failed.add(name)
|
||||
|
||||
|
||||
# Profiles this run wrote. Their new pins name trees the site's record of
|
||||
# cited source paths does not hold yet.
|
||||
_WRITTEN: set[str] = set()
|
||||
@@ -5400,8 +5469,9 @@ def _emit(
|
||||
selected: dict,
|
||||
reports: list[ProfileReport],
|
||||
shown: list[ProfileReport],
|
||||
) -> None:
|
||||
"""Write the reports in the form the flags ask for."""
|
||||
) -> bool | None:
|
||||
"""Write the reports in the form the flags ask for. False when the
|
||||
triage was cut short."""
|
||||
if args.as_json:
|
||||
print(json.dumps([report_to_dict(r) for r in shown], indent=2))
|
||||
return
|
||||
@@ -5417,8 +5487,7 @@ def _emit(
|
||||
print(url)
|
||||
return
|
||||
if args.triage:
|
||||
_print_triage(args, selected, shown)
|
||||
return
|
||||
return _print_triage(args, selected, shown)
|
||||
for report in shown:
|
||||
print(format_report(report, args.changed_only))
|
||||
_print_extras(args, selected[report.name], report)
|
||||
|
||||
@@ -384,6 +384,9 @@ def main() -> int:
|
||||
"(the pipeline writes derivable.json)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
from common import work_from_repo_root # noqa: PLC0415
|
||||
|
||||
work_from_repo_root(args, parser, ["db", "provenance_dir", "emulators_dir", "write_derivable"])
|
||||
if args.write_derivable and not args.derive:
|
||||
parser.error("--write-derivable records what --derive finds: pass both")
|
||||
|
||||
|
||||
@@ -148,18 +148,28 @@ def _run_check_freshness(areas: tuple[str, ...], extra: list[str]) -> list[dict]
|
||||
if areas and set(areas) != set(AUTO_AREAS + MANUAL_AREAS):
|
||||
cmd += ["--only", ",".join(areas)]
|
||||
cmd += extra
|
||||
proc = subprocess.run(
|
||||
cmd, cwd=REPO_ROOT, capture_output=True, text=True, check=False, timeout=1800
|
||||
)
|
||||
if proc.returncode not in (0, 1):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, cwd=REPO_ROOT, capture_output=True, text=True, check=False, timeout=1800
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(
|
||||
f"check_freshness.py did not finish in {exc.timeout:.0f} s: run "
|
||||
f"`{' '.join(cmd[1:])} > report.json` alone, then pass --findings report.json"
|
||||
) from exc
|
||||
# Exit 1 is the STALE rows, and also a crash: only a JSON list on stdout
|
||||
# tells them apart, and the crash's own message is on stderr.
|
||||
try:
|
||||
findings = json.loads(proc.stdout) if proc.returncode in (0, 1) else None
|
||||
except json.JSONDecodeError:
|
||||
findings = None
|
||||
if not isinstance(findings, list):
|
||||
tail = (proc.stderr or proc.stdout).strip().splitlines()[-3:]
|
||||
raise RuntimeError(
|
||||
"check_freshness.py failed:\n" + "\n".join(tail or ["(no output)"])
|
||||
f"check_freshness.py failed (exit {proc.returncode}):\n"
|
||||
+ "\n".join(tail or ["(no output)"])
|
||||
)
|
||||
try:
|
||||
return json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"check_freshness.py returned unreadable JSON: {exc}") from exc
|
||||
return findings
|
||||
|
||||
|
||||
def load_findings(path: Path, areas: tuple[str, ...]) -> list[dict]:
|
||||
|
||||
@@ -201,6 +201,9 @@ def main() -> int:
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Machine-readable output")
|
||||
args = parser.parse_args()
|
||||
from common import work_from_repo_root # noqa: PLC0415
|
||||
|
||||
work_from_repo_root(args, parser, ["db", "emulators_dir"])
|
||||
|
||||
db = load_database(args.db)
|
||||
profiles = load_emulator_profiles(args.emulators_dir)
|
||||
|
||||
@@ -36,6 +36,14 @@ import urllib.request
|
||||
from collections.abc import Callable, Iterable
|
||||
from pathlib import Path
|
||||
|
||||
# The probe tools do not import common.py, which states the 3.9 floor for the
|
||||
# other scripts. Under 3.8 they stopped on a str.removesuffix AttributeError.
|
||||
if sys.version_info < (3, 9):
|
||||
sys.exit(
|
||||
f"{Path(sys.argv[0]).name} needs Python 3.9 or newer, "
|
||||
f"this is {sys.version.split()[0]}"
|
||||
)
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import yaml # noqa: E402
|
||||
|
||||
@@ -1122,7 +1122,7 @@ def main() -> int:
|
||||
which.add_argument("--all", action="store_true", help="every profile with an arcade source")
|
||||
parser.add_argument("--emulators-dir", default="emulators",
|
||||
help="profiles whose BIOS sets are read (default emulators)")
|
||||
parser.add_argument("--database", default="database.json",
|
||||
parser.add_argument("--db", default="database.json",
|
||||
help="collection index, to name BIOS sets the collection lacks")
|
||||
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT,
|
||||
help=f"where the parent maps are written (default {DEFAULT_OUTPUT})")
|
||||
@@ -1133,7 +1133,7 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
|
||||
profiles = load_emulator_profiles(args.emulators_dir, skip_aliases=False)
|
||||
names = collection_names(Path(args.database))
|
||||
names = collection_names(Path(args.db))
|
||||
failed = False
|
||||
for profile in sorted(PROFILES) if args.all else args.profile:
|
||||
if profile not in profiles:
|
||||
|
||||
@@ -14,6 +14,9 @@ games/catalogs/ and never in provenance/.
|
||||
|
||||
No-Intro comes from the auto-datfile-generator release asset (Dat-o-Matic
|
||||
bans automated clients), Redump from redump.info's per-system datfile route.
|
||||
The mirror is dantob's fork: hugo19941994's original has failed its daily
|
||||
rebuild since Dat-o-Matic's 2026-07 re-layout (no-intro.zip last rebuilt
|
||||
2026-07-08), and the fork carries the fix (d4a9d769, 2026-07-31).
|
||||
Every download is cached under .cache/game-dats/ with what the server said
|
||||
about it, so a second run reads nothing from the network and writes the same
|
||||
bytes. A DAT's region tag is a market label, never the value a core computes.
|
||||
@@ -49,8 +52,11 @@ SOURCES = ("no-intro", "redump")
|
||||
CACHE_DIR = Path(".cache") / "game-dats"
|
||||
OUTPUT_DIR = Path("games") / "catalogs"
|
||||
|
||||
NOINTRO_MIRROR = "https://github.com/dantob/auto-datfile-generator"
|
||||
NOINTRO_RELEASE = "Daily_Rebuild"
|
||||
NOINTRO_RELEASE_API = (
|
||||
"https://api.github.com/repos/hugo19941994/auto-datfile-generator/releases/tags/Daily_Rebuild"
|
||||
NOINTRO_MIRROR.replace("https://github.com/", "https://api.github.com/repos/")
|
||||
+ f"/releases/tags/{NOINTRO_RELEASE}"
|
||||
)
|
||||
NOINTRO_ASSET = "no-intro.zip"
|
||||
REDUMP_DATFILE_URL = "https://redump.info/datfile/{code}"
|
||||
@@ -120,7 +126,7 @@ LICENCES: dict[str, dict] = {
|
||||
"website or its operators is required for such use."
|
||||
),
|
||||
"mirror": {
|
||||
"repo": "https://github.com/hugo19941994/auto-datfile-generator",
|
||||
"repo": NOINTRO_MIRROR,
|
||||
"licence": "MIT",
|
||||
"scope": "the mirror's scripts only, the DATs it republishes stay under the terms above",
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -32,6 +33,7 @@ STEAMOS_API = (
|
||||
WINDOWS_API = (
|
||||
"https://api.github.com/repos/EmuDeck/emudeck-we/contents/functions/EmuScripts"
|
||||
)
|
||||
RETROARCH_TARGETS = Path(__file__).resolve().parents[3] / "platforms" / "targets" / "retroarch.yml"
|
||||
|
||||
def _fetch(url: str) -> str:
|
||||
headers = {"User-Agent": "retrobios-scraper/1.0"}
|
||||
@@ -75,7 +77,7 @@ class Scraper(BaseTargetScraper):
|
||||
return self._revision
|
||||
|
||||
def _fetch_cores_for_target(
|
||||
self, api_url: str, label: str, arch: str = "x86_64"
|
||||
self, api_url: str, label: str, retroarch_target: str
|
||||
) -> list[str]:
|
||||
print(f" fetching {label} EmuScripts...", file=sys.stderr)
|
||||
scripts = _list_emuscripts(api_url)
|
||||
@@ -94,7 +96,7 @@ class Scraper(BaseTargetScraper):
|
||||
standalone_count = len(cores)
|
||||
# EmuDeck ships RetroArch = all its libretro cores are available
|
||||
if has_retroarch:
|
||||
ra_cores = self._load_retroarch_cores(arch)
|
||||
ra_cores = self._load_retroarch_cores(retroarch_target)
|
||||
for c in ra_cores:
|
||||
if c not in seen:
|
||||
seen.add(c)
|
||||
@@ -108,24 +110,21 @@ class Scraper(BaseTargetScraper):
|
||||
return sorted(cores)
|
||||
|
||||
@staticmethod
|
||||
def _load_retroarch_cores(arch: str) -> list[str]:
|
||||
"""Load RetroArch target cores for given architecture."""
|
||||
import os
|
||||
|
||||
target_path = os.path.join("platforms", "targets", "retroarch.yml")
|
||||
with open(target_path) as f:
|
||||
def _load_retroarch_cores(target: str, path: Path = RETROARCH_TARGETS) -> list[str]:
|
||||
"""The buildbot's libretro cores for one RetroArch target, by name:
|
||||
linux-x86_64 and windows-x86_64 share an architecture, not a list."""
|
||||
with path.open(encoding="utf-8") as f:
|
||||
data = yaml_load(f) or {}
|
||||
# Find a target matching the architecture
|
||||
for tinfo in data.get("targets", {}).values():
|
||||
if tinfo.get("architecture") == arch and tinfo.get("cores"):
|
||||
return tinfo["cores"]
|
||||
raise RuntimeError(f"no {arch} core list in {target_path}")
|
||||
cores = ((data.get("targets") or {}).get(target) or {}).get("cores")
|
||||
if not cores:
|
||||
raise RuntimeError(f"no {target} core list in {path}")
|
||||
return cores
|
||||
|
||||
def fetch_targets(self) -> dict:
|
||||
steamos_cores = self._fetch_cores_for_target(
|
||||
f"{STEAMOS_API}?ref={self.revision}", "SteamOS"
|
||||
f"{STEAMOS_API}?ref={self.revision}", "SteamOS", "linux-x86_64"
|
||||
)
|
||||
windows_cores = self._fetch_cores_for_target(WINDOWS_API, "Windows")
|
||||
windows_cores = self._fetch_cores_for_target(WINDOWS_API, "Windows", "windows-x86_64")
|
||||
|
||||
targets: dict[str, dict] = {}
|
||||
if steamos_cores:
|
||||
|
||||
@@ -1009,6 +1009,9 @@ def main() -> int:
|
||||
"without it the run reports them and exits 0",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
from common import work_from_repo_root # noqa: PLC0415
|
||||
|
||||
work_from_repo_root(args, parser, ["db", "platforms_dir", "emulators_dir"])
|
||||
|
||||
import contextlib
|
||||
|
||||
|
||||
+41
-38
@@ -41,8 +41,10 @@ from common import (
|
||||
_foreign_tree,
|
||||
_owner_tokens,
|
||||
platform_arg,
|
||||
work_from_repo_root,
|
||||
target_listing_line,
|
||||
suggest_names,
|
||||
system_index,
|
||||
require_platform,
|
||||
archive_entry,
|
||||
archive_is_optional,
|
||||
@@ -129,29 +131,6 @@ DEFAULT_EMULATORS_DIR = "emulators"
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def work_from_repo_root(
|
||||
args: argparse.Namespace, parser: argparse.ArgumentParser, path_flags: list[str]
|
||||
) -> None:
|
||||
"""Run from the repository root, whichever directory the command started in.
|
||||
|
||||
database.json records every file from the root and the data registry
|
||||
names its caches the same way, so a run from scripts/ ended on a
|
||||
FileNotFoundError traceback. A path the caller typed is read from where
|
||||
they typed it, a default from the root.
|
||||
"""
|
||||
if os.path.realpath(os.getcwd()) == os.path.realpath(REPO_ROOT):
|
||||
return
|
||||
for flag in path_flags:
|
||||
value = getattr(args, flag, None)
|
||||
if value and value != parser.get_default(flag):
|
||||
setattr(args, flag, os.path.abspath(value))
|
||||
os.chdir(REPO_ROOT)
|
||||
if getattr(args, "platform", None):
|
||||
# Read before the move, the registry was not there to map
|
||||
# "RetroArch" to its slug.
|
||||
args.platform = platform_arg(args.platform)
|
||||
|
||||
|
||||
# Status model: aligned with Batocera BiosStatus (batocera-systems:967-969)
|
||||
|
||||
|
||||
@@ -1808,7 +1787,8 @@ def _print_undeclared_entry(
|
||||
ref_part = f" [{u['source_ref']}]" if u.get("source_ref") else ""
|
||||
print(f" validates {checks_label}{ref_part}")
|
||||
else:
|
||||
print(f" [{'+'.join(checks)}]")
|
||||
# A bare "[size]" under the entry said nothing to a reader.
|
||||
print(f" validates {'+'.join(checks)}")
|
||||
|
||||
|
||||
def _split_undeclared(undeclared: list[dict]) -> tuple[list[dict], list[dict], list[dict]]:
|
||||
@@ -1920,10 +1900,15 @@ def _print_undeclared_section(result: dict, verbose: bool) -> None:
|
||||
u for u in bundled
|
||||
if u["required"] and u.get("category", "bios") != "game_data"
|
||||
]
|
||||
names = ", ".join(sorted({u["name"] for u in shown}))
|
||||
named = sorted({u["name"] for u in shown})
|
||||
# The required firmware alone read as the whole count: 14 names
|
||||
# for 154 entries, nothing saying the rest existed.
|
||||
hidden = len({u["name"] for u in bundled}) - len(named)
|
||||
more = f"{hidden} more, " if named else ""
|
||||
items = named + ([f"{more}--verbose lists them"] if hidden else [])
|
||||
print(
|
||||
f" Bundled with their emulator, not collected: {len(bundled)}"
|
||||
+ (f" ({names})" if names else "")
|
||||
+ (f" ({', '.join(items)})" if items else "")
|
||||
)
|
||||
if unplaced:
|
||||
print(f" Core files held but not packed: {len(unplaced)} "
|
||||
@@ -2611,7 +2596,10 @@ def verify_system(
|
||||
f"No emulators found for system(s): {', '.join(system_ids)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(suggest_names(system_ids, sorted(all_systems), "--list-systems"),
|
||||
# --list-systems merges spellings that differ in case or separator:
|
||||
# the hint counted 579 spellings for the 532 lines it prints.
|
||||
print(suggest_names(system_ids, sorted(all_systems), "--list-systems",
|
||||
listed=len(system_index(profiles))),
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -2821,7 +2809,7 @@ def _main(held: contextlib.ExitStack) -> None:
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False, description="Platform-native BIOS verification")
|
||||
parser.add_argument("--platform", "-p", type=platform_arg, help="Platform name")
|
||||
parser.add_argument(
|
||||
"--all", action="store_true", help="Verify all active platforms"
|
||||
"--all", action="store_true", help="Verify every registered platform"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--emulator", "-e", help="Emulator profile name(s), comma-separated"
|
||||
@@ -3121,6 +3109,13 @@ def _overall_summary(group_results: list[tuple[dict, list[str]]]) -> str:
|
||||
named = [f"{name} {count}" for name, count in counts.items() if count]
|
||||
return f" ({', '.join(named)})" if named else ""
|
||||
|
||||
def on(groups: tuple[str, ...]) -> str:
|
||||
"""The platforms a file is missing on, by the shorter side."""
|
||||
if len(group_results) < 2 or len(groups) * 2 <= len(group_results):
|
||||
return ", ".join(groups)
|
||||
others = [" / ".join(g) for _, g in group_results if " / ".join(g) not in groups]
|
||||
return "every platform" + (f" but {', '.join(others)}" if others else "")
|
||||
|
||||
parts = []
|
||||
for severity, label in ((Severity.CRITICAL, "critical"), (Severity.WARNING, "warning"),
|
||||
(Severity.INFO, "info")):
|
||||
@@ -3139,18 +3134,26 @@ def _overall_summary(group_results: list[tuple[dict, list[str]]]) -> str:
|
||||
parts.append(f"{total} {'discrepancy' if total == 1 else 'discrepancies'}{where(counts)}")
|
||||
lines = [f"{platforms} platforms: {', '.join(parts)}"]
|
||||
|
||||
missing = {
|
||||
" / ".join(group): sorted({
|
||||
# Grouped by file, not by platform: sfiii2a.zip missing everywhere was
|
||||
# counted and printed once per platform, 24 files for 10 distinct ones.
|
||||
holders: dict[str, list[str]] = {}
|
||||
for r, group in group_results:
|
||||
for name in sorted({
|
||||
u["name"] for u in _core_required_missing(_split_undeclared(
|
||||
r.get("undeclared_files", []))[1])
|
||||
})
|
||||
for r, group in group_results
|
||||
}
|
||||
total = sum(len(names) for names in missing.values())
|
||||
if total:
|
||||
detail = "; ".join(f"{group}: {', '.join(names)}"
|
||||
for group, names in missing.items() if names)
|
||||
lines.append(f"{total} core file(s) a core cannot run without are missing ({detail})")
|
||||
}):
|
||||
holders.setdefault(name, []).append(" / ".join(group))
|
||||
if holders:
|
||||
by_platforms: dict[tuple[str, ...], list[str]] = {}
|
||||
for name, groups in sorted(holders.items()):
|
||||
by_platforms.setdefault(tuple(groups), []).append(name)
|
||||
detail = "; ".join(
|
||||
f"{', '.join(names)} ({on(groups)})"
|
||||
for groups, names in sorted(by_platforms.items(),
|
||||
key=lambda item: (-len(item[0]), item[1]))
|
||||
)
|
||||
lines.append(f"{len(holders)} core file(s) a core cannot run without are "
|
||||
f"missing: {detail}")
|
||||
for category, label in _NOT_FIRMWARE.items():
|
||||
counts = {
|
||||
" / ".join(group): len((r.get("missing_not_firmware") or {}).get(category, []))
|
||||
|
||||
Reference in new issue
Block a user