Files
SemesterapparatsManager/src/backend/semester.py

243 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
from src import LOG_DIR
log = loguru.logger
log.remove()
log.add(sys.stdout, level="INFO")
log.add(f"{LOG_DIR}/application.log", rotation="1 MB", retention="10 days")
# @dataclass
class Semester:
"""Represents a German university semester (WiSe or SoSe)."""
# ------------------------------------------------------------------
# Classlevel 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 # Humanreadable 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 "<invalid Semester>"
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:
"""Humanreadable 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, other: "Semester") -> bool:
if self.year < other.year:
return True
if self.year == other.year:
return (
self.semester == "WiSe" and other.semester == "SoSe"
) # WiSe before next SoSe
return False
def isFutureSemester(self, other: "Semester") -> bool:
if self.year > other.year:
return True
if self.year == other.year:
return (
self.semester == "SoSe" and other.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 humanreadable semester label and return a :class:`Semester`.
Accepted formats (caseinsensitive)::
"SoSe <YY>" → SoSe of year YY
"WiSe <YY>/<YY+1>" → Winter term starting in YY
"WiSe <YY>" → 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 selftest -------------------------
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))