#!/usr/bin/env python3 """Full-stack dev launcher: FastAPI backend + Vite frontend in one command. Starts uvicorn, waits until /api/health actually answers, then starts the Vite dev server. Both children's logs are streamed to this console with a [backend]/[frontend] prefix, and Ctrl+C shuts both down together. Deliberately stdlib-only: this script is the entry point *before* anything is guaranteed to be installed, so it must run under a bare system Python (it re-execs the backend under backend/venv if that exists). Do not add third-party imports here. Usage: python run_project.py # both services python run_project.py --backend-only python run_project.py --frontend-only python run_project.py --no-reload # no uvicorn autoreload python run_project.py --backend-port 8001 Note on data: the backend only ever connects to the LOCAL electronics_catalog database (docker compose up -d); settings.py refuses any other host. """ from __future__ import annotations import argparse import json import os import shutil import signal import subprocess import sys import threading import time import urllib.error import urllib.request from pathlib import Path from typing import NoReturn ROOT = Path(__file__).resolve().parent BACKEND = ROOT / "backend" FRONTEND = ROOT / "frontend" IS_WINDOWS = os.name == "nt" # Backend boot is dominated by imports (sentence-transformers, sklearn, # scipy), not by the app itself - app.main defers heavy work to a startup # thread. 120s is slack for a cold first run on the 8GB/CPU-only target. HEALTH_TIMEOUT_S = 120 def log(msg: str) -> None: print(f"[run] {msg}", flush=True) def die(msg: str) -> NoReturn: print(f"[run] ERROR: {msg}", file=sys.stderr, flush=True) sys.exit(1) def backend_python() -> str: """Prefer backend/venv - that's where requirements.txt is installed.""" candidates = [ BACKEND / "venv" / "Scripts" / "python.exe", BACKEND / "venv" / "bin" / "python", BACKEND / ".venv" / "Scripts" / "python.exe", BACKEND / ".venv" / "bin" / "python", ] for c in candidates: if c.exists(): return str(c) log("no backend/venv found - falling back to the current interpreter") return sys.executable def npm_command() -> str: # On Windows the real executable is npm.cmd; resolving it explicitly lets # us keep shell=False, so there's a real PID to kill on shutdown. for name in (("npm.cmd", "npm") if IS_WINDOWS else ("npm",)): found = shutil.which(name) if found: return found die("npm not found on PATH - install Node.js, or use --backend-only") def preflight(want_backend: bool, want_frontend: bool) -> None: if want_backend: if not (BACKEND / "app" / "main.py").exists(): die(f"missing {BACKEND / 'app' / 'main.py'} - run from the project root") if not (BACKEND / ".env").exists(): log("WARNING: backend/.env not found. Copy backend/.env.example and fill it in,") log(" or the backend will start with defaults and fail to reach the DB.") if want_frontend: if not (FRONTEND / "package.json").exists(): die(f"missing {FRONTEND / 'package.json'} - run from the project root") if not (FRONTEND / "node_modules").exists(): die("frontend/node_modules missing - run `npm install` in frontend/ first") def stream(proc: subprocess.Popen, tag: str) -> threading.Thread: """Pump a child's merged output into our stdout with a prefix.""" def pump() -> None: assert proc.stdout is not None for raw in proc.stdout: print(f"[{tag}] {raw.rstrip()}", flush=True) t = threading.Thread(target=pump, name=f"stream-{tag}", daemon=True) t.start() return t def spawn(cmd: list[str], cwd: Path, tag: str) -> subprocess.Popen: log(f"starting {tag}: {' '.join(cmd)}") kwargs: dict = {} if IS_WINDOWS: # Own process group => Ctrl+C reaches this launcher only, so we can # tear both children down deterministically instead of racing them. kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP else: kwargs["start_new_session"] = True proc = subprocess.Popen( cmd, cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, # No FORCE_COLOR: both children's output is piped through stream() # rather than reaching a terminal, so forcing colour only embeds raw # ANSI escapes in the log. It also made Node warn on every start - # npm sets NO_COLOR when stdout is not a TTY, and Node complains when # both are present. env={**os.environ, "PYTHONUNBUFFERED": "1"}, **kwargs, ) stream(proc, tag) return proc def kill_tree(proc: subprocess.Popen, tag: str) -> None: """Kill a child *and its descendants*. Needed because the visible child is rarely the server: npm spawns node, and `uvicorn --reload` spawns the actual worker. Terminating just the parent leaves the grandchild holding the port, so the next run fails with EADDRINUSE. """ if proc.poll() is not None: return log(f"stopping {tag}...") try: if IS_WINDOWS: subprocess.run( ["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True, check=False, ) else: os.killpg(os.getpgid(proc.pid), signal.SIGTERM) except Exception as exc: # already dead, or no permission log(f" ({tag} kill fell back to terminate: {exc})") proc.terminate() try: proc.wait(timeout=15) except subprocess.TimeoutExpired: proc.kill() def wait_for_health(port: int, proc: subprocess.Popen) -> bool: """Poll /api/health until it answers. Returns False if the backend died. The endpoint returns 200 even when degraded (see routers/health.py), so a 200 means "server is up" and the payload tells us what's actually broken. """ url = f"http://127.0.0.1:{port}/api/health" log(f"waiting for backend at {url} (up to {HEALTH_TIMEOUT_S}s)...") deadline = time.monotonic() + HEALTH_TIMEOUT_S while time.monotonic() < deadline: if proc.poll() is not None: log(f"backend exited early with code {proc.returncode} - see [backend] output above") return False try: with urllib.request.urlopen(url, timeout=5) as resp: body = json.loads(resp.read().decode("utf-8")) log(f"backend up - status={body.get('status')}") if not body.get("database"): log(" WARNING: database unreachable. Check DB_* in backend/.env.") log(" For a local Postgres+pgvector instead of a remote one:") log(" cd backend && docker compose up -d") if not body.get("ollama"): log(" WARNING: Ollama unreachable. Run `ollama serve` and") log(f" `ollama pull {body.get('ollama_model', 'qwen2.5:1.5b')}`.") log(" Browse/search still work; /api/chat will not.") return True except (urllib.error.URLError, OSError, json.JSONDecodeError, TimeoutError): time.sleep(1.5) log(f"backend did not answer within {HEALTH_TIMEOUT_S}s - starting frontend anyway") return True def main() -> int: ap = argparse.ArgumentParser( description="Run the Global Catalogue backend and frontend together.", formatter_class=argparse.RawDescriptionHelpFormatter, ) ap.add_argument("--backend-only", action="store_true", help="skip the Vite dev server") ap.add_argument("--frontend-only", action="store_true", help="skip uvicorn") ap.add_argument("--backend-port", type=int, default=8000) ap.add_argument("--frontend-port", type=int, default=5173) ap.add_argument("--no-reload", action="store_true", help="disable uvicorn autoreload") args = ap.parse_args() if args.backend_only and args.frontend_only: die("--backend-only and --frontend-only are mutually exclusive") want_backend = not args.frontend_only want_frontend = not args.backend_only preflight(want_backend, want_frontend) procs: list[tuple[subprocess.Popen, str]] = [] exit_code = 0 try: if want_backend: cmd = [ backend_python(), "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(args.backend_port), ] if not args.no_reload: cmd.append("--reload") backend_proc = spawn(cmd, BACKEND, "backend") procs.append((backend_proc, "backend")) if want_frontend and not wait_for_health(args.backend_port, backend_proc): return 1 if want_frontend: # Vite proxies /api/* to the backend (see frontend/vite.config.js), # so the app stays same-origin and needs no CORS or VITE_API_BASE_URL. frontend_proc = spawn( [npm_command(), "run", "dev", "--", "--port", str(args.frontend_port)], FRONTEND, "frontend", ) procs.append((frontend_proc, "frontend")) log("-" * 60) if want_frontend: log(f" App: http://localhost:{args.frontend_port}") if want_backend: log(f" API docs: http://localhost:{args.backend_port}/docs") log(" Ctrl+C to stop everything") log("-" * 60) # Exit as soon as *either* service dies - a half-running stack is # more confusing than a clean shutdown. while True: for proc, tag in procs: if proc.poll() is not None: log(f"{tag} exited with code {proc.returncode} - shutting down") return proc.returncode or 0 time.sleep(0.5) except KeyboardInterrupt: print(flush=True) log("interrupted") finally: for proc, tag in reversed(procs): kill_tree(proc, tag) log("all services stopped") return exit_code if __name__ == "__main__": sys.exit(main())