rework semester class using chatgpt - fixes offset bug, implements generate_missing function

This commit is contained in:
2025-06-18 13:32:59 +02:00
parent 7eb55c21d0
commit 86849b67f5

View File

@@ -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
# ------------------------------------------------------------------
# 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
log.debug(
f"Initialized Semester class with values: month: {_month}, semester: {_semester}, year {_year}"
)
value: str | None = None # Humanreadable 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 "<invalid Semester>"
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:
"""Humanreadable 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 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))