#!/usr/bin/env python3 """Run the gRPC server with auto-reload. Watches only the core server code (and alembic.ini) to avoid noisy directories. """ from __future__ import annotations import importlib import os import shutil import subprocess import sys from collections.abc import Callable from pathlib import Path from typing import Protocol, cast class _WatchfilesPythonFilter(Protocol): def __call__(self, change: object, path: str) -> bool: ... def _is_truthy(value: str | None) -> bool: if value is None: return False return value.strip().lower() in {"1", "true", "yes", "on"} def _ner_enabled() -> bool: return _is_truthy(os.getenv("NOTEFLOW_FEATURE_NER_ENABLED")) def _ensure_spacy_model() -> None: if not _ner_enabled(): return try: import importlib.util except ModuleNotFoundError: print("importlib unavailable; skipping model install.") return if importlib.util.find_spec("spacy") is None: print("spaCy not installed; skipping model install.") return if importlib.util.find_spec("en_core_web_sm") is not None: return wheel_url = ( "https://github.com/explosion/spacy-models/releases/download/" "en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" ) uv_path = shutil.which("uv") if uv_path: subprocess.run([uv_path, "pip", "install", wheel_url], check=False) return subprocess.run([sys.executable, "-m", "spacy", "download", "en_core_web_sm"], check=False) def _load_watchfiles() -> tuple[type[_WatchfilesPythonFilter], Callable[..., object]]: # cast required: watchfiles is optional at import time but provides the runtime types. module = importlib.import_module("watchfiles") python_filter = cast(type[_WatchfilesPythonFilter], module.PythonFilter) run_process = cast(Callable[..., object], module.run_process) return python_filter, run_process def run_server() -> None: """Start the gRPC server process. Uses os.execvp() to replace this process with the server, avoiding nested subprocess spawning that causes zombie processes when watchfiles restarts on file changes. """ try: os.execvp(sys.executable, [sys.executable, "-m", "noteflow.grpc.server"]) except OSError as exc: print(f"Failed to exec gRPC server: {exc}", file=sys.stderr) raise SystemExit(1) from exc def main() -> None: _ensure_spacy_model() root = Path(__file__).resolve().parents[1] watch_paths = [root / "src" / "noteflow", root / "alembic.ini"] existing_paths = [str(path) for path in watch_paths if path.exists()] or [ str(root / "src" / "noteflow") ] python_filter, run_process = _load_watchfiles() run_process( *existing_paths, target=run_server, watch_filter=python_filter(), ) if __name__ == "__main__": main()