Skip to content

Instantly share code, notes, and snippets.

@reagle
Last active January 3, 2024 20:17
Show Gist options
  • Save reagle/19806122fdb22515ea0b to your computer and use it in GitHub Desktop.
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"
#!/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