Last active
January 3, 2024 20:17
-
-
Save reagle/19806122fdb22515ea0b 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 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 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" | |
""" | |
# https://gist.github.com/reagle/19806122fdb22515ea0b | |
__author__ = "Joseph Reagle" | |
__copyright__ = "Copyright (C) 2011-2023 Joseph Reagle" | |
__license__ = "GLPv3" | |
__version__ = "1.0" | |
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, 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 | |
# CALENDARS ############ | |
# https://registrar.northeastern.edu/article/calendar-current-year/ | |
# https://registrar.northeastern.edu/article/future-calendars/ | |
# Spring semester; updated 2023-12-08 | |
SPRING_SEM_FIRST = "20240108" | |
SPRING_SEM_LAST = "20240417" | |
SPRING_HOLIDAYS = { | |
"20240115": "MLK", | |
"20240219": "Presidents", | |
"20240304": "Spring break", | |
"20240305": "Spring break", | |
"20240306": "Spring break", | |
"20240307": "Spring break", | |
"20240308": "Spring break", | |
"20240415": "Patriots", | |
} | |
SPRING = (SPRING_SEM_FIRST, SPRING_SEM_LAST, SPRING_HOLIDAYS) | |
# Fall semester; updated 2023-06-21 | |
FALL_SEM_FIRST = "20230906" | |
FALL_SEM_LAST = "20231206" | |
FALL_HOLIDAYS = { | |
"20231009": "Indigenous Peoples' Day", | |
"20231111": "Veterans", | |
"20231122": "Thanksgiving", | |
"20231123": "Thanksgiving", | |
"20231124": "Thanksgiving", | |
} | |
FALL = (FALL_SEM_FIRST, FALL_SEM_LAST, FALL_HOLIDAYS) | |
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("%b %d %a") | |
print(class_date + " ", end="") | |
meeting_str = meeting.strftime("%Y%m%d") | |
if meeting_str in holidays: | |
debug(f"{meeting_str=} in {holidays=}") | |
holiday = f"NO CLASS {semester[2][meeting_str]}" | |
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"(?<!#)### (?P<date>\w\w\w \d\d \w\w\w)(?P<topic>.*)") | |
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("### %s - NO CLASS" % g_date) | |
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 syllabus. | |
See https://registrar.northeastern.edu/wp-content/uploads/sites/9/semcrsseq-flsp-new.pdf""" | |
) | |
# positional arguments | |
arg_parser.add_argument("file", type=Path, nargs="?", metavar="FILE") | |
# optional arguments | |
arg_parser.add_argument( | |
"-b", | |
"--block", | |
choices=["mw", "tf"], | |
default="tf", | |
help="use Northeastern block B (mo/we) or D (tu/fr) (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., '### Sep 30 Fri')", | |
) | |
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) | |
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