Created
June 18, 2025 17:43
-
-
Save s4lt3d/086f8e03c795603406276dc8e3f91bc9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Generate a bi-weekly payroll calendar. | |
| Rules | |
| • “Last working day” is the final business day (Mon–Fri, not a holiday) | |
| of each 14-day period. | |
| • Pay date = exactly three business days before that last working day. | |
| Run | |
| python payroll_calendar.py | |
| python payroll_calendar.py --start 2025-01-10 --periods 26 \ | |
| --holidays ca_holidays_2025.csv | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| from datetime import date, timedelta | |
| from pathlib import Path | |
| import csv | |
| import sys | |
| # ─────────────────────────── CONFIG (defaults) ──────────────────────────── | |
| ANCHOR_LAST_WORKING_DAY = date(2025, 6, 15) # first period end (Friday) | |
| NUM_PERIODS = 26 # 26 bi-weekly periods/year | |
| HOLIDAYS_FILE = None # optional csv/tsv of YYYY-MM-DD | |
| # Example holiday fallback | |
| # !!!! CHECK THESE DATES FOR ALBERTA !!!! | |
| FALLBACK_HOLIDAYS = { | |
| date(2025, 1, 1), | |
| date(2025, 4, 18), | |
| date(2025, 5, 19), | |
| date(2025, 7, 1), | |
| date(2025, 9, 1), | |
| date(2025, 9, 30), | |
| date(2025, 10, 13), | |
| date(2025, 12, 25), | |
| date(2025, 12, 26), | |
| } | |
| # ─────────────────────────────────────────────────────────────────────────── | |
| # ────────────────────────── core helpers ────────────────────────────────── | |
| def load_holidays(path: str | None) -> set[date]: | |
| """Load YYYY-MM-DD strings (any csv/tsv) into a date set.""" | |
| if not path: | |
| return set(FALLBACK_HOLIDAYS) | |
| fp = Path(path) | |
| if not fp.exists(): | |
| sys.exit(f"Holiday file not found: {fp}") | |
| hol = set() | |
| with fp.open(newline="") as f: | |
| # accept plain list, csv, or tsv | |
| reader = csv.reader(f, delimiter="," if "," in f.read(1024) else None) | |
| f.seek(0) | |
| for row in reader: | |
| if not row: | |
| continue | |
| try: | |
| y, m, d = map(int, row[0].strip().split("-")) | |
| hol.add(date(y, m, d)) | |
| except Exception as e: | |
| sys.exit(f"Bad holiday entry '{row[0]}': {e}") | |
| return hol | |
| def is_business_day(day: date, holidays: set[date]) -> bool: | |
| return day.weekday() < 5 and day not in holidays | |
| def prev_business_day(day: date, holidays: set[date]) -> date: | |
| """Move back to previous business day (if today is not one).""" | |
| while not is_business_day(day, holidays): | |
| day -= timedelta(days=1) | |
| return day | |
| def subtract_business_days(day: date, days: int, holidays: set[date]) -> date: | |
| """Go back exactly <days> business days from <day>.""" | |
| cur, done = day, 0 | |
| while done < days: | |
| cur -= timedelta(days=1) | |
| if is_business_day(cur, holidays): | |
| done += 1 | |
| return cur | |
| def generate_calendar(anchor: date, | |
| periods: int, | |
| holidays: set[date]) -> list[tuple[int, date, date]]: | |
| """ | |
| Return [(index, last_work_day, pay_date)] for <periods> bi-weekly spans | |
| beginning with <anchor>. | |
| """ | |
| calendar = [] | |
| for idx in range(periods): | |
| last_day = anchor + timedelta(days=14 * idx) | |
| last_day = prev_business_day(last_day, holidays) | |
| pay_day = subtract_business_days(last_day, 3, holidays) | |
| calendar.append((idx + 1, last_day, pay_day)) | |
| return calendar | |
| # ────────────────────────────── CLI ─────────────────────────────────────── | |
| def parse_args() -> argparse.Namespace: | |
| p = argparse.ArgumentParser(description="Bi-weekly payroll calendar") | |
| p.add_argument("--start", type=str, | |
| help="first period end (YYYY-MM-DD)", | |
| default=str(ANCHOR_LAST_WORKING_DAY)) | |
| p.add_argument("--periods", type=int, | |
| help="number of bi-weekly periods to build", | |
| default=NUM_PERIODS) | |
| p.add_argument("--holidays", type=str, | |
| help="csv/tsv file of YYYY-MM-DD holiday dates") | |
| return p.parse_args() | |
| def main(): | |
| args = parse_args() | |
| try: | |
| y, m, d = map(int, args.start.split("-")) | |
| anchor = date(y, m, d) | |
| except Exception as e: | |
| sys.exit(f"Bad --start date '{args.start}': {e}") | |
| holidays = load_holidays(args.holidays) | |
| cal = generate_calendar(anchor, args.periods, holidays) | |
| print(f"{'Period':<6} {'Last work day':<15} Pay date") | |
| print("-" * 40) | |
| for idx, last_day, pay_day in cal: | |
| print(f"{idx:<6} {last_day.isoformat():<15} {pay_day.isoformat()}") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment