- Updated the `from_tuple` method in `Prof` class to specify return type. - Added type hints for various methods in `LehmannsClient`, `OpenAI`, `WebRequest`, and `ZoteroController` classes to improve code clarity and type safety. - Modified `pdf_to_csv` function to return a string instead of a DataFrame. - Enhanced error handling and type hints in `wordparser` and `xmlparser` modules. - Removed unused UI file `Ui_medianadder.ts`. - Improved the layout and structure of the `semesterapparat_ui` to enhance user experience. - Updated file picker to support `.doc` files in addition to `.docx`. - Added unique item handling in `Ui` class to prevent duplicates in apparat list. - General code cleanup and consistency improvements across various files.
249 lines
9.5 KiB
Python
249 lines
9.5 KiB
Python
"""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 "<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:
|
||
"""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 <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 ---------------------------------------------------------
|
||
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=})")
|