Files
QuantumTerminal/backend/consumer_startup.py
T

252 lines
9.2 KiB
Python

# version: v3
"""
================================================================================
Quantum Terminal Consumer — Startup Gate & Route Wiring
================================================================================
v3 — consumer_gate() now accepts `skip_sync=False`. data_server's lifespan
calls it with `skip_sync=True` so the auth phase runs synchronously
(needed for login routing) while the slow sync_all() is kicked off in
a background thread after lifespan returns. HTTP routes become
available immediately; frontend polls /api/sync/status for the SYNCING
pill. Eliminates the "15-minute loading screen" symptom caused by
persistent 5xx responses on specific data endpoints.
v2 — Wire /api/version (version_info.py) so the app can report its running
version and check for updates via /api/version endpoint.
Bridges the consumer-specific modules (data_sync_client)
into the existing data_server.py with MINIMAL changes to the PRO codebase.
Usage in data_server.py lifespan:
from consumer_startup import consumer_gate, wire_consumer_routes
# In lifespan, BEFORE provider connect:
gate_result = consumer_gate()
if not gate_result["ok"]:
# Terminal won't render — gate_result has details
pass
# After app creation:
wire_consumer_routes(app)
This module does NOT introduce any calculation triggers.
================================================================================
"""
import logging
from typing import Optional
log = logging.getLogger("mk.consumer_startup")
# ============================================================
# 1. CONSUMER STARTUP GATE (Rule C2)
# ============================================================
def consumer_gate(skip_sync: bool = False) -> dict:
"""
Run the consumer startup gate (Rule C2).
Three conditions must be met before terminal renders:
1. Auth always passes (Quantum Terminal is free)
2. data_sync_client completes download OR loads from cache
3. MT5 connection established (handled by existing data_server code)
This function handles conditions 1 and 2.
v3: when `skip_sync=True`, only the auth phase runs (fast). Caller is
responsible for running sync in a background thread so the FastAPI
lifespan isn't blocked by a slow/unhealthy server. `sync_summary`
will be returned as {"in_progress": True} in that case — populate it
from the thread when sync completes.
Returns dict with gate status:
{
"ok": bool,
"auth_result": AuthResult dict,
"sync_summary": dict,
"needs_login": bool,
"needs_update": bool,
"offline_mode": bool,
}
"""
result = {
"ok": False,
"auth_result": None,
"sync_summary": None,
"needs_login": False,
"needs_update": False,
"offline_mode": False,
}
# ── Gate 1: Auth validation ──
# Removed in Quantum Terminal: Always authenticated
result["auth_result"] = {"status": "ok", "message": "Quantum Terminal - Free Edition"}
result["needs_login"] = False
# ── Gate 2: Data sync ──
# v3: skip_sync=True → lifespan will run sync_all() in a background
# thread instead, so the HTTP server is responsive immediately.
if skip_sync:
result["sync_summary"] = {"in_progress": True}
else:
try:
from data_sync_client import get_sync_client
sync_client = get_sync_client()
sync_summary = sync_client.sync_all()
result["sync_summary"] = sync_summary
# Rule C6: format version mismatch blocks terminal
if not sync_summary.get("format_version_ok", True):
log.error("Startup gate: FORMAT VERSION MISMATCH — update required")
result["needs_update"] = True
result["ok"] = False
return result
if sync_summary.get("offline_mode", False):
result["offline_mode"] = True
except ImportError:
log.warning("data_sync_client not available — skipping data sync (dev mode)")
result["sync_summary"] = {"note": "dev mode — no sync"}
except Exception as e:
log.error(f"Data sync error: {e}")
result["sync_summary"] = {"error": str(e)}
result["ok"] = True
log.info("Consumer startup gate PASSED")
return result
# ============================================================
# 2. ROUTE WIRING
# ============================================================
def wire_consumer_routes(app):
"""
Wire consumer-specific FastAPI routes into the existing app.
Call this AFTER app = FastAPI() in data_server.py.
Adds:
/api/auth/* — login, register, logout, status, refresh
/api/sync/* — sync status, cone/options data, staleness, refresh
/api/consumer/* — consumer-specific status endpoints
"""
routes_added = []
# Auth routes
# Removed in Quantum Terminal
# Data sync routes
try:
from data_sync_client import create_data_sync_routes
app.include_router(create_data_sync_routes())
routes_added.append("data_sync")
log.info("Consumer data sync routes wired: /api/sync/*")
except ImportError:
log.warning("data_sync_client not available — sync routes not wired")
except Exception as e:
log.error(f"Failed to wire data sync routes: {e}")
# v2: version/update-check route
try:
from version_info import create_version_router, CURRENT_VERSION
app.include_router(create_version_router())
routes_added.append("version")
log.info(f"Consumer version route wired: /api/version (running v{CURRENT_VERSION})")
except ImportError:
log.warning("version_info not available — /api/version not wired")
except Exception as e:
log.error(f"Failed to wire version route: {e}")
# Consumer status endpoint
from fastapi import APIRouter
consumer_router = APIRouter(tags=["consumer"])
@consumer_router.get("/api/consumer/status")
async def consumer_status():
"""Consumer terminal status — used by frontend to decide what to show."""
status = {
"is_consumer": True,
"routes_available": routes_added,
}
# Auth status
status["authenticated"] = True
status["user_email"] = "free@quantumterminal"
status["subscription_status"] = "active"
# Sync status
try:
from data_sync_client import get_sync_client
sc = get_sync_client()
status["synced_tickers"] = sc.synced_tickers
status["offline_mode"] = sc.is_offline
status["format_version_ok"] = sc.format_version_ok
status["sync_in_progress"] = sc.is_syncing # drives the top-bar pill
status["last_synced_at"] = sc.last_synced_at
staleness = sc.get_overall_staleness()
status["analysis_date"] = staleness.display_date
status["staleness_level"] = staleness.level.value
except Exception:
status["synced_tickers"] = []
status["offline_mode"] = True
status["sync_in_progress"] = False
return status
@consumer_router.post("/api/consumer/restart")
async def consumer_restart():
"""
Restart the terminal backend process. Spawns a fresh instance and
exits the current one after a brief delay so the HTTP response can
flush before the process exits.
Rule C1 compliance: this restarts the display server only — no
calculation modules are involved.
"""
import sys, os, subprocess, threading
import time as _time
def _do_restart():
_time.sleep(1.0)
try:
creationflags = 0
if os.name == "nt":
creationflags = getattr(subprocess, "DETACHED_PROCESS", 0) | \
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
subprocess.Popen(
[sys.executable] + sys.argv,
close_fds=True,
creationflags=creationflags,
)
except Exception as e:
log.error(f"Restart spawn failed: {e}")
os._exit(0)
threading.Thread(target=_do_restart, daemon=True).start()
log.warning("Consumer restart requested via /api/consumer/restart")
return {"status": "restarting", "delay_ms": 1000}
app.include_router(consumer_router)
log.info("Consumer status route wired: /api/consumer/status")
return routes_added
# ============================================================
# 3. BLOCKED ENDPOINTS (Rule C1)
# ============================================================
# These endpoint prefixes must NEVER exist in the consumer terminal.
# consumer_verify.py test script checks for them.
BLOCKED_ENDPOINT_PREFIXES = [
"/api/calculate",
"/api/calibrate",
"/api/calibration/run",
"/api/run-backtest",
"/api/wf/run",
"/api/flow/calibrate",
"/api/anchor/calibrate",
"/api/dev/clear-cache", # calc target clears calculation data
]