Newer
Older
Handbook / calendar / generate_dates / app.py
#!/usr/bin/env python3

import argparse
import copy
import datetime
import importlib.resources as pkg_resources
import pathlib
import sys
import yaml

from generate_dates import TeachingCalendar, config, templates


class PeriodError(KeyError):
    pass

class PaperError(KeyError):
    pass

class LectureStyleError(KeyError):
    pass


def parse_command_line():
    """Parse command-line arguments.

    Example command lines:
    generate_dates --style calendar --format latex --year 2020 --period S1 --paper INFO201
    generate_dates --style lecture --format xml --period S2 --paper INFO202
    """
    format_map = {
        "t": "text",
        "text": "text",
        "l": "latex",
        "latex": "latex",
        "x": "xml",
        "xml": "xml",
    }
    style_map = {
        "c": "calendar",
        "calendar": "calendar",
        "l": "lecture",
        "lecture": "lecture",
        "i": "iso",
        "iso": "iso",
    }

    parser = argparse.ArgumentParser(
        prog="generate_dates",
        description="")
    
    parser.add_argument(
        "--config", "-c", default=None, dest="config_file",
        help="file name of the configuration file "
            "[default 'default.yml' in generate_dates.config]")
    
    parser.add_argument(
        "--debug", "-d", action='store_true',
        help="enable debugging output")
    
    parser.add_argument(
        "--format", "-f", default="latex",
        choices=["text", "t", "latex", "l", "xml", "x"],
        help="output format [default 'latex']")
    
    parser.add_argument(
        "--output", "-o",
        type=argparse.FileType("w", encoding="UTF-8"),
        help="file name to write the output to; existing "
            "files of the same name will be overwritten!")
    
    parser.add_argument(
        "--paper", "-p", default=None,
        help="paper code (e.g., INFO201) [required for 'lecture' style, "
            "ignored otherwise]")
    
    parser.add_argument(
        "--style", "-s", default="calendar",
        choices=["calendar", "c", "lecture", "l", "iso", "i"],
        help="output style: 'calendar' for teaching calendars in course "
            "outlines, 'lecture' for individual lecture dates, or 'iso'"
            "for dates across the entire ISO-8601 year [default 'calendar']")
    
    parser.add_argument(
        "--teaching-period", "-t", default=None,
        choices=["SS", "S1", "S2", "FY"], dest="period",
        help="teaching period [required]")
    
    parser.add_argument(
        "--year", "-y", type=int, default=datetime.date.today().year,
        help=("the year to generate dates for [default {y}]".format(
            y=datetime.date.today().year)))
    
    args = parser.parse_args()
    # normalise format and style
    args.format = format_map[args.format]
    args.style = style_map[args.style]

    # Load the default config if none is explicitly specified.
    if not args.config_file:
        args.config = yaml.safe_load(
            pkg_resources.read_text(config, "default.yml"))
    else:
        with open(args.config_file) as cfg:
            args.config = yaml.safe_load(cfg.read())

    if args.style == "calendar":
        # --paper is irrelevant
        if args.paper:
            print("generate_dates: warning: --paper/-p is ignored for "
                "'calendar' style")
        # --teaching-period is required
        if not args.period:
            parser.exit(2, "generate_dates: error: --teaching-period/-t "
                "is required for 'calendar' style\n")

    elif args.style == "iso":
        # both --paper and --teaching-period are irrelevant
        if args.paper or args.period:
            print("generate_dates: warning: --paper/-p and "
                "--teaching-period/-t are ignored for 'iso' style")

    elif args.style == "lecture":
        # both --paper and --teaching-period are required
        if not args.paper:
            parser.exit(2, message="generate_dates: error: -paper/-p and "
                "--teaching-period/-t are required for 'lecture' style\n")
    
    # --teaching-period must be specified if --style is "calendar" or "lecture"
    else:
        pass
    
    return args
    

def parse_config(config, args):
    """Validate and filter the top-level configuration.

    Returns separate references to the period configuration and the
    paper configuration, as applicable. (Yes, the period configuration
    includes the paper configuration, but it may also include other
    papers, so it's more convenient to return a separate reference
    for the specified paper.)

    The configuration file will normally contain specifications for
    several teaching periods and papers. We don't need all of this:
    usually we're only interested in one teaching period and one
    paper (if relevant).
    """
    period_config = paper_config = None
    # this is all irrelevant if we're going ISO style
    if args.style != "iso":
        if args.period not in config.keys():
            raise PeriodError()

        period_config = config[args.period]
        # raises KeyError if source key is missing
        period_config["name"] and period_config["weeks"]

        # papers are irrelevant in "calendar" style
        if args.style == "lecture":
            if "papers" in period_config.keys():
                if args.paper in period_config["papers"].keys():
                    paper_config = period_config["papers"][args.paper]
                    # we expect at least a "lectures" entry
                    paper_config["lectures"]
                else:
                    raise PaperError()
            else:
                raise LectureStyleError()
    
    return period_config, paper_config


def run():
    args = parse_command_line()
    if args.debug:
        print("DEBUG: args: {0}".format(args))

    try:
        period_config, paper_config = parse_config(args.config, args)
        if args.debug:
            print("DEBUG: period config: {0}".format(period_config))
            print("DEBUG: paper config: {0}".format(paper_config))
        # priority: year on command line (defaults to current year),
        # year in config
        if period_config:
            args.year = (
                args.config["year"] if "year" in args.config.keys()
                else args.year)
        cal = TeachingCalendar.TeachingCalendar(
            args.year, args.period, args.paper,
            period_config, paper_config)
        print("Period = {p}".format(p=args.period))
        print(cal.calendar())
        print(cal.lecture_dates())
    except LectureStyleError as e:
        print("ERROR: 'lecture' style was requested but {c} contains no "
            "papers for {t}.".format(c=args.config, t=args.period))
    except PaperError as e:
        print("ERROR: no entry in {c} for paper {p} ({t}).".format(
            c=args.config, p=args.paper, t=args.period))
    except PeriodError as e:
        print("ERROR: no entry in {c} for teaching period "
            "{p}.".format(c=args.config_file, p=args.period))
    except KeyError as e:
        print("ERROR: teaching period {p} in {c} is missing required "
            "key {k}.".format(p=args.period, c=args.config, k=e))