"""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 src.shared.logging import log class Semester: """Represents a German university semester (WiSe or SoSe).""" # ------------------------------------------------------------------ # 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 = None # Human‑readable label, e.g. "WiSe 23/24" # ------------------------------------------------------------------ # 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 = int(str(datetime.datetime.now().year)[2:]) if self._month is None: self._month = datetime.datetime.now().month if self._semester is None: self._generate_semester_from_month() self._compute_value() # ------------------------------------------------------------------ # Dunder helpers # ------------------------------------------------------------------ def __str__(self) -> str: # noqa: D401 – keep original name return self.value or "" def __repr__(self) -> str: # Helpful for debugging lists return f"Semester({self._year!r}, {self._semester!r})" # ------------------------------------------------------------------ # 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 if self._semester == "WiSe": next_year = (year + 1) % 100 # wrap 99 → 0 self.value = f"WiSe {year}/{next_year}" else: # SoSe self.value = f"SoSe {year}" # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def offset(self, value: int) -> "Semester": """Return a new :class:`Semester` *value* steps away. The algorithm maps every semester to a monotonically increasing *linear index* so that simple addition suffices: ``index = year * 2 + (0 if SoSe else 1)``. """ if not isinstance(value, int): raise TypeError("value must be an int (number of semesters to jump)") if value == 0: return Semester(self._year, self._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") new_year, semester_bit = divmod(target_idx, 2) new_semester = "SoSe" if semester_bit == 0 else "WiSe" return Semester(new_year, new_semester) # ------------------------------------------------------------------ # Comparison helpers # ------------------------------------------------------------------ def isPastSemester(self, current: "Semester") -> bool: log.debug(f"Comparing {self} < {current}") if self.year < current.year: return True if self.year == current.year: return ( self.semester == "WiSe" and current.semester == "SoSe" ) # WiSe before next SoSe return False def isFutureSemester(self, current: "Semester") -> bool: if self.year > current.year: return True if self.year == current.year: return ( self.semester == "SoSe" and current.semester == "WiSe" ) # SoSe after WiSe of same year return False def isMatch(self, other: "Semester") -> bool: return self.year == other.year and self.semester == other.semester # ------------------------------------------------------------------ # Convenience properties # ------------------------------------------------------------------ @property def next(self) -> "Semester": return self.offset(1) @property def previous(self) -> "Semester": return self.offset(-1) @property def year(self) -> int: return self._year @property 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 --------------------------------------------------------- examples = [ "SoSe 6", "WiSe 6/7", "WiSe 6", "SoSe 23", "WiSe 23/24", "WiSe 24", "WiSe 99/00", "SoSe 00", "WiSe 100/101", # test large year ] for ex in examples: parsed = Semester.from_string(ex) print(f"'{ex}' → {parsed} ({parsed.year=}, {parsed.semester=})")