Last active
August 18, 2025 19:57
-
-
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 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 | |
# /// script | |
# dependencies = [ | |
# "pyyaml", | |
# "python-dateutil", | |
# ] | |
# /// | |
"""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-2025 Joseph Reagle" | |
__license__ = "GLPv3" | |
__version__ = "1.1.0" | |
import logging | |
import re | |
import sys | |
import textwrap | |
from pathlib import Path # https://docs.python.org/3/library/pathlib.html | |
import yaml # You'll need to install: pip install pyyaml | |
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()) | |
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 2025-11-12 | |
SPRING_SEM_FIRST = "20250106" | |
SPRING_SEM_LAST = "20250415" | |
SPRING_HOLIDAYS = { | |
"20250120": "MLK", | |
"20250217": "Presidents", | |
"20250303": "Spring break", | |
"20250304": "Spring break", | |
"20250305": "Spring break", | |
"20250306": "Spring break", | |
"20250307": "Spring break", | |
"20250421": "Patriots", | |
} | |
SPRING = (SPRING_SEM_FIRST, SPRING_SEM_LAST, SPRING_HOLIDAYS) | |
# Fall semester; updated 2025-08-07 | |
FALL_SEM_FIRST = "20250903" | |
FALL_SEM_LAST = "20251209" # first class of final weeks; last assignment due | |
FALL_HOLIDAYS = { | |
"20251013": "Indigenous Peoples' Day", | |
"20251111": "Veterans", | |
"20251126": "Thanksgiving", | |
"20251127": "Thanksgiving", | |
"20251128": "Thanksgiving", | |
} | |
FALL = (FALL_SEM_FIRST, FALL_SEM_LAST, FALL_HOLIDAYS) | |
def extract_yaml_metadata(file_path: Path) -> dict | None: | |
"""Extract YAML metadata from a markdown file.""" | |
content = file_path.read_text() | |
# Match YAML front matter between --- delimiters | |
yaml_match = re.match(r"^---\s*\n(.*?)\n(?:---|\.\.\.)\s*\n", content, re.DOTALL) | |
if not yaml_match: | |
return None | |
yaml_content = yaml_match.group(1) | |
try: | |
metadata = yaml.safe_load(yaml_content) | |
return metadata if isinstance(metadata, dict) else None | |
except yaml.YAMLError as e: | |
warning(f"Failed to parse YAML metadata: {e}") | |
return None | |
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. | |
Syllabus can include `semester_` and `block_` variables.""" | |
) | |
# positional arguments | |
arg_parser.add_argument("file", type=Path, nargs="?", metavar="FILE") | |
# optional arguments | |
arg_parser.add_argument( | |
"-b", | |
"--block", | |
choices=["mw", "tf"], | |
default=None, # Changed from "tf" to None | |
help="use Northeastern block B (mo/we) or D (tu/fr)", | |
) | |
arg_parser.add_argument( | |
"-t", | |
"--term", | |
choices=["f", "s"], | |
required=False, # Changed from True to False | |
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 from critical though error, warning, info, and debug", | |
) | |
arg_parser.add_argument("--version", action="version", version="0.2") | |
args = arg_parser.parse_args() | |
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) | |
info(args) | |
# Get metadata from file if updating | |
metadata = None | |
if args.file and args.file.suffix == ".md": | |
metadata = extract_yaml_metadata(args.file) | |
info(f"Extracted metadata: {metadata}") | |
# Determine block and term | |
block_value = args.block | |
term_value = args.term | |
if metadata: | |
# Check for conflicts | |
if "block_" in metadata: | |
if args.block and args.block != metadata["block_"]: | |
raise ValueError( | |
f"Command line block '{args.block}' conflicts with " | |
f"document metadata block '{metadata['block_']}' in {args.file}" | |
) | |
block_value = metadata["block_"] | |
if "semester_" in metadata: | |
if args.term and args.term != metadata["semester_"]: | |
raise ValueError( | |
f"Command line term '{args.term}' conflicts with " | |
f"document metadata semester '{metadata['semester_']}' in {args.file}" | |
) | |
term_value = metadata["semester_"] | |
# Ensure we have values | |
if not block_value: | |
raise ValueError("No block specified in command line or document metadata") | |
if not term_value: | |
raise ValueError( | |
"No term/semester specified in command line or document metadata" | |
) | |
# Set block and term based on final values | |
if block_value == "mw": | |
block = (MO, WE) | |
elif block_value == "tf": | |
block = (TU, FR) | |
else: | |
raise ValueError(f"Unknown course block {block_value}") | |
if term_value.lower()[0] == "f": | |
term = FALL | |
elif term_value.lower()[0] == "s": | |
term = SPRING | |
else: | |
raise ValueError(f"Unknown term {term_value}") | |
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