#!/usr/bin/env python3 """Index comparison — Appendix E, specification 2. Takes: two inventories of what should be the same set (one item per line, or a named column of a CSV). Emits: the three-way difference — present in both, present only in the first, present only in the second. Standard library only. Deterministic. python3 index_compare.py --a loadfile.csv --a-col BegBates --b produced.txt --out compare.json """ import argparse, csv, json, os, platform, sys from datetime import datetime, timezone VERSION = "1.0" def load(path, col, normalize): raw, bad = [], [] if col: with open(path, newline="", encoding="utf-8-sig", errors="replace") as f: r = csv.DictReader(f) if r.fieldnames is None or col not in r.fieldnames: sys.exit(f"{path}: column {col!r} not found; columns are {r.fieldnames}") for i, row in enumerate(r, start=2): v = (row.get(col) or "").strip() if v: raw.append(v) else: bad.append({"line": i, "reason": "empty value"}) else: with open(path, encoding="utf-8", errors="replace") as f: for i, line in enumerate(f, start=1): v = line.strip() if v: raw.append(v) keyed = {} for v in raw: k = v.strip().casefold() if normalize else v keyed.setdefault(k, []).append(v) dupes = {k: v for k, v in keyed.items() if len(v) > 1} return raw, keyed, dupes, bad def main(): ap = argparse.ArgumentParser(description="Three-way comparison of two inventories.") ap.add_argument("--a", required=True); ap.add_argument("--a-col") ap.add_argument("--b", required=True); ap.add_argument("--b-col") ap.add_argument("--normalize", action="store_true", help="compare case-insensitively after trimming whitespace") ap.add_argument("--out", required=True) x = ap.parse_args() a_raw, a_k, a_d, a_bad = load(x.a, x.a_col, x.normalize) b_raw, b_k, b_d, b_bad = load(x.b, x.b_col, x.normalize) both = sorted(set(a_k) & set(b_k)) only_a = sorted(set(a_k) - set(b_k)) only_b = sorted(set(b_k) - set(a_k)) report = { "specification": "Index comparison (Appendix E, 2)", "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": {"a": x.a, "a_col": x.a_col, "b": x.b, "b_col": x.b_col, "normalize": x.normalize}, "totals": { "a_rows_read": len(a_raw), "a_distinct": len(a_k), "b_rows_read": len(b_raw), "b_distinct": len(b_k), "in_both": len(both), "only_in_a": len(only_a), "only_in_b": len(only_b), }, "in_both": [a_k[k][0] for k in both], "only_in_a": [a_k[k][0] for k in only_a], "only_in_b": [b_k[k][0] for k in only_b], "duplicates_within_a": {a_d[k][0]: len(a_d[k]) for k in sorted(a_d)}, "duplicates_within_b": {b_d[k][0]: len(b_d[k]) for k in sorted(b_d)}, "unusable_rows": {"a": a_bad, "b": b_bad}, } assert len(both) + len(only_a) == len(a_k), "arithmetic check failed on A" assert len(both) + len(only_b) == len(b_k), "arithmetic check failed on B" with open(x.out, "w", encoding="utf-8") as f: json.dump(report, f, indent=1); f.write("\n") t = report["totals"] print(f"both={t['in_both']} only_a={t['only_in_a']} only_b={t['only_in_b']} -> {x.out}") if a_d or b_d: print("NOTE: duplicate keys were present; distinct counts differ from rows read.") if __name__ == "__main__": main()