import re from typing import Optional, Sequence from .dates_add_days import add_days, days_in_month, format_iso_date, parse_iso_date from .dates_add_days_types import CivilDate from .education_academic_year_types import AcademicYear, Term MONTH_DAY = re.compile(r"([0-9]{2})-([0-9]{2})") def academic_year(on_date: str, year_start: Optional[str], terms: Sequence[Term]) -> AcademicYear: """The academic year and term for a date. The end is the day before the next year's start, worked out by date arithmetic, so a 1 March start ends on 29 February in a leap year. """ on = parse_iso_date(on_date) start_text = "09-01" if year_start is None else year_start match = MONTH_DAY.fullmatch(start_text) if isinstance(start_text, str) else None month = int(match.group(1)) if match else 0 day = int(match.group(2)) if match else 0 if match is None or month < 1 or month > 12 or day < 1 or day > days_in_month(2001, month): raise ValueError('yearStart must be MM-DD on a day every year has, received "%s"' % (start_text,)) ordered = sorted(terms, key=lambda t: t.start) for t in ordered: parse_iso_date(t.start) parse_iso_date(t.end) if t.end < t.start: raise ValueError("term %s ends (%s) before it starts (%s)" % (t.name, t.end, t.start)) for i in range(1, len(ordered)): if ordered[i].start <= ordered[i - 1].end: raise ValueError("terms %s and %s overlap" % (ordered[i - 1].name, ordered[i].name)) before_start = (on.month, on.day) < (month, day) start_year = on.year - 1 if before_start else on.year start = format_iso_date(CivilDate(year=start_year, month=month, day=day)) end = add_days(format_iso_date(CivilDate(year=start_year + 1, month=month, day=day)), -1) end_year = int(end[0:4]) label = str(start_year) if end_year == start_year else "%d/%02d" % (start_year, end_year % 100) term = next((t for t in ordered if t.start <= on_date <= t.end), None) return AcademicYear( label=label, start=start, end=end, term=None if term is None else term.name, term_start=None if term is None else term.start, term_end=None if term is None else term.end, )