#!/usr/bin/env python3 import os import sys import shutil import signal import subprocess import threading import time from pathlib import Path import argparse def which(cmd: str): return shutil.which(cmd) def is_executable(p: Path): return p.exists() and os.access(str(p), os.X_OK) def build_cmd_display(cmd): return " ".join(cmd) if isinstance(cmd, (list, tuple)) else str(cmd) def pick_python_and_uvicorn(repo_root: Path): venv_bin = repo_root / ".venv" / "bin" candidates = {} # Pick python venv_python = venv_bin / "python" if is_executable(venv_python): candidates["python"] = str(venv_python) else: candidates["python"] = which("python3") or which("python") or sys.executable # Pick uvicorn venv_uvicorn = venv_bin / "uvicorn" if is_executable(venv_uvicorn): candidates["uvicorn_mode"] = "binary" candidates["uvicorn"] = str(venv_uvicorn) else: sys_uvicorn = which("uvicorn") if sys_uvicorn: candidates["uvicorn_mode"] = "binary" candidates["uvicorn"] = sys_uvicorn else: candidates["uvicorn_mode"] = "module" candidates["uvicorn"] = candidates["python"] return candidates def stream_output(proc, prefix): # ANSI colors: backend -> green, frontend -> cyan, others -> yellow RESET = "\033[0m" COLOR = "\033[36m" if prefix == "frontend" else ("\033[32m" if prefix == "backend" else "\033[33m") for line in iter(proc.stdout.readline, b""): if not line: break try: decoded = line.decode(errors="replace") sys.stdout.write(f"{COLOR}[{prefix}]{RESET} {decoded}") except Exception: sys.stdout.write(f"{COLOR}[{prefix}]{RESET} {line!r}\n") sys.stdout.flush() def start_process(cmd, cwd, prefix, env=None): proc = subprocess.Popen( cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env or os.environ.copy(), text=False, bufsize=1, ) t = threading.Thread(target=stream_output, args=(proc, prefix), daemon=True) t.start() return proc def terminate_process(proc, name, timeout=8): if proc.poll() is not None: return try: proc.terminate() except Exception: pass for _ in range(timeout * 10): if proc.poll() is not None: return time.sleep(0.1) try: proc.kill() except Exception: pass def main(): parser = argparse.ArgumentParser(description="Dev runner: backend (FastAPI/uvicorn) + frontend (Next.js)") parser.add_argument("--backend-host", default=os.getenv("BACKEND_HOST", "127.0.0.1")) parser.add_argument("--backend-port", default=os.getenv("BACKEND_PORT", "8000")) parser.add_argument("--no-frontend", action="store_true", help="Start backend only") parser.add_argument("--no-backend", action="store_true", help="Start frontend only") parser.add_argument("--frontend-cmd", default=os.getenv("FRONTEND_CMD", "npm run dev")) parser.add_argument("--backend-app", default=os.getenv("BACKEND_APP", "main:app"), help="Uvicorn app path, e.g. main:app") args = parser.parse_args() # scripts/dev.py -> 仓库根目录 repo_root = Path(__file__).resolve().parents[1] backend_dir = repo_root / "backend" frontend_dir = repo_root / "frontend" picks = pick_python_and_uvicorn(repo_root) py_path = picks["python"] uvicorn_mode = picks["uvicorn_mode"] uvicorn_ref = picks["uvicorn"] print("======== dev runner ========") print(f"Repo root: {repo_root}") print(f"Python: {py_path}") if uvicorn_mode == "binary": print(f"Uvicorn: {uvicorn_ref} (binary)") else: print(f"Uvicorn: {uvicorn_ref} -m uvicorn (module)") print("============================") procs = [] # Backend if not args.no_backend: if uvicorn_mode == "binary": backend_cmd = [ uvicorn_ref, args.backend_app, "--reload", "--host", args.backend_host, "--port", args.backend_port, ] else: backend_cmd = [ py_path, "-m", "uvicorn", args.backend_app, "--reload", "--host", args.backend_host, "--port", args.backend_port, ] print(f"Starting backend: {build_cmd_display(backend_cmd)} (cwd={backend_dir})") procs.append(("backend", start_process(backend_cmd, backend_dir, "backend"))) # Frontend if not args.no_frontend: frontend_env = os.environ.copy() frontend_cmd = args.frontend_cmd print(f"Starting frontend: {frontend_cmd} (cwd={frontend_dir})") proc_fe = subprocess.Popen( frontend_cmd, cwd=frontend_dir, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=frontend_env, shell=True, text=False, bufsize=1, ) t = threading.Thread(target=stream_output, args=(proc_fe, "frontend"), daemon=True) t.start() procs.append(("frontend", proc_fe)) def handle_signal(signum, frame): print("\nCaught signal, terminating children...") for name, p in procs: terminate_process(p, name) sys.exit(0) for sig in (signal.SIGINT, signal.SIGTERM): try: signal.signal(sig, handle_signal) except Exception: pass exit_code = 0 try: while True: all_done = True for name, p in procs: ret = p.poll() if ret is None: all_done = False else: if ret != 0: exit_code = ret if all_done: break time.sleep(0.3) except KeyboardInterrupt: handle_signal(signal.SIGINT, None) for name, p in procs: terminate_process(p, name) sys.exit(exit_code) if __name__ == "__main__": main()