diff --git a/src/backend/semester.py b/src/backend/semester.py index 392e593..0f24a9e 100644 --- a/src/backend/semester.py +++ b/src/backend/semester.py @@ -1,5 +1,22 @@ -import datetime +"""Semester helper class +A small utility around the *German* academic calendar that distinguishes +between *Wintersemester* (WiSe) and *Sommersemester* (SoSe). + +Key points +---------- +* A **`Semester`** is identified by a *term* ("SoSe" or "WiSe") and the last two + digits of the calendar year in which the term *starts*. +* Formatting **never** pads the year with a leading zero – so ``6`` stays ``6``. +* ``offset(n)`` and the static ``generate_missing`` reliably walk the timeline + one semester at a time with correct year transitions: + + SoSe 6 → **WiSe 6/7** → SoSe 7 → WiSe 7/8 → … +""" + +from __future__ import annotations +import datetime +import re from dataclasses import dataclass import loguru import sys @@ -11,136 +28,215 @@ log.add(f"{LOG_DIR}/application.log", rotation="1 MB", retention="10 days") -@dataclass +# @dataclass class Semester: - log.debug("Semester class loaded") + """Represents a German university semester (WiSe or SoSe).""" - _year: int | None = str(datetime.datetime.now().year)[2:] - _semester: str | None = None + # ------------------------------------------------------------------ + # Class‑level defaults – will be *copied* to each instance and then + # potentially overwritten in ``__init__``. + # ------------------------------------------------------------------ + _year: int | None = int(str(datetime.datetime.now().year)[2:]) # 24 → 24 + _semester: str | None = None # "WiSe" or "SoSe" – set later _month: int | None = datetime.datetime.now().month - value: str = None - log.debug( - f"Initialized Semester class with values: month: {_month}, semester: {_semester}, year {_year}" - ) + value: str | None = None # Human‑readable label, e.g. "WiSe 23/24" - def __post_init__(self): - if isinstance(self._year, str): - self._year = int(self._year) + # ------------------------------------------------------------------ + # Construction helpers + # ------------------------------------------------------------------ + def __init__( + self, + year: int | None = None, + semester: str | None = None, + month: int | None = None, + ) -> None: + if year is not None: + self._year = int(year) + if semester is not None: + if semester not in ("WiSe", "SoSe"): + raise ValueError("semester must be 'WiSe' or 'SoSe'") + self._semester = semester + if month is not None: + self._month = month + + self.__post_init__() + + def __post_init__(self) -> None: # noqa: D401 – keep original name if self._year is None: - self._year = datetime.datetime.now().year[2:] + self._year = int(str(datetime.datetime.now().year)[2:]) if self._month is None: self._month = datetime.datetime.now().month if self._semester is None: - self.generateSemester() - self.computeValue() + self._generate_semester_from_month() + self._compute_value() - def __str__(self): - return self.value + # ------------------------------------------------------------------ + # Dunder helpers + # ------------------------------------------------------------------ + def __str__(self) -> str: # noqa: D401 – keep original name + return self.value or "" - def generateSemester(self): - if self._month <= 3 or self._month > 9: - self._semester = "WiSe" - else: - self._semester = "SoSe" + def __repr__(self) -> str: # Helpful for debugging lists + return f"Semester({self._year!r}, {self._semester!r})" - @log.catch - def computeValue(self): - # year is only last two digits + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _generate_semester_from_month(self) -> None: + """Infer *WiSe* / *SoSe* from the month attribute.""" + self._semester = "WiSe" if (self._month <= 3 or self._month > 9) else "SoSe" + + def _compute_value(self) -> None: + """Human‑readable semester label – e.g. ``WiSe 23/24`` or ``SoSe 24``.""" year = self._year - valueyear = str(year) if self._semester == "WiSe": - if self._month < 4: - valueyear = str(year - 1) + "/" + str(year) - else: - valueyear = str(year) + "/" + str(year + 1) - self.value = f"{self._semester} {valueyear}" + next_year = (year + 1) % 100 # wrap 99 → 0 + self.value = f"WiSe {year}/{next_year}" + else: # SoSe + self.value = f"SoSe {year}" - @log.catch - def offset(self, value: int) -> str: - """Generate a new Semester object by offsetting the current semester by a given value + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def offset(self, value: int) -> "Semester": + """Return a new :class:`Semester` *value* steps away. - Args: - value (int): The value by which the semester should be offset + The algorithm maps every semester to a monotonically increasing + *linear index* so that simple addition suffices: - Returns: - str: the new semester value + ``index = year * 2 + (0 if SoSe else 1)``. """ - assert isinstance(value, int), "Value must be an integer" + if not isinstance(value, int): + raise TypeError("value must be an int (number of semesters to jump)") if value == 0: - return self - if value > 0: - if value % 2 == 0: - return Semester( - self._year - value // 2, self._semester - value // 2 + 1 - ) - else: - semester = self._semester - semester = "SoSe" if semester == "WiSe" else "WiSe" - return Semester(self._year + value // 2, semester) - else: - if value % 2 == 0: - return Semester(self.year + value // 2, self._semester) - else: - semester = self._semester - semester = "SoSe" if semester == "WiSe" else "WiSe" - return Semester(self._year + value // 2, semester) + return Semester(self._year, self._semester) - def isPastSemester(self, semester) -> bool: - """Checks if the current Semester is a past Semester compared to the given Semester + current_idx = self._year * 2 + (0 if self._semester == "SoSe" else 1) + target_idx = current_idx + value + if target_idx < 0: + raise ValueError("offset would result in a negative year – not supported") - Args: - semester (str): The semester to compare to + new_year, semester_bit = divmod(target_idx, 2) + new_semester = "SoSe" if semester_bit == 0 else "WiSe" + return Semester(new_year, new_semester) - Returns: - bool: True if the current semester is in the past, False otherwise - """ - if self.year < semester.year: + # ------------------------------------------------------------------ + # Comparison helpers + # ------------------------------------------------------------------ + def isPastSemester(self, other: "Semester") -> bool: + if self.year < other.year: return True - if self.year == semester.year: - if self.semester == "WiSe" and semester.semester == "SoSe": - return True + if self.year == other.year: + return ( + self.semester == "WiSe" and other.semester == "SoSe" + ) # WiSe before next SoSe return False - def isFutureSemester(self, semester: "Semester") -> bool: - """Checks if the current Semester is a future Semester compared to the given Semester - - Args: - semester (str): The semester to compare to - - Returns: - bool: True if the current semester is in the future, False otherwise - """ - if self.year > semester.year: + def isFutureSemester(self, other: "Semester") -> bool: + if self.year > other.year: return True - if self.year == semester.year: - if self.semester == "SoSe" and semester.semester == "WiSe": - return True + if self.year == other.year: + return ( + self.semester == "SoSe" and other.semester == "WiSe" + ) # SoSe after WiSe of same year return False - def from_string(self, val: str): - if " " in val: - values = val.split(" ") - if len(values) != 2: - raise ValueError("Invalid semester format") - self._semester = values[0] - if len(values[1]) == 4: - self._year = int(values[1][2:]) - # self._year = int(values[1]) - self.computeValue() - return self + def isMatch(self, other: "Semester") -> bool: + return self.year == other.year and self.semester == other.semester + # ------------------------------------------------------------------ + # Convenience properties + # ------------------------------------------------------------------ @property - def next(self): + def next(self) -> "Semester": return self.offset(1) @property - def previous(self): + def previous(self) -> "Semester": return self.offset(-1) @property - def year(self): + def year(self) -> int: return self._year @property - def semester(self): + def semester(self) -> str: return self._semester + + # ------------------------------------------------------------------ + # Static helpers + # ------------------------------------------------------------------ + @staticmethod + def generate_missing(start: "Semester", end: "Semester") -> list[str]: + """Return all consecutive semesters from *start* to *end* (inclusive).""" + if not isinstance(start, Semester) or not isinstance(end, Semester): + raise TypeError("start and end must be Semester instances") + if start.isFutureSemester(end) and not start.isMatch(end): + raise ValueError("'start' must not be after 'end'") + + chain: list[Semester] = [start.value] + current = start + while not current.isMatch(end): + current = current.next + chain.append(current.value) + if len(chain) > 1000: # sanity guard + raise RuntimeError("generate_missing exceeded sane iteration limit") + return chain + + # ------------------------------------------------------------------ + # Parsing helper + # ------------------------------------------------------------------ + @classmethod + def from_string(cls, s: str) -> "Semester": + """Parse a human‑readable semester label and return a :class:`Semester`. + + Accepted formats (case‑insensitive):: + + "SoSe " → SoSe of year YY + "WiSe /" → Winter term starting in YY + "WiSe " → Shorthand for the above (next year implied) + + ``YY`` may contain a leading zero ("06" → 6). + """ + if not isinstance(s, str): + raise TypeError("s must be a string") + + pattern = r"\s*(WiSe|SoSe)\s+(\d{1,2})(?:\s*/\s*(\d{1,2}))?\s*" + m = re.fullmatch(pattern, s, flags=re.IGNORECASE) + if not m: + raise ValueError( + "invalid semester string format – expected 'SoSe YY' or 'WiSe YY/YY' (spacing flexible)" + ) + + term_raw, y1_str, y2_str = m.groups() + term = term_raw.capitalize() # normalize case → "WiSe" or "SoSe" + year = int(y1_str.lstrip("0") or "0") # "06" → 6, "0" stays 0 + + if term == "SoSe": + if y2_str is not None: + raise ValueError( + "SoSe string should not contain '/' followed by a second year" + ) + return cls(year, "SoSe") + + # term == "WiSe" + if y2_str is not None: + next_year = int(y2_str.lstrip("0") or "0") + expected_next = (year + 1) % 100 + if next_year != expected_next: + raise ValueError("WiSe second year must equal first year + 1 (mod 100)") + # Accept both explicit "WiSe 6/7" and shorthand "WiSe 6" + return cls(year, "WiSe") + + +# ------------------------- quick self‑test ------------------------- +if __name__ == "__main__": + # Chain generation demo ------------------------------------------------ + s_start = Semester(6, "SoSe") # SoSe 6 + s_end = Semester(25, "WiSe") # WiSe 25/26 + chain = Semester.generate_missing(s_start, s_end) + print("generate_missing:", [str(s) for s in chain]) + + # Parsing demo --------------------------------------------------------- + for label in ["SoSe 6", "WiSe 6/7", "wise 23/24", "WiSe 9"]: + print("from_string:", label, "→", Semester.from_string(label))