diff --git a/cista/serve.py b/cista/serve.py index 79e1439..79fff6b 100644 --- a/cista/serve.py +++ b/cista/serve.py @@ -4,11 +4,22 @@ from pathlib import Path from fastapi_vue.hostutil import parse_endpoint from sanic import Sanic +from sanic.worker.loader import AppLoader from cista import config, server80 from cista.app import app +def load_app() -> Sanic: + """Load the primary app in spawned worker processes. + + Sanic's default multiprocess fallback looks up apps from the in-memory + registry, but that registry starts empty under the `spawn` start method. + Importing this module rebuilds the registry before returning the app. + """ + return app + + def run(*, dev=False): """Run Sanic main process that spawns worker processes to serve HTTP requests.""" _url, opts = parse_listen(config.config.listen) @@ -29,7 +40,7 @@ def run(*, dev=False): access_log=False, ) # type: ignore[call-arg] if dev: - Sanic.serve() + Sanic.serve(app_loader=AppLoader(factory=load_app)) else: Sanic.serve_single() diff --git a/cista/watching.py b/cista/watching.py index 6b41ce3..ea64164 100644 --- a/cista/watching.py +++ b/cista/watching.py @@ -656,7 +656,16 @@ def watcher(loop): while not stop_event.is_set(): if use_inotify: - inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix()) + try: + inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix()) + except OSError as e: + inotify_tree = None + use_inotify = False + logger.warning( + "Inotify watcher unavailable for %s; falling back to polling: %r", + rootpath, + e, + ) # Initialize the tree from filesystem update_root(loop) diff --git a/pyproject.toml b/pyproject.toml index 2ca8fdc..6e4d7e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ source = "vcs" [tool.hatch.build] artifacts = ["cista/frontend-build", "cista/docker"] -targets.sdist.hooks.custom.path = "scripts/fastapi-vue/build-frontend.py" +targets.sdist.hooks.custom.path = "scripts/fastapi-vue/buildhook.py" targets.sdist.include = [ "/cista", ] diff --git a/scripts/devserver.py b/scripts/devserver.py index bbdd33f..4648e12 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -16,15 +16,16 @@ Environment: import argparse import asyncio -import contextlib import os import sys +from contextlib import suppress from pathlib import Path # Import devutil from scripts/fastapi-vue (not a package, so we adjust sys.path) sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue"))) from devutil import ( # type: ignore[import-not-found] ProcessGroup, + check_ports_free, logger, ready, setup_vite, @@ -33,7 +34,9 @@ from devutil import ( # type: ignore[import-not-found] from cista import config from cista.serve import parse_listen +DEFAULT_VITE_PORT = 8989 DEFAULT_BACKEND_PORT = 8999 +HEALTH = "/api/health?from=devserver.py" def setup_sanic_backend( @@ -64,7 +67,7 @@ async def run_devserver( logger.warning("Frontend source not found at %s", front) raise SystemExit(1) - _frontend_url, npm_install, vite = setup_vite(frontend or "") + frontend_url, npm_install, vite = setup_vite(frontend or "", DEFAULT_VITE_PORT) backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args) # Tell vite where to proxy API requests @@ -72,19 +75,17 @@ async def run_devserver( async with ProcessGroup() as pg: install_proc = await pg.spawn(*npm_install, cwd=str(front)) - await asyncio.sleep(0.2) # reduce message overlap + await check_ports_free(frontend_url, backend_url) await pg.spawn(*sanic_cmd, cwd=str(reporoot)) - # Wait for both install and backend to be ready - async with asyncio.TaskGroup() as tg: - tg.create_task(pg.wait(install_proc)) - tg.create_task(ready(backend_url, path="/api/health?from=devserver.py")) + # Wait for dependencies to be installed and backend to accept requests + await pg.wait(install_proc, ready(backend_url, path=HEALTH)) # Start Vite dev server (ProcessGroup waits for any exit, then terminates others) await pg.spawn(*vite, cwd=str(front)) -def main(): +def main() -> None: parser = argparse.ArgumentParser( description="Run Vite and Cista (Sanic) development servers", formatter_class=argparse.RawDescriptionHelpFormatter, @@ -102,7 +103,7 @@ def main(): help="Cista backend endpoint (default: from config, or :8999)", ) args, unknown = parser.parse_known_args() - with contextlib.suppress(KeyboardInterrupt): + with suppress(KeyboardInterrupt): asyncio.run(run_devserver(args.listen, args.backend, unknown)) diff --git a/scripts/fastapi-vue/build-frontend.py b/scripts/fastapi-vue/build-frontend.py deleted file mode 100644 index ac96dab..0000000 --- a/scripts/fastapi-vue/build-frontend.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Hatch build hook for building Vue frontend during package build.""" - -import sys -from pathlib import Path - -from hatchling.builders.hooks.plugin.interface import ( - BuildHookInterface, # type: ignore[import-not-found] -) - -sys.path.insert(0, str(Path(__file__).parent)) -from buildutil import build - - -class CustomBuildHook(BuildHookInterface): - def initialize(self, version, build_data): - super().initialize(version, build_data) - build("frontend") diff --git a/scripts/fastapi-vue/buildhook.py b/scripts/fastapi-vue/buildhook.py new file mode 100644 index 0000000..db91983 --- /dev/null +++ b/scripts/fastapi-vue/buildhook.py @@ -0,0 +1,18 @@ +"""Hatch build hook for building Vue frontend during package build.""" + +import sys +from pathlib import Path + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +sys.path.insert(0, str(Path(__file__).parent)) +from buildutil import build + + +class CustomBuildHook(BuildHookInterface): # type: ignore[misc] + """Hatch build hook that builds Vue frontend during package build.""" + + def initialize(self, version: str, build_data: dict) -> None: # type: ignore[override] + """Build frontend before package is built.""" + super().initialize(version, build_data) + build("frontend") diff --git a/scripts/fastapi-vue/buildutil.py b/scripts/fastapi-vue/buildutil.py index be2b3e9..49559e6 100644 --- a/scripts/fastapi-vue/buildutil.py +++ b/scripts/fastapi-vue/buildutil.py @@ -7,13 +7,15 @@ import shutil import subprocess from pathlib import Path +MIN_NODE_VERSION = 20 + class _PrefixFormatter(logging.Formatter): """Formatter that adds prefix based on log level.""" def format(self, record: logging.LogRecord) -> str: if record.levelno >= logging.WARNING: - return f"┃ ⚠️ {record.getMessage()}" + return f"⚠️ {record.getMessage()}" return record.getMessage() @@ -41,74 +43,108 @@ def _check_node_version(node_path: str) -> None: match = re.match(r"v(\d+)", version_str) if match: major_version = int(match.group(1)) - if major_version >= 20: + if major_version >= MIN_NODE_VERSION: return - raise RuntimeError( - f"Node.js {version_str} found, but v20+ required (install with nvm)" - ) + msg = f"Node.js {version_str} found, but v{MIN_NODE_VERSION}+ required" + raise RuntimeError(msg) except (subprocess.CalledProcessError, FileNotFoundError, ValueError): pass - raise RuntimeError("Could not determine Node.js version") + msg = "Could not determine Node.js version" + raise RuntimeError(msg) + + +def _validate_npm_runtime(tool: str) -> bool: + """Validate npm runtime by checking Node.js version. Returns True if valid.""" + node_path = shutil.which("node", path=str(Path(tool).parent)) + if node_path is None: + return False + try: + _check_node_version(node_path) + except RuntimeError: + return False + return True + + +def _find_runtime_from_env(options: list[str]) -> tuple[str, str] | None: + """Find runtime specified by JS_RUNTIME environment variable.""" + js_runtime_env = os.environ.get("JS_RUNTIME") + if not js_runtime_env: + return None + + js_runtime = js_runtime_env + js_path = Path(js_runtime) + runtime_name = js_path.name + + # Map node to npm + if runtime_name == "node": + runtime_name = "npm" + js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm" + + for option in options: + if option != runtime_name and not runtime_name.startswith(option): + continue + + tool = shutil.which(js_runtime) + if tool is None: + msg = f"JS_RUNTIME={js_runtime_env}: {option} not found" + raise RuntimeError(msg) + + if option == "npm": + node_path = shutil.which("node", path=str(Path(tool).parent)) + if node_path is None: + msg = f"JS_RUNTIME={js_runtime_env}: node not found" + raise RuntimeError(msg) + _check_node_version(node_path) + + return tool, option + + msg = f"JS_RUNTIME={js_runtime_env} not recognized" + raise RuntimeError(msg) + + +def _auto_detect_runtime(options: list[str]) -> tuple[str, str]: + """Auto-detect JavaScript runtime from available options.""" + node_version_error: RuntimeError | None = None + + for option in options: + tool = shutil.which(option) + if not tool: + continue + + if option == "npm" and not _validate_npm_runtime(tool): + try: + node_path = shutil.which("node", path=str(Path(tool).parent)) + if node_path: + _check_node_version(node_path) + except RuntimeError as e: + node_version_error = e + continue + + return tool, option + + if node_version_error: + raise node_version_error + msg = "Node.js (v20+), Deno or Bun is required but none was found" + raise RuntimeError(msg) def find_js_runtime() -> tuple[str, str]: """Find a JavaScript runtime from JS_RUNTIME env or auto-detect. Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun". - Raises JSRuntimeError if no suitable runtime is found. + Raises RuntimeError if no suitable runtime is found. """ options = ["npm", "deno", "bun"] - node_version_error: RuntimeError | None = None # Check for JS_RUNTIME environment variable - if js_runtime_env := os.environ.get("JS_RUNTIME"): - js_runtime = js_runtime_env - js_path = Path(js_runtime) - runtime_name = js_path.name - # Map node to npm - if runtime_name == "node": - runtime_name = "npm" - js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm" - for option in options: - if option == runtime_name or runtime_name.startswith(option): - tool = shutil.which(js_runtime) - if tool is None: - raise RuntimeError( - f"JS_RUNTIME={js_runtime_env}: {option} not found" - ) - # Check Node.js version if using npm - if option == "npm": - node_path = shutil.which("node", path=str(Path(tool).parent)) - if node_path is None: - raise RuntimeError( - f"JS_RUNTIME={js_runtime_env}: node not found" - ) - _check_node_version(node_path) # Raises on failure - return tool, option - raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized") + if result := _find_runtime_from_env(options): + return result # Auto-detect - for option in options: - if tool := shutil.which(option): - # Check Node.js version if using npm - if option == "npm": - node_path = shutil.which("node", path=str(Path(tool).parent)) - if node_path is None: - continue - try: - _check_node_version(node_path) - except RuntimeError as e: - node_version_error = e - continue # Try next runtime - return tool, option - - # No runtime found - provide helpful error - if node_version_error: - raise node_version_error - raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found") + return _auto_detect_runtime(options) -def find_build_tool(): +def find_build_tool() -> tuple[list[str], list[str]]: """Find JavaScript runtime and construct install/build commands. Returns (install_cmd, build_cmd) tuples of command lists. @@ -146,7 +182,7 @@ def find_dev_tool() -> list[str]: if name == "bun": logger.warning( - "Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead." + "Bun has a WS proxy bug (github.com/oven-sh/bun/issues/9882). Consider npm.", ) return [tool, *dev_args[name]] @@ -179,10 +215,10 @@ def build(folder: str = "frontend") -> None: install_cmd, build_cmd = find_build_tool() except RuntimeError as e: logger.warning(e) - raise SystemExit(1) from e + raise SystemExit(1) from None - def run(cmd): - display_cmd = [Path(cmd[0]).name, *cmd[1:]] + def run(cmd: list[str]) -> None: + display_cmd = [Path(cmd[0]).stem, *cmd[1:]] logger.info("### %s", " ".join(display_cmd)) subprocess.run(cmd, check=True, cwd=folder) # noqa: S603 @@ -190,5 +226,5 @@ def build(folder: str = "frontend") -> None: run(install_cmd) logger.info("") run(build_cmd) - except subprocess.CalledProcessError as e: - raise SystemExit(1) from e + except subprocess.CalledProcessError: + raise SystemExit(1) from None diff --git a/scripts/fastapi-vue/devutil.py b/scripts/fastapi-vue/devutil.py index f8a76bd..c30ec58 100644 --- a/scripts/fastapi-vue/devutil.py +++ b/scripts/fastapi-vue/devutil.py @@ -1,111 +1,156 @@ """Utilities meant for devserver script, used only in source repository with dev deps.""" import asyncio -import contextlib +import subprocess +import sys +from contextlib import suppress from pathlib import Path +from typing import TYPE_CHECKING, Any, Self import httpx from buildutil import find_dev_tool, find_install_tool, logger from fastapi_vue.hostutil import parse_endpoint -DEFAULT_VITE_PORT = 8989 -DEFAULT_BACKEND_PORT = 8999 +if TYPE_CHECKING: + from collections.abc import Coroutine class ProcessGroup: """Manage async subprocesses with automatic cleanup, like TaskGroup for processes.""" - def __init__(self): + def __init__(self) -> None: + """Initialize empty process tracking.""" self._procs: list[asyncio.subprocess.Process] = [] + self._cmds: dict[int, str] = {} # pid -> command name async def spawn( - self, *cmd: str, cwd: str | None = None + self, + *cmd: str, + cwd: str | None = None, ) -> asyncio.subprocess.Process: """Spawn a subprocess and track it.""" - logger.info(">>> %s", " ".join([Path(cmd[0]).name, *cmd[1:]])) + cmd_name = Path(cmd[0]).stem + logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]])) proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd) self._procs.append(proc) + self._cmds[proc.pid] = cmd_name return proc - async def wait(self, proc: asyncio.subprocess.Process) -> None: - """Wait for a process to complete, raise SystemExit(1) on failure.""" - if await proc.wait() != 0: - logger.warning("Command failed") - raise SystemExit(1) + async def wait( + self, + *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]", + ) -> None: + """Wait for processes/coroutines to complete, raise SystemExit on failure.""" - async def __aenter__(self): + async def wait_proc(proc: asyncio.subprocess.Process) -> None: + returncode = await proc.wait() + if returncode != 0: + cmd_name = self._cmds.get(proc.pid, "unknown") + raise subprocess.CalledProcessError(returncode, cmd_name) + + tasks = [ + wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w + for w in waitables + ] + try: + await asyncio.gather(*tasks) + except subprocess.CalledProcessError as e: + logger.warning("%s failed with exit status %d", e.cmd, e.returncode) + raise SystemExit(1) from None + + async def __aenter__(self) -> Self: + """Enter the async context manager.""" return self - async def __aexit__(self, exc_type, *_): + async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None: """Wait for one process to exit, terminate others, then wait for all.""" - cleanup_task = asyncio.create_task(self._cleanup()) - try: - await asyncio.shield(cleanup_task) - except asyncio.CancelledError: - # Shield was cancelled but cleanup_task continues - wait for it - await cleanup_task + await self._cleanup(immediate=exc_type is not None) - async def _cleanup(self): + async def _cleanup(self, *, immediate: bool = False) -> None: running = [p for p in self._procs if p.returncode is None] if not running: return - # Wait for any one process to exit - await asyncio.wait( - [asyncio.create_task(p.wait()) for p in running], - return_when=asyncio.FIRST_COMPLETED, - ) + if not immediate: + # Wait for any one process to exit + with suppress(asyncio.CancelledError): + await asyncio.wait( + [asyncio.create_task(p.wait()) for p in running], + return_when=asyncio.FIRST_COMPLETED, + ) # Terminate remaining processes for p in self._procs: if p.returncode is None: - with contextlib.suppress(ProcessLookupError): + with suppress(ProcessLookupError): p.terminate() - # Wait for all to finish (with overall timeout) + # Wait for all to finish (with overall timeout), shielded from cancellation still_running = [p for p in self._procs if p.returncode is None] if still_running: - try: - await asyncio.wait_for( - asyncio.gather(*[p.wait() for p in still_running]), - timeout=10, - ) - except TimeoutError: - for p in self._procs: - if p.returncode is None: - with contextlib.suppress(ProcessLookupError): - p.kill() - await p.wait() + with suppress(asyncio.CancelledError): + try: + await asyncio.shield( + asyncio.wait_for( + asyncio.gather(*[p.wait() for p in still_running]), + timeout=10, + ), + ) + except TimeoutError: + for p in self._procs: + if p.returncode is None: + with suppress(ProcessLookupError): + p.kill() + await p.wait() -async def ready(url: str, path: str = "") -> None: +async def check_ports_free(*urls: str) -> None: + """Verify URLs are not responding (ports are free). Raise SystemExit if any respond.""" + + async def check(client: httpx.AsyncClient, url: str) -> None: + with suppress(httpx.RequestError): + res = await client.get(url, timeout=0.1) + server = res.headers.get("server", "server") + logger.warning("Conflicting %s already running at %s", server, url) + raise SystemExit(1) + + async with httpx.AsyncClient() as client: + await asyncio.gather(*[check(client, url) for url in urls]) + + +async def ready(url: str, path: str = "", max_attempts: int = 50) -> None: """Wait for the server to be ready by polling an endpoint. + Use empty path to disable the check and make this return immediately. Raises SystemExit(1) if server doesn't start in time. """ - max_attempts = 50 - full_url = f"{url}{path}" + if not path: + return async with httpx.AsyncClient() as client: for attempt in range(max_attempts): try: - await client.get(full_url, timeout=1.0) - logger.info("✓ Backend ready!") - return - except httpx.RequestError as e: + await client.get(f"{url}{path}", timeout=1.0) + except httpx.RequestError: if attempt == max_attempts - 1: logger.warning("Backend didn't start in time") - raise SystemExit(1) from e + raise SystemExit(1) from None await asyncio.sleep(0.1) + else: + logger.info("✓ Backend ready!") + return -def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]: +def setup_vite( + endpoint: str, + default_port: int = 5173, +) -> tuple[str, list[str], list[str]]: """Parse frontend endpoint and build commands. Returns (url, install_cmd, dev_cmd). Raises SystemExit(1) on invalid config. """ - endpoints = parse_endpoint(endpoint, DEFAULT_VITE_PORT) + endpoints = parse_endpoint(endpoint, default_port) if "uds" in endpoints[0]: logger.warning("Unix sockets not supported with vite devserver") @@ -118,18 +163,53 @@ def setup_vite(endpoint: str) -> tuple[str, list[str], list[str]]: dev_cmd = find_dev_tool() if host != "localhost": dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}") - if port != 5173: - dev_cmd.append(f"--port={port}") + dev_cmd.append(f"--port={port}") return f"http://{host}:{port}", install_cmd, dev_cmd def setup_fastapi( - endpoint: str, module: str, default_port: int = DEFAULT_BACKEND_PORT + endpoint: str, + module: str, + default_port: int = 8000, ) -> tuple[str, list[str]]: - """Parse backend endpoint and build fastapi dev command. + """Parse backend endpoint and build uvicorn command. - Returns (url, cmd). + Returns (url, uvicorn_cmd). + Raises SystemExit(1) on invalid config. + """ + endpoints = parse_endpoint(endpoint, default_port) + + if "uds" in endpoints[0]: + logger.warning("Unix sockets not supported with vite devserver") + raise SystemExit(1) + + host = endpoints[0]["host"] + port = endpoints[0]["port"] + reload_dir = module.split(".", maxsplit=1)[0] # Don't reload on frontend changes + + cmd = [ + sys.executable, + "-m", + "uvicorn", + module, + f"--host={host}", + f"--port={port}", + "--reload", + f"--reload-dir={reload_dir}", + "--forwarded-allow-ips=*", + ] + return f"http://{host}:{port}", cmd + + +def setup_cli( + cli: str, + endpoint: str, + default_port: int = 8000, +) -> tuple[str, list[str]]: + """Parse backend endpoint and build CLI command. + + Returns (url, cli_cmd). Raises SystemExit(1) on invalid config. """ endpoints = parse_endpoint(endpoint, default_port) @@ -141,14 +221,5 @@ def setup_fastapi( host = endpoints[0]["host"] port = endpoints[0]["port"] - cmd = [ - "fastapi", - "dev", - "--entrypoint", - module, - "--host", - host, - "--port", - str(port), - ] + cmd = [cli, f"--listen={host}:{port}"] return f"http://{host}:{port}", cmd