Files
loyaly-catalogue/run_project.py
sriram c7e4d59188 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>
2026-10-01 12:17:42 +05:30

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())