rework semester class using chatgpt - fixes offset bug, implements generate_missing function
This commit is contained in:
@@ -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
|
from dataclasses import dataclass
|
||||||
import loguru
|
import loguru
|
||||||
import sys
|
import sys
|
||||||
@@ -11,136 +28,215 @@ log.add(f"{LOG_DIR}/application.log", rotation="1 MB", retention="10 days")
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
# @dataclass
|
||||||
class Semester:
|
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
|
_month: int | None = datetime.datetime.now().month
|
||||||
value: str = None
|
value: str | None = None # Human‑readable label, e.g. "WiSe 23/24"
|
||||||
log.debug(
|
|
||||||
f"Initialized Semester class with values: month: {_month}, semester: {_semester}, year {_year}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __post_init__(self):
|
# ------------------------------------------------------------------
|
||||||
if isinstance(self._year, str):
|
# Construction helpers
|
||||||
self._year = int(self._year)
|
# ------------------------------------------------------------------
|
||||||
|
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:
|
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:
|
if self._month is None:
|
||||||
self._month = datetime.datetime.now().month
|
self._month = datetime.datetime.now().month
|
||||||
if self._semester is None:
|
if self._semester is None:
|
||||||
self.generateSemester()
|
self._generate_semester_from_month()
|
||||||
self.computeValue()
|
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):
|
def __repr__(self) -> str: # Helpful for debugging lists
|
||||||
if self._month <= 3 or self._month > 9:
|
return f"Semester({self._year!r}, {self._semester!r})"
|
||||||
self._semester = "WiSe"
|
|
||||||
else:
|
|
||||||
self._semester = "SoSe"
|
|
||||||
|
|
||||||
@log.catch
|
# ------------------------------------------------------------------
|
||||||
def computeValue(self):
|
# Internal helpers
|
||||||
# year is only last two digits
|
# ------------------------------------------------------------------
|
||||||
|
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
|
year = self._year
|
||||||
valueyear = str(year)
|
|
||||||
if self._semester == "WiSe":
|
if self._semester == "WiSe":
|
||||||
if self._month < 4:
|
next_year = (year + 1) % 100 # wrap 99 → 0
|
||||||
valueyear = str(year - 1) + "/" + str(year)
|
self.value = f"WiSe {year}/{next_year}"
|
||||||
else:
|
else: # SoSe
|
||||||
valueyear = str(year) + "/" + str(year + 1)
|
self.value = f"SoSe {year}"
|
||||||
self.value = f"{self._semester} {valueyear}"
|
|
||||||
|
|
||||||
@log.catch
|
# ------------------------------------------------------------------
|
||||||
def offset(self, value: int) -> str:
|
# Public API
|
||||||
"""Generate a new Semester object by offsetting the current semester by a given value
|
# ------------------------------------------------------------------
|
||||||
|
def offset(self, value: int) -> "Semester":
|
||||||
|
"""Return a new :class:`Semester` *value* steps away.
|
||||||
|
|
||||||
Args:
|
The algorithm maps every semester to a monotonically increasing
|
||||||
value (int): The value by which the semester should be offset
|
*linear index* so that simple addition suffices:
|
||||||
|
|
||||||
Returns:
|
``index = year * 2 + (0 if SoSe else 1)``.
|
||||||
str: the new semester value
|
|
||||||
"""
|
"""
|
||||||
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:
|
if value == 0:
|
||||||
return self
|
return Semester(self._year, self._semester)
|
||||||
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)
|
|
||||||
|
|
||||||
def isPastSemester(self, semester) -> bool:
|
current_idx = self._year * 2 + (0 if self._semester == "SoSe" else 1)
|
||||||
"""Checks if the current Semester is a past Semester compared to the given Semester
|
target_idx = current_idx + value
|
||||||
|
if target_idx < 0:
|
||||||
|
raise ValueError("offset would result in a negative year – not supported")
|
||||||
|
|
||||||
Args:
|
new_year, semester_bit = divmod(target_idx, 2)
|
||||||
semester (str): The semester to compare to
|
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
|
# Comparison helpers
|
||||||
"""
|
# ------------------------------------------------------------------
|
||||||
if self.year < semester.year:
|
def isPastSemester(self, other: "Semester") -> bool:
|
||||||
return True
|
if self.year < other.year:
|
||||||
if self.year == semester.year:
|
|
||||||
if self.semester == "WiSe" and semester.semester == "SoSe":
|
|
||||||
return True
|
return True
|
||||||
|
if self.year == other.year:
|
||||||
|
return (
|
||||||
|
self.semester == "WiSe" and other.semester == "SoSe"
|
||||||
|
) # WiSe before next SoSe
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def isFutureSemester(self, semester: "Semester") -> bool:
|
def isFutureSemester(self, other: "Semester") -> bool:
|
||||||
"""Checks if the current Semester is a future Semester compared to the given Semester
|
if self.year > other.year:
|
||||||
|
|
||||||
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:
|
|
||||||
return True
|
|
||||||
if self.year == semester.year:
|
|
||||||
if self.semester == "SoSe" and semester.semester == "WiSe":
|
|
||||||
return True
|
return True
|
||||||
|
if self.year == other.year:
|
||||||
|
return (
|
||||||
|
self.semester == "SoSe" and other.semester == "WiSe"
|
||||||
|
) # SoSe after WiSe of same year
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def from_string(self, val: str):
|
def isMatch(self, other: "Semester") -> bool:
|
||||||
if " " in val:
|
return self.year == other.year and self.semester == other.semester
|
||||||
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
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Convenience properties
|
||||||
|
# ------------------------------------------------------------------
|
||||||
@property
|
@property
|
||||||
def next(self):
|
def next(self) -> "Semester":
|
||||||
return self.offset(1)
|
return self.offset(1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def previous(self):
|
def previous(self) -> "Semester":
|
||||||
return self.offset(-1)
|
return self.offset(-1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def year(self):
|
def year(self) -> int:
|
||||||
return self._year
|
return self._year
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def semester(self):
|
def semester(self) -> str:
|
||||||
return self._semester
|
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 <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 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))
|
||||||
|
|||||||
Reference in New Issue
Block a user