-
-
Save kbmcgowan/8cca0303b5de62d079163329e3d37f67 to your computer and use it in GitHub Desktop.
Generate a class calendar using duration of semester, the days of the week a class meets, and holidays. It can modify a markdown syllabus if they share the same number of sessions and classes are designed with the pattern "### Sep 30 Fri"
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/local/bin/python3 | |
| """ | |
| Generate a semester calendar using duration of semester, the days of the week a | |
| class meets, and holidays. It can modify a markdown syllabus if they share the | |
| same number of sessions and classes are designed with the pattern '| Monday, Sep 30th'. | |
| Modified from Joseph Reagle's original (see copyright information below) to support | |
| markdown output, more flexible weekly meeting schedules, and a frankly sillier date format. | |
| """ | |
| # based on https://gist.github.com/reagle/19806122fdb22515ea0b | |
| # by Joseph Reagle, Copyright (C) 2011-2023 GLPv3 | |
| import logging | |
| import re | |
| import sys | |
| import textwrap | |
| from pathlib import Path # https://docs.python.org/3/library/pathlib.html | |
| from dateutil.parser import parse # http://labix.org/python-dateutil | |
| from dateutil.rrule import FR, MO, SU, TU, WE, TH, WEEKLY, rrule, weekday | |
| HOME = str(Path("~").expanduser()) | |
| "test" | |
| exception = logging.exception | |
| critical = logging.critical | |
| error = logging.error | |
| warning = logging.warning | |
| info = logging.info | |
| debug = logging.debug | |
| # Spring semester; updated 2025-04-12 | |
| SPRING_SEM_FIRST = "20260112" | |
| SPRING_SEM_LAST = "20260429" | |
| SPRING_HOLIDAYS = { | |
| "20260119": "MLK", | |
| "20260316": "Spring break", | |
| "20260317": "Spring break", | |
| "20260318": "Spring break", | |
| "20260319": "Spring break", | |
| "20260320": "Spring break", | |
| } | |
| SPRING = (SPRING_SEM_FIRST, SPRING_SEM_LAST, SPRING_HOLIDAYS) | |
| # Fall semester; updated 2025-04-12 | |
| FALL_SEM_FIRST = "20250825" | |
| FALL_SEM_LAST = "20251210" | |
| FALL_HOLIDAYS = { | |
| "20250901": "Labor Day", | |
| "20251027": "Fall Break", | |
| "20251028": "Fall Break", | |
| "20251126": "Thanksgiving", | |
| "20251127": "Thanksgiving", | |
| "20251128": "Thanksgiving", | |
| } | |
| FALL = (FALL_SEM_FIRST, FALL_SEM_LAST, FALL_HOLIDAYS) | |
| ENDBITS = {1: 'st', 2: 'nd', 3: 'rd'} | |
| def endbit(i): | |
| """ | |
| Return the last two letters of the ordinal date number in | |
| English: firST, secoND, thiRD, fourTH, etc. | |
| """ | |
| if 10 <= i % 100 <= 20: | |
| return 'th' | |
| else: | |
| return ENDBITS.get(i % 10, 'th') | |
| def generate_classes( | |
| semester: tuple[str, str, dict], days: tuple[weekday, ...] | |
| ) -> tuple[list[tuple[int, str, str]], int]: | |
| """ | |
| Take a tuple of enums representing the days of the week the classes should be on. | |
| Return a tuple with two elements. The first: a list of tuples representing the | |
| classes, where each tuple consists of the class number, date, and whether or not | |
| it's a holiday. Second: an integer representing the total number of | |
| holidays during the semester. | |
| """ | |
| # PRINT HEADER #### | |
| sem_first, sem_last, holidays = semester | |
| print("=======================") | |
| print(f"{sem_first}: First") | |
| for date, holiday in holidays.items(): | |
| print(f"{date}: {holiday}") | |
| print(f"{sem_last}: Last") | |
| print("=======================") | |
| holidays = holidays.keys() | |
| # GENERATE CLASSES ### | |
| num_holidays: int = 0 | |
| meetings = list( | |
| rrule( | |
| WEEKLY, | |
| wkst=SU, | |
| byweekday=(days), | |
| dtstart=parse(sem_first), | |
| until=parse(sem_last), | |
| ) | |
| ) | |
| classes = [] | |
| for class_num, meeting in enumerate(meetings, start=1): | |
| class_date = holiday = None | |
| class_date = meeting.strftime("%A, %b %-d" + endbit(int(meeting.strftime("%d")))) | |
| print(class_date + " ", end="") | |
| meeting_str = meeting.strftime("%Y%m%d") | |
| if meeting_str in holidays: | |
| debug(f"{meeting_str=} in {holidays=}") | |
| holiday = f"{semester[2][meeting_str]} (NO CLASS)" | |
| num_holidays += 1 | |
| print(holiday, end="") | |
| print("") | |
| classes.append((class_num, class_date, holiday)) | |
| available_classes = len(classes) - num_holidays | |
| print( | |
| "{:d} classes total ({:d} available, as {:d} are holidays)\n".format( len(classes), available_classes, num_holidays ) | |
| ) | |
| return classes, num_holidays | |
| def update_md( | |
| file_name: str, | |
| gen_classes: list[tuple[int, str, str]], | |
| num_gen_holidays: int, | |
| purge_holidays: bool, | |
| ) -> None: | |
| """ | |
| Move through syllabus line by line. | |
| "classes" are generated, "sessions" are found. | |
| """ | |
| with open(file_name) as fd: | |
| content = fd.read() | |
| new_content = [] | |
| SESSION_RE = re.compile(r'\|\s*(?P<date>(?:Mon|Tues|Wednes|Thurs|Fri|Satur|Sun)day, \w\w\w \d?\d\w\w)\s*\|\s*(?P<topic>.*)\|\s*?') | |
| found_sessions = SESSION_RE.findall(content) | |
| info(f"{found_sessions=}") | |
| found_holidays = [s for s in found_sessions if "NO CLASS" in s[1]] | |
| info(f"{found_holidays=}") | |
| purged_holidays = found_holidays | |
| info(f"{purged_holidays=}") | |
| if not purge_holidays: | |
| purged_holidays = [] | |
| if len(gen_classes) - num_gen_holidays != len(found_sessions) - len( | |
| purged_holidays | |
| ): | |
| print_mismatch_classes_error( | |
| gen_classes, | |
| num_gen_holidays, | |
| purge_holidays, | |
| found_sessions, | |
| found_holidays, | |
| purged_holidays, | |
| ) | |
| gen_counter = 0 # ctr for generated classes | |
| for line in content.split("\n"): | |
| debug("line = '%s'" % line) | |
| m = SESSION_RE.match(line) | |
| if m: | |
| info(f"{line=}") | |
| info(" matched!!!") | |
| _g_num, g_date, g_holiday = gen_classes[gen_counter] | |
| if g_holiday: | |
| debug(" g_holiday") | |
| debug(" inserting = '| %s | NO CLASS |'" % g_date) | |
| new_content.append("| {} | {} |".format( g_date, g_holiday)) | |
| gen_counter += 1 | |
| _g_num, g_date, g_holiday = gen_classes[gen_counter] | |
| if purge_holidays and "NO CLASS" in line: | |
| debug(" purging holiday %s" % line) | |
| continue | |
| else: | |
| debug(" Checking and replacing dates\n") | |
| debug( | |
| " gen_classes[gen_counter] = '%s'" | |
| % ",".join(map(str, gen_classes[gen_counter])) | |
| ) | |
| f_date, f_topic = m.groups() # found date and topic | |
| debug(f" f_date = '{f_date}' f_topic = '{f_topic}'") | |
| debug(" g_date = '%s'" % g_date) | |
| if f_date == g_date: | |
| debug(" no change") | |
| else: | |
| debug(f" replace '{f_date}' with '{g_date}'") | |
| line = line.replace(f_date, g_date) | |
| gen_counter += 1 | |
| new_content.append(line) | |
| with open(file_name, "w") as fd: | |
| fd.write("\n".join(new_content)) | |
| print(f"Updated {file_name}") | |
| def print_mismatch_classes_error( | |
| gen_classes: list[tuple[int, str, str]], | |
| num_gen_holidays: int, | |
| purge_holidays: bool, | |
| found_sessions: list[str], | |
| found_holidays: list[str], | |
| purged_holidays: list[str], | |
| ) -> None: | |
| """ | |
| Print an error message indicating that the available classes does not equal the | |
| available sessions. | |
| """ | |
| error_msg = textwrap.dedent( | |
| """ | |
| Error: Available classes does NOT equal available sessions. | |
| {:^18s} - {:^18s} != {:^19s} - {:^18s} | |
| {:^18d} - {:^18d} != {:^19d} - {:^18d} | |
| """.format( | |
| "len(gen_classes)", | |
| "num_gen_holidays", | |
| "len(found_sessions)", | |
| "len(purged_holidays)", | |
| len(gen_classes), | |
| num_gen_holidays, | |
| len(found_sessions), | |
| len(purged_holidays), | |
| ) | |
| ) | |
| print(error_msg) | |
| num_needed_classes = (len(gen_classes) - num_gen_holidays) - ( | |
| len(found_sessions) - len(purged_holidays) | |
| ) | |
| print(f"\tYou need {num_needed_classes:+d} class sessions.") | |
| if not purge_holidays: | |
| print(f"\tI found {len(found_holidays):+d} holidays, purge them?") | |
| sys.exit() | |
| if __name__ == "__main__": | |
| import argparse # http://docs.python.org/dev/library/argparse.html | |
| arg_parser = argparse.ArgumentParser( | |
| description="""Generate class schedules and update associated markdown syllabus. """ | |
| ) | |
| # positional arguments | |
| arg_parser.add_argument("file", type=Path, nargs="?", metavar="FILE") | |
| # optional arguments | |
| arg_parser.add_argument( | |
| "-b", | |
| "--block", | |
| choices=["mw", "tf", "tt", "mwf", "m", "f"], | |
| default="mw", | |
| help="Days of week mwf (mo/we/fr), tt (tu/th), mw (mo/we), tf (tu/fr), or just f (potential colloquium dates) (default: %(default)s)", | |
| ) | |
| arg_parser.add_argument( | |
| "-t", | |
| "--term", | |
| choices=["f", "s"], | |
| required=True, | |
| help="use the Fall or Spring term dates specified within source", | |
| ) | |
| arg_parser.add_argument( | |
| "-u", | |
| "--update", | |
| action="store_true", | |
| default=False, | |
| help="update date sessions in syllabus (e.g., '| Monday, Sep 30th')", | |
| ) | |
| arg_parser.add_argument( | |
| "-L", | |
| "--log-to-file", | |
| action="store_true", | |
| default=False, | |
| help="log to file %(prog)s.log", | |
| ) | |
| arg_parser.add_argument( | |
| "-p", | |
| "--purge-holidays", | |
| action="store_true", | |
| default=False, | |
| help="purge existing holidays, use with --update", | |
| ) | |
| arg_parser.add_argument( | |
| "-V", | |
| "--verbose", | |
| action="count", | |
| default=0, | |
| help="Increase verbosity (specify multiple times for more)", | |
| ) | |
| arg_parser.add_argument("--version", action="version", version="0.2") | |
| args = arg_parser.parse_args() | |
| if args.block == "mw": | |
| block = (MO, WE) | |
| elif args.block == "tf": | |
| block = (TU, FR) | |
| elif args.block == "tt": | |
| block = (TU, TH) | |
| elif args.block == "mwf": | |
| block = (MO, WE, FR) | |
| elif args.block == "m": | |
| block = (MO) | |
| elif args.block == "f": | |
| block = (FR) | |
| else: | |
| raise ValueError("unknown course block {args.block}") | |
| if args.term == "f": | |
| term = FALL | |
| elif args.term == "s": | |
| term = SPRING | |
| else: | |
| raise ValueError("unknown term {args.semester}") | |
| log_level = logging.ERROR # 40 | |
| if args.verbose == 1: | |
| log_level = logging.WARNING # 30 | |
| elif args.verbose == 2: | |
| log_level = logging.INFO # 20 | |
| elif args.verbose >= 3: | |
| log_level = logging.DEBUG # 10 | |
| LOG_FORMAT = "%(levelname).3s %(funcName).5s: %(message)s" | |
| if args.log_to_file: | |
| print("logging to file") | |
| logging.basicConfig( | |
| filename="_PROG-TEMPLATE.log", | |
| filemode="w", | |
| level=log_level, | |
| format=LOG_FORMAT, | |
| ) | |
| else: | |
| logging.basicConfig(level=log_level, format=LOG_FORMAT) | |
| classes, num_holidays = generate_classes(semester=term, days=block) | |
| if args.update: | |
| if args.file.suffix == ".md": | |
| update_md(args.file, classes, num_holidays, args.purge_holidays) | |
| else: | |
| raise ValueError(f"No known file extension: {args.file.suffix}") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment