chore: start transition layout — python into version-python/, scaffold version-rust/

- git mv of all existing files into version-python/ (history preserved)
- version-rust/: minimal cargo scaffold (edition 2024), no deps yet
- root AGENTS.md documents the transition rules and artifact/schema contract
- main is untouched; the python layout there stays as-is
This commit is contained in:
2026-10-09 13:18:31 +02:00
parent 8f4cc55bb4
commit 271fb3bf68
38 changed files with 385 additions and 305 deletions
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""One-off diagnostic: inspect the raw gofile API response for one content id.
Prints everything except the account token. Creates exactly one guest account.
"""
import hashlib
import json
import sys
import time
import requests
CID = sys.argv[1]
USER_AGENT = "Mozilla/5.0"
LOCALE = "en-US"
TOKEN_SECRET = "12af056dacea0b"
WINDOW_SEC = 14400
COMMON = {"User-Agent": USER_AGENT, "Accept-Encoding": "gzip", "Accept": "*/*",
"Connection": "keep-alive"}
resp = requests.post("https://api.gofile.io/accounts", headers=COMMON, timeout=15)
data = resp.json()
assert data.get("status") == "ok", data
token = data["data"]["token"]
window = int(time.time()) // WINDOW_SEC
seed = f"{USER_AGENT}::{LOCALE}::{token}::{window}::{TOKEN_SECRET}"
wtoken = hashlib.sha256(seed.encode()).hexdigest()
headers = {
**COMMON,
"Authorization": f"Bearer {token}",
"X-Website-Token": wtoken,
"X-BL": LOCALE,
}
r = requests.get(
f"https://api.gofile.io/contents/{CID}?cache=true&sortField=createTime&sortDirection=1",
headers=headers, timeout=30,
)
print("HTTP", r.status_code)
body = r.json()
print("api status:", body.get("status"))
def scrub(node):
"""Remove anything token-like from the tree before printing."""
if isinstance(node, dict):
return {
k: ("<redacted>" if k in ("token", "accountToken") else scrub(v))
for k, v in node.items()
}
if isinstance(node, list):
return [scrub(x) for x in node]
return node
d = body.get("data") or {}
print("data keys:", sorted(d.keys()))
print("type:", d.get("type"))
children = d.get("children") or {}
print("children count:", len(children))
for cid, child in list(children.items())[:5]:
print(" child:", cid, "type:", child.get("type"), "name:", child.get("name"),
"has link:", "link" in child)
print()
print(json.dumps(scrub(body), indent=2)[:3000])
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""Recursively extract every archive under the source roots into one tree.
python3 tools/extract_all.py <src_root>... --out <extracted_root>
Behavior:
- Every archive (zip/7z/rar/tar/gz/bz2/xz) is extracted into a directory
named after the archive (suffix stripped), preserving the relative path
under the source root: <src>/zips/The Twin Lance.7z
-> <out>/1/zips/The Twin Lance/...
- Archives found *inside* an extraction are extracted too (BFS, until no
archive remains), then deleted - the extracted tree holds no archives.
- Plain files are hardlinked into the tree (fallback: copy), so nothing
is lost and no data is duplicated.
- The source roots are never modified (originals stay).
- A provenance manifest `<out>/extract_manifest.jsonl` records one line
per extraction: {"archive": ..., "dest": ..., "unit": ...} for the
sorter's --provenance unit mode.
- Failures (bad download/HTML page, wrong password, disk full) are moved
to `<out>/.broken/<relpath>` and logged; the run never stops.
Re-runs are safe: destinations recorded in the manifest are skipped (a
partial dir from a crash is NOT skipped - it gets re-extracted); a final
sweep expands archives stranded by interrupted nested expansions.
"""
from __future__ import annotations
import argparse
import logging
import os
import shutil
import subprocess
import sys
import tempfile
from collections import deque
from pathlib import Path
log = logging.getLogger("extract_all")
ARCHIVE_SUFFIXES = {".zip", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz"}
EXTRACT_TIMEOUT = 4 * 3600 # seconds per archive
MIN_FREE_BYTES = 20 * 1024**3 # stop extracting below this free space
RUN_ARTIFACTS = {
"export.json", "export.html", "state.json", "manifest.jsonl",
"failed_urls.txt", "catalogue.md", "catalogue.jsonl",
"sorter_applied.jsonl", "catalogue_moves.jsonl", "urls.txt",
"urls_0807.txt", "urls_gofile.txt", "failed_0807.txt",
"extract_manifest.jsonl",
}
def archive_suffixes(name: str) -> list[str]:
"""All trailing archive suffixes, e.g. 'x.tar.gz' -> ['.gz', '.tar']."""
out = []
p = Path(name)
while p.suffix.lower() in ARCHIVE_SUFFIXES:
out.append(p.suffix.lower())
p = p.with_suffix("")
return out
def is_archive(path: Path) -> bool:
return bool(archive_suffixes(path.name))
def display_unit(name: str) -> str:
"""Archive name with all archive suffixes stripped: the unit folder."""
p = Path(name)
while p.suffix.lower() in ARCHIVE_SUFFIXES:
p = p.with_suffix("")
return p.name
def free_bytes(path: Path) -> int:
return shutil.disk_usage(path).free
def _fix_component(s: str) -> str:
"""Repair filenames 7z wrote with raw legacy bytes (cp437 zip entries).
Raw non-UTF8 bytes in a name cannot be created on the CIFS share
(observed: errno 2 for a name containing byte 0x87). Decode the raw
bytes as cp437 so e.g. a raw-0x87 name becomes a proper 'ç' name.
"""
try:
s.encode("utf-8")
return s
except UnicodeEncodeError:
pass
raw = os.fsencode(s)
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return raw.decode("cp437", "replace")
def _move_tree(tmp: Path, dest: Path) -> int:
"""Move a freshly extracted local tree onto the (possibly CIFS) dest.
Returns the number of files that could not be moved."""
bad = 0
for f in sorted(tmp.rglob("*")):
rel = f.relative_to(tmp)
tgt = dest.joinpath(*(_fix_component(p) for p in rel.parts))
if f.is_dir():
tgt.mkdir(parents=True, exist_ok=True)
continue
tgt.parent.mkdir(parents=True, exist_ok=True)
try:
if tgt.exists() and tgt.is_file() \
and tgt.stat().st_size == f.stat().st_size:
f.unlink()
continue
if tgt.exists():
tgt.unlink()
shutil.move(str(f), str(tgt))
except OSError as exc:
bad += 1
log.error("Cannot place %s: %s", tgt, exc)
return bad
def _run_7z(cmd: list[str]) -> tuple[int, str]:
try:
# 7z prints legacy-encoded inner filenames (non-UTF8 bytes); decode
# tolerantly or the whole run crashes (seen with byte 0x87).
proc = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
encoding="utf-8", errors="replace", timeout=EXTRACT_TIMEOUT,
)
except subprocess.TimeoutExpired:
return -1, f"extraction timed out after {EXTRACT_TIMEOUT}s"
except OSError as exc:
return -1, f"7z failed to run: {exc}"
tail = "\n".join((proc.stdout or "").splitlines()[-5:])
return proc.returncode, tail
def extract(archive: Path, dest: Path) -> tuple[bool, str, Path]:
"""7z x into dest. Empty -p avoids interactive prompts (no password).
Returns (ok, error, dest_used): on a name collision (the archive stem
exists as a *file*, e.g. 'model.stl.zip' next to 'model.stl') the
destination gets a '_dup' suffix instead of crashing.
If 7z cannot write directly onto the (CIFS) destination - legacy
cp437 entry names - the archive is extracted to a local temp dir and
moved over with repaired filenames.
"""
actual = dest
for _ in range(5):
try:
actual.mkdir(parents=True, exist_ok=True)
break
except FileExistsError:
actual = dest.with_name(dest.name + "_dup")
except NotADirectoryError as exc:
return False, f"cannot create destination {actual}: {exc}", dest
else:
return False, f"cannot create destination {dest} (repeated collisions)", dest
rc, tail = _run_7z(["7z", "x", "-y", "-p", f"-o{actual}", str(archive)])
if rc in (0, 1): # 0 ok, 1 warnings (e.g. minor CRC)
if rc == 1:
log.warning("7z reported warnings extracting %s", archive.name)
return True, "", actual
if rc == -1:
return False, tail, actual
# Fallback: local extraction, then move with repaired filenames.
tmp = Path(tempfile.mkdtemp(prefix="7zext-", dir="/var/tmp"))
try:
rc2, tail2 = _run_7z(["7z", "x", "-y", "-p", f"-o{tmp}", str(archive)])
if rc2 not in (0, 1):
return False, f"7z exit {rc2}: {tail2}", actual
bad = _move_tree(tmp, actual)
if bad:
return False, f"{bad} files could not be moved onto the share", actual
return True, "", actual
finally:
shutil.rmtree(tmp, ignore_errors=True)
def move_to_broken(path: Path, out_root: Path, src_root: Path, note: str) -> Path:
rel = path.relative_to(src_root)
dest = out_root / ".broken" / rel
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
dest = dest.with_name(dest.name + ".1")
return shutil.move(str(path), str(dest))
def is_macosx_junk(p: Path) -> bool:
"""AppleDouble resource-fork 'zips' (.__MACOSX/._x.zip) - no content."""
return "__MACOSX" in p.parts or p.name.startswith("._")
def expand_nested(dest: Path, out_root: Path, manifest, src_root: Path,
done: set[str]) -> int:
"""Extract archives inside `dest` until none remain. Returns count."""
done_count = 0
queue: deque[Path] = deque([dest])
while queue:
cur = queue.popleft()
for p in sorted(cur.rglob("*")):
if not p.is_file() or not is_archive(p):
continue
if is_macosx_junk(p):
p.unlink()
log.info("Junk (macOS AppleDouble) removed: %s", p.name)
continue
stem_dir = p.with_name(display_unit(p.name))
if str(stem_dir.relative_to(out_root)) in done:
# already extracted in a previous run; drop the archive copy
p.unlink()
log.info("Nested %s already extracted - archive removed", p.name)
continue
if free_bytes(out_root) < MIN_FREE_BYTES:
log.error("Out of disk space - skipping %s (kept in place)", p)
continue
ok, err, actual = extract(p, stem_dir)
if not ok:
log.error("FAILED nested extraction %s: %s", p, err)
move_to_broken(p, out_root, out_root, err)
continue
manifest.write(f"{json_line(p.relative_to(out_root), actual)}\n")
done.add(str(actual.relative_to(out_root)))
p.unlink() # archive copy consumed
done_count += 1
queue.append(actual)
return done_count
def json_line(archive_rel: Path, dest: Path) -> str:
import json
return json.dumps({
"archive": str(archive_rel),
"dest": str(dest.relative_to(_OUT_ROOT)),
"unit": display_unit(archive_rel.name),
}, ensure_ascii=False)
_OUT_ROOT: Path = Path(".") # set in main; used by json_line
def process_root(src_root: Path, out_root: Path, manifest, done: set[str]) -> tuple[int, int, int]:
"""Returns (extracted, mirrored, failed)."""
idx = src_root.name
extracted = mirrored = failed = 0
for f in sorted(src_root.rglob("*")):
if not f.is_file():
continue
if (f.name in RUN_ARTIFACTS or f.name.startswith(".")
or f.name.endswith(".part")):
continue
rel = f.relative_to(src_root)
if is_archive(f):
dest_rel = Path(idx) / Path(str(rel.with_name(display_unit(f.name))))
dest = out_root / dest_rel
# Skip on a manifest record, never on "dir non-empty": a crash
# mid-extraction leaves a partial dir that must be redone.
if str(dest_rel) in done:
log.info("Skip (already extracted): %s", rel)
continue
if free_bytes(out_root) < MIN_FREE_BYTES:
log.error("Out of disk space - skipping archive %s", f)
failed += 1
continue
ok, err, actual = extract(f, dest)
if not ok:
log.error("FAILED %s: %s", rel, err)
move_to_broken(f, out_root, src_root, err)
failed += 1
continue
manifest.write(f"{json_line(Path(idx) / rel, actual)}\n")
done.add(str(actual.relative_to(out_root)))
extracted += 1
log.info("Extracted %s -> %s", rel, actual.relative_to(out_root))
expand_nested(actual, out_root, manifest, src_root, done)
else:
link_dest = out_root / idx / rel
if link_dest.exists():
continue
link_dest.parent.mkdir(parents=True, exist_ok=True)
try:
os.link(f, link_dest)
except OSError:
shutil.copy2(f, link_dest)
mirrored += 1
return extracted, mirrored, failed
def load_done(manifest_path: Path) -> set[str]:
"""Dest dirs already recorded in the manifest (relative to out root)."""
import json
done: set[str] = set()
if manifest_path.exists():
with manifest_path.open(encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
done.add(json.loads(line)["dest"])
except (json.JSONDecodeError, KeyError):
continue
return done
def sweep(out_root: Path, manifest, done: set[str]) -> int:
"""Expand any archive left anywhere in the tree (interrupted nested
expansions leave stranded copies even when the parent has a record)."""
import json
swept = 0
for p in sorted(out_root.rglob("*")):
if not p.is_file() or not is_archive(p):
continue
if ".broken" in p.relative_to(out_root).parts:
continue
if is_macosx_junk(p):
p.unlink()
log.info("Junk (macOS AppleDouble) removed: %s", p.name)
continue
stem_dir = p.with_name(display_unit(p.name))
if str(stem_dir.relative_to(out_root)) in done:
p.unlink()
log.info("Sweep: %s already extracted - archive removed", p.name)
continue
if free_bytes(out_root) < MIN_FREE_BYTES:
log.error("Out of disk space - sweep skipping %s", p)
continue
ok, err, actual = extract(p, stem_dir)
if not ok:
log.error("FAILED sweep extraction %s: %s", p, err)
move_to_broken(p, out_root, out_root, err)
continue
manifest.write(f"{json_line(p.relative_to(out_root), actual)}\n")
done.add(str(actual.relative_to(out_root)))
p.unlink()
swept += 1
expand_nested(actual, out_root, manifest, out_root, done)
return swept
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("sources", nargs="+", type=Path)
parser.add_argument("--out", type=Path, required=True, metavar="DIR")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s",
datefmt="%H:%M:%S")
global _OUT_ROOT
_OUT_ROOT = args.out
args.out.mkdir(parents=True, exist_ok=True)
totals = [0, 0, 0]
done = load_done(args.out / "extract_manifest.jsonl")
log.info("%d destinations already recorded in manifest", len(done))
with (args.out / "extract_manifest.jsonl").open("a", encoding="utf-8") as manifest:
for src in args.sources:
log.info("== processing %s", src)
counts = process_root(src, args.out, manifest, done)
log.info("== %s: %d extracted, %d mirrored, %d failed",
src.name, *counts)
for i, c in enumerate(counts):
totals[i] += c
swept = sweep(args.out, manifest, done)
log.info("TOTAL: %d archives extracted, %d files mirrored, %d failed, %d swept",
*totals, swept)
if totals[2]:
log.warning("Failures were moved under %s - inspect and re-run", args.out / ".broken")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""One-shot browse of the GW Algolia index -> gwshop_raw.json (gitignored)."""
import json
import logging
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import dlphelper.gwshop as gw
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
app, key = gw.load_credentials()
client = gw.GwShopClient(app, key)
hits = client.browse("prod-lazarus-product-en-ww", hits_per_page=100)
print("total hits", len(hits))
pathlib.Path("gwshop_raw.json").write_text(
json.dumps(hits, ensure_ascii=False), encoding="utf-8"
)
print("written gwshop_raw.json")
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""gwshop_raw.json -> data/gwshop_catalog.json + quick shape report."""
import json
import logging
import pathlib
import sys
from collections import Counter
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import dlphelper.gwshop as gw
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
hits = json.loads(pathlib.Path("gwshop_raw.json").read_text(encoding="utf-8"))
print("raw hits:", len(hits))
unique = len({h.get("objectID") for h in hits})
print("unique objectIDs:", unique)
catalog = gw.build_catalog(hits)
pathlib.Path("data/gwshop_catalog.json").write_text(
json.dumps(catalog, ensure_ascii=False, indent=1), encoding="utf-8"
)
print("entries:", catalog["generated_entries"])
entries = catalog["entries"]
print("no system:", sum(1 for e in entries if e["system"] is None))
print("by system:", Counter(e["system"] for e in entries).most_common())
print("40k groups:", Counter(
e["group"] for e in entries if e["system"] == "Warhammer 40,000"
).most_common())
print("lvl depth:", Counter(
len((e.get("gw_paths") or [[]])[0]) for e in entries
).most_common())
print("classes (lvl>=4):", Counter(
e["class"] for e in entries if e["class"]
).most_common(12))
sample = next(
e for e in entries
if e["name"].startswith("Codex: Adeptus Custodes")
)
print(json.dumps(sample, indent=1, ensure_ascii=False)[:900])
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Inspect gwshop catalog shape: Space Marines chapters, AoS, dedupe stats."""
import json
import pathlib
from collections import Counter
cat = json.loads(pathlib.Path("data/gwshop_catalog.json").read_text(encoding="utf-8"))
entries = cat["entries"]
def show(pred, label, n=6):
print(f"--- {label}")
shown = 0
for e in entries:
if pred(e):
p = " > ".join(x for x in (e["system"], e["group"], e["faction"], e["subfaction"], e["class"]) if x)
print(f" {e['name'][:60]!r} -> {p}")
shown += 1
if shown >= n:
break
show(lambda e: e["group"] == "Space Marines", "Space Marines group samples")
show(lambda e: e["group"] == "Armies of the Imperium" and e["subfaction"], "Imperium with subfaction")
show(lambda e: e["system"] == "Age of Sigmar", "Age of Sigmar samples", 5)
show(lambda e: e["system"] == "The Old World", "Old World samples", 5)
show(lambda e: e["system"] == "Other Games", "Other Games samples", 5)
show(lambda e: e["group"] == "Unit Type", "Unit Type branch", 6)
names = Counter(e["name"].casefold() for e in entries)
dups = {n: c for n, c in names.items() if c > 1}
print("duplicate names:", len(dups), "example:", list(dups.items())[:3])
# faction-level value census for 40k
print("--- 40k factions census")
print(Counter(e["faction"] for e in entries if e["system"] == "Warhammer 40,000").most_common(40))
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Create or list repositories on the Gitea instance at git.dieterunterstrich.de.
Credentials are read from ~/.git-credentials (the line for that host); the
secret is never printed. Useful when you need a new repo without touching the
web UI.
Usage:
python3 tools/new_repo.py list
python3 tools/new_repo.py create <name> [--private] [--desc "..."]
"""
from __future__ import annotations
import argparse
import base64
import json
import sys
from pathlib import Path
from urllib.request import Request, urlopen
HOST = "git.dieterunterstrich.de"
API = f"https://{HOST}/api/v1"
CREDENTIALS_FILE = Path.home() / ".git-credentials"
def load_credentials() -> tuple[str, str]:
"""Return (username, password) for the Gitea host from ~/.git-credentials."""
for line in CREDENTIALS_FILE.read_text().splitlines():
line = line.strip()
if not line.startswith("https://") or HOST not in line:
continue
# https://user:pass@host
rest = line.removeprefix("https://")
userinfo = rest.split("@", 1)[0]
user, _, password = userinfo.partition(":")
return user, password
raise SystemExit(f"No credentials for {HOST} in {CREDENTIALS_FILE}")
def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict]:
"""Call the Gitea API; returns (status_code, parsed_json)."""
user, password = load_credentials()
auth = base64.b64encode(f"{user}:{password}".encode()).decode()
data = json.dumps(body).encode() if body is not None else None
req = Request(
f"{API}{path}",
data=data,
method=method,
headers={
"Authorization": f"Basic {auth}",
"Content-Type": "application/json",
},
)
try:
resp = urlopen(req, timeout=30)
return resp.status, json.loads(resp.read() or b"{}")
except Exception as exc: # noqa: BLE001
# HTTPError carries the response body, useful for API errors
code = getattr(exc, "code", None)
if code is not None:
try:
return code, json.loads(exc.read() or b"{}")
except Exception: # noqa: BLE001
return code, {"message": str(exc)}
raise
def main() -> None:
parser = argparse.ArgumentParser(description="Gitea repo helper")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list")
p_create = sub.add_parser("create")
p_create.add_argument("name")
p_create.add_argument("--desc", default=None)
p_create.add_argument("--public", action="store_true")
args = parser.parse_args()
if args.command == "list":
status, repos = api("GET", "/user/repos?limit=100")
print(f"HTTP {status}")
for repo in repos:
print(f" {repo['full_name']} (private={repo['private']})")
return
status, result = api(
"POST",
"/user/repos",
{
"name": args.name,
"description": args.desc or "",
"private": not args.public,
"auto_init": False,
"default_branch": "main",
},
)
if status in (201, 200):
print(f"Created: {result['full_name']} -> {result['clone_url']}")
else:
print(f"Failed (HTTP {status}): {result.get('message', result)}")
sys.exit(1)
if __name__ == "__main__":
main()
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Diff old vs new sorter plans by source path -> target."""
import json
import sys
changed = 0
same = 0
examples = []
for n in (1, 2, 3):
old = {p["source"]: p for p in json.load(open(f"/tmp/plan{n}.json"))}
new = {p["source"]: p for p in json.load(open(f"/tmp/plan{n}b.json"))}
for src, np in new.items():
op = old.get(src)
if not op:
continue
if op["target"] != np["target"] or op["kind"] != np["kind"]:
changed += 1
if len(examples) < 40:
examples.append((n, op, np))
else:
same += 1
print(f"same: {same} changed: {changed}")
for n, op, np in examples:
print(f"\n[{n}] {op['name']} (score {op['score']} -> {np['score']})")
print(f" old: {op['target']}")
print(f" new: {np['target']}")
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""Summarize a sorter plan JSON: match kinds, top targets, oddities."""
import json
import sys
from collections import Counter
from pathlib import Path
plan = json.loads(Path(sys.argv[1]).read_text())
print(f"files: {len(plan)}")
print("kinds:", Counter(p["kind"] for p in plan))
print("actions:", Counter(p.get("action", "?") for p in plan))
# system breakdown
print("systems:", Counter(p["system"] for p in plan).most_common(10))
# top-level targets
roots = Counter(str(Path(p["target"]).relative_to(
Path(p["target"]).parents[3])) if len(Path(p["target"]).parents) > 3
else p["target"] for p in plan)
for root, n in roots.most_common(15):
print(f" {n:5d} {root}")
# anything with empty target?
empties = [p for p in plan if not p.get("target")]
print("empty targets:", len(empties))