Reload services on debounced file edits and replacement saves

This commit is contained in:
Andras Schmelczer 2026-10-27 20:00:17 +00:00
commit dc24c4d4fe
3 changed files with 77 additions and 9 deletions

View file

@ -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:

View file

@ -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()

View file

@ -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"]