Files
libretro/scripts/retired_files.py
T
Abdessamad Derraz f12ba5632b fix: run retired_files through run_cli
The new script's entry point skipped run_cli and its options were in no
tools.md row, which two suite checks hold every tracked script to.
2026-11-06 05:18:11 +01:00

150 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Files that left the collection, and the file that took their place.
A file's content address (files/?sha1=, #sha1-<12> on its card) is
published, and a published address never stops answering. When a rebuild
of database.json drops a sha1, the file it named is recorded here: its
name, its last path, its hashes, the day it left and the sha1 the
collection now holds at that path, or under that name in that folder.
The site's lookup reads the record and answers with it.
The record is append-only, written by generate_db.py beside the database.
A sha1 that comes back leaves it. `--backfill` rebuilds it from the
history of database.json since content addresses were published.
Usage:
python scripts/retired_files.py --backfill
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import run_cli, write_if_changed # noqa: E402
REPO_ROOT = Path(__file__).resolve().parents[1]
RETIRED_FILE = REPO_ROOT / "retired.json"
# The commit that published content addresses: 0d586ef6b, 2026-10-19.
FIRST_ADDRESSED = "0d586ef6b"
KEPT_FIELDS = ("name", "path", "size", "md5", "crc32", "sha256")
def retired_path(database: str | Path) -> Path:
"""The record kept beside a database file."""
return Path(database).with_name("retired.json")
def _successor(entry: dict, by_path: dict[str, str],
by_name: dict[str, list[tuple[str, str]]]) -> str | None:
"""The sha1 the new database holds at the entry's path, else under its
name in the same folder, else the one file of that name: a2diskiing.zip
was rebuilt from its recipe and filed under Apple II the same day."""
path = str(entry.get("path", ""))
if path in by_path:
return by_path[path]
named = by_name.get(str(entry.get("name", "")), [])
folder = path.rsplit("/", 1)[0]
beside = [sha1 for sha1, other in named if other.rsplit("/", 1)[0] == folder]
for found in (beside, [sha1 for sha1, _other in named]):
if len(found) == 1:
return found[0]
return None
def retired_between(old: dict, new: dict, day: str) -> dict[str, dict]:
"""sha1 -> record, for each file of *old* that *new* no longer holds."""
old_files = old.get("files") or {}
new_files = new.get("files") or {}
by_path = {str(e.get("path", "")): sha1 for sha1, e in new_files.items()}
by_name: dict[str, list[tuple[str, str]]] = {}
for sha1, e in new_files.items():
by_name.setdefault(str(e.get("name", "")), []).append((sha1, str(e.get("path", ""))))
records = {}
for sha1 in sorted(set(old_files) - set(new_files)):
entry = old_files[sha1]
record = {key: entry[key] for key in KEPT_FIELDS if entry.get(key) not in (None, "")}
record["left"] = day
successor = _successor(entry, by_path, by_name)
if successor:
record["successor"] = successor
records[sha1] = record
return records
def merge(record: dict[str, dict], left: dict[str, dict], new: dict) -> dict[str, dict]:
"""The record with *left* added, first departure kept, and every sha1
the new database holds again taken out."""
held = set(new.get("files") or {})
merged = {sha1: entry for sha1, entry in record.items() if sha1 not in held}
for sha1, entry in left.items():
merged.setdefault(sha1, entry)
return dict(sorted(merged.items()))
def load(path: Path) -> dict[str, dict]:
if not path.is_file():
return {}
return json.loads(path.read_text(encoding="utf-8")).get("files") or {}
def dump(records: dict[str, dict]) -> str:
return json.dumps({"schema_version": 1, "files": records}, indent=1, sort_keys=True) + "\n"
def update(path: Path, old: dict, new: dict, day: str) -> int:
"""Records what *new* dropped from *old*. Returns how many left now."""
left = retired_between(old, new, day)
record = load(path)
merged = merge(record, left, new)
if merged != record:
write_if_changed(str(path), dump(merged))
return len(set(left) - set(record))
def _git(*args: str) -> str:
return subprocess.run(["git", *args], cwd=REPO_ROOT, capture_output=True, text=True,
check=True).stdout
def backfill() -> dict[str, dict]:
"""Every sha1 that left database.json in the commits since content
addresses were published, with the day of the commit that dropped it."""
revisions = [line.split() for line in _git(
"log", "--reverse", "--format=%H %cs", f"{FIRST_ADDRESSED}^..HEAD", "--", "database.json",
).splitlines() if line.strip()]
records: dict[str, dict] = {}
previous: dict | None = None
for commit, day in revisions:
current = json.loads(_git("show", f"{commit}:database.json"))
if previous is not None:
records = merge(records, retired_between(previous, current, day), current)
previous = current
return records
def main() -> int:
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__.split("\n\n")[0])
parser.add_argument("--backfill", action="store_true",
help="rebuild the record from the history of database.json")
parser.add_argument("--output", default=str(RETIRED_FILE),
help="the record to write (default: retired.json)")
args = parser.parse_args()
if not args.backfill:
parser.print_help()
return 2
records = backfill()
current = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8"))
records = merge(records, {}, current)
write_if_changed(args.output, dump(records))
print(f"{len(records)} files left the collection since {FIRST_ADDRESSED}")
return 0
if __name__ == "__main__":
run_cli(main)