""" Holiday calendar for web automation scheduling. Covers: - US federal holidays (observed) — relevant to IRS, SSA, and US state portals - Canadian federal statutory holidays — relevant to CRTC, BC Registry - BC provincial holidays — relevant to BC Corporate Registry, BC Online - State-specific observed holidays (closures vary by SOS office) Usage: from scripts.formation.holidays import is_holiday, next_business_day if is_holiday(date.today(), jurisdiction="US"): ... if is_holiday(date.today(), jurisdiction="BC"): ... """ from datetime import date, timedelta from typing import Literal, Optional Jurisdiction = Literal["US", "CA", "BC", "IRS"] # ── Helpers ──────────────────────────────────────────────────────────────────── def _nth_weekday(year: int, month: int, weekday: int, n: int) -> date: """Return the nth occurrence of weekday (Mon=0 … Sun=6) in given month/year. n=1 → first, n=2 → second, n=-1 → last. """ if n > 0: first = date(year, month, 1) offset = (weekday - first.weekday()) % 7 return first + timedelta(days=offset + 7 * (n - 1)) else: # last # find last day, walk back if month == 12: last = date(year + 1, 1, 1) - timedelta(days=1) else: last = date(year, month + 1, 1) - timedelta(days=1) offset = (last.weekday() - weekday) % 7 return last - timedelta(days=offset) def _observed(d: date) -> date: """Return the observed date for a holiday falling on a weekend. Saturday → Friday, Sunday → Monday. """ if d.weekday() == 5: # Saturday return d - timedelta(days=1) if d.weekday() == 6: # Sunday return d + timedelta(days=1) return d # ── US Federal Holidays ──────────────────────────────────────────────────────── def _us_federal_holidays(year: int) -> set[date]: """Return the set of US federal holiday observed dates for a given year.""" MON, TUE, WED, THU, FRI, SAT, SUN = range(7) holidays = set() # New Year's Day — Jan 1 holidays.add(_observed(date(year, 1, 1))) # Martin Luther King Jr. Day — 3rd Monday in January holidays.add(_nth_weekday(year, 1, MON, 3)) # Presidents' Day (Washington's Birthday) — 3rd Monday in February holidays.add(_nth_weekday(year, 2, MON, 3)) # Memorial Day — last Monday in May holidays.add(_nth_weekday(year, 5, MON, -1)) # Juneteenth — June 19 holidays.add(_observed(date(year, 6, 19))) # Independence Day — July 4 holidays.add(_observed(date(year, 7, 4))) # Labor Day — 1st Monday in September holidays.add(_nth_weekday(year, 9, MON, 1)) # Columbus Day — 2nd Monday in October holidays.add(_nth_weekday(year, 10, MON, 2)) # Veterans Day — November 11 holidays.add(_observed(date(year, 11, 11))) # Thanksgiving — 4th Thursday in November holidays.add(_nth_weekday(year, 11, THU, 4)) # Christmas — December 25 holidays.add(_observed(date(year, 12, 25))) # New Year's Day (observed for next year, sometimes Dec 31) ny_next = _observed(date(year + 1, 1, 1)) if ny_next.year == year: holidays.add(ny_next) return holidays # ── Canadian Federal Statutory Holidays ─────────────────────────────────────── def _canada_federal_holidays(year: int) -> set[date]: """Return Canadian federal statutory holiday dates for a given year.""" MON, TUE, WED, THU, FRI, SAT, SUN = range(7) holidays = set() # New Year's Day holidays.add(_observed(date(year, 1, 1))) # Good Friday — Friday before Easter easter = _easter(year) holidays.add(easter - timedelta(days=2)) # Good Friday # Easter Monday holidays.add(easter + timedelta(days=1)) # Victoria Day — Monday before May 25 may25 = date(year, 5, 25) days_since_mon = may25.weekday() # Mon=0 if days_since_mon == 0: holidays.add(may25 - timedelta(days=7)) else: holidays.add(may25 - timedelta(days=days_since_mon)) # Canada Day — July 1 holidays.add(_observed(date(year, 7, 1))) # Labour Day — 1st Monday in September holidays.add(_nth_weekday(year, 9, MON, 1)) # National Day for Truth and Reconciliation — Sept 30 (federal) holidays.add(_observed(date(year, 9, 30))) # Thanksgiving — 2nd Monday in October holidays.add(_nth_weekday(year, 10, MON, 2)) # Remembrance Day — November 11 holidays.add(_observed(date(year, 11, 11))) # Christmas Day — December 25 holidays.add(_observed(date(year, 12, 25))) # Boxing Day — December 26 holidays.add(_observed(date(year, 12, 26))) return holidays # ── BC Provincial Holidays ──────────────────────────────────────────────────── def _bc_holidays(year: int) -> set[date]: """Return BC provincial statutory holidays (superset of Canadian federal).""" MON = 0 holidays = _canada_federal_holidays(year) # BC Day — 1st Monday in August holidays.add(_nth_weekday(year, 8, MON, 1)) # Family Day — 3rd Monday in February (BC-specific — started 2013) if year >= 2013: holidays.add(_nth_weekday(year, 2, MON, 3)) return holidays # ── Easter (Gregorian) ──────────────────────────────────────────────────────── def _easter(year: int) -> date: """Return date of Easter Sunday using the Anonymous Gregorian algorithm.""" a = year % 19 b = year // 100 c = year % 100 d = b // 4 e = b % 4 f = (b + 8) // 25 g = (b - f + 1) // 3 h = (19 * a + b - d - g + 15) % 30 i = c // 4 k = c % 4 l = (32 + 2 * e + 2 * i - h - k) % 7 m = (a + 11 * h + 22 * l) // 451 month = (h + l - 7 * m + 114) // 31 day = ((h + l - 7 * m + 114) % 31) + 1 return date(year, month, day) # ── Cache ───────────────────────────────────────────────────────────────────── _cache: dict[tuple[int, str], set[date]] = {} def _get_holidays(year: int, jurisdiction: Jurisdiction) -> set[date]: key = (year, jurisdiction) if key not in _cache: if jurisdiction == "US" or jurisdiction == "IRS": _cache[key] = _us_federal_holidays(year) elif jurisdiction == "CA": _cache[key] = _canada_federal_holidays(year) elif jurisdiction == "BC": _cache[key] = _bc_holidays(year) else: _cache[key] = set() return _cache[key] # ── Public API ──────────────────────────────────────────────────────────────── def is_holiday(d: date, jurisdiction: Jurisdiction = "US") -> bool: """Return True if `d` is a holiday in the given jurisdiction.""" return d in _get_holidays(d.year, jurisdiction) def is_weekend(d: date) -> bool: """Return True if `d` is Saturday or Sunday.""" return d.weekday() >= 5 def is_business_day(d: date, jurisdiction: Jurisdiction = "US") -> bool: """Return True if `d` is a weekday and not a holiday.""" return not is_weekend(d) and not is_holiday(d, jurisdiction) def next_business_day( after: Optional[date] = None, jurisdiction: Jurisdiction = "US", ) -> date: """Return the next business day after `after` (default: today).""" d = (after or date.today()) + timedelta(days=1) while not is_business_day(d, jurisdiction): d += timedelta(days=1) return d def holiday_name(d: date, jurisdiction: Jurisdiction = "US") -> Optional[str]: """Return a human-readable name for the holiday on `d`, or None.""" # Build a labelled lookup for the year labels = _labelled_holidays(d.year, jurisdiction) return labels.get(d) def _labelled_holidays(year: int, jurisdiction: Jurisdiction) -> dict[date, str]: """Return holiday dates mapped to their names.""" MON, TUE, WED, THU, FRI, SAT, SUN = range(7) result: dict[date, str] = {} if jurisdiction in ("US", "IRS"): result[_observed(date(year, 1, 1))] = "New Year's Day" result[_nth_weekday(year, 1, MON, 3)] = "Martin Luther King Jr. Day" result[_nth_weekday(year, 2, MON, 3)] = "Presidents' Day" result[_nth_weekday(year, 5, MON, -1)] = "Memorial Day" result[_observed(date(year, 6, 19))] = "Juneteenth" result[_observed(date(year, 7, 4))] = "Independence Day" result[_nth_weekday(year, 9, MON, 1)] = "Labor Day" result[_nth_weekday(year, 10, MON, 2)] = "Columbus Day" result[_observed(date(year, 11, 11))] = "Veterans Day" result[_nth_weekday(year, 11, THU, 4)] = "Thanksgiving" result[_observed(date(year, 12, 25))] = "Christmas Day" ny_next = _observed(date(year + 1, 1, 1)) if ny_next.year == year: result[ny_next] = "New Year's Day (observed)" elif jurisdiction in ("CA", "BC"): easter = _easter(year) result[_observed(date(year, 1, 1))] = "New Year's Day" result[easter - timedelta(days=2)] = "Good Friday" result[easter + timedelta(days=1)] = "Easter Monday" may25 = date(year, 5, 25) daysback = may25.weekday() if may25.weekday() > 0 else 7 result[may25 - timedelta(days=daysback)] = "Victoria Day" result[_observed(date(year, 7, 1))] = "Canada Day" result[_nth_weekday(year, 9, MON, 1)] = "Labour Day" result[_observed(date(year, 9, 30))] = "National Day for Truth and Reconciliation" result[_nth_weekday(year, 10, MON, 2)] = "Thanksgiving" result[_observed(date(year, 11, 11))] = "Remembrance Day" result[_observed(date(year, 12, 25))] = "Christmas Day" result[_observed(date(year, 12, 26))] = "Boxing Day" if jurisdiction == "BC": result[_nth_weekday(year, 8, MON, 1)] = "BC Day" if year >= 2013: result[_nth_weekday(year, 2, MON, 3)] = "Family Day (BC)" return result def upcoming_holidays( days: int = 30, jurisdiction: Jurisdiction = "US", from_date: Optional[date] = None, ) -> list[tuple[date, str]]: """Return (date, name) pairs for holidays in the next `days` days.""" start = from_date or date.today() end = start + timedelta(days=days) labelled = _labelled_holidays(start.year, jurisdiction) if end.year != start.year: labelled.update(_labelled_holidays(end.year, jurisdiction)) return sorted( [(d, name) for d, name in labelled.items() if start <= d <= end], key=lambda x: x[0], )