-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Closes #101
- Loading branch information
Showing
8 changed files
with
269 additions
and
33 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
import asyncio | ||
import logging | ||
from contextlib import AbstractContextManager | ||
from pathlib import Path | ||
from types import TracebackType | ||
from typing import Self | ||
|
||
from watchdog.events import ( | ||
FileClosedEvent, | ||
FileOpenedEvent, | ||
FileSystemEvent, | ||
FileSystemEventHandler, | ||
FileSystemMovedEvent, | ||
) | ||
from watchdog.observers import Observer | ||
from watchdog.utils.event_debouncer import EventDebouncer | ||
|
||
from questionpy_common.constants import DIST_DIR | ||
from questionpy_sdk.package.builder import DirPackageBuilder | ||
from questionpy_sdk.package.errors import PackageBuildError, PackageSourceValidationError | ||
from questionpy_sdk.package.source import PackageSource | ||
from questionpy_sdk.webserver.app import WebServer | ||
|
||
log = logging.getLogger("questionpy-sdk:watcher") | ||
|
||
|
||
class _EventHandler(FileSystemEventHandler): | ||
DEBOUNCE_INTERVAL = 0.5 | ||
|
||
def __init__(self, path: Path, web_server: WebServer) -> None: | ||
self._path = path | ||
self._web_server = web_server | ||
self._event_debouncer = EventDebouncer(self.DEBOUNCE_INTERVAL, self._on_file_changes) | ||
|
||
def start(self) -> None: | ||
self._event_debouncer.start() | ||
|
||
def stop(self) -> None: | ||
self._event_debouncer.stop() | ||
|
||
def dispatch(self, event: FileSystemEvent) -> None: | ||
if self._ignore_event(event): | ||
return | ||
|
||
self._event_debouncer.handle_event(event) | ||
|
||
def _on_file_changes(self, events: list[FileSystemEvent]) -> None: | ||
log.info("File changes detected. Rebuilding package...") | ||
|
||
try: | ||
# build package | ||
package_source = PackageSource(self._path) | ||
with DirPackageBuilder(package_source) as builder: | ||
builder.write_package() | ||
|
||
# reload web server's package location | ||
fut = asyncio.run_coroutine_threadsafe(self._web_server.reload_package(), self._web_server.loop) | ||
try: | ||
fut.result(5) | ||
except Exception: | ||
log.exception("Failed to reload package. The exception was:") | ||
|
||
except (PackageBuildError, PackageSourceValidationError): | ||
log.exception("Failed to build package. The exception was:") | ||
|
||
def _ignore_event(self, event: FileSystemEvent) -> bool: | ||
"""Ignores events that should not trigger a rebuild. | ||
Args: | ||
event: The event to check. | ||
Returns: | ||
`True` if event should be ignored, otherwise `False`. | ||
""" | ||
if isinstance(event, FileOpenedEvent | FileClosedEvent): | ||
return True | ||
|
||
# ignore events happening under `dist` dir | ||
relevant_path = event.dest_path if isinstance(event, FileSystemMovedEvent) else event.src_path | ||
try: | ||
return Path(relevant_path).relative_to(self._path).parts[0] == DIST_DIR | ||
except IndexError: | ||
return False | ||
|
||
|
||
class Watcher(AbstractContextManager): | ||
def __init__(self, path: Path, web_server: WebServer) -> None: | ||
self._path = path | ||
self._web_server = web_server | ||
self._event_handler = _EventHandler(path.absolute(), self._web_server) | ||
self._observer = Observer() | ||
|
||
def __enter__(self) -> Self: | ||
self.start() | ||
return self | ||
|
||
def __exit__( | ||
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None | ||
) -> None: | ||
self.stop() | ||
|
||
def start(self) -> None: | ||
self._event_handler.start() | ||
self._observer.schedule(self._event_handler, self._path.absolute(), recursive=True) | ||
self._observer.start() | ||
log.info("Watching '%s' for changes...", self._path) | ||
|
||
def stop(self) -> None: | ||
log.debug("Shutting down...") | ||
self._observer.stop() | ||
self._event_handler.stop() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,8 @@ | ||
# This file is part of the QuestionPy SDK. (https://questionpy.org) | ||
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. | ||
# (c) Technische Universität Berlin, innoCampus <[email protected]> | ||
import asyncio | ||
import logging | ||
import traceback | ||
from functools import cached_property | ||
from pathlib import Path | ||
|
@@ -12,15 +14,17 @@ | |
from jinja2 import PackageLoader | ||
|
||
from questionpy_common.api.qtype import InvalidQuestionStateError | ||
from questionpy_common.constants import MiB | ||
from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME, MiB | ||
from questionpy_common.manifest import Manifest | ||
from questionpy_server import WorkerPool | ||
from questionpy_server.worker.runtime.package_location import PackageLocation | ||
from questionpy_server.worker.runtime.package_location import DirPackageLocation, PackageLocation | ||
from questionpy_server.worker.worker.thread import ThreadWorker | ||
|
||
if TYPE_CHECKING: | ||
from questionpy_server.worker.worker import Worker | ||
|
||
log = logging.getLogger("questionpy-sdk:web-server") | ||
|
||
|
||
async def _extract_manifest(app: web.Application) -> None: | ||
webserver = app[SDK_WEBSERVER_APP_KEY] | ||
|
@@ -47,6 +51,7 @@ def __init__( | |
self, | ||
package_location: PackageLocation, | ||
state_storage_path: Path = Path(__file__).parent / "question_state_storage", | ||
loop: asyncio.AbstractEventLoop | None = None, | ||
) -> None: | ||
# We import here, so we don't have to work around circular imports. | ||
from questionpy_sdk.webserver.routes.attempt import routes as attempt_routes # noqa: PLC0415 | ||
|
@@ -56,6 +61,10 @@ def __init__( | |
self.package_location = package_location | ||
self._state_storage_path = state_storage_path | ||
|
||
if loop is None: | ||
loop = asyncio.new_event_loop() | ||
self._loop = loop | ||
|
||
self.web_app = web.Application() | ||
self.web_app[SDK_WEBSERVER_APP_KEY] = self | ||
|
||
|
@@ -76,10 +85,10 @@ def save_question_state(self, question_state: str) -> None: | |
self._state_file_path.write_text(question_state) | ||
|
||
def load_question_state(self) -> str | None: | ||
path = self._state_file_path | ||
if path.exists(): | ||
return path.read_text() | ||
return None | ||
try: | ||
return self._state_file_path.read_text() | ||
except FileNotFoundError: | ||
return None | ||
|
||
def delete_question_state(self) -> None: | ||
self._state_file_path.unlink(missing_ok=True) | ||
|
@@ -89,8 +98,31 @@ def _state_file_path(self) -> Path: | |
manifest = self.web_app[MANIFEST_APP_KEY] | ||
return self._state_storage_path / f"{manifest.namespace}-{manifest.short_name}-{manifest.version}.txt" | ||
|
||
@property | ||
def loop(self) -> asyncio.AbstractEventLoop: | ||
return self._loop | ||
|
||
def start_server(self) -> None: | ||
web.run_app(self.web_app) | ||
web.run_app(self.web_app, loop=self._loop) | ||
|
||
async def reload_package(self) -> None: | ||
log.info("Reloading package location...") | ||
|
||
if not isinstance(self.package_location, DirPackageLocation): | ||
msg = "reload_pkg_location only works with DirPackageLocation" | ||
raise TypeError(msg) | ||
|
||
pkg_path = self.package_location.path | ||
manifest = Manifest.model_validate_json((pkg_path / DIST_DIR / MANIFEST_FILENAME).read_text()) | ||
|
||
worker: Worker | ||
async with self.worker_pool.get_worker(self.package_location, 0, None) as worker: | ||
# reload package in worker | ||
await worker.load_package(reload=True) | ||
|
||
self.web_app[MANIFEST_APP_KEY] = manifest | ||
self.package_location = DirPackageLocation(pkg_path, manifest) | ||
self.delete_question_state() | ||
|
||
|
||
SDK_WEBSERVER_APP_KEY = web.AppKey("sdk_webserver_app", WebServer) | ||
|
Oops, something went wrong.