Electronics Catalog: API, MCP server, frontend and deployment
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>
This commit is contained in:
279
run_project.py
Normal file
279
run_project.py
Normal file
@@ -0,0 +1,279 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user