Repository navigation
Expand file tree
/
Copy pathdev.py
More file actions
169 lines (137 loc) · 6.76 KB
/
Copy pathdev.py
File metadata and controls
169 lines (137 loc) · 6.76 KB
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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
"""DEV ONLY: build the extension and run the whole stack with one command. Remove before production.
.\\dev (Windows, from the repo root; dev.cmd calls this file)
apps/api/.venv/Scripts/python dev.py
Starts API :8010, web :5173, fixtures :8101 (easy), :8102 (hard) and :8103 (showcase), builds the extension into
apps/extension/.output/chrome-mv3, prints the URLs, and stops everything on Ctrl+C.
"""
import os
import signal
import socket
import subprocess
import sys
import threading
import time
import urllib.request
ROOT = os.path.dirname(os.path.abspath(__file__))
PY = os.path.join(ROOT, "apps", "api", ".venv", "Scripts" if os.name == "nt" else "bin", "python")
NODE = "node"
EXT = os.path.join(ROOT, "apps", "extension")
SERVERS = {
# name: (command, cwd, port, url to check)
# No --reload: uvicorn's Windows reload uses console Ctrl+C events and hangs under a supervisor.
# This script restarts the API itself when apps/api/app changes (see api_changed).
"api": ([PY, "-m", "uvicorn", "app.main:app", "--port", "8010", "--timeout-graceful-shutdown", "3"], os.path.join(ROOT, "apps", "api"), 8010, "http://127.0.0.1:8010/health"),
"web": ([NODE, "node_modules/vite/bin/vite.js", "--port", "5173", "--strictPort"], os.path.join(ROOT, "apps", "web"), 5173, "http://localhost:5173/"),
"fixtures": ([PY, "evals/serve.py"], ROOT, 8101, "http://127.0.0.1:8102/"),
}
COLORS = {"api": "36", "web": "35", "fixtures": "33", "ext": "32", "dev": "1"}
procs: list[subprocess.Popen] = []
API_SRC = os.path.join(ROOT, "apps", "api", "app")
def api_mtime() -> float:
newest = 0.0
for folder, _, files in os.walk(API_SRC):
for f in files:
if f.endswith(".py"):
newest = max(newest, os.path.getmtime(os.path.join(folder, f)))
return newest
def say(name: str, line: str) -> None:
print(f"\033[{COLORS[name]}m[{name:<8}]\033[0m {line}", flush=True)
def pipe(name: str, proc: subprocess.Popen) -> None:
for raw in proc.stdout: # type: ignore[union-attr]
line = raw.decode("utf-8", "replace").rstrip()
if line:
say(name, line)
def server_env(name: str) -> dict[str, str]:
"""This fixed-port dev stack overrides stale .env deployment/preview addresses in its children only."""
env = os.environ | {"PYTHONUNBUFFERED": "1", "FORCE_COLOR": "1"}
if name == "api":
env["WEB_URL"] = "http://localhost:5173"
elif name in {"web", "ext"}:
env.update(VITE_API_URL="http://localhost:8010", VITE_WEB_URL="http://localhost:5173")
return env
def start(name: str, cmd: list[str], cwd: str) -> subprocess.Popen:
# Windows: each server gets its own hidden console. uvicorn --reload stops its worker with a console
# Ctrl+C event, and in a shared console that event would also kill this script and the whole stack.
flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
proc = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=server_env(name), creationflags=flags)
procs.append(proc)
threading.Thread(target=pipe, args=(name, proc), daemon=True).start()
return proc
def port_busy(port: int, host: str = "127.0.0.1") -> bool:
family = socket.AF_INET6 if ":" in host else socket.AF_INET
with socket.socket(family) as s:
return s.connect_ex((host, port)) == 0
def localhost_address() -> str:
"""Vite binds to the first address "localhost" resolves to (::1 on Windows). Another app on 127.0.0.1 does not clash."""
return socket.getaddrinfo("localhost", None)[0][4][0]
def up(url: str) -> bool:
try:
with urllib.request.urlopen(url, timeout=3) as r:
return r.status < 500
except Exception: # noqa: BLE001 - not up yet
return False
def kill(proc: subprocess.Popen) -> None:
if proc.poll() is None:
if os.name == "nt": # kill the whole tree: vite and uvicorn spawn children
subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)], capture_output=True, check=False)
else:
proc.terminate()
proc.wait(timeout=10)
def stop_all() -> None:
for proc in procs:
kill(proc)
def main() -> int:
busy = [f"{n} :{p}" for n, (_, _, p, _) in SERVERS.items() if port_busy(p, localhost_address() if n == "web" else "127.0.0.1")]
busy += [f"fixtures :{p}" for p in (8102, 8103) if port_busy(p)]
if busy:
say("dev", f"Already in use: {', '.join(busy)}. Stop those servers first (or close the other terminal).")
return 1
say("ext", "building extension ...")
ext = start("ext", [NODE, "node_modules/wxt/bin/wxt.mjs", "build"], EXT)
running = {name: start(name, cmd, cwd) for name, (cmd, cwd, _, _) in SERVERS.items()}
ext_ok = ext.wait() == 0
deadline = time.time() + 90
pending = {n: u for n, (_, _, _, u) in SERVERS.items()}
while pending and time.time() < deadline:
pending = {n: u for n, u in pending.items() if not up(u)}
if any(p.poll() is not None for p in running.values()):
say("dev", "a server exited during startup; see its output above")
stop_all()
return 1
time.sleep(1)
out = os.path.join(EXT, ".output", "chrome-mv3")
print(
"\n\033[1mWalkthru dev stack\033[0m (Ctrl+C stops everything)\n"
" Web app http://localhost:5173 dashboard: /app\n"
" API http://127.0.0.1:8010/docs\n"
" Easy fixture http://127.0.0.1:8101\n"
" Hard fixture http://127.0.0.1:8102\n"
" Showcase site http://127.0.0.1:8103 (strong GEO, journey traps)\n"
f" Extension {'built' if ext_ok else 'BUILD FAILED, see [ext] lines'}: {out}\n"
" chrome://extensions > Reload on Walkthru (first time: Load unpacked from the folder above)\n"
+ (f" Still starting: {', '.join(pending)}\n" if pending else ""),
flush=True,
)
seen = api_mtime()
try:
while all(p.poll() is None for p in running.values()):
time.sleep(1)
now = api_mtime()
if now != seen: # API source changed: restart it cleanly
seen = now
say("api", "source changed, restarting API ...")
old = running["api"]
kill(old)
procs.remove(old)
cmd, cwd, _, _ = SERVERS["api"]
running["api"] = start("api", cmd, cwd)
say("dev", "a server stopped; shutting down the rest")
except KeyboardInterrupt:
say("dev", "stopping ...")
finally:
stop_all()
return 0
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # child output has spinners and arrows; never crash a reader
signal.signal(signal.SIGINT, signal.default_int_handler)
sys.exit(main())