#!/usr/bin/env python3 """OPT / STEM OPT unemployment day counter. MIT License. No dependencies. Counts every calendar day (weekends included) from the EAD start date to the earlier of the as-of date and the authorization end that no qualifying job covers. A job's start and end dates count as days worked. Example: python3 opt_counter.py --ead-start 2026-06-01 --auth-end 2027-05-31 \ --as-of 2026-09-30 --job 2026-07-01:2026-08-14 --job 2026-09-01: """ import argparse from datetime import date, timedelta DISCLAIMER = ("Counts dates only. It does not decide whether a job qualifies " "or whether you are in status.") ONE_DAY = timedelta(days=1) def count_unemployment(ead_start, auth_end, as_of, jobs, stem_start=None): """jobs: list of {"start": date, "end": date | None, "qualifying": bool}. end=None means the job is ongoing.""" count_to = min(as_of, auth_end) covered = set() # a set, so overlapping jobs never double count for job in jobs: if not job["qualifying"]: continue d = max(job["start"], ead_start) end = count_to if job["end"] is None else min(job["end"], count_to) while d <= end: covered.add(d) d += ONE_DAY unemployed_dates = [] first_day_over_limit = None d = ead_start while d <= count_to: if d not in covered: unemployed_dates.append(d) limit_that_day = 150 if stem_start and d >= stem_start else 90 if first_day_over_limit is None and len(unemployed_dates) > limit_that_day: first_day_over_limit = d d += ONE_DAY limit = 150 if stem_start and count_to >= stem_start else 90 return { "days_used": len(unemployed_dates), "limit": limit, "days_remaining": limit - len(unemployed_dates), "first_day_over_limit": first_day_over_limit, "unemployed_dates": unemployed_dates, } def to_ranges(dates): """[Jun 1, Jun 2, Jun 5] -> [(Jun 1, Jun 2), (Jun 5, Jun 5)]""" ranges = [] for d in dates: if ranges and ranges[-1][1] + ONE_DAY == d: ranges[-1][1] = d else: ranges.append([d, d]) return ranges def stem_pending_window(opt_end, as_of): """A timely STEM OPT application is still pending. Like EADly, counting runs up to 180 days past the OPT EAD end date (8 CFR 274a.12(b)(6)(iv)) and the 150-day limit applies from the next day. Returns (auth_end, stem_start); after day 180 the count falls back to the OPT EAD end date and the 90-day limit, as in EADly.""" pending_end = opt_end + timedelta(days=180) if as_of <= pending_end: return pending_end, opt_end + ONE_DAY return opt_end, None def parse_job(text): """START:END[:no] END may be empty (ongoing). ':no' = not qualifying.""" parts = text.split(":") if len(parts) not in (2, 3) or (len(parts) == 3 and parts[2] != "no"): raise argparse.ArgumentTypeError(f"bad --job {text!r}, use START:END or START:END:no") return { "start": date.fromisoformat(parts[0]), "end": date.fromisoformat(parts[1]) if parts[1] else None, "qualifying": len(parts) == 2, } def main(): p = argparse.ArgumentParser(description="Count OPT / STEM OPT unemployment days.") p.add_argument("--ead-start", type=date.fromisoformat, required=True, help="'Valid From' date on your OPT EAD (YYYY-MM-DD)") p.add_argument("--auth-end", type=date.fromisoformat, required=True, help="last authorized date: OPT EAD end, STEM EAD end, or cap-gap date on your I-20") p.add_argument("--as-of", type=date.fromisoformat, default=date.today(), help="count through this date (default: today)") p.add_argument("--job", type=parse_job, action="append", default=[], help="START:END (END empty if ongoing); add ':no' if not qualifying. Repeatable.") p.add_argument("--stem-start", type=date.fromisoformat, help="'Valid From' date on your STEM OPT EAD, once approved") p.add_argument("--stem-pending", action="store_true", help="you filed for STEM OPT by your OPT EAD end date (--auth-end) " "and are waiting for a decision") a = p.parse_args() auth_end, stem_start = a.auth_end, a.stem_start if a.stem_pending and not stem_start: auth_end, stem_start = stem_pending_window(a.auth_end, a.as_of) r = count_unemployment(a.ead_start, auth_end, a.as_of, a.job, stem_start) print(f"Unemployment days used: {r['days_used']}") print(f"Limit: {r['limit']}") if a.stem_pending and not a.stem_start: if stem_start: print("STEM OPT application pending: 150-day limit after your OPT EAD end date, " "for up to 180 days, including the days used during OPT. Confirm with your DSO.") else: print(f"The 180-day pending period ended on {a.auth_end + timedelta(days=180)}. " "Once STEM OPT is approved, use --stem-start. Confirm with your DSO.") print(f"Days remaining: {r['days_remaining']}") print(f"First day over limit: {r['first_day_over_limit'] or 'none'}") for start, end in to_ranges(r["unemployed_dates"]): n = (end - start).days + 1 print(f" unemployed {start} to {end} ({n} day{'s' if n > 1 else ''})") print(DISCLAIMER) if __name__ == "__main__": main()