File size: 3,168 Bytes
56dc7f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
"""
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())