Spaces:
Running
Running
Add /api/data JSON endpoint (3 tracks incl. downstream) + CORS
Browse files
app.py
CHANGED
|
@@ -2,8 +2,8 @@
|
|
| 2 |
|
| 3 |
Computes the leaderboard live from the per-user substrate in the
|
| 4 |
``MyHeartCounts/OpenMHC-leaderboard-data`` HF dataset (see
|
| 5 |
-
``leaderboard_compute.py``) and serves it as an HTML page.
|
| 6 |
-
|
| 7 |
|
| 8 |
The dataset is public, so no token is required. Styling mirrors the public
|
| 9 |
MyHeartCounts / OpenMHC site (light theme, red accent). The table is grouped by
|
|
@@ -13,10 +13,12 @@ sub-track (single-day / long-context) and can be filtered by method type.
|
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import html
|
|
|
|
| 16 |
from pathlib import Path
|
| 17 |
|
| 18 |
from fastapi import FastAPI
|
| 19 |
-
from fastapi.
|
|
|
|
| 20 |
|
| 21 |
from leaderboard_compute import (
|
| 22 |
compute_downstream_rows,
|
|
@@ -26,6 +28,14 @@ from leaderboard_compute import (
|
|
| 26 |
|
| 27 |
app = FastAPI(title="OpenMHC Leaderboard", docs_url=None, redoc_url=None)
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
# Compute is mildly expensive (download + reduce); cache per track in-process.
|
| 30 |
_CACHE: dict = {
|
| 31 |
"downstream": {"rows": None, "error": None},
|
|
@@ -681,6 +691,66 @@ def health() -> dict:
|
|
| 681 |
return {"status": "ok" if ok else "error", "tracks": tracks}
|
| 682 |
|
| 683 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
@app.get("/logo.png")
|
| 685 |
def logo() -> FileResponse:
|
| 686 |
return FileResponse(Path(__file__).parent / "logo.png", media_type="image/png")
|
|
|
|
| 2 |
|
| 3 |
Computes the leaderboard live from the per-user substrate in the
|
| 4 |
``MyHeartCounts/OpenMHC-leaderboard-data`` HF dataset (see
|
| 5 |
+
``leaderboard_compute.py``) and serves it as an HTML page. The same data is
|
| 6 |
+
also exposed as JSON at ``/api/data`` (CORS-enabled) for the public site.
|
| 7 |
|
| 8 |
The dataset is public, so no token is required. Styling mirrors the public
|
| 9 |
MyHeartCounts / OpenMHC site (light theme, red accent). The table is grouped by
|
|
|
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import html
|
| 16 |
+
import math
|
| 17 |
from pathlib import Path
|
| 18 |
|
| 19 |
from fastapi import FastAPI
|
| 20 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 21 |
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
| 22 |
|
| 23 |
from leaderboard_compute import (
|
| 24 |
compute_downstream_rows,
|
|
|
|
| 28 |
|
| 29 |
app = FastAPI(title="OpenMHC Leaderboard", docs_url=None, redoc_url=None)
|
| 30 |
|
| 31 |
+
# The public site (myheartcounts.stanford.edu) reads /api/data cross-origin.
|
| 32 |
+
app.add_middleware(
|
| 33 |
+
CORSMiddleware,
|
| 34 |
+
allow_origins=["*"],
|
| 35 |
+
allow_methods=["GET"],
|
| 36 |
+
allow_headers=["*"],
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
# Compute is mildly expensive (download + reduce); cache per track in-process.
|
| 40 |
_CACHE: dict = {
|
| 41 |
"downstream": {"rows": None, "error": None},
|
|
|
|
| 691 |
return {"status": "ok" if ok else "error", "tracks": tracks}
|
| 692 |
|
| 693 |
|
| 694 |
+
# ---------------------------------------------------------------------------
|
| 695 |
+
# JSON API — consumed cross-origin by the public MyHeartCounts site.
|
| 696 |
+
# ---------------------------------------------------------------------------
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
def _sanitize(row: dict) -> dict:
|
| 700 |
+
"""Replace non-finite floats (NaN/inf) with None so the row is valid JSON.
|
| 701 |
+
|
| 702 |
+
Starlette's JSONResponse serialises with ``allow_nan=False``; an unsanitised
|
| 703 |
+
NaN would 500 the endpoint and break the client's ``.json()``.
|
| 704 |
+
"""
|
| 705 |
+
return {
|
| 706 |
+
k: (None if isinstance(v, float) and not math.isfinite(v) else v)
|
| 707 |
+
for k, v in row.items()
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
def _api_subtracks(cfg: dict, rows: list[dict]) -> list[tuple[str, str]]:
|
| 712 |
+
"""Subtracks for the payload; mirrors the HTML's dynamic "Other" bucket.
|
| 713 |
+
|
| 714 |
+
The frontend silently drops rows whose ``subtrack`` matches no subtrack key,
|
| 715 |
+
so when any row falls outside the configured set we append ("other", "Other")
|
| 716 |
+
— exactly as ``_table`` does for the HTML page.
|
| 717 |
+
"""
|
| 718 |
+
subs = list(cfg["subtracks"])
|
| 719 |
+
if subs:
|
| 720 |
+
known = {k for k, _ in subs}
|
| 721 |
+
if any(r.get("subtrack") not in known for r in rows):
|
| 722 |
+
subs = subs + [("other", "Other")]
|
| 723 |
+
return subs
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def _track_payload(cfg: dict, rows: list[dict] | None, error: str | None) -> dict:
|
| 727 |
+
# `fallback` is a downstream-only column in the public API contract.
|
| 728 |
+
columns = [
|
| 729 |
+
{"key": k, "label": label}
|
| 730 |
+
for k, label in cfg["columns"]
|
| 731 |
+
if not (k == "fallback" and cfg["key"] != "downstream")
|
| 732 |
+
]
|
| 733 |
+
rows = rows or []
|
| 734 |
+
return {
|
| 735 |
+
"title": cfg["title"],
|
| 736 |
+
"tab": cfg["tab"],
|
| 737 |
+
"columns": columns,
|
| 738 |
+
"subtracks": [{"key": k, "label": label} for k, label in _api_subtracks(cfg, rows)],
|
| 739 |
+
"legend_html": cfg["note"],
|
| 740 |
+
"rows": [_sanitize(r) for r in rows],
|
| 741 |
+
"error": error,
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
@app.get("/api/data")
|
| 746 |
+
def api_data() -> JSONResponse:
|
| 747 |
+
payload = {
|
| 748 |
+
cfg["key"]: _track_payload(cfg, *_rows(cfg["key"], cfg["compute"]))
|
| 749 |
+
for cfg in TRACKS
|
| 750 |
+
}
|
| 751 |
+
return JSONResponse(payload)
|
| 752 |
+
|
| 753 |
+
|
| 754 |
@app.get("/logo.png")
|
| 755 |
def logo() -> FileResponse:
|
| 756 |
return FileResponse(Path(__file__).parent / "logo.png", media_type="image/png")
|