#!/usr/bin/env python3 """Pair the rows of CBP's "View Travel History" into trips. Input: a CSV with the header date,type,port (ISO dates, type Arrival or Departure), typed in from https://i94.cbp.dhs.gov/ -> "View Travel History". python3 pair_trips.py history.csv python3 pair_trips.py history.csv --opt-start 2026-06-01 --opt-end 2027-05-31 Standard library only, Python 3.8+. MIT License. Not legal advice. """ import argparse import calendar import csv import sys from datetime import date BANNER = ( "Built from CBP's travel history, which CBP says is not an official record " "for legal purposes. The two day counts are illustrative conventions, not the " "rule for any particular form. Not legal advice." ) def add_months(d, months): """d plus calendar months, clamped to the month end (Aug 31 + 5 -> Jan 31).""" m = d.month - 1 + months y, m = d.year + m // 12, m % 12 + 1 return date(y, m, min(d.day, calendar.monthrange(y, m)[1])) def read_rows(lines): """Parse CSV lines (file object or list of strings) into (date, type, port) tuples.""" reader = csv.DictReader(lines) reader.fieldnames = [f.strip().lower() for f in reader.fieldnames or []] if "date" not in reader.fieldnames or "type" not in reader.fieldnames: raise ValueError("first line must be the header: date,type,port") rows = [] for rec in reader: raw_date = (rec.get("date") or "").strip() raw_type = (rec.get("type") or "").strip().lower() if not raw_date and not raw_type: continue # blank row try: d = date.fromisoformat(raw_date) except ValueError: raise ValueError(f"line {reader.line_num}: bad date {raw_date!r}, use YYYY-MM-DD") if raw_type.startswith("d"): kind = "Departure" elif raw_type.startswith("a"): kind = "Arrival" else: raise ValueError(f"line {reader.line_num}: type must be Arrival or Departure") rows.append((d, kind, (rec.get("port") or "").strip())) return rows def pair_trips(rows, opt_start=None, opt_end=None): """Return (trips, flags). Each Departure pairs with the next Arrival.""" trips, flags, open_dep = [], [], None # Sort by date; on the same date a Departure comes before an Arrival. for d, kind, port in sorted(rows, key=lambda r: (r[0], r[1] != "Departure")): if kind == "Departure": if open_dep: flags.append({"flag": "departure_without_return", "date": open_dep[0], "port": open_dep[1]}) open_dep = (d, port) elif open_dep is None: # CBP says land departures into Mexico are not recorded automatically. flags.append({"flag": "arrival_without_departure", "date": d, "port": port}) else: dep, dep_port = open_dep open_dep = None days = (d - dep).days trip = { "depart": dep, "depart_port": dep_port, "return": d, "return_port": port, "days_between": days, "full_days_outside": max(days - 1, 0), # Reminder only: no official source defines how to count "5 months". "over_5_months": d > add_months(dep, 5), } if opt_start is not None and opt_end is not None: trip["days_between_in_opt"] = max(0, (min(d, opt_end) - max(dep, opt_start)).days) trips.append(trip) if open_dep: flags.append({"flag": "departure_without_return", "date": open_dep[0], "port": open_dep[1]}) return trips, flags def _at(d, port): return f"{d} {port}" if port else str(d) def main(argv=None): p = argparse.ArgumentParser(description="Pair CBP travel-history rows into trips.") p.add_argument("csv", help="CSV file with header date,type,port (or - for stdin)") p.add_argument("--opt-start", type=date.fromisoformat, help="OPT start date, YYYY-MM-DD") p.add_argument("--opt-end", type=date.fromisoformat, help="OPT end date, YYYY-MM-DD") a = p.parse_args(argv) if (a.opt_start is None) != (a.opt_end is None): p.error("use --opt-start and --opt-end together") try: if a.csv == "-": rows = read_rows(sys.stdin) else: with open(a.csv, newline="", encoding="utf-8-sig") as f: rows = read_rows(f) except (OSError, ValueError) as e: p.error(str(e)) trips, flags = pair_trips(rows, a.opt_start, a.opt_end) print(BANNER) print() if not trips: print("No complete trips found.") for i, t in enumerate(trips, 1): line = ( f"Trip {i}: left {_at(t['depart'], t['depart_port'])} -> back {_at(t['return'], t['return_port'])}" f" | {t['days_between']} days between, {t['full_days_outside']} full days outside" ) if "days_between_in_opt" in t: line += f", {t['days_between_in_opt']} days in OPT dates" if t["over_5_months"]: line += " | MORE THAN 5 CALENDAR MONTHS: talk to your DSO" print(line) if flags: print() for f in flags: print(f"CHECK {_at(f['date'], f['port'])}: {f['flag']}") print("Fill gaps from passport stamps, boarding passes, I-797A notices or a CBP FOIA request.") return 0 if __name__ == "__main__": sys.exit(main())