extract stage + system-locked matching + provenance unit mode

- tools/extract_all.py: recursive extraction of all archives (incl.
  nested) into a plain-file tree; nested archives are deleted after
  successful extraction, plain files are hardlinked, failures go to
  .broken/, extract_manifest.jsonl records archive->dest->unit.
- sorter --provenance: classify each extraction directory once under
  the archive's name (files of one archive are one unit; inner
  structure kept), so loose STLs sort with their model set.
- matching discipline: a fuzzy match never leaves the file's system;
  system-less files need CROSS_SYSTEM_MIN (0.75) for a catalog unit,
  and a same-system faction jump below 0.75 is rejected. Fixes 40k
  files landing in Middle-earth/Blood Bowl/AoS buckets.
- speed for big trees: lru_cache on keyword matchers and pair scores,
  quick_ratio pruning; keywords: night lord, csm.
This commit is contained in:
2026-10-05 08:09:22 +02:00
parent 7e28518a52
commit 86fcb3aecc
7 changed files with 455 additions and 26 deletions
+18 -1
View File
@@ -56,10 +56,14 @@ dlphelper/
categories as classes, GROUP_OF_FACTION map categories as classes, GROUP_OF_FACTION map
gwshop.py GW webshop catalog dump via Algolia (see §7.3) gwshop.py GW webshop catalog dump via Algolia (see §7.3)
sorter.py library sorter: inspect/classify/fuzzy-match sorter.py library sorter: inspect/classify/fuzzy-match
against the catalog, plan + hardlink apply against the catalog, plan + hardlink apply,
optional --provenance unit mode for extracted trees
parts/grab.sh legacy groundstone script (kept, not used by dlp.py) parts/grab.sh legacy groundstone script (kept, not used by dlp.py)
tools/new_repo.py Gitea repo helper (reads ~/.git-credentials itself) tools/new_repo.py Gitea repo helper (reads ~/.git-credentials itself)
tools/debug_gofile.py inspect raw gofile API response for one content id tools/debug_gofile.py inspect raw gofile API response for one content id
tools/extract_all.py recursive archive extraction into a plain-file tree
(extract_manifest.jsonl feeds the sorter's
--provenance unit mode; failures go to .broken/)
tests/test_extract.py offline extraction test (plain python3, no deps) tests/test_extract.py offline extraction test (plain python3, no deps)
tests/test_catalogue.py offline classifier test tests/test_catalogue.py offline classifier test
``` ```
@@ -209,6 +213,19 @@ never re-fetch them again):
the **GW webshop taxonomy** (see below); `docs/SORTING.md` is the the **GW webshop taxonomy** (see below); `docs/SORTING.md` is the
working agreement for the sorting pipeline. working agreement for the sorting pipeline.
**Sorter matching rules (fixed 2026-10-05 after the first full run):**
the outer file name wins when it scores >= threshold; inner archive
names only vote as fallback (hash-named gofile archives need that). A
fuzzy match never jumps into another game's section: keyword-identified
systems constrain the catalog pool, and a file with neither system nor
faction keyword only adopts a catalog unit on a strong hit
(`CROSS_SYSTEM_MIN = 0.75`) - weaker guesses stay in `_unsorted`.
Token-containment boosts exist for short names vs long catalog names
("telemon" → "Telemon Heavy Dreadnought"), guarded by a generic-token
stoplist. The catalog deliberately covers all GW systems (needed for
catalog-agnostic use) - wrong-system placement is prevented by matching
discipline, not by shrinking the catalog.
**Catalog source: the GW webshop via Algolia (`dlphelper/gwshop.py`, **Catalog source: the GW webshop via Algolia (`dlphelper/gwshop.py`,
working, verified 2026-10-04 — how it was obtained, do not re-derive):** working, verified 2026-10-04 — how it was obtained, do not re-derive):**
scraping warhammer.com directly is blocked — the shop HTML and all scraping warhammer.com directly is blocked — the shop HTML and all
+6
View File
@@ -53,6 +53,10 @@ class CatalogueEntry:
source: dict = field(default_factory=dict) # manifest attribution if known source: dict = field(default_factory=dict) # manifest attribution if known
from functools import lru_cache
@lru_cache(maxsize=None)
def _clean_name(name: str) -> str: def _clean_name(name: str) -> str:
stem = Path(name).stem stem = Path(name).stem
stem = re.sub(r"[_\.\+\[\]\(\)]+", " ", stem) stem = re.sub(r"[_\.\+\[\]\(\)]+", " ", stem)
@@ -65,6 +69,7 @@ def _kw_regex(keyword: str) -> str:
return rf"(?<![a-z0-9]){re.escape(keyword)}s?(?![a-z0-9])" return rf"(?<![a-z0-9]){re.escape(keyword)}s?(?![a-z0-9])"
@lru_cache(maxsize=None)
def _match_faction(clean: str) -> tuple[str, str] | None: def _match_faction(clean: str) -> tuple[str, str] | None:
"""Return (system, faction) with the most specific keyword hit.""" """Return (system, faction) with the most specific keyword hit."""
best: tuple[int, str, str] | None = None # (len, system, faction) best: tuple[int, str, str] | None = None # (len, system, faction)
@@ -76,6 +81,7 @@ def _match_faction(clean: str) -> tuple[str, str] | None:
return (best[1], best[2]) if best else None return (best[1], best[2]) if best else None
@lru_cache(maxsize=None)
def _match_class(clean: str) -> str | None: def _match_class(clean: str) -> str | None:
"""Return the most specific force-organization class keyword hit.""" """Return the most specific force-organization class keyword hit."""
best: tuple[int, str] | None = None best: tuple[int, str] | None = None
+156 -14
View File
@@ -19,6 +19,7 @@ import shutil
import subprocess import subprocess
import zipfile import zipfile
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path from pathlib import Path
from dlphelper.catalogue import _clean_name, _match_class, _match_faction from dlphelper.catalogue import _clean_name, _match_class, _match_faction
@@ -29,6 +30,10 @@ log = logging.getLogger(__name__)
ARCHIVE_SUFFIXES = {".zip", ".7z", ".rar", ".tar", ".gz"} ARCHIVE_SUFFIXES = {".zip", ".7z", ".rar", ".tar", ".gz"}
UNSORTED = "_unsorted" UNSORTED = "_unsorted"
UNCLASSIFIED_CLASS = "Unclassified" UNCLASSIFIED_CLASS = "Unclassified"
# A file whose system the keyword tables could not identify only gets a
# catalog unit when the fuzzy hit is strong - otherwise it would wander
# into other games' sections (Middle-earth, Blood Bowl, ...) on a guess.
CROSS_SYSTEM_MIN = 0.75
# Run-artifact files that must never be sorted into a library # Run-artifact files that must never be sorted into a library
RUN_ARTIFACTS = { RUN_ARTIFACTS = {
@@ -169,10 +174,70 @@ def load_catalog(path: Path) -> list[dict]:
def _best_ratio(clean: str, candidates: list[str]) -> float: def _best_ratio(clean: str, candidates: list[str]) -> float:
return max( """Best sequence ratio, pruned with cheap upper-bound checks.
(difflib.SequenceMatcher(None, clean, c).ratio() for c in candidates),
default=0.0, lru_cached because the same (name, entry) pair is scored over and
) over across thousands of files.
"""
return _best_ratio_cached(clean, tuple(candidates))
@lru_cache(maxsize=None)
def _best_ratio_cached(clean: str, candidates: tuple[str, ...]) -> float:
best = 0.0
toks = set(_stem_tokens(clean))
best = _containment_boost(best, toks, candidates)
for c in candidates:
sm = difflib.SequenceMatcher(None, clean, c)
if sm.real_quick_ratio() < best or sm.quick_ratio() < best:
continue
r = sm.ratio()
if r > best:
best = r
if best == 1.0:
break
return best
def _stem_tokens(clean: str) -> list[str]:
"""Tokens with a crude plural 's' stripped, for containment checks."""
return [t[:-1] if len(t) > 3 and t.endswith("s") else t
for t in clean.split()]
def _containment_boost(best: float, toks: set[str], candidates: tuple[str, ...]) -> float:
"""Token-containment bonus: a name whose tokens all appear in the
other (or vice versa) matches strongly even when string ratio is low
("telemon" vs "Telemon Heavy Dreadnought"). Guarded: needs >= 2
tokens, or a single rare one (>= 7 chars), and the smaller side must
not consist of generic army words ("chaos lord" is contained in half
the catalog) so "head" or "guard" never trigger it."""
if not toks:
return best
for c in candidates:
ctoks = set(_stem_tokens(c))
if not ctoks:
continue
small = toks if toks <= ctoks else (ctoks if ctoks <= toks else None)
if small is None or any(t in GENERIC_TOKENS for t in small):
continue
if len(small) >= 2 or len(next(iter(small))) >= 7:
if best < 0.9:
best = 0.9
break
return best
# words too generic for token-containment matching: entries built from
# them alone would claim any file whose name happens to mention them
GENERIC_TOKENS = frozenset({
"chaos", "lord", "space", "marine", "guard", "knight", "dark",
"death", "blood", "war", "warrior", "daemon", "hero", "god",
"free", "black", "white", "grey", "green", "gold", "iron",
"steel", "storm", "soul", "shadow", "bone", "ghost", "king",
"clan", "tribe", "legion", "regiment", "squad", "warhound",
"orc", "ogre", "elf", "dwarf", "giant", "company", "chapter",
}) # noqa: C405
def _best_in_pool( def _best_in_pool(
@@ -207,21 +272,45 @@ def match_catalog(item: SortItem, catalog: list[dict], threshold: float) -> None
return return
if item.match_kind == "invalid": if item.match_kind == "invalid":
return # HTML-page masquerading as an archive; never fuzzy-match it return # HTML-page masquerading as an archive; never fuzzy-match it
system_known = item.system != "Unknown"
same_faction = [e for e in catalog if e.get("faction") == item.faction] same_faction = [e for e in catalog if e.get("faction") == item.faction]
pool = same_faction or catalog # fall back to whole catalog if faction unknown if system_known:
best = _best_in_pool(item, pool, use_inner=False) # a fuzzy match must never jump into another game's section
same_faction = [e for e in same_faction if e.get("system") == item.system]
if same_faction:
best = _best_in_pool(item, same_faction, use_inner=False)
if best is None or best[0] < threshold: if best is None or best[0] < threshold:
# Outer name not confident: let the archive's inner file names vote # Outer name not confident: let the archive's inner file names
# (this is the only signal for hash-named gofile archives). # vote (the only signal for hash-named gofile archives).
best = _best_in_pool(item, pool, use_inner=True) best = _best_in_pool(item, same_faction, use_inner=True)
if best is None or best[0] < threshold: if best is None or best[0] < threshold:
# The keyword tables can lag behind GW's current categorization # The keyword tables can lag behind GW's current categorization
# (e.g. units moved between factions); if the faction pool has no # (e.g. units moved between factions); retry within the same
# confident match, try the whole catalog before giving up. # system before giving up - never across systems.
if same_faction: sys_pool = [e for e in catalog if e.get("system") == item.system]
best = _best_in_pool(item, sys_pool, use_inner=False)
if best is None or best[0] < threshold:
best = _best_in_pool(item, sys_pool, use_inner=True)
# a retry hit from a *different* faction is only trusted when
# it is strong; weak inner-name votes must not re-home the file
if best is not None and best[0] >= threshold \
and best[1].get("faction") != item.faction \
and best[0] < CROSS_SYSTEM_MIN:
best = None
elif system_known:
sys_pool = [e for e in catalog if e.get("system") == item.system]
best = _best_in_pool(item, sys_pool, use_inner=False)
if best is None or best[0] < threshold:
best = _best_in_pool(item, sys_pool, use_inner=True)
else:
# Neither system nor faction known: whole catalog, but only a
# strong hit (CROSS_SYSTEM_MIN) may adopt another game's section;
# weaker guesses stay in _unsorted.
best = _best_in_pool(item, catalog, use_inner=False) best = _best_in_pool(item, catalog, use_inner=False)
if best is None or best[0] < threshold: if best is None or best[0] < threshold:
best = _best_in_pool(item, catalog, use_inner=True) best = _best_in_pool(item, catalog, use_inner=True)
if best is not None and best[0] < CROSS_SYSTEM_MIN:
best = None
if best and best[0] >= threshold: if best and best[0] >= threshold:
entry = best[1] entry = best[1]
item.unit = entry["name"] item.unit = entry["name"]
@@ -278,10 +367,13 @@ def match_catalog(item: SortItem, catalog: list[dict], threshold: float) -> None
item.match_kind = "implicit" item.match_kind = "implicit"
def build_target(item: SortItem, library: Path) -> Path: def build_target(item: SortItem, library: Path, inner_rel: Path | None = None) -> Path:
parts: list[str] = [] parts: list[str] = []
if item.system == "Unknown": if item.system == "Unknown":
parts = [UNSORTED] parts = [UNSORTED]
if inner_rel is not None and item.unit:
# extracted archives keep their unit folder even when unplaced
parts.append(_display_name(item.unit))
else: else:
parts = [item.system] parts = [item.system]
if item.faction != UNSORTED: if item.faction != UNSORTED:
@@ -296,6 +388,8 @@ def build_target(item: SortItem, library: Path) -> Path:
else: else:
parts.append(UNSORTED) parts.append(UNSORTED)
target_dir = Path(library).joinpath(*parts) target_dir = Path(library).joinpath(*parts)
if inner_rel is not None:
target_dir = target_dir.joinpath(*inner_rel.parts)
return target_dir / item.name return target_dir / item.name
@@ -314,7 +408,20 @@ def sort_plan(
library: Path, library: Path,
catalog: list[dict], catalog: list[dict],
threshold: float = 0.62, threshold: float = 0.62,
provenance: dict[str, str] | None = None,
root: Path | None = None,
) -> list[SortItem]: ) -> list[SortItem]:
"""Build sort items for `files`.
`provenance` (with `root`) activates extracted-archive mode: it maps
extraction directories (relative to `root`) to the archive they came
from. All files inside such a directory are classified and matched
once under the archive's name and then placed together, keeping the
archive's inner structure - a loose "Backpack.stl" alone is noise,
under "The Twin Lance/" it belongs to its unit.
"""
prov = provenance or {}
unit_cache: dict[str, dict] = {}
items = [] items = []
for f in sorted(files): for f in sorted(files):
if not f.is_file(): if not f.is_file():
@@ -322,10 +429,45 @@ def sort_plan(
if (f.name.endswith(".part") or f.name.startswith(".") if (f.name.endswith(".part") or f.name.startswith(".")
or f.name in RUN_ARTIFACTS): or f.name in RUN_ARTIFACTS):
continue continue
unit_name = dest_key = None
inner_rel: Path | None = None
if root is not None and prov:
rel = f.relative_to(root)
p = rel.parent
while p != Path("."):
if str(p) in prov:
dest_key, unit_name = str(p), prov[str(p)]
inner_rel = rel.relative_to(p)
break
p = p.parent
if unit_name is None:
item = inspect(f) item = inspect(f)
classify_item(item) classify_item(item)
match_catalog(item, catalog, threshold) match_catalog(item, catalog, threshold)
item.target = build_target(item, library) else:
# one classification per extraction directory, reused for
# every file inside it
fields = unit_cache.get(dest_key)
if fields is None:
listing = sorted(
str(g.relative_to(root / dest_key))
for g in (root / dest_key).rglob("*") if g.is_file()
)[:40]
probe = SortItem(
path=f, size=0, name=unit_name,
clean=_clean_name(unit_name), inner_names=listing,
)
classify_item(probe)
match_catalog(probe, catalog, threshold)
fields = {k: getattr(probe, k) for k in (
"system", "group", "faction", "subfaction", "klass",
"unit", "match_score", "match_kind",
)}
unit_cache[dest_key] = fields
item = inspect(f)
for k, v in fields.items():
setattr(item, k, v)
item.target = build_target(item, library, inner_rel=inner_rel)
item.action = "link" item.action = "link"
items.append(item) items.append(item)
return items return items
+2 -2
View File
@@ -133,13 +133,13 @@ SYSTEMS: dict[str, dict[str, list[str]]] = {
# -- Chaos ----------------------------------------------------------- # -- Chaos -----------------------------------------------------------
"Chaos Space Marines": [ "Chaos Space Marines": [
"chaos space marine", "black legion", "iron warriors", "word bearers", "chaos space marine", "black legion", "iron warriors", "word bearers",
"night lords", "alpha legion", "chaos terminator", "chosen", "havoc", "night lords", "night lord", "alpha legion", "chaos terminator", "chosen", "havoc",
"raptor", "warp talon", "chaos cultist", "cultist", "daemon prince", "raptor", "warp talon", "chaos cultist", "cultist", "daemon prince",
"chaos lord", "chaos sorcerer", "abaddon", "kharn", "ahriman", "chaos lord", "chaos sorcerer", "abaddon", "kharn", "ahriman",
"typhus", "fabius bile", "vashtorr", "defiler", "forgefiend", "typhus", "fabius bile", "vashtorr", "defiler", "forgefiend",
"maulerfiend", "helldrake", "helbrute", "chaos spawn", "maulerfiend", "helldrake", "helbrute", "chaos spawn",
"obliterator", "chaos rhino", "chaos vindicator", "spartan assault", "obliterator", "chaos rhino", "chaos vindicator", "spartan assault",
"mastodon", "typhon", "mastodon", "typhon", "csm",
], ],
"Death Guard": [ "Death Guard": [
"death guard", "nurgle", "plague marine", "deathshroud", "mortarion", "death guard", "nurgle", "plague marine", "deathshroud", "mortarion",
+26
View File
@@ -95,6 +95,32 @@ graph LR
unpack step will live in the sorter (`--unpack`, 7z binary) but stays off unpack step will live in the sorter (`--unpack`, 7z binary) but stays off
until the link-based flow is proven on a small batch. until the link-based flow is proven on a small batch.
## Extraction stage (`tools/extract_all.py`)
That endgame stage now exists as a standalone tool (the sorter stays
plan/apply-only):
python3 tools/extract_all.py <src_root>... --out <extracted_root>
- Every archive (zip/7z/rar/tar/gz/bz2/xz, also nested ones) is extracted
into a folder named after the archive, preserving relative structure;
nested archives are expanded recursively and deleted, so the tree ends
up with **no archives at all**.
- Plain files are hardlinked into the tree (fallback copy) — nothing lost,
no duplicate data.
- Sources are never modified. Failed extractions (HTML-page fake zips,
password archives, disk full) are moved to `<out>/.broken/` and the run
continues; re-runs skip already-extracted destinations.
- `<out>/extract_manifest.jsonl` records `{archive, dest, unit}` per
extraction. The sorter's `--provenance` flag reads it: all files inside
an extraction directory are classified **once under the archive's
name** (a loose `Backpack.stl` alone is noise; under
`The Twin Lance/` it belongs to that unit) and placed together, keeping
the archive's inner directory structure:
python3 sorter.py <extracted_root> --library <lib> \
--provenance <extracted_root>/extract_manifest.jsonl --apply
## Catalog file (`data/library_catalog.json`) ## Catalog file (`data/library_catalog.json`)
```json ```json
+14 -1
View File
@@ -52,6 +52,9 @@ def main() -> None:
parser.add_argument("--apply", action="store_true", parser.add_argument("--apply", action="store_true",
help="create the targets (hardlink, fallback copy)") help="create the targets (hardlink, fallback copy)")
parser.add_argument("--audit", type=Path, default=None, metavar="FILE") parser.add_argument("--audit", type=Path, default=None, metavar="FILE")
parser.add_argument("--provenance", type=Path, default=None, metavar="FILE",
help="extract_manifest.jsonl from tools/extract_all.py: "
"sort whole extraction directories as units")
parser.add_argument("--json", action="store_true", parser.add_argument("--json", action="store_true",
help="print the plan as JSON instead of text") help="print the plan as JSON instead of text")
args = parser.parse_args() args = parser.parse_args()
@@ -65,7 +68,17 @@ def main() -> None:
catalog = load_catalog(args.catalog) catalog = load_catalog(args.catalog)
audit = args.audit or (args.library / "sorter_applied.jsonl") audit = args.audit or (args.library / "sorter_applied.jsonl")
items = sort_plan(files, args.library, catalog, threshold=args.threshold) provenance = None
if args.provenance:
provenance = {}
with args.provenance.open(encoding="utf-8") as fh:
for line in fh:
rec = json.loads(line)
provenance[rec["dest"]] = rec["unit"]
log.info("Provenance: %d extraction directories", len(provenance))
items = sort_plan(files, args.library, catalog, threshold=args.threshold,
provenance=provenance, root=source)
if args.json: if args.json:
print(json.dumps([ print(json.dumps([
+225
View File
@@ -0,0 +1,225 @@
#!/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 that already have content are skipped.
"""
from __future__ import annotations
import argparse
import logging
import os
import shutil
import subprocess
import sys
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 extract(archive: Path, dest: Path) -> tuple[bool, str]:
"""7z x into dest. Empty -p avoids interactive prompts (no password)."""
dest.mkdir(parents=True, exist_ok=True)
cmd = ["7z", "x", "-y", "-p", f"-o{dest}", str(archive)]
try:
proc = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, timeout=EXTRACT_TIMEOUT,
)
except subprocess.TimeoutExpired:
return False, f"extraction timed out after {EXTRACT_TIMEOUT}s"
except OSError as exc:
return False, f"7z failed to run: {exc}"
if proc.returncode in (0, 1): # 0 ok, 1 warnings (e.g. minor CRC)
if proc.returncode == 1:
log.warning("7z reported warnings extracting %s", archive.name)
return True, ""
tail = "\n".join((proc.stdout or "").splitlines()[-5:])
return False, f"7z exit {proc.returncode}: {tail}"
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 expand_nested(dest: Path, out_root: Path, manifest, src_root: Path) -> int:
"""Extract archives inside `dest` until none remain. Returns count."""
done = 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
stem_dir = p.with_name(display_unit(p.name))
if stem_dir.exists() and any(stem_dir.iterdir()):
# 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 = extract(p, stem_dir)
if not ok:
log.error("FAILED nested extraction %s: %s", p, err)
move_to_broken(p, out_root, src_root, err)
continue
manifest.write(f"{json_line(p.relative_to(out_root), stem_dir)}\n")
p.unlink() # archive copy consumed
done += 1
queue.append(stem_dir)
return done
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) -> 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
if dest.exists() and any(dest.iterdir()):
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 = 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, dest)}\n")
extracted += 1
log.info("Extracted %s -> %s", rel, dest_rel)
expand_nested(dest, out_root, manifest, src_root)
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 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]
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)
log.info("== %s: %d extracted, %d mirrored, %d failed",
src.name, *counts)
for i, c in enumerate(counts):
totals[i] += c
log.info("TOTAL: %d archives extracted, %d files mirrored, %d failed",
*totals)
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)