refactor: update documentation server implementation and improve semester class logic

This commit is contained in:
2026-02-10 14:59:34 +01:00
parent 2e5cda6689
commit 29824e8c04
5 changed files with 79 additions and 59 deletions

View File

@@ -1,23 +1,28 @@
from PySide6.QtCore import QThread, Slot from PySide6.QtCore import QThread, Slot
from src.utils.documentation import website, QuietHandler
from wsgiref.simple_server import make_server from src.utils.documentation import start_documentation_server
class DocumentationThread(QThread): class DocumentationThread(QThread):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._server = None # store server so we can shut it down self._process = None # store subprocess so we can shut it down
def run(self): def run(self):
# launch_documentation() # Start the zensical documentation server
self._server = make_server( self._process = start_documentation_server()
"localhost", 8000, website(), handler_class=QuietHandler
) # Keep thread alive until interruption is requested
while not self.isInterruptionRequested(): if self._process:
self._server.handle_request() while not self.isInterruptionRequested():
self.msleep(100) # Check every 100ms
@Slot() # slot you can connect to aboutToQuit @Slot() # slot you can connect to aboutToQuit
def stop(self): def stop(self):
self.requestInterruption() # ask the loop above to exit self.requestInterruption() # ask the loop above to exit
if self._server: if self._process:
self._server.shutdown() # unblock handle_request() self._process.terminate() # terminate the subprocess
try:
self._process.wait(timeout=5) # wait up to 5 seconds
except:
self._process.kill() # force kill if it doesn't stop

View File

@@ -29,9 +29,9 @@ class Semester:
# Classlevel defaults will be *copied* to each instance and then # Classlevel defaults will be *copied* to each instance and then
# potentially overwritten in ``__init__``. # potentially overwritten in ``__init__``.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
_year: int | None = int(str(datetime.datetime.now().year)[2:]) # 24 → 24 _year: int | None = None # Will be set in __post_init__
_semester: str | None = None # "WiSe" or "SoSe" set later _semester: str | None = None # "WiSe" or "SoSe" set later
_month: int | None = datetime.datetime.now().month _month: int | None = None # Will be set in __post_init__
value: str | None = None # Humanreadable label, e.g. "WiSe 23/24" value: str | None = None # Humanreadable label, e.g. "WiSe 23/24"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -54,11 +54,23 @@ class Semester:
self.__post_init__() self.__post_init__()
def __post_init__(self) -> None: # noqa: D401 keep original name def __post_init__(self) -> None:
if self._year is None: now = datetime.datetime.now()
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 = now.month
if self._year is None:
# Extract last 2 digits of current year
current_year = int(str(now.year)[2:])
# For winter semester in Jan-Mar, we need to use the previous year
# because WiSe started in October of the previous calendar year
if self._month <= 3:
self._year = (current_year - 1) % 100
else:
self._year = current_year
if self._semester is None: if self._semester is None:
self._generate_semester_from_month() self._generate_semester_from_month()
self._compute_value() self._compute_value()
@@ -66,7 +78,7 @@ class Semester:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Dunder helpers # Dunder helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def __str__(self) -> str: # noqa: D401 keep original name def __str__(self) -> str:
return self.value or "<invalid Semester>" return self.value or "<invalid Semester>"
def __repr__(self) -> str: # Helpful for debugging lists def __repr__(self) -> str: # Helpful for debugging lists
@@ -84,6 +96,7 @@ class Semester:
year = self._year year = self._year
if self._semester == "WiSe": if self._semester == "WiSe":
next_year = (year + 1) % 100 # wrap 99 → 0 next_year = (year + 1) % 100 # wrap 99 → 0
self.value = f"WiSe {year}/{next_year}" self.value = f"WiSe {year}/{next_year}"
else: # SoSe else: # SoSe
self.value = f"SoSe {year}" self.value = f"SoSe {year}"
@@ -91,7 +104,7 @@ class Semester:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public API # Public API
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def offset(self, value: int) -> "Semester": def offset(self, value: int) -> Semester:
"""Return a new :class:`Semester` *value* steps away. """Return a new :class:`Semester` *value* steps away.
The algorithm maps every semester to a monotonically increasing The algorithm maps every semester to a monotonically increasing
@@ -116,7 +129,7 @@ class Semester:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Comparison helpers # Comparison helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def isPastSemester(self, current: "Semester") -> bool: def is_past_semester(self, current: Semester) -> bool:
log.debug(f"Comparing {self} < {current}") log.debug(f"Comparing {self} < {current}")
if self.year < current.year: if self.year < current.year:
return True return True
@@ -126,7 +139,7 @@ class Semester:
) # WiSe before next SoSe ) # WiSe before next SoSe
return False return False
def isFutureSemester(self, current: "Semester") -> bool: def is_future_semester(self, current: Semester) -> bool:
if self.year > current.year: if self.year > current.year:
return True return True
if self.year == current.year: if self.year == current.year:
@@ -135,18 +148,18 @@ class Semester:
) # SoSe after WiSe of same year ) # SoSe after WiSe of same year
return False return False
def isMatch(self, other: "Semester") -> bool: def is_match(self, other: Semester) -> bool:
return self.year == other.year and self.semester == other.semester return self.year == other.year and self.semester == other.semester
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Convenience properties # Convenience properties
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@property @property
def next(self) -> "Semester": def next(self) -> Semester:
return self.offset(1) return self.offset(1)
@property @property
def previous(self) -> "Semester": def previous(self) -> Semester:
return self.offset(-1) return self.offset(-1)
@property @property
@@ -161,11 +174,11 @@ class Semester:
# Static helpers # Static helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod @staticmethod
def generate_missing(start: "Semester", end: "Semester") -> list[str]: def generate_missing(start: Semester, end: Semester) -> list[str]:
"""Return all consecutive semesters from *start* to *end* (inclusive).""" """Return all consecutive semesters from *start* to *end* (inclusive)."""
if not isinstance(start, Semester) or not isinstance(end, Semester): if not isinstance(start, Semester) or not isinstance(end, Semester):
raise TypeError("start and end must be Semester instances") raise TypeError("start and end must be Semester instances")
if start.isFutureSemester(end) and not start.isMatch(end): if start.is_future_semester(end) and not start.isMatch(end):
raise ValueError("'start' must not be after 'end'") raise ValueError("'start' must not be after 'end'")
chain: list[Semester] = [start.value] chain: list[Semester] = [start.value]
@@ -181,7 +194,7 @@ class Semester:
# Parsing helper # Parsing helper
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@classmethod @classmethod
def from_string(cls, s: str) -> "Semester": def from_string(cls, s: str) -> Semester:
"""Parse a humanreadable semester label and return a :class:`Semester`. """Parse a humanreadable semester label and return a :class:`Semester`.
Accepted formats (caseinsensitive):: Accepted formats (caseinsensitive)::
@@ -199,7 +212,7 @@ class Semester:
m = re.fullmatch(pattern, s, flags=re.IGNORECASE) m = re.fullmatch(pattern, s, flags=re.IGNORECASE)
if not m: if not m:
raise ValueError( raise ValueError(
"invalid semester string format expected 'SoSe YY' or 'WiSe YY/YY' (spacing flexible)" "invalid semester string format expected 'SoSe YY' or 'WiSe YY/YY' (spacing flexible)",
) )
term_raw, y1_str, y2_str = m.groups() term_raw, y1_str, y2_str = m.groups()
@@ -209,7 +222,7 @@ class Semester:
if term == "SoSe": if term == "SoSe":
if y2_str is not None: if y2_str is not None:
raise ValueError( raise ValueError(
"SoSe string should not contain '/' followed by a second year" "SoSe string should not contain '/' followed by a second year",
) )
return cls(year, "SoSe") return cls(year, "SoSe")

