-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathrun.py
More file actions
168 lines (136 loc) · 5.83 KB
/
Copy pathrun.py
File metadata and controls
168 lines (136 loc) · 5.83 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
#!/usr/bin/env python3
"""One-command launcher for CodeABC.
Gets a non-technical user from "I downloaded this" to a running app in a single
step: it prepares the Python dependencies, builds the web UI the first time,
then starts the server and opens it in the browser. The backend serves the
built UI itself, so there is only one process and one URL to think about.
python run.py # any platform
start.bat # Windows, double-click
./start.sh # macOS / Linux
Set CODEABC_PORT to use a different port (default 8000).
"""
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
import webbrowser
from pathlib import Path
from typing import Callable
ROOT = Path(__file__).resolve().parent
FRONTEND = ROOT / "frontend"
DIST_INDEX = FRONTEND / "dist" / "index.html"
HOST = os.getenv("CODEABC_HOST", "127.0.0.1")
PORT = os.getenv("CODEABC_PORT", "8000")
def say(msg: str) -> None:
print(f" {msg}", flush=True)
def run(cmd: list[str], cwd: Path) -> None:
subprocess.run(cmd, cwd=cwd, check=True)
def have(tool: str) -> bool:
return shutil.which(tool) is not None
def ensure_frontend_built() -> None:
"""Build the web UI once; reuse it on every later run."""
if DIST_INDEX.is_file():
return
npm = shutil.which("npm")
if not npm:
sys.exit(
"\n Node.js / npm was not found, and the web UI hasn't been built yet.\n"
" Install Node 18+ from https://nodejs.org and run this again.\n"
" (Node is only needed once, to build the interface.)\n"
)
say("Building the web interface - first run only, about a minute...")
installer = "ci" if (FRONTEND / "package-lock.json").is_file() else "install"
run([npm, installer], FRONTEND)
run([npm, "run", "build"], FRONTEND)
say("Web interface ready.")
def server_command(host: str, port: str) -> list[str]:
"""Command that starts the server, installing backend deps if needed."""
uvicorn = ["-m", "uvicorn", "backend.app:app", "--host", host, "--port", port]
if have("uv"):
# uv syncs the dependencies from pyproject/uv.lock on the fly
return ["uv", "run", "python", *uvicorn]
# No uv: use a local virtualenv so the system Python is never touched.
venv = ROOT / ".venv"
bin_dir = "Scripts" if os.name == "nt" else "bin"
py = venv / bin_dir / ("python.exe" if os.name == "nt" else "python")
if not py.is_file():
say("Setting up a virtual environment (first run only)...")
run([sys.executable, "-m", "venv", str(venv)], ROOT)
run([str(py), "-m", "pip", "install", "-q", "--upgrade", "pip"], ROOT)
say("Installing backend dependencies...")
run([str(py), "-m", "pip", "install", "-q", "-e", "."], ROOT)
return [str(py), *uvicorn]
def wait_until(probe: Callable[[], bool], timeout: float, interval: float = 0.5) -> bool:
"""Poll ``probe`` until it returns True or ``timeout`` seconds pass.
Returns True if the probe ever succeeded, False if it timed out first.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if probe():
return True
time.sleep(interval)
return False
def find_free_port(preferred: int, host: str, span: int = 20) -> int:
"""Return ``preferred`` if it's bindable, otherwise the next free port.
Picking a free port up front means CodeABC still starts when something else
already holds 8000 (a stale copy, another app), instead of crashing uvicorn
with a cryptic bind error.
"""
for port in range(preferred, preferred + span):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind((host, port))
return port
except OSError:
continue
return preferred
def server_responding(health_url: str) -> bool:
"""True once the backend answers its health check."""
try:
with urllib.request.urlopen(health_url, timeout=1) as resp:
return resp.status == 200
except (urllib.error.URLError, OSError):
return False
def open_browser_when_ready(url: str, health_url: str) -> None:
# Wait for the server to actually answer before opening the browser. A first
# run may still be building a virtualenv or installing dependencies, and
# popping open the page too early would show a "can't connect" error. Give it
# a generous window for slow first installs, then open anyway as a fallback
# so the browser always opens eventually.
wait_until(lambda: server_responding(health_url), timeout=120)
try:
webbrowser.open(url)
except Exception:
pass
def main() -> None:
print("\n CodeABC - read any codebase without learning to code")
print(" " + "-" * 50)
if not have("uv"):
say("Tip: installing uv (https://docs.astral.sh/uv/) makes startup faster.")
ensure_frontend_built()
try:
requested = int(PORT)
except ValueError:
requested = 8000
port = find_free_port(requested, HOST)
if port != requested:
say(f"Port {requested} is busy, using {port} instead.")
url = f"http://{HOST}:{port}"
health_url = f"{url}/api/health"
threading.Thread(target=open_browser_when_ready, args=(url, health_url), daemon=True).start()
say(f"CodeABC will open in your browser at {url} as soon as it's ready.")
say("Leave this window open while you use it; press Ctrl+C to stop.\n")
try:
run(server_command(HOST, str(port)), ROOT)
except KeyboardInterrupt:
say("Stopped. See you next time.")
except subprocess.CalledProcessError as exc:
sys.exit(f"\n Could not start CodeABC (exit {exc.returncode}). See the messages above.\n")
if __name__ == "__main__":
main()