Initial commit: Add OpenAPI Mock Server project
This commit is contained in:
198
.src/openapi_mock/server/hot_reload.py
Normal file
198
.src/openapi_mock/server/hot_reload.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"""Hot-reload functionality for automatic server restart on spec changes."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
|
|
||||||
|
class FileWatcher:
|
||||||
|
"""Watch OpenAPI spec file and restart server on changes."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
spec_path: str,
|
||||||
|
port: int = 8080,
|
||||||
|
host: str = "127.0.0.1",
|
||||||
|
delay: Optional[Tuple[float, float]] = None,
|
||||||
|
auth: str = "none",
|
||||||
|
debounce_seconds: float = 0.5
|
||||||
|
):
|
||||||
|
"""Initialize the file watcher.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spec_path: Path to the OpenAPI spec file.
|
||||||
|
port: Port to run the server on.
|
||||||
|
host: Host address to bind to.
|
||||||
|
delay: Optional response delay range.
|
||||||
|
auth: Authentication type.
|
||||||
|
debounce_seconds: Debounce time to avoid multiple reloads.
|
||||||
|
"""
|
||||||
|
self.spec_path = Path(spec_path).resolve()
|
||||||
|
self.port = port
|
||||||
|
self.host = host
|
||||||
|
self.delay = delay
|
||||||
|
self.auth = auth
|
||||||
|
self.debounce_seconds = debounce_seconds
|
||||||
|
|
||||||
|
self.observer: Optional[Observer] = None
|
||||||
|
self.server_process: Optional[subprocess.Popen] = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._reload_event = threading.Event()
|
||||||
|
self._last_modified = 0.0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _get_delay_str(self) -> str:
|
||||||
|
"""Get delay as string for CLI option."""
|
||||||
|
if self.delay is None:
|
||||||
|
return ""
|
||||||
|
if self.delay[0] == self.delay[1]:
|
||||||
|
return f"--delay {self.delay[0]}"
|
||||||
|
return f"--delay {self.delay[0]},{self.delay[1]}"
|
||||||
|
|
||||||
|
def _start_server(self) -> subprocess.Popen:
|
||||||
|
"""Start the mock server process.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Popen process handle.
|
||||||
|
"""
|
||||||
|
cmd = [
|
||||||
|
sys.executable, "-m", "openapi_mock.cli.cli", "start",
|
||||||
|
str(self.spec_path),
|
||||||
|
"--host", self.host,
|
||||||
|
"--port", str(self.port),
|
||||||
|
"--no-watch",
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.delay:
|
||||||
|
cmd.extend(["--delay", self._get_delay_str().replace("--delay ", "")])
|
||||||
|
|
||||||
|
if self.auth != "none":
|
||||||
|
cmd.extend(["--auth", self.auth])
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PYTHONUNBUFFERED"] = "1"
|
||||||
|
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
return process
|
||||||
|
|
||||||
|
def _read_output(self, process: subprocess.Popen) -> None:
|
||||||
|
"""Read and print process output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
process: Popen process to read from.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for line in iter(process.stdout.readline, ""):
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
if line:
|
||||||
|
print(line, end="")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Run the file watcher and server."""
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._reload_event.clear()
|
||||||
|
|
||||||
|
event_handler = FileSystemEventHandler()
|
||||||
|
event_handler.on_modified = self._on_file_changed
|
||||||
|
event_handler.on_created = self._on_file_changed
|
||||||
|
event_handler.on_moved = self._on_file_changed
|
||||||
|
|
||||||
|
self.observer = Observer()
|
||||||
|
self.observer.schedule(
|
||||||
|
event_handler,
|
||||||
|
str(self.spec_path.parent),
|
||||||
|
recursive=False
|
||||||
|
)
|
||||||
|
self.observer.start()
|
||||||
|
|
||||||
|
print(f"Watching: {self.spec_path}")
|
||||||
|
|
||||||
|
self._restart_server()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
if self._reload_event.is_set():
|
||||||
|
self._reload_event.clear()
|
||||||
|
self._restart_server()
|
||||||
|
time.sleep(0.1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Watcher error: {e}")
|
||||||
|
finally:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def _on_file_changed(self, event) -> None:
|
||||||
|
"""Handle file change events.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: File system event.
|
||||||
|
"""
|
||||||
|
if event.src_path != str(self.spec_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
with self._lock:
|
||||||
|
if current_time - self._last_modified < self.debounce_seconds:
|
||||||
|
return
|
||||||
|
self._last_modified = current_time
|
||||||
|
|
||||||
|
print(f"\nDetected change in {self.spec_path}")
|
||||||
|
self._reload_event.set()
|
||||||
|
|
||||||
|
def _restart_server(self) -> None:
|
||||||
|
"""Restart the server process."""
|
||||||
|
self._stop_server()
|
||||||
|
|
||||||
|
print("Restarting server...")
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
self.server_process = self._start_server()
|
||||||
|
output_thread = threading.Thread(
|
||||||
|
target=self._read_output,
|
||||||
|
args=(self.server_process,),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
output_thread.start()
|
||||||
|
|
||||||
|
def _stop_server(self) -> None:
|
||||||
|
"""Stop the server process."""
|
||||||
|
if self.server_process:
|
||||||
|
try:
|
||||||
|
self.server_process.terminate()
|
||||||
|
self.server_process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.server_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.server_process = None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the file watcher and server."""
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
if self.observer:
|
||||||
|
try:
|
||||||
|
self.observer.stop()
|
||||||
|
self.observer.join(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.observer = None
|
||||||
|
|
||||||
|
self._stop_server()
|
||||||
Reference in New Issue
Block a user