View File

@@ -1,4 +1,3 @@
from src.shared.logging import log from src.shared.logging import log
from PySide6 import QtWidgets from PySide6 import QtWidgets

View File

@@ -255,7 +255,6 @@ class Settings(QtWidgets.QDialog, _settings):
def launch_settings(): def launch_settings():
app = QtWidgets.QApplication(sys.argv) app = QtWidgets.QApplication(sys.argv)
window = Settings() window = Settings()
window.show() window.show()

View File

@@ -1,8 +1,7 @@
import logging import logging
import os import os
from wsgiref.simple_server import WSGIRequestHandler import subprocess
import sys
from flask import Flask, send_from_directory
from src import LOG_DIR from src import LOG_DIR
@@ -20,29 +19,34 @@ logger = logging.getLogger(__name__) # inherits the same file handler
docport = 8000 docport = 8000
class QuietHandler(WSGIRequestHandler):
# suppress “GET /…” access log
def log_request(self, code="-", size="-"):
logger.info("Request: {} {}".format(self.requestline, code))
pass
# suppress all other messages (errors, etc.) def start_documentation_server():
def log_message(self, fmt, *args): """
logger.error("Error: {}, Args: {}".format(fmt, args)) Start the Zensical documentation server as a subprocess.
pass
Returns:
subprocess.Popen: The subprocess object, or None if startup failed.
def website() -> object: """
app = Flask(__name__, static_folder=os.path.join(os.getcwd(), "docs", "public")) try:
# Prepare subprocess arguments
# Serve the main index.html at root creationflags = 0
@app.route("/") if sys.platform == "win32":
def index(): # Hide console window on Windows
return send_from_directory(app.static_folder, "index.html") creationflags = subprocess.CREATE_NO_WINDOW
# Serve all other static files # Start subprocess with all output suppressed
@app.route("/<path:path>") process = subprocess.Popen(
def serve_static(path): ["uv", "run", "zensical", "serve"],
return send_from_directory(app.static_folder, path) stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
return app stdin=subprocess.DEVNULL,
creationflags=creationflags,
cwd=os.getcwd(),
)
logger.info(f"Documentation server started with PID {process.pid}")
return process
except Exception as e:
logger.error(f"Failed to start documentation server: {e}")
return None