From dc24c4d4fed585d1f429bf12e71d273eb1aafe45 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 27 Oct 2026 20:00:17 +0000 Subject: [PATCH] Reload services on debounced file edits and replacement saves --- great_ai/__main__.py | 13 +++---- great_ai/deploy/reload_event_handler.py | 46 +++++++++++++++++++++++++ tests/test_cli_regressions.py | 27 +++++++++++++++ 3 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 great_ai/deploy/reload_event_handler.py create mode 100644 tests/test_cli_regressions.py diff --git a/great_ai/__main__.py b/great_ai/__main__.py index ec4915d..8e79328 100644 --- a/great_ai/__main__.py +++ b/great_ai/__main__.py @@ -11,7 +11,6 @@ import uvicorn from uvicorn._subprocess import get_subprocess from uvicorn.config import LOGGING_CONFIG, Config from uvicorn.supervisors import BaseReload, Multiprocess -from watchdog.events import FileSystemEvent, PatternMatchingEventHandler from watchdog.observers import Observer from great_ai.constants import SERVER_NAME @@ -20,6 +19,7 @@ from great_ai.deploy import GreatAI from great_ai.errors import ArgumentValidationError, MissingArgumentError from great_ai.utilities import get_logger +from .deploy.reload_event_handler import ReloadEventHandler from .parse_arguments import parse_arguments logger = get_logger(SERVER_NAME) @@ -83,18 +83,12 @@ def serve() -> None: pass else: - class EventHandler(PatternMatchingEventHandler): + class EventHandler(ReloadEventHandler): def __init__(self) -> None: - super().__init__( - patterns=["*.py", "*.ipynb"], ignore_patterns=["__*.py"] - ) + super().__init__(self.restart) self.server: Optional[GreatAIReload] = None self.restart() - def on_closed(self, event: FileSystemEvent) -> None: - logger.warning(f"File {event.src_path} has triggered a restart") - self.restart() - def restart(self) -> None: file_name = get_script_name(args.file_name) app = find_app(file_name) @@ -124,6 +118,7 @@ def serve() -> None: Event().wait() finally: observer.stop() + restart_handler.close() restart_handler.stop_server() if args.file_name.endswith(".ipynb"): try: diff --git a/great_ai/deploy/reload_event_handler.py b/great_ai/deploy/reload_event_handler.py new file mode 100644 index 0000000..5524f84 --- /dev/null +++ b/great_ai/deploy/reload_event_handler.py @@ -0,0 +1,46 @@ +from threading import RLock, Timer +from typing import Callable, Optional + +from watchdog.events import FileSystemEvent, PatternMatchingEventHandler + + +class ReloadEventHandler(PatternMatchingEventHandler): + """Debounce edit and replacement events emitted by supported OS observers.""" + + def __init__( + self, restart: Callable[[], None], debounce_seconds: float = 0.3 + ) -> None: + super().__init__(patterns=["*.py", "*.ipynb"], ignore_patterns=["__*.py"]) + self._restart = restart + self._debounce_seconds = debounce_seconds + self._lock = RLock() + self._timer: Optional[Timer] = None + self._closed = False + + def on_any_event(self, event: FileSystemEvent) -> None: + if event.is_directory or event.event_type not in { + "created", + "modified", + "moved", + "closed", + }: + return + with self._lock: + if self._closed: + return + if self._timer is not None: + self._timer.cancel() + self._timer = Timer(self._debounce_seconds, self._run_restart) + self._timer.daemon = True + self._timer.start() + + def _run_restart(self) -> None: + with self._lock: + if not self._closed: + self._restart() + + def close(self) -> None: + with self._lock: + self._closed = True + if self._timer is not None: + self._timer.cancel() diff --git a/tests/test_cli_regressions.py b/tests/test_cli_regressions.py new file mode 100644 index 0000000..4be66ea --- /dev/null +++ b/tests/test_cli_regressions.py @@ -0,0 +1,27 @@ +from threading import Event + +from watchdog.events import FileClosedEvent, FileModifiedEvent, FileMovedEvent + +from great_ai.deploy.reload_event_handler import ReloadEventHandler + + +def test_reload_debounces_edit_and_replacement_events() -> None: + completed = Event() + calls = [] + + def restart() -> None: + calls.append("restart") + completed.set() + + handler = ReloadEventHandler(restart, debounce_seconds=0.02) + try: + handler.dispatch(FileModifiedEvent("service.py")) + handler.dispatch(FileClosedEvent("service.py")) + handler.dispatch(FileMovedEvent("temporary", "service.py")) + assert completed.wait(timeout=2) + assert calls == ["restart"] + handler.dispatch(FileModifiedEvent("__service__.py")) + finally: + handler.close() + handler.dispatch(FileModifiedEvent("service.py")) + assert calls == ["restart"]