Files
libretro/scripts/retired_files.py
T
Abdessamad Derraz 350cd6d600 feat: keep the address of a file that left
files/?sha1= of a file the collection no longer holds answered that no
file the site knows has the hash. generate_db.py now records each sha1 a
rebuild drops in retired.json, with the day and the file in its place,
backfilled from the history since 0d586ef6b (4,139 files). The lookup
answers with it and leads to the successor's card.
2026-11-06 04:24:44 +01:00

152 lines
5.8 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
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."""
from common import write_if_changed # noqa: PLC0415
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
sys.path.insert(0, str(Path(__file__).parent))
from common import write_if_changed # noqa: PLC0415
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__":
sys.exit(main())