refactor: update documentation server implementation and improve semester class logic
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ class Semester:
|
|||||||
# Class‑level defaults – will be *copied* to each instance and then
|
# Class‑level 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 # Human‑readable label, e.g. "WiSe 23/24"
|
value: str | None = None # Human‑readable 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 human‑readable semester label and return a :class:`Semester`.
|
"""Parse a human‑readable semester label and return a :class:`Semester`.
|
||||||
|
|
||||||
Accepted formats (case‑insensitive)::
|
Accepted formats (case‑insensitive)::
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
from src.shared.logging import log
|
from src.shared.logging import log
|
||||||
from PySide6 import QtWidgets
|
from PySide6 import QtWidgets
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user