Verified catalogue of mobiles and laptops sold in India, collected from real retail listings (FastAPI backend, React frontend, Postgres/pgvector). - REST API under /api/elec (read-only catalogue; admin endpoints need login) - MCP server (FastMCP) at /mcp/ with list_categories, search_products, get_product and price_history tools - Real ratings and reviews read from product pages and search results - Production Dockerfile (requirements-api.txt, no PyTorch) and .env.production.example; remote database only via an explicit ELEC_ALLOW_REMOTE_DB host/name allowlist - docs/API.md: endpoint and MCP reference with live examples Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
280 lines
10 KiB
Python
280 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Full-stack dev launcher: FastAPI backend + Vite frontend in one command.
|
|
|
|
Starts uvicorn, waits until /api/health actually answers, then starts the
|
|
Vite dev server. Both children's logs are streamed to this console with a
|
|
[backend]/[frontend] prefix, and Ctrl+C shuts both down together.
|
|
|
|
Deliberately stdlib-only: this script is the entry point *before* anything
|
|
is guaranteed to be installed, so it must run under a bare system Python
|
|
(it re-execs the backend under backend/venv if that exists). Do not add
|
|
third-party imports here.
|
|
|
|
Usage:
|
|
python run_project.py # both services
|
|
python run_project.py --backend-only
|
|
python run_project.py --frontend-only
|
|
python run_project.py --no-reload # no uvicorn autoreload
|
|
python run_project.py --backend-port 8001
|
|
|
|
Note on data: the backend only ever connects to the LOCAL electronics_catalog
|
|
database (docker compose up -d); settings.py refuses any other host.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import NoReturn
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
BACKEND = ROOT / "backend"
|
|
FRONTEND = ROOT / "frontend"
|
|
|
|
IS_WINDOWS = os.name == "nt"
|
|
|
|
# Backend boot is dominated by imports (sentence-transformers, sklearn,
|
|
# scipy), not by the app itself - app.main defers heavy work to a startup
|
|
# thread. 120s is slack for a cold first run on the 8GB/CPU-only target.
|
|
HEALTH_TIMEOUT_S = 120
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
print(f"[run] {msg}", flush=True)
|
|
|
|
|
|
def die(msg: str) -> NoReturn:
|
|
print(f"[run] ERROR: {msg}", file=sys.stderr, flush=True)
|
|
sys.exit(1)
|
|
|
|
|
|
def backend_python() -> str:
|
|
"""Prefer backend/venv - that's where requirements.txt is installed."""
|
|
candidates = [
|
|
BACKEND / "venv" / "Scripts" / "python.exe",
|
|
BACKEND / "venv" / "bin" / "python",
|
|
BACKEND / ".venv" / "Scripts" / "python.exe",
|
|
BACKEND / ".venv" / "bin" / "python",
|
|
]
|
|
for c in candidates:
|
|
if c.exists():
|
|
return str(c)
|
|
log("no backend/venv found - falling back to the current interpreter")
|
|
return sys.executable
|
|
|
|
|
|
def npm_command() -> str:
|
|
# On Windows the real executable is npm.cmd; resolving it explicitly lets
|
|
# us keep shell=False, so there's a real PID to kill on shutdown.
|
|
for name in (("npm.cmd", "npm") if IS_WINDOWS else ("npm",)):
|
|
found = shutil.which(name)
|
|
if found:
|
|
return found
|
|
die("npm not found on PATH - install Node.js, or use --backend-only")
|
|
|
|
|
|
def preflight(want_backend: bool, want_frontend: bool) -> None:
|
|
if want_backend:
|
|
if not (BACKEND / "app" / "main.py").exists():
|
|
die(f"missing {BACKEND / 'app' / 'main.py'} - run from the project root")
|
|
if not (BACKEND / ".env").exists():
|
|
log("WARNING: backend/.env not found. Copy backend/.env.example and fill it in,")
|
|
log(" or the backend will start with defaults and fail to reach the DB.")
|
|
if want_frontend:
|
|
if not (FRONTEND / "package.json").exists():
|
|
die(f"missing {FRONTEND / 'package.json'} - run from the project root")
|
|
if not (FRONTEND / "node_modules").exists():
|
|
die("frontend/node_modules missing - run `npm install` in frontend/ first")
|
|
|
|
|
|
def stream(proc: subprocess.Popen, tag: str) -> threading.Thread:
|
|
"""Pump a child's merged output into our stdout with a prefix."""
|
|
|
|
def pump() -> None:
|
|
assert proc.stdout is not None
|
|
for raw in proc.stdout:
|
|
print(f"[{tag}] {raw.rstrip()}", flush=True)
|
|
|
|
t = threading.Thread(target=pump, name=f"stream-{tag}", daemon=True)
|
|
t.start()
|
|
return t
|
|
|
|
|
|
def spawn(cmd: list[str], cwd: Path, tag: str) -> subprocess.Popen:
|
|
log(f"starting {tag}: {' '.join(cmd)}")
|
|
kwargs: dict = {}
|
|
if IS_WINDOWS:
|
|
# Own process group => Ctrl+C reaches this launcher only, so we can
|
|
# tear both children down deterministically instead of racing them.
|
|
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
else:
|
|
kwargs["start_new_session"] = True
|
|
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
cwd=str(cwd),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
# No FORCE_COLOR: both children's output is piped through stream()
|
|
# rather than reaching a terminal, so forcing colour only embeds raw
|
|
# ANSI escapes in the log. It also made Node warn on every start -
|
|
# npm sets NO_COLOR when stdout is not a TTY, and Node complains when
|
|
# both are present.
|
|
env={**os.environ, "PYTHONUNBUFFERED": "1"},
|
|
**kwargs,
|
|
)
|
|
stream(proc, tag)
|
|
return proc
|
|
|
|
|
|
def kill_tree(proc: subprocess.Popen, tag: str) -> None:
|
|
"""Kill a child *and its descendants*.
|
|
|
|
Needed because the visible child is rarely the server: npm spawns node,
|
|
and `uvicorn --reload` spawns the actual worker. Terminating just the
|
|
parent leaves the grandchild holding the port, so the next run fails
|
|
with EADDRINUSE.
|
|
"""
|
|
if proc.poll() is not None:
|
|
return
|
|
log(f"stopping {tag}...")
|
|
try:
|
|
if IS_WINDOWS:
|
|
subprocess.run(
|
|
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
else:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
except Exception as exc: # already dead, or no permission
|
|
log(f" ({tag} kill fell back to terminate: {exc})")
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=15)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
|
|
def wait_for_health(port: int, proc: subprocess.Popen) -> bool:
|
|
"""Poll /api/health until it answers. Returns False if the backend died.
|
|
|
|
The endpoint returns 200 even when degraded (see routers/health.py), so a
|
|
200 means "server is up" and the payload tells us what's actually broken.
|
|
"""
|
|
url = f"http://127.0.0.1:{port}/api/health"
|
|
log(f"waiting for backend at {url} (up to {HEALTH_TIMEOUT_S}s)...")
|
|
deadline = time.monotonic() + HEALTH_TIMEOUT_S
|
|
|
|
while time.monotonic() < deadline:
|
|
if proc.poll() is not None:
|
|
log(f"backend exited early with code {proc.returncode} - see [backend] output above")
|
|
return False
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=5) as resp:
|
|
body = json.loads(resp.read().decode("utf-8"))
|
|
log(f"backend up - status={body.get('status')}")
|
|
if not body.get("database"):
|
|
log(" WARNING: database unreachable. Check DB_* in backend/.env.")
|
|
log(" For a local Postgres+pgvector instead of a remote one:")
|
|
log(" cd backend && docker compose up -d")
|
|
if not body.get("ollama"):
|
|
log(" WARNING: Ollama unreachable. Run `ollama serve` and")
|
|
log(f" `ollama pull {body.get('ollama_model', 'qwen2.5:1.5b')}`.")
|
|
log(" Browse/search still work; /api/chat will not.")
|
|
return True
|
|
except (urllib.error.URLError, OSError, json.JSONDecodeError, TimeoutError):
|
|
time.sleep(1.5)
|
|
|
|
log(f"backend did not answer within {HEALTH_TIMEOUT_S}s - starting frontend anyway")
|
|
return True
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(
|
|
description="Run the Global Catalogue backend and frontend together.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
ap.add_argument("--backend-only", action="store_true", help="skip the Vite dev server")
|
|
ap.add_argument("--frontend-only", action="store_true", help="skip uvicorn")
|
|
ap.add_argument("--backend-port", type=int, default=8000)
|
|
ap.add_argument("--frontend-port", type=int, default=5173)
|
|
ap.add_argument("--no-reload", action="store_true", help="disable uvicorn autoreload")
|
|
args = ap.parse_args()
|
|
|
|
if args.backend_only and args.frontend_only:
|
|
die("--backend-only and --frontend-only are mutually exclusive")
|
|
|
|
want_backend = not args.frontend_only
|
|
want_frontend = not args.backend_only
|
|
preflight(want_backend, want_frontend)
|
|
|
|
procs: list[tuple[subprocess.Popen, str]] = []
|
|
exit_code = 0
|
|
|
|
try:
|
|
if want_backend:
|
|
cmd = [
|
|
backend_python(), "-m", "uvicorn", "app.main:app",
|
|
"--host", "127.0.0.1", "--port", str(args.backend_port),
|
|
]
|
|
if not args.no_reload:
|
|
cmd.append("--reload")
|
|
backend_proc = spawn(cmd, BACKEND, "backend")
|
|
procs.append((backend_proc, "backend"))
|
|
|
|
if want_frontend and not wait_for_health(args.backend_port, backend_proc):
|
|
return 1
|
|
|
|
if want_frontend:
|
|
# Vite proxies /api/* to the backend (see frontend/vite.config.js),
|
|
# so the app stays same-origin and needs no CORS or VITE_API_BASE_URL.
|
|
frontend_proc = spawn(
|
|
[npm_command(), "run", "dev", "--", "--port", str(args.frontend_port)],
|
|
FRONTEND,
|
|
"frontend",
|
|
)
|
|
procs.append((frontend_proc, "frontend"))
|
|
|
|
log("-" * 60)
|
|
if want_frontend:
|
|
log(f" App: http://localhost:{args.frontend_port}")
|
|
if want_backend:
|
|
log(f" API docs: http://localhost:{args.backend_port}/docs")
|
|
log(" Ctrl+C to stop everything")
|
|
log("-" * 60)
|
|
|
|
# Exit as soon as *either* service dies - a half-running stack is
|
|
# more confusing than a clean shutdown.
|
|
while True:
|
|
for proc, tag in procs:
|
|
if proc.poll() is not None:
|
|
log(f"{tag} exited with code {proc.returncode} - shutting down")
|
|
return proc.returncode or 0
|
|
time.sleep(0.5)
|
|
|
|
except KeyboardInterrupt:
|
|
print(flush=True)
|
|
log("interrupted")
|
|
finally:
|
|
for proc, tag in reversed(procs):
|
|
kill_tree(proc, tag)
|
|
log("all services stopped")
|
|
|
|
return exit_code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|