nsaroiu's picture
Deploy 41a05c7
08b6c63 verified
Raw History Blame Contribute Delete
7.46 kB
"""
app.py
FastAPI app: a small JSON API over server/deckbuilder.py, plus the built
React frontend (web/dist) when it exists.
Run from the repo root (the training code loads ./tokenizer.json and
./data/*.pt by relative path):
uvicorn server.app:app --port 8000
Completions run one at a time (DeckBuilder holds a lock), so at most
MTG_MAX_WAITING (default 4) may wait behind the running one; past that,
/api/complete answers 503 at once instead of queueing without bound.
MTG_API_DOCS=0 turns off FastAPI's /docs, /redoc and /openapi.json.
"""
from __future__ import annotations
import os
import threading
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Literal
from fastapi import FastAPI, Query, Request
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from server.deckbuilder import DEFAULT_TARGET_LANDS, MIN_TARGET_LANDS, DeckBuilder, DeckInputError
from server.card_prices import PriceBook, downloads_enabled
from server.card_stats import ensure_card_stats
from server.model_files import sync_model_files, update_check_enabled
WEB_DIST = Path(__file__).resolve().parent.parent / "web" / "dist"
BUSY_MESSAGE = "The server is busy building other decks. Try again in a moment."
class Admission:
"""Counts completions running or waiting, and turns away the excess."""
def __init__(self, max_waiting: int):
self.limit = max_waiting + 1 # the running one plus the waiting ones
self.active = 0
self._lock = threading.Lock()
def enter(self) -> bool:
with self._lock:
if self.active >= self.limit:
return False
self.active += 1
return True
def leave(self) -> None:
with self._lock:
self.active -= 1
class LockedCard(BaseModel):
name: str = Field(min_length=1, max_length=200)
quantity: int = Field(default=1, ge=1, le=99)
source: Literal["input", "synergy", "staple", "basic"] = "synergy"
# False: a card the reroll keeps without the user locking it (their basics,
# or the rest of a deck over 100). Only locked cards are safe from cuts.
locked: bool = True
class CompleteRequest(BaseModel):
commanders: list[str] = Field(min_length=1, max_length=2)
decklist: str = Field(default="", max_length=20_000)
target_lands: int | None = Field(default=None, ge=MIN_TARGET_LANDS, le=60)
prune: bool = True
# Over 100 is allowed: a hand-edited deck can be bigger, and gets cut to 100.
locked: list[LockedCard] = Field(default_factory=list, max_length=250)
exclude: list[str] = Field(default_factory=list, max_length=1000)
def create_app(builder: DeckBuilder | None = None) -> FastAPI:
"""`builder` lets tests inject an already-loaded DeckBuilder; otherwise
one is created at startup, after syncing the model files with the Hub
(server/model_files.py: installs a newer release if there is one)."""
@asynccontextmanager
async def lifespan(app: FastAPI):
if builder is None:
version = sync_model_files(check_remote=update_check_enabled())
prices = PriceBook(allow_download=downloads_enabled())
stop_prices = prices.start_schedule() # background: startup never waits on it
builder_ = DeckBuilder(version, prices=prices)
builder_.card_stats = ensure_card_stats(
(card.oracle_id for card in builder_.tok.card_by_token.values()),
allow_download=update_check_enabled(),
)
app.state.builder = builder_
else:
app.state.builder = builder
stop_prices = None
yield
if stop_prices:
stop_prices.set()
docs = os.environ.get("MTG_API_DOCS", "1") != "0"
app = FastAPI(
title="MTG Deck Completion", lifespan=lifespan,
docs_url="/docs" if docs else None, redoc_url="/redoc" if docs else None,
openapi_url="/openapi.json" if docs else None,
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
admission = app.state.admission = Admission(int(os.environ.get("MTG_MAX_WAITING", "4")))
@app.middleware("http")
async def headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# No X-Frame-Options: Hugging Face Spaces shows the app in an iframe.
path = request.url.path
if path.startswith("/assets/"):
# Vite puts a content hash in every asset's name.
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
elif not path.startswith("/api/") and response.headers.get("content-type", "").startswith("text/html"):
response.headers["Cache-Control"] = "no-cache" # always pick up a new build
return response
@app.exception_handler(DeckInputError)
async def deck_input_error(_request, exc: DeckInputError):
return JSONResponse(status_code=422, content={"error": exc.message, "unresolved": exc.unresolved})
@app.get("/api/health")
def health():
builder = app.state.builder
return {
"status": "ok",
"device": str(builder.device),
"model": builder.version,
"prices_updated": builder.prices.built_at,
}
@app.get("/api/config")
def config():
return {
"default_target_lands": DEFAULT_TARGET_LANDS,
"min_target_lands": MIN_TARGET_LANDS,
"prices_updated": app.state.builder.prices.built_at,
}
@app.get("/api/cards")
def search_cards(
q: str = Query(min_length=1, max_length=100),
commander: bool = False,
limit: int = Query(default=10, ge=1, le=25),
colors: str | None = Query(default=None, max_length=5, pattern="^[WUBRG]*$"),
basics: bool = False,
):
"""`colors`: only cards within this color identity ("" = colorless);
`basics`: also offer basic lands (for adding cards to a deck)."""
return {"cards": app.state.builder.search(q, commander_only=commander, limit=limit, colors=colors, basics=basics)}
@app.post("/api/complete")
def complete(req: CompleteRequest):
if not admission.enter():
return JSONResponse(status_code=503, content={"error": BUSY_MESSAGE}, headers={"Retry-After": "5"})
try:
completion = app.state.builder.complete(
req.commanders, req.decklist, target_lands=req.target_lands, prune=req.prune,
locked=[(c.name, c.quantity, c.source, c.locked) for c in req.locked], exclude=req.exclude,
)
finally:
admission.leave()
return completion.to_dict()
if WEB_DIST.is_dir():
app.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
@app.api_route("/{path:path}", methods=["GET", "HEAD"], include_in_schema=False)
def spa(path: str):
file = (WEB_DIST / path).resolve()
if path and file.is_file() and file.is_relative_to(WEB_DIST):
return FileResponse(file)
return FileResponse(WEB_DIST / "index.html")
return app
app = create_app()