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:
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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])
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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']}")
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user