#!/usr/bin/env python3 """Enumeration — Appendix E, specification 1. Takes: a directory tree, or a file of one item per line, and an optional filter. Emits: the count AND the enumerated list, because a count without its list cannot be checked by anyone. Standard library only. Deterministic: the same inputs produce byte-identical output. python3 enumerate_items.py --dir ./production --ext .pdf --out enumeration.json python3 enumerate_items.py --list items.txt --contains INV- --out enumeration.json """ import argparse, hashlib, json, os, platform, sys from datetime import datetime, timezone VERSION = "1.0" def sha256_file(p): h = hashlib.sha256() with open(p, "rb") as f: for b in iter(lambda: f.read(1 << 20), b""): h.update(b) return h.hexdigest() def main(): ap = argparse.ArgumentParser(description="Enumerate items and emit the list, not only the count.") src = ap.add_mutually_exclusive_group(required=True) src.add_argument("--dir", help="directory to walk") src.add_argument("--list", help="text file, one item per line") ap.add_argument("--ext", action="append", default=[], help="restrict to extension (repeatable), e.g. --ext .pdf") ap.add_argument("--contains", help="restrict to items containing this substring (case-sensitive)") ap.add_argument("--hash", action="store_true", help="also record a SHA-256 for each file (slower)") ap.add_argument("--out", required=True, help="JSON report to write") a = ap.parse_args() items, unreadable = [], [] if a.dir: if not os.path.isdir(a.dir): sys.exit(f"not a directory: {a.dir}") for dp, dns, fns in os.walk(a.dir): dns.sort() for fn in sorted(fns): p = os.path.join(dp, fn) rel = os.path.relpath(p, a.dir) if a.ext and os.path.splitext(fn)[1].lower() not in [e.lower() for e in a.ext]: continue if a.contains and a.contains not in rel: continue rec = {"item": rel.replace(os.sep, "/")} try: rec["bytes"] = os.path.getsize(p) if a.hash: rec["sha256"] = sha256_file(p) except OSError as e: unreadable.append({"item": rel.replace(os.sep, "/"), "error": str(e)}) continue items.append(rec) else: with open(a.list, encoding="utf-8", errors="replace") as f: for line in f: s = line.strip() if not s: continue if a.contains and a.contains not in s: continue items.append({"item": s}) items.sort(key=lambda r: r["item"]) report = { "specification": "Enumeration (Appendix E, 1)", "script": os.path.basename(__file__), "script_version": VERSION, "run_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "python": platform.python_version(), "parameters": {"dir": a.dir, "list": a.list, "ext": a.ext or None, "contains": a.contains, "hash": a.hash}, "count": len(items), "unreadable_count": len(unreadable), "items": items, "unreadable": unreadable, } with open(a.out, "w", encoding="utf-8") as f: json.dump(report, f, indent=1, sort_keys=False) f.write("\n") print(f"count={len(items)} unreadable={len(unreadable)} -> {a.out}") if unreadable: print("NOTE: items that could not be read are listed in the report and are NOT in the count.") if __name__ == "__main__": main()