feat: add freshness tooling and override defaults

This commit is contained in:
Abdessamad Derraz committed 2026-10-04 14:54:11 +02:00
1 parent 08ef1e8dcf
commit 8a2a9c6ffa
5 files changed
+1724

No files matched your search

+43
View File
@@ -5,6 +5,8 @@
# Format:
# platform_name:
# targets:
# _default: # applies to every target
# remove_cores: [...]
# target-name:
# aliases: [alias1, alias2]
# add_cores: [core_to_add]
@@ -15,6 +17,27 @@
# doesn't resolve. These are confirmed available on x86_64 via Config.in.
batocera:
targets:
_default:
# Amiga machine variants (A500, CD32, etc.) are selectors passed to
# fsuae/amiberry, not emulators of their own; batocera-es-system
# groups them under the emulator's node in es_systems.yml.
# odcommander is OpenDingux's file manager; flatpak is a runtime
# launcher. Neither emulates anything.
remove_cores:
- A500
- "A500+"
- A600
- A1000
- A1200
- A3000
- A4000
- CD32
- CDTV
- flatpak
- odcommander
- sh
- steam
- wine-tkg
x86_64:
add_cores: [citron, demul, model2, xenia]
zen3:
@@ -37,3 +60,23 @@ retroarch:
aliases: [ps2]
playstation-psp:
aliases: [psp]
retrodeck:
targets:
_default:
# RetroDECK ships RetroArch itself as a component; its target list
# includes the frontend name alongside the actual cores.
remove_cores: [retroarch]
emudeck:
targets:
_default:
# EmuDeck's EmuScripts tree carries a template.sh placeholder used
# to scaffold new installers; it is not an emulator.
remove_cores: [template]
retropie:
targets:
_default:
# Homebrew game scriptmodule, not an emulator.
remove_cores: [superflappybirds]
File diff suppressed because it is too large. Load diff
+30
View File
@@ -276,7 +276,12 @@ def load_target_config(
cores = set(str(c) for c in targets[canonical].get("cores", []))
default_ovr = overrides.get("_default", {})
ovr = overrides.get(canonical, {})
for c in default_ovr.get("add_cores", []):
cores.add(str(c))
for c in default_ovr.get("remove_cores", []):
cores.discard(str(c))
for c in ovr.get("add_cores", []):
cores.add(str(c))
for c in ovr.get("remove_cores", []):
@@ -1096,6 +1101,29 @@ def group_identical_platforms(
return result
def runs_standalone(
emu_name: str, profile: dict, standalone_cores: set[str]
) -> bool:
"""Whether a platform lays this emulator's files out for its standalone build.
A platform names the emulators it launches standalone in
``standalone_cores``, by profile key or by any name in ``cores:``. The
name alone is not enough: Recalbox and Batocera call their standalone
ScummVM ``scummvm``, which is also the key of the libretro core's
profile, and that profile documents no standalone build. Laid out
"standalone" it would lose every ``path:`` and drop its files at the
root, so a profile whose ``type`` has no standalone build keeps its
libretro layout whatever the platform calls it.
"""
if not standalone_cores:
return False
if "standalone" not in str(profile.get("type", "")):
return False
return emu_name in standalone_cores or bool(
standalone_cores & {str(c) for c in profile.get("cores", [])}
)
def resolve_platform_cores(
config: dict,
profiles: dict[str, dict],
@@ -1193,6 +1221,8 @@ MANUFACTURER_PREFIXES = (
"interton-",
"texas-instruments-",
"videoton-",
"wenquxing-",
"aquaplus-",
)
+430
View File
@@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""Refresh everything check_freshness.py reports as STALE, in parallel.
check_freshness.py already answers "what is out of date". This script turns
that answer into actions: it parses the JSON report, matches each stale
subject to the command that pulls its upstream, runs them concurrently, and
leaves every write in the working tree for human review. Nothing is ever
committed.
Mapping (stale -> command):
platforms/<name> python -m scripts.scraper.<module>_scraper
-o platforms/<name>.yml
targets/<name> python -m scripts.scraper.targets.<module>
-o platforms/targets/<name>.yml
data/<key> python scripts/refresh_data_dirs.py --key <key>
--force
catalogs/mame recipes python -m scripts.scraper.romset_dat_importer
--source mame --fetch
catalogs/fbneo recipes python -m scripts.scraper.romset_dat_importer
--source fbneo --fetch
Surfaced only (no refresh): coreinfo gaps, redump/no-intro/tosec packs,
CI pins (install.py SHA-256, PyPI, pinned actions), profile_sync. Each needs
a human read (new .info file, manual DAT download, PyPI version bump).
Usage:
python scripts/refresh_stale.py --dry-run
python scripts/refresh_stale.py --only platforms,targets
python scripts/refresh_stale.py --jobs 6
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
LOG_DIR = REPO_ROOT / "tmp" / "refresh_stale"
CHECK_FRESHNESS = REPO_ROOT / "scripts" / "check_freshness.py"
PLATFORMS_REGISTRY = REPO_ROOT / "platforms" / "_registry.yml"
AUTO_AREAS = ("platforms", "targets", "data", "catalogs")
MANUAL_AREAS = ("coreinfo", "ci", "profiles")
JOB_TIMEOUT = 1800 # 30 minutes per refresher; scrapers rarely exceed 10
@dataclass(frozen=True)
class Job:
"""One refresh action derived from a stale finding."""
area: str
subject: str
command: list[str]
log_path: Path
@dataclass(frozen=True)
class JobResult:
job: Job
returncode: int
duration: float
tail: str # last non-empty line of stderr or stdout
def _load_platform_registry() -> dict[str, dict]:
"""Return the platforms map from _registry.yml.
pyyaml is already a project dependency; the registry is small so we read
it once at startup rather than parsing each dispatch.
"""
import yaml
with PLATFORMS_REGISTRY.open(encoding="utf-8") as fh:
return (yaml.safe_load(fh) or {}).get("platforms") or {}
def _run_check_freshness(areas: tuple[str, ...], extra: list[str]) -> list[dict]:
"""Run check_freshness.py --json and return its findings."""
cmd = [sys.executable, str(CHECK_FRESHNESS), "--json"]
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):
tail = (proc.stderr or proc.stdout).strip().splitlines()[-3:]
raise RuntimeError(
"check_freshness.py failed:\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
def _slug(subject: str) -> str:
"""A safe filename stem for a finding's subject."""
return re.sub(r"[^A-Za-z0-9_.-]+", "_", subject).strip("_") or "job"
def plan_jobs(
findings: list[dict], registry: dict[str, dict]
) -> tuple[list[Job], list[dict], list[dict]]:
"""Split findings into (refreshable jobs, surfaced-only, ignored).
Surfaced-only entries carry stale state that only a human can resolve
(new .info file, manual DAT download, PyPI bump). Ignored entries are
anything not STALE (OK, SKIPPED, UNKNOWN, ERROR); ERROR is also surfaced
so the final report mentions it.
"""
jobs: list[Job] = []
seen: set[tuple[str, str]] = set()
surfaced: list[dict] = []
ignored: list[dict] = []
for finding in findings:
status = finding.get("status")
area = finding.get("area")
subject = str(finding.get("subject") or "")
if status == "ERROR":
surfaced.append(finding)
continue
if status != "STALE":
ignored.append(finding)
continue
command = _command_for(area, subject, registry)
if command is None:
surfaced.append(finding)
continue
key = (area, " ".join(command))
if key in seen:
# Two findings funneling to the same command (e.g. mame recipes
# listed twice) collapse to one job.
continue
seen.add(key)
log = LOG_DIR / f"{area}__{_slug(subject)}.log"
jobs.append(Job(area=area, subject=subject, command=command, log_path=log))
return jobs, surfaced, ignored
def _command_for(
area: str, subject: str, registry: dict[str, dict]
) -> list[str] | None:
"""Map a stale finding to its refresh command, or None if manual."""
if area == "platforms":
entry = registry.get(subject) or {}
scraper = entry.get("scraper")
if not scraper:
return None
return [
sys.executable,
"-m",
f"scripts.scraper.{scraper}_scraper",
"-o",
f"platforms/{subject}.yml",
]
if area == "targets":
# "<platform> cores" rows list buildbot names without a profile; no
# scraper fixes that, a human writes `cores:` or _overrides.yml.
if subject.endswith(" cores"):
return None
entry = registry.get(subject) or {}
module = entry.get("target_scraper")
if not module:
return None
return [
sys.executable,
"-m",
f"scripts.scraper.targets.{module}_scraper",
"-o",
f"platforms/targets/{subject}.yml",
]
if area == "data":
if subject == "_data_dirs.yml":
# The registry file itself cannot be refreshed; its entries can.
return None
return [
sys.executable,
"scripts/refresh_data_dirs.py",
"--key",
subject,
"--force",
]
if area == "catalogs":
if subject == "mame recipes":
return [
sys.executable,
"-m",
"scripts.scraper.romset_dat_importer",
"--source",
"mame",
"--fetch",
]
if subject == "fbneo recipes":
return [
sys.executable,
"-m",
"scripts.scraper.romset_dat_importer",
"--source",
"fbneo",
"--fetch",
]
# redump / no-intro / tosec need manual DAT download.
return None
# coreinfo, ci, profiles: never auto-refresh.
return None
def _github_token_env() -> dict[str, str]:
"""Hand `gh auth token` to the subprocess environment.
emudeck and retropie target scrapers hit the GitHub API; without a token
the unauthenticated rate limit (60/h) is burnt within a few platforms.
"""
env = os.environ.copy()
if env.get("GITHUB_TOKEN"):
return env
try:
proc = subprocess.run(
["gh", "auth", "token"], capture_output=True, text=True, timeout=10, check=False
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return env
token = (proc.stdout or "").strip()
if proc.returncode == 0 and token:
env["GITHUB_TOKEN"] = token
return env
def run_job(job: Job, env: dict[str, str]) -> JobResult:
"""Execute one refresh command and collect its output."""
start = time.monotonic()
job.log_path.parent.mkdir(parents=True, exist_ok=True)
with job.log_path.open("w", encoding="utf-8") as log:
log.write(f"$ {' '.join(job.command)}\n")
log.flush()
try:
proc = subprocess.run(
job.command,
cwd=REPO_ROOT,
stdout=log,
stderr=subprocess.STDOUT,
env=env,
timeout=JOB_TIMEOUT,
check=False,
)
returncode = proc.returncode
except subprocess.TimeoutExpired:
log.write(f"\nTIMEOUT after {JOB_TIMEOUT}s\n")
returncode = 124
duration = time.monotonic() - start
tail = _log_tail(job.log_path)
return JobResult(job=job, returncode=returncode, duration=duration, tail=tail)
def _log_tail(path: Path) -> str:
"""Last non-empty line of a log, truncated for the summary table."""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
for line in reversed(text.splitlines()):
stripped = line.strip()
if stripped and not stripped.startswith("$ "):
return stripped[:120]
return ""
def render(
results: list[JobResult],
surfaced: list[dict],
ignored: list[dict],
) -> str:
"""Human-readable table of what ran and what still needs a human."""
lines: list[str] = []
subject_w = max((len(r.job.subject) for r in results), default=10)
subject_w = min(max(subject_w, 10), 44)
if results:
lines.append("\n[refreshed]")
lines.append(
f" {'STATUS':8} {'AREA':10} {'SUBJECT':{subject_w}} "
f"{'DUR':>7} LOG"
)
for r in sorted(results, key=lambda x: (x.job.area, x.job.subject)):
status = "OK" if r.returncode == 0 else f"FAIL ({r.returncode})"
rel_log = r.job.log_path.relative_to(REPO_ROOT)
lines.append(
f" {status:8} {r.job.area:10} {r.job.subject[:subject_w]:{subject_w}} "
f"{r.duration:6.1f}s {rel_log}"
)
if r.returncode != 0 and r.tail:
lines.append(f" -> {r.tail}")
if surfaced:
lines.append("\n[manual review needed]")
for f in surfaced:
detail = str(f.get("detail") or "").strip()
lines.append(
f" {f.get('status', ''):7} {f.get('area', ''):10} "
f"{f.get('subject', ''):28} {detail[:100]}"
)
if ignored:
counts: dict[str, int] = {}
for f in ignored:
counts[str(f.get("status") or "?")] = counts.get(str(f.get("status") or "?"), 0) + 1
parts = ", ".join(f"{n} {status.lower()}" for status, n in sorted(counts.items()))
lines.append(f"\n[ignored] {parts}")
ok = sum(1 for r in results if r.returncode == 0)
fail = len(results) - ok
lines.append(
f"\nREFRESH: {ok} ok, {fail} failed, {len(surfaced)} to review manually"
)
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument(
"--only",
help="comma-separated areas to check: " + ",".join(AUTO_AREAS),
)
parser.add_argument(
"--dry-run",
action="store_true",
help="list what would be refreshed; do not run any command",
)
parser.add_argument(
"--jobs",
type=int,
default=4,
help="parallel refreshers (default: 4)",
)
parser.add_argument(
"--json",
action="store_true",
help="machine-readable summary (jobs, surfaced, ignored)",
)
parser.add_argument(
"--freshness-arg",
action="append",
default=[],
help="extra flag to pass through to check_freshness.py (repeatable)",
)
args = parser.parse_args()
all_areas = AUTO_AREAS + MANUAL_AREAS
if args.only:
areas = tuple(a.strip() for a in args.only.split(",") if a.strip())
unknown = sorted(set(areas) - set(all_areas))
if unknown:
parser.error(f"unknown area: {', '.join(unknown)}")
else:
areas = all_areas
try:
findings = _run_check_freshness(areas, args.freshness_arg)
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 2
registry = _load_platform_registry()
jobs, surfaced, ignored = plan_jobs(findings, registry)
if args.dry_run:
print(f"\n[plan] {len(jobs)} refreshable, {len(surfaced)} manual, "
f"{len(ignored)} already clean/skipped")
for job in sorted(jobs, key=lambda j: (j.area, j.subject)):
print(f" {job.area:10} {job.subject:28} {' '.join(job.command)}")
if surfaced:
print("\n[manual review needed]")
for f in surfaced:
print(f" {f.get('status', ''):7} {f.get('area', ''):10} "
f"{f.get('subject', ''):28} {str(f.get('detail') or '')[:100]}")
return 0
LOG_DIR.mkdir(parents=True, exist_ok=True)
env = _github_token_env()
results: list[JobResult] = []
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
futures = [pool.submit(run_job, job, env) for job in jobs]
for future in as_completed(futures):
results.append(future.result())
if args.json:
payload = {
"refreshed": [
{
"area": r.job.area,
"subject": r.job.subject,
"returncode": r.returncode,
"duration_sec": round(r.duration, 2),
"log": str(r.job.log_path.relative_to(REPO_ROOT)),
"tail": r.tail,
}
for r in results
],
"surfaced": surfaced,
"ignored_count": len(ignored),
}
print(json.dumps(payload, indent=2))
else:
print(render(results, surfaced, ignored))
return 0 if all(r.returncode == 0 for r in results) else 1
if __name__ == "__main__":
sys.exit(main())
+218
View File
@@ -0,0 +1,218 @@
"""check_freshness: the pure parts, no network.
The script answers "is the local copy the one upstream serves" for every
transcribed layer. What can be locked without a network is how a diff is
read, how a core name is resolved, how a pin is parsed, and how a profile
report is folded, since each of those decides whether a row says STALE.
"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import check_freshness as cf # noqa: E402
from common import yaml_load # noqa: E402
class PlatformDiffTests(unittest.TestCase):
def _platform(self, version="1", files=None, cores=None):
return {
"version": version,
"cores": cores or ["a", "b"],
"systems": {
"sys": {"files": files if files is not None else [
{"name": "x.bin", "destination": "x.bin", "md5": "1"},
]},
},
}
def test_identical_files_are_not_a_change(self):
diff = cf.diff_platform(self._platform(), self._platform())
self.assertFalse(diff.changed)
self.assertEqual(diff.summary(), "identical")
def test_version_alone_is_a_change_but_not_content(self):
diff = cf.diff_platform(self._platform("1"), self._platform("2"))
self.assertTrue(diff.changed)
self.assertFalse(diff.content_changed)
self.assertEqual(diff.version, ("1", "2"))
def test_hash_change_on_a_destination_is_a_change_not_a_swap(self):
old = self._platform(files=[{"name": "x.bin", "destination": "x.bin", "md5": "1"}])
new = self._platform(files=[{"name": "x.bin", "destination": "x.bin", "md5": "2"}])
diff = cf.diff_platform(old, new)
self.assertEqual(diff.files_changed, ["sys/x.bin"])
self.assertEqual(diff.files_added, [])
self.assertEqual(diff.files_removed, [])
def test_systems_files_and_cores_are_reported_separately(self):
old = self._platform()
new = self._platform(cores=["a", "c"])
new["systems"]["other"] = {"files": []}
new["systems"]["sys"]["files"].append({"name": "y.bin", "destination": "y.bin"})
new["standalone_cores"] = ["dolphin"]
diff = cf.diff_platform(old, new)
self.assertEqual(diff.systems_added, ["other"])
self.assertEqual(diff.files_added, ["sys/y.bin"])
self.assertEqual(diff.cores_added, ["c", "standalone:dolphin"])
self.assertEqual(diff.cores_removed, ["b"])
self.assertIn("+cores 2", diff.summary())
def test_target_stamp_is_not_a_change(self):
old = {"scraped_at": "2026-01-01", "targets": {"t": {"cores": ["a"]}}}
new = {"scraped_at": "2026-02-01", "targets": {"t": {"cores": ["a"]}}}
self.assertFalse(cf.diff_targets(old, new).content_changed)
new["targets"]["t"]["cores"].append("b")
diff = cf.diff_targets(old, new)
self.assertEqual(diff.cores_added, ["t/b"])
class CoreResolutionTests(unittest.TestCase):
PROFILES = {
"beetle_psx": {"cores": ["mednafen_psx"], "type": "libretro"},
"eka2l1": {"cores": ["eka2l1"], "type": "standalone"},
"FreeIntv": {"cores": ["freeintvtsoverlay"], "type": "alias"},
}
def test_index_maps_key_and_every_core_alias(self):
index = cf.profile_name_index(self.PROFILES)
self.assertEqual(index["mednafen_psx"], "beetle_psx")
self.assertEqual(index["beetle_psx"], "beetle_psx")
def test_unresolved_honours_remove_cores_per_target(self):
index = cf.profile_name_index(self.PROFILES)
targets = {
"targets": {
"android": {"cores": ["mednafen_psx", "na", "ghost"]},
"linux": {"cores": ["ghost"]},
}
}
removed = {"android": {"na"}}
self.assertEqual(
cf.unresolved_target_cores(targets, index, removed),
{"ghost": ["android", "linux"]},
)
def test_coreinfo_gaps_fold_case_and_flag_standalone_profiles(self):
names = ["FreeIntvTSOverlay", "eka2l1", "wqxemu", "mednafen_psx"]
unprofiled, standalone = cf.coreinfo_gaps(names, self.PROFILES)
self.assertEqual(unprofiled, ["wqxemu"])
self.assertEqual(standalone, [("eka2l1", "eka2l1")])
class PinParsingTests(unittest.TestCase):
WORKFLOW = """
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- run: pip install pyyaml jsonschema==4.23.0 "mkdocs-material>=9.7.5,<10" "pymdown-extensions>=10.14"
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128
"""
def test_pip_pins_keep_the_specifier(self):
pins = cf.parse_pip_pins(self.WORKFLOW)
self.assertEqual(pins["pyyaml"], "")
self.assertEqual(pins["jsonschema"], "==4.23.0")
self.assertEqual(pins["mkdocs-material"], ">=9.7.5,<10")
self.assertEqual(pins["pymdown-extensions"], ">=10.14")
def test_action_pins_carry_sha_and_optional_tag(self):
pins = cf.parse_action_pins(self.WORKFLOW)
self.assertEqual(pins["actions/checkout"], ("3d3c42e5aac5ba805825da76410c181273ba90b1", "v7"))
self.assertEqual(pins["actions/deploy-pages"][1], "")
def test_specifier_admits_or_refuses_the_latest(self):
self.assertTrue(cf.specifier_allows("", "9.9"))
self.assertTrue(cf.specifier_allows(">=9.7.5,<10", "9.7.7"))
self.assertFalse(cf.specifier_allows(">=9.7.5,<10", "10.0.0"))
self.assertFalse(cf.specifier_allows("==4.23.0", "4.26.0"))
self.assertTrue(cf.specifier_allows("==4.23.0", "4.23.0"))
self.assertTrue(cf.specifier_allows(">=10.14", "12.0.1"))
class CatalogParsingTests(unittest.TestCase):
def test_tosec_newest_release_from_category_links(self):
html = (
'<a href="/downloads/category/58-2024-05-17">x</a>'
'<a href="/downloads/category/59-2025-03-13">y</a>'
'<a href="/downloads/category/22-datfiles">z</a>'
)
self.assertEqual(cf.tosec_latest_pack(html), "2025-03-13")
self.assertIsNone(cf.tosec_latest_pack("<html></html>"))
def test_fbneo_drift_compares_blob_shas_and_reports_untracked(self):
listing = [
{"type": "file", "name": "A.dat", "sha": "1"},
{"type": "file", "name": "B.dat", "sha": "2"},
{"type": "dir", "name": "old"},
]
snapshot = {"upstream": {"blobs": {"A.dat": "1", "B.dat": "9"}}}
self.assertEqual(cf.fbneo_blob_drift(snapshot, listing), (["B.dat"], True))
self.assertEqual(cf.fbneo_blob_drift({"upstream": {}}, listing), ([], False))
snapshot = {"upstream": {"blobs": {"A.dat": "1", "B.dat": "2", "C.dat": "3"}}}
self.assertEqual(cf.fbneo_blob_drift(snapshot, listing), (["C.dat"], True))
def test_mame_versions_read_from_dat_labels(self):
recipes = {"dats": {"MAME mame0250": "0.250", "MAME mame0289": "0.289", "FinalBurn Neo": "x"}}
self.assertEqual(cf.mame_versions(recipes), ["mame0250", "mame0289"])
class ProfileSummaryTests(unittest.TestCase):
def test_summary_separates_review_moved_and_unreachable(self):
report = [
{"name": "a", "pin": "1" * 40, "head": "1" * 40, "needs_review": 0},
{"name": "b", "pin": "1" * 40, "head": "2" * 40, "needs_review": 0},
{"name": "c", "pin": "1" * 40, "head": "2" * 40, "needs_review": 3},
{"name": "d", "skipped": "host does not resolve"},
]
findings = cf.summarize_profile_sync(report)
by_subject = {f.subject: f for f in findings}
self.assertEqual(by_subject["profile_sync"].status, cf.STALE)
self.assertEqual(by_subject["profile_sync"].local, "1 at tip, 1 anchored past the tip")
self.assertEqual(by_subject["c"].status, cf.STALE)
self.assertEqual(by_subject["d"].status, cf.UNKNOWN)
self.assertNotIn("a", by_subject)
self.assertNotIn("b", by_subject)
def test_a_non_list_report_is_an_error_not_a_crash(self):
findings = cf.summarize_profile_sync({"oops": 1})
self.assertEqual(findings[0].status, cf.ERROR)
class RepositoryWiringTests(unittest.TestCase):
"""The script derives its work from the registry: a platform whose scraper
module does not exist would be skipped in silence."""
def test_every_registered_scraper_module_exists(self):
rows = cf._scrapable_platforms(REPO_ROOT / "platforms")
self.assertGreaterEqual(len(rows), 10)
for _name, module, _path in rows:
self.assertTrue(
(REPO_ROOT / (module.replace(".", "/") + ".py")).is_file(), module
)
def test_inheriting_platforms_without_a_source_are_not_scraped(self):
names = {name for name, _m, _p in cf._scrapable_platforms(REPO_ROOT / "platforms")}
with (REPO_ROOT / "platforms" / "lakka.yml").open(encoding="utf-8") as fh:
lakka = yaml_load(fh)
self.assertTrue(lakka.get("inherits"))
self.assertNotIn("lakka", names)
self.assertIn("retropie", names)
def test_render_counts_every_status_once(self):
findings = [
cf.Finding("ci", "a", cf.OK),
cf.Finding("ci", "b", cf.STALE, detail="x"),
cf.Finding("data", "c", cf.UNKNOWN),
]
text = cf.render(findings)
self.assertIn("FRESHNESS: 1 stale, 1 unknown, 1 ok", text)
self.assertIn("[ci]", text)
self.assertIn("[data]", text)
if __name__ == "__main__":
unittest.main()