""" Single-command dev launcher: starts the FastAPI backend (main.py) and the Vite frontend dev server together, both streaming to this terminal. Ctrl+C stops both. Usage: python run_dev.py """ from __future__ import annotations import os import platform import signal import subprocess import sys import time ROOT = os.path.dirname(os.path.abspath(__file__)) FRONTEND = os.path.join(ROOT, "frontend") IS_WINDOWS = platform.system() == "Windows" def _venv_python() -> str: """Prefer this repo's own .venv interpreter over whatever `python` happens to be on PATH, so backend deps resolve the same way they do when running `python main.py` directly.""" candidate = ( os.path.join(ROOT, ".venv", "Scripts", "python.exe") if IS_WINDOWS else os.path.join(ROOT, ".venv", "bin", "python") ) return candidate if os.path.exists(candidate) else sys.executable def _popen_frontend() -> subprocess.Popen: """`npm run dev` needs shell=True to resolve npm.cmd/npm on PATH, which makes the Popen object track a shell wrapper, not the actual vite/node process underneath it - terminating just the wrapper leaves vite running as an orphan (observed: it kept the dev port bound after this script had already exited). Put it in its own process group/session so _kill_tree can take out the whole tree.""" if IS_WINDOWS: return subprocess.Popen( "npm run dev", cwd=FRONTEND, shell=True, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, ) return subprocess.Popen( "npm run dev", cwd=FRONTEND, shell=True, preexec_fn=os.setsid, ) def _kill_tree(proc: subprocess.Popen) -> None: if proc.poll() is not None: return if IS_WINDOWS: subprocess.run( ["taskkill", "/PID", str(proc.pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) else: try: os.killpg(os.getpgid(proc.pid), signal.SIGTERM) except ProcessLookupError: pass def main() -> int: backend_cmd = [_venv_python(), "main.py"] print(f"[dev] backend: {' '.join(backend_cmd)} (cwd={ROOT})") backend = subprocess.Popen(backend_cmd, cwd=ROOT) print(f"[dev] frontend: npm run dev (cwd={FRONTEND})") frontend = _popen_frontend() exit_code = 0 try: while True: if backend.poll() is not None: exit_code = backend.returncode print(f"[dev] backend exited ({exit_code}) - stopping frontend...") _kill_tree(frontend) break if frontend.poll() is not None: exit_code = frontend.returncode print(f"[dev] frontend exited ({exit_code}) - stopping backend...") _kill_tree(backend) break time.sleep(0.5) except KeyboardInterrupt: print("\n[dev] stopping backend and frontend...") _kill_tree(backend) _kill_tree(frontend) finally: backend.wait() frontend.wait() return exit_code if __name__ == "__main__": sys.exit(main())