#!/usr/bin/env python3 """Chronology merge — Appendix E, specification 3. Takes: two or more CSV sources of events whose timestamp formats are inconsistent. Emits: a single ordered sequence with each entry's source retained, which is what makes the merged chronology defensible when one source turns out to be wrong. Rows whose timestamp cannot be parsed are NOT silently dropped: they are emitted in an `unparsed` block and counted. Standard library only. Deterministic. python3 chronology_merge.py --source logs:server.csv:ts:message \ --source email:mail.csv:Date:Subject --out chrono.json """ import argparse, csv, json, os, platform, sys from datetime import datetime, timezone VERSION = "1.0" FORMATS = [ "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S%z", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d", "%m/%d/%Y %H:%M:%S", "%m/%d/%Y %H:%M", "%m/%d/%Y", "%m/%d/%y %H:%M", "%d/%m/%Y %H:%M:%S", "%d %b %Y %H:%M:%S", "%d %B %Y", "%a, %d %b %Y %H:%M:%S %z", "%b %d, %Y %I:%M %p", "%b %d %Y", ] def parse_ts(s): s = (s or "").strip() if not s: return None, None for fmt in FORMATS: try: dt = datetime.strptime(s, fmt) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt, fmt except ValueError: continue try: dt = datetime.fromisoformat(s) return (dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)), "fromisoformat" except ValueError: return None, None def main(): ap = argparse.ArgumentParser(description="Merge events from several sources into one ordered sequence.") ap.add_argument("--source", action="append", required=True, help="NAME:path.csv:timestamp_column:text_column (repeatable)") ap.add_argument("--assume-utc", action="store_true", help="record that naive timestamps were treated as UTC (they always are; this documents it)") ap.add_argument("--out", required=True) a = ap.parse_args() events, unparsed, per_source = [], [], {} for spec in a.source: parts = spec.split(":") if len(parts) != 4: sys.exit(f"--source must be NAME:path:ts_col:text_col, got {spec!r}") name, path, tscol, txtcol = parts read = ok = 0 with open(path, newline="", encoding="utf-8-sig", errors="replace") as f: r = csv.DictReader(f) if r.fieldnames is None or tscol not in r.fieldnames: sys.exit(f"{path}: column {tscol!r} not found; columns are {r.fieldnames}") for i, row in enumerate(r, start=2): read += 1 dt, fmt = parse_ts(row.get(tscol)) rec = {"source": name, "source_row": i, "timestamp_raw": (row.get(tscol) or "").strip(), "text": (row.get(txtcol) or "").strip()} if dt is None: unparsed.append(rec) continue rec["timestamp_utc"] = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") rec["timestamp_format"] = fmt events.append(rec); ok += 1 per_source[name] = {"path": path, "rows_read": read, "parsed": ok, "unparsed": read - ok} events.sort(key=lambda e: (e["timestamp_utc"], e["source"], e["source_row"])) report = { "specification": "Chronology merge (Appendix E, 3)", "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(), "naive_timestamps_treated_as": "UTC", "sources": per_source, "totals": {"events_merged": len(events), "unparsed": len(unparsed)}, "events": events, "unparsed": unparsed, } with open(a.out, "w", encoding="utf-8") as f: json.dump(report, f, indent=1); f.write("\n") print(f"merged={len(events)} unparsed={len(unparsed)} -> {a.out}") if unparsed: print("NOTE: rows whose timestamp could not be parsed are listed and excluded from the sequence.") if __name__ == "__main__": main()