SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
27.1 KB · 443 lines python
Raw Blame History
1"""QA-only fixture API mirroring src/countryatlas/api/schemas.py shapes.23Serves deterministic SYNTHETIC values (seeded per country×indicator) from the registries so the web app can be4rendered and screenshotted before the real snapshot exists. Every provenance object says "Fixture (mock data)".5NEVER point production at this. Run:  cd <repo> && .venv/bin/python apps/web/qa/mock_api.py  (port 8299)6"""7from __future__ import annotations89import math10import random11from datetime import datetime, timezone12from pathlib import Path13from typing import Any1415import yaml16from fastapi import FastAPI, Query17from fastapi.responses import JSONResponse1819ROOT = Path(__file__).resolve().parents[3]20REG = ROOT / "registry"21countries = [c for c in yaml.safe_load((REG / "countries.yaml").read_text())["countries"] if c.get("kind", "country") != "aggregate"]22indicators = yaml.safe_load((REG / "indicators.yaml").read_text())["indicators"]23topics = yaml.safe_load((REG / "topics.yaml").read_text())24groups = yaml.safe_load((REG / "groups.yaml").read_text())["groups"]25BY_ID = {c["id"]: c for c in countries}26BY_SLUG = {c["slug"]: c for c in countries}27IND = {i["slug"]: i for i in indicators}28BUILT = "2026-09-11T03:20:11Z"29META = {"built_at": BUILT, "run_id": "fixture-20260911", "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")}3031app = FastAPI(title="CountryAtlas fixture API")3233BASELINE = {  # rough scale per format so numbers look plausible34    "currency": (2e9, 2e13), "percent": (1, 90), "number": (1e5, 5e7), "years": (52, 84), "index": (60, 140), "per_1000": (2, 40),35    "per_100k": (0.5, 40), "per_million": (50, 8000), "ratio": (0.5, 12), "celsius": (0.001, 0.2), "tonnes": (0.1, 25), "kwh": (200, 15000),36    "ha": (0.05, 3), "km": (500, 250000),37}38SIZE = {"HIC": 1.0, "UMC": 0.55, "LMC": 0.3, "LIC": 0.15}394041def rng(*keys: Any) -> random.Random:42    return random.Random("|".join(map(str, keys)))434445def has_data(c: dict, ind: dict) -> bool:46    return rng(c["id"], ind["slug"], "has").random() < (0.92 if c.get("status") == "country" else 0.55)474849def series(c: dict, ind: dict) -> list[dict]:50    r = rng(c["id"], ind["slug"])51    lo, hi = BASELINE.get(ind.get("format", "number"), (1, 100))52    size = SIZE.get(c.get("income_group") or "LMC", 0.3)53    fmt = ind.get("format")54    if fmt == "currency" and "per-capita" not in ind["slug"]:55        base = lo * (1 + 400 * size * r.random()) * (c.get("area_km2") or 1e5) ** 0.25 / 2056    elif fmt == "currency":57        base = 1500 + 90000 * size * r.random()58    elif fmt == "number":59        base = lo * (1 + 200 * r.random()) * (c.get("area_km2") or 1e5) ** 0.3 / 3060    else:61        base = lo + (hi - lo) * (0.2 + 0.7 * r.random()) * (0.6 + 0.6 * size if fmt in ("years", "kwh") else 1)62    start = 1990 if r.random() < 0.7 else 200063    end = 2024 if r.random() < 0.85 else 202364    trend = r.uniform(-0.01, 0.035) if fmt not in ("percent", "years") else r.uniform(-0.2, 0.3)65    out = []66    v = base67    for y in range(start, end + 1):68        noise = r.gauss(0, 0.03 if fmt != "percent" else 0.5)69        v = v * (1 + trend + noise) if fmt not in ("percent", "years", "celsius") else v + trend + noise70        v = max(0.001, v) if fmt != "percent" or "balance" not in ind["slug"] else v71        out.append({"period": f"{y}-01-01", "year": y, "frequency": "A", "value": round(v, 3), "is_forecast": False, "is_estimate": y >= 2023, "status": "verified", "source_id": "worldbank", "provenance": prov(ind, c)})72    if ind["slug"] in ("gdp", "gdp-per-capita", "gdp-growth", "inflation", "unemployment-rate", "general-government-gross-debt-pct-gdp"):73        for y in range(end + 1, end + 4):74            v = v * (1 + trend) if fmt not in ("percent",) else v + trend75            out.append({"period": f"{y}-01-01", "year": y, "frequency": "A", "value": round(v, 3), "is_forecast": True, "is_estimate": False, "status": "imported", "source_id": "imf", "provenance": prov(ind, c, "imf")})76    return out777879def prov(ind: dict, c: dict | None = None, src: str = "worldbank") -> dict:80    s = next((x for x in ind.get("sources", []) if x.get("connector") == src), (ind.get("sources") or [{}])[0])81    return {"source": src, "source_name": "Fixture (mock data)", "dataset": s.get("dataset", "WDI"), "series_code": s.get("code", ind["slug"].upper()),82            "retrieved_at": BUILT, "source_updated_at": "2026-07-01", "url": f"https://data.worldbank.org/indicator/{s.get('code', '')}" + (f"?locations={c['iso2']}" if c else ""),83            "transform": s.get("transform"), "licence": "CC BY 4.0"}848586def fmt_value(v: float | None, ind: dict) -> str:87    if v is None:88        return "—"89    f = ind.get("format", "number")90    if f == "currency":91        for d, s in ((1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "k")):92            if abs(v) >= d:93                return f"US${v / d:.1f}{s}"94        return f"US${v:,.0f}"95    if f == "percent":96        return f"{v:.1f} %"97    if f == "years":98        return f"{v:.1f} yrs"99    if f == "number":100        for d, s in ((1e9, "B"), (1e6, "M")):101            if abs(v) >= d:102                return f"{v / d:.2f}{s}"103        return f"{v:,.0f}"104    return f"{v:,.{ind.get('precision', 1)}f}"105106107def card(c: dict) -> dict:108    return {"id": c["id"], "iso2": c.get("iso2"), "slug": c["slug"], "name": c["short_name"], "flag": c.get("flag_emoji"), "region": c.get("region_wb"),109            "region_name": c.get("region_wb_name"), "income": c.get("income_group"), "income_name": c.get("income_group_name"), "kind": c.get("kind", "country")}110111112def icard(ind: dict) -> dict:113    return {"id": ind["slug"], "slug": ind["slug"], "name": ind["name"], "short_name": ind.get("short_name", ind["name"]), "topic": ind.get("topic"), "subtopic": ind.get("subtopic"),114            "unit": ind.get("unit"), "unit_short": ind.get("unit_short"), "format": ind.get("format", "number"), "precision": ind.get("precision", 1), "frequency": ind.get("frequency", "A"),115            "aggregation": ind.get("aggregation"), "higher_is_better": ind.get("higher_is_better"), "ranking_eligible": ind.get("ranking_eligible", True), "featured": ind.get("featured", False)}116117118_latest_cache: dict[str, dict[str, dict]] = {}119120121def latest_all(ind_slug: str) -> dict[str, dict]:122    """country id → last actual value dict for an indicator (with ranks)."""123    if ind_slug in _latest_cache:124        return _latest_cache[ind_slug]125    ind = IND[ind_slug]126    rows = {}127    for c in countries:128        if not has_data(c, ind):129            continue130        s = [p for p in series(c, ind) if not p["is_forecast"]]131        if len(s) < 2:132            continue133        rows[c["id"]] = {"last": s[-1], "prev": s[-2], "ten": s[-11] if len(s) > 11 else None, "spark": [[p["year"], p["value"]] for p in s[-30:]]}134    ranked = sorted(rows.items(), key=lambda kv: -(kv[1]["last"]["value"]))135    if ind.get("higher_is_better") is False:136        ranked.reverse()137    for i, (cid, r) in enumerate(ranked):138        r["rank_world"] = i + 1139        r["n_world"] = len(ranked)140        region = BY_ID[cid].get("region_wb")141        peers = [x for x, _ in ranked if BY_ID[x].get("region_wb") == region]142        r["rank_region"] = peers.index(cid) + 1143        r["n_region"] = len(peers)144    _latest_cache[ind_slug] = rows145    return rows146147148def metric(c: dict, ind: dict) -> dict:149    row = latest_all(ind["slug"]).get(c["id"])150    if not row:151        return {"indicator": ind["slug"], "indicator_name": ind.get("short_name", ind["name"]), "has_data": False, "value": None, "formatted": "—", "unit": ind.get("unit"),152                "unit_short": ind.get("unit_short"), "format": ind.get("format", "number"), "higher_is_better": ind.get("higher_is_better"), "sparkline": [], "provenance": None,153                "period": None, "year": None, "frequency": None, "is_estimate": False, "is_forecast": False, "status": None, "prev": None, "change": None, "change_10y": None,154                "rank_world": None, "n_world": None, "rank_region": None, "n_region": None, "rank_income": None, "n_income": None, "rank_year": None, "rank_is_stale": False}155    last, prev = row["last"], row["prev"]156    ch_abs = last["value"] - prev["value"]157    ch_pct = ch_abs / prev["value"] * 100 if prev["value"] else None158    pts = ind.get("format") in ("percent", "index", "ratio", "years")159    return {160        "indicator": ind["slug"], "indicator_name": ind.get("short_name", ind["name"]), "has_data": True, "value": last["value"], "formatted": fmt_value(last["value"], ind),161        "period": last["period"], "year": last["year"], "frequency": "A", "unit": ind.get("unit"), "unit_short": ind.get("unit_short"), "format": ind.get("format", "number"),162        "is_estimate": last["is_estimate"], "is_forecast": False, "status": "verified", "prev": {"period": prev["period"], "value": prev["value"]},163        "change": {"abs": round(ch_abs, 3), "pct": round(ch_pct, 2) if ch_pct is not None else None, "formatted": (f"{ch_abs:+.1f} pts" if pts else f"{ch_pct:+.1f} %") if ch_pct is not None else None},164        "change_10y": None, "rank_world": row["rank_world"], "n_world": row["n_world"], "rank_region": row["rank_region"], "n_region": row["n_region"], "rank_income": None, "n_income": None,165        "rank_year": last["year"], "rank_is_stale": last["year"] < 2022, "higher_is_better": ind.get("higher_is_better"), "sparkline": row["spark"], "provenance": prov(ind, c),166    }167168169def problem(status: int, title: str, detail: str) -> JSONResponse:170    return JSONResponse({"type": "about:blank", "title": title, "status": status, "detail": detail}, status_code=status, media_type="application/problem+json")171172173def resolve(ident: str) -> dict | None:174    return BY_ID.get(ident.upper()) or BY_SLUG.get(ident.lower())175176177@app.get("/api/v1/health")178def health():179    return {"status": "ok", "run_id": META["run_id"], "built_at": BUILT, "observations": 3_412_338, "countries": len(countries), "indicators": len(indicators), "db_path": "fixture", "version": "0.0-fixture", "cache": {}}180181182@app.get("/api/v1/countries")183def list_countries(region: str | None = None, income: str | None = None, q: str | None = None, sort: str = "name", limit: int = 1000, offset: int = 0):184    items = []185    for c in countries:186        if region and (c.get("region_wb") or "").lower() != region.lower() and c.get("region_wb", "").lower() != region.lower():187            continue188        if income and (c.get("income_group") or "").upper() != income.upper():189            continue190        if q and q.lower() not in c["short_name"].lower():191            continue192        pop = latest_all("population").get(c["id"])193        gdp = latest_all("gdp").get(c["id"])194        gpc = latest_all("gdp-per-capita").get(c["id"])195        item = card(c)196        item.update({"capital": c.get("capital"), "continent": c.get("continent"), "subregion": c.get("subregion"),197                     "population_latest": pop["last"]["value"] if pop else None, "population_year": pop["last"]["year"] if pop else None,198                     "gdp_latest": gdp["last"]["value"] if gdp else None, "gdp_year": gdp["last"]["year"] if gdp else None,199                     "gdp_per_capita_latest": gpc["last"]["value"] if gpc else None, "gdp_per_capita_year": gpc["last"]["year"] if gpc else None,200                     "coverage_pct": round(100 * sum(1 for i in indicators if has_data(c, i)) / len(indicators), 1), "n_indicators": sum(1 for i in indicators if has_data(c, i)),201                     "iso_numeric": c.get("iso_numeric")})202        items.append(item)203    return {"meta": META, "n": len(items), "filters": {"region": region, "income": income, "q": q, "sort": sort}, "items": items[offset: offset + limit]}204205206@app.get("/api/v1/countries/{ident}")207def get_country(ident: str):208    c = resolve(ident)209    if not c:210        return problem(404, "Country not found", f"Unknown country '{ident}'.")211    full = card(c)212    for k in ("official_name", "iso3", "iso_numeric", "capital", "continent", "subregion", "currency_code", "currency_name", "area_km2", "latitude", "longitude", "un_member", "independent", "landlocked", "borders", "languages", "demonym", "status"):213        full[k] = c.get(k)214    with_data = {i["slug"] for i in indicators if has_data(c, i)}215    tps = [{"id": tp["id"], "name": tp["name"], "short": tp.get("short"), "order": tp.get("order"), "blurb": tp.get("blurb"), "n_indicators": len(tp["indicators"]),216            "n_with_data": sum(1 for s in tp["indicators"] if s in with_data)} for tp in sorted(topics["topics"], key=lambda x: x["order"])]217    return {"meta": META, "country": full, "groups": [{"id": g["id"], "slug": g["slug"], "name": g["name"], "kind": g["kind"], "wb_code": g.get("wb_code"), "n_members": None} for g in groups[:4]],218            "coverage": {"n_indicators": len(with_data), "n_observations": 34 * len(with_data), "latest_year": 2024, "coverage_pct": round(100 * len(with_data) / len(indicators), 1), "updated_at": BUILT},219            "freshness": {"source_updated_at": "2026-07-01", "retrieved_at": BUILT, "built_at": BUILT},220            "headline": [metric(c, IND[s]) for s in topics["headline"] if s in IND], "topics": tps,221            "neighbours": [card(BY_ID[b]) for b in (c.get("borders") or []) if b in BY_ID]}222223224@app.get("/api/v1/countries/{ident}/topics/{topic}")225def get_topic(ident: str, topic: str):226    c = resolve(ident)227    tp = next((x for x in topics["topics"] if x["id"] == topic), None)228    if not c:229        return problem(404, "Country not found", f"Unknown country '{ident}'.")230    if not tp:231        return problem(404, "Topic not found", f"Unknown topic '{topic}'.")232    blocks: dict[str, list] = {}233    n_with = 0234    for s in tp["indicators"]:235        ind = IND.get(s)236        if not ind:237            continue238        m = metric(c, ind)239        n_with += m["has_data"]240        blocks.setdefault(ind.get("subtopic") or "Other", []).append(m)241    return {"meta": META, "country": card(c), "topic": {k: tp.get(k) for k in ("id", "name", "short", "order", "blurb")}, "n_with_data": n_with, "n_indicators": sum(len(v) for v in blocks.values()),242            "subtopics": [{"subtopic": k, "indicators": v} for k, v in blocks.items()]}243244245@app.get("/api/v1/countries/{ident}/series/{slug}")246def get_series(ident: str, slug: str):247    c = resolve(ident)248    ind = IND.get(slug)249    if not c or not ind:250        return problem(404, "Not found", "Unknown country or indicator.")251    vals = series(c, ind) if has_data(c, ind) else []252    act = [v for v in vals if not v["is_forecast"]]253    stats = {"min": None, "max": None, "first": None, "last": None, "cagr": None, "n": len(act)}254    if act:255        mn = min(act, key=lambda v: v["value"]); mx = max(act, key=lambda v: v["value"])256        stats.update({"min": {"year": mn["year"], "value": mn["value"]}, "max": {"year": mx["year"], "value": mx["value"]}, "first": {"year": act[0]["year"], "value": act[0]["value"]}, "last": {"year": act[-1]["year"], "value": act[-1]["value"]}})257    return {"meta": META, "indicator": icard(ind), "country": card(c), "unit": ind.get("unit"), "frequency": "A", "values": vals, "alternatives": None,258            "provenance": prov(ind, c) if vals else None, "sources": [dict(prov(ind, c), n_values=len(act))] if vals else [], "stats": stats}259260261KINDS = ["yoy_jump", "yoy_drop", "record_high", "record_low", "n_year_high", "sign_flip", "accelerating"]262263264def change_items(c: dict, n: int, whole: bool = False) -> list[dict]:265    r = rng(c["id"], "changes", whole)266    items = []267    slugs = [s for s in IND if has_data(c, IND[s])]268    r.shuffle(slugs)269    for s in slugs[:n]:270        ind = IND[s]271        row = latest_all(s).get(c["id"])272        if not row:273            continue274        kind = r.choice(KINDS)275        year = row["last"]["year"] if not whole else r.randint(1995, 2024)276        v = row["last"]["value"]; ref = row["prev"]["value"]277        d = v - ref278        sev = round(r.uniform(0.2, 1.0), 2)279        head = {"yoy_jump": f"{ind.get('short_name', ind['name'])} jumped to {fmt_value(v, ind)} in {year}", "yoy_drop": f"{ind.get('short_name', ind['name'])} fell to {fmt_value(v, ind)} in {year}",280                "record_high": f"{ind.get('short_name', ind['name'])} reached a record high of {fmt_value(v, ind)} in {year}", "record_low": f"{ind.get('short_name', ind['name'])} hit a record low of {fmt_value(v, ind)} in {year}",281                "n_year_high": f"{ind.get('short_name', ind['name'])} at a 10-year high ({fmt_value(v, ind)}) in {year}", "sign_flip": f"{ind.get('short_name', ind['name'])} turned {'positive' if v > 0 else 'negative'} in {year}",282                "accelerating": f"{ind.get('short_name', ind['name'])} accelerating for three years running ({year})"}[kind]283        items.append({"id": f"{c['id']}-{s}-{year}", "country": card(c), "indicator": icard(ind), "kind": kind, "period": f"{year}-01-01", "year": year, "value": v, "ref_value": ref, "delta": round(d, 3),284                      "delta_pct": round(d / ref * 100, 2) if ref else None, "window_years": 10 if kind.startswith("n_year") else None, "severity": sev, "headline": head, "detail": None,285                      "detected_at": BUILT, "formatted": fmt_value(v, ind), "provenance": prov(ind, c)})286    items.sort(key=lambda x: (-x["severity"], x["year"]) if not whole else (-x["year"], -x["severity"]))287    return items288289290@app.get("/api/v1/countries/{ident}/changes")291def get_changes(ident: str, limit: int = 50):292    c = resolve(ident)293    if not c:294        return problem(404, "Country not found", ident)295    items = change_items(c, min(limit, 10))296    return {"meta": META, "n": len(items), "items": items}297298299@app.get("/api/v1/countries/{ident}/events")300def get_events(ident: str, limit: int = 100):301    c = resolve(ident)302    if not c:303        return problem(404, "Country not found", ident)304    items = change_items(c, min(limit, 24), whole=True)305    return {"meta": META, "n": len(items), "items": items}306307308@app.get("/api/v1/countries/{ident}/similar")309def get_similar(ident: str, mode: str = "overall", limit: int = 12):310    c = resolve(ident)311    if not c:312        return problem(404, "Country not found", ident)313    r = rng(c["id"], "similar", mode)314    pool = [x for x in countries if x["id"] != c["id"] and x.get("income_group") == c.get("income_group")] or countries315    r.shuffle(pool)316    feats = ["gdp-per-capita-ppp", "median-age", "urban-population-share", "trade-pct-gdp", "energy-use-per-capita", "co2-per-capita", "life-expectancy", "unemployment-rate"]317    peers = []318    for i, p in enumerate(pool[:limit]):319        contrib = {f: {"z_a": round(r.gauss(0, 1), 2), "z_b": round(r.gauss(0, 1), 2), "weight": 1.0, "contribution": round(r.uniform(0, 0.4), 3)} for f in feats}320        peers.append({"country": card(p), "score": round(92 - i * 4.3 - r.random() * 2, 1), "rank": i + 1, "contributions": contrib})321    return {"meta": META, "country": card(c), "mode": mode, "modes": ["overall", "economic", "demographic", "energy", "social"], "peers": peers}322323324@app.get("/api/v1/countries/{ident}/insights")325def get_insights(ident: str):326    c = resolve(ident)327    if not c:328        return problem(404, "Country not found", ident)329    items = []330    for s, tmpl in (("population", "{n}'s population grew {pct} % since 1990."), ("gdp-per-capita", "GDP per capita in {n} is {v}, ranking {rank} of {nw}."), ("life-expectancy", "Life expectancy in {n} reached {v} in {y}."), ("renewable-electricity-share", "{v} of {n}'s electricity comes from renewables."), ("co2-per-capita", "{n} emits {v} of CO₂ per person, {cmp} the world median.")):331        row = latest_all(s).get(c["id"])332        if not row:333            continue334        ind = IND[s]335        v = fmt_value(row["last"]["value"], ind)336        items.append({"id": f"{c['id']}-{s}", "template_id": s, "text": tmpl.format(n=c["short_name"], v=v, y=row["last"]["year"], pct=f"{rng(c['id'], s).uniform(3, 60):.0f}", rank=row["rank_world"], nw=row["n_world"], cmp=rng(c['id'], s, 'c').choice(["above", "below"])),337                      "values": {"value": row["last"]["value"]}, "indicators": [s], "computed_at": BUILT, "provenance": [prov(ind, c)]})338    return {"meta": META, "country": card(c), "items": items}339340341@app.get("/api/v1/countries/{ident}/dna")342def get_dna(ident: str):343    c = resolve(ident)344    if not c:345        return problem(404, "Country not found", ident)346    r = rng(c["id"], "dna")347    keys = ["income", "demographics", "urbanization", "trade", "energy", "emissions", "innovation", "education", "public_spending"]348    dims = {k: (round(r.uniform(5, 98), 1) if r.random() > 0.08 else None) for k in keys}349    return {"meta": META, "country": card(c), "dims": dims, "year_ref": 2024, "dimensions": [{"id": k, "label": k.title(), "indicator": None, "value": v} for k, v in dims.items()]}350351352@app.get("/api/v1/indicators/{slug}/map")353def get_map(slug: str, year: int | None = None, nearest: bool = False):354    ind = IND.get(slug)355    if not ind:356        return problem(404, "Indicator not found", slug)357    rows = latest_all(slug)358    values = {cid: r["last"]["value"] for cid, r in rows.items() if BY_ID[cid].get("status") == "country"}359    vals = sorted(values.values())360    k = 6361    breaks = [vals[int(i / k * (len(vals) - 1))] for i in range(1, k)] if len(vals) > 10 else []362    return {"meta": META, "indicator": icard(ind), "year": year, "year_used": 2024, "nearest": nearest, "values": values, "years": None, "formatted": {c: fmt_value(v, ind) for c, v in values.items()},363            "legend": {"min": vals[0] if vals else None, "max": vals[-1] if vals else None, "breaks": breaks, "n_classes": len(breaks) + 1}, "n": len(values), "provenance": prov(ind), "sources": [prov(ind)]}364365366def ranking_rows(slug: str, n: int, order: str = "desc", min_pop: float | None = None) -> list[dict]:367    ind = IND[slug]368    rows = [(cid, r) for cid, r in latest_all(slug).items() if BY_ID[cid].get("status") == "country" and (not min_pop or (latest_all("population").get(cid, {}).get("last", {}).get("value", 0) >= min_pop))]369    rows.sort(key=lambda kv: kv[1]["last"]["value"], reverse=(order == "desc"))370    out = []371    for i, (cid, r) in enumerate(rows[:n]):372        v = r["last"]["value"]; pv = r["prev"]["value"]373        out.append({"rank": i + 1, "rank_world": r["rank_world"], "n_world": r["n_world"], "pct_rank": None, "country": card(BY_ID[cid]), "value": v, "formatted": fmt_value(v, ind), "year": r["last"]["year"],374                    "change_pct": round((v - pv) / pv * 100, 2) if pv else None, "change_abs": round(v - pv, 3), "change_1y": {"abs": round(v - pv, 3), "pct": round((v - pv) / pv * 100, 2) if pv else None, "formatted": None}, "change_10y": None,375                    "sparkline": r["spark"], "provenance": prov(ind, BY_ID[cid])})376    return out377378379@app.get("/api/v1/rankings/{slug}")380def get_ranking(slug: str, limit: int = 50, sort: str = "desc", offset: int = 0):381    ind = IND.get(slug)382    if not ind:383        return problem(404, "Indicator not found", slug)384    rows = ranking_rows(slug, limit + offset, sort)[offset:]385    return {"meta": META, "indicator": icard(ind), "group": {"id": "world", "slug": "world", "name": "World", "kind": "world", "wb_code": "WLD", "n_members": len(countries)}, "year": None, "year_used": 2024,386            "years_available": list(range(1990, 2025)), "sort": sort, "n": len(latest_all(slug)), "limit": limit, "offset": offset, "rows": rows}387388389def ind_summary(ind: dict) -> dict:390    n = sum(1 for c in countries if has_data(c, ind))391    out = icard(ind)392    out.update({"description": ind.get("description"), "n_countries": n, "n_observations": n * 34, "first_year": 1990, "last_year": 2024, "latest_source_updated_at": "2026-07-01", "primary_source_id": "worldbank", "coverage_pct": round(100 * n / len(countries), 1), "tags": []})393    return out394395396@app.get("/api/v1/home")397def home():398    lists = {}399    for key, title, slug, order, min_pop, desc in (("largest_economies", "Largest economies", "gdp", "desc", None, "GDP, current US$"), ("fastest_population_growth", "Fastest population growth", "population-growth", "desc", 1e6, "Countries above 1 M inhabitants"),400                                                   ("highest_life_expectancy", "Highest life expectancy", "life-expectancy", "desc", None, "Years at birth"), ("energy_transition_leaders", "Energy transition leaders", "renewable-electricity-share", "desc", None, "Share of electricity from renewables")):401        lists[key] = {"title": title, "description": desc, "indicator": icard(IND[slug]), "sort": order, "rows": ranking_rows(slug, 8, order, min_pop)}402    pop = sum(r["last"]["value"] for cid, r in latest_all("population").items() if BY_ID[cid].get("status") == "country")403    gdp = sum(r["last"]["value"] for cid, r in latest_all("gdp").items() if BY_ID[cid].get("status") == "country")404    les = sorted(r["last"]["value"] for r in latest_all("life-expectancy").values())405    changes = []406    for c in rng("home").sample(countries, 12):407        changes += change_items(c, 1)408    changes.sort(key=lambda x: -x["severity"])409    featured = [ind_summary(i) for i in indicators if i.get("featured")]410    return {"meta": META, "snapshot": {"world_population": pop, "world_population_formatted": fmt_value(pop, {"format": "number"}), "world_population_year": 2024, "world_gdp": gdp, "world_gdp_formatted": fmt_value(gdp, {"format": "currency"}), "world_gdp_year": 2024,411                                       "median_life_expectancy": les[len(les) // 2] if les else None, "median_life_expectancy_year": 2024, "n_countries": sum(1 for c in countries if c.get("status") == "country"), "n_territories": sum(1 for c in countries if c.get("status") != "country"),412                                       "n_indicators": len(indicators), "n_indicators_with_data": len(indicators) - 3, "n_observations": 3_412_338, "n_sources": 9, "built_at": BUILT, "run_id": META["run_id"], "note": "Fixture data."},413            "lists": lists, "recent_changes": changes[:12], "recently_updated": [ind_summary(i) for i in indicators[:12]], "featured_indicators": featured, "trending": featured[:12]}414415416@app.get("/api/v1/search")417def search(q: str = Query(""), limit: int = 12):418    ql = q.lower().strip()419    hits = []420    for c in countries:421        if ql and (ql in c["short_name"].lower() or ql == c["id"].lower()):422            hits.append({"type": "country", "id": c["id"], "slug": c["slug"], "name": c["short_name"], "hint": f"Country · {c.get('region_wb_name')}", "score": 1.0 if c["short_name"].lower().startswith(ql) else 0.8, "url": f"/countries/{c['slug']}", "country": card(c), "topic": None, "indicator": None})423    for i in indicators:424        if ql and ql in i["name"].lower():425            hits.append({"type": "indicator", "id": i["slug"], "slug": i["slug"], "name": i["name"], "hint": f"Indicator · {i['topic'].capitalize()} · {i.get('unit')}", "score": 0.7, "url": f"/indicators/{i['slug']}", "country": None, "topic": None, "indicator": None})426    for tp in topics["topics"]:427        if ql and ql in tp["name"].lower():428            hits.append({"type": "topic", "id": tp["id"], "slug": tp["id"], "name": tp["name"], "hint": "Topic", "score": 0.6, "url": f"/indicators?topic={tp['id']}", "country": None, "topic": tp["id"], "indicator": None})429    hits.sort(key=lambda h: -h["score"])430    return {"meta": META, "q": q, "n": len(hits), "hits": hits[:limit]}431432433@app.get("/api/v1/countries/{ident}/download.csv")434def download(ident: str):435    from fastapi.responses import PlainTextResponse436    return PlainTextResponse("country_id,indicator_id,period,value\n", media_type="text/csv")437438439if __name__ == "__main__":440    import uvicorn441442    uvicorn.run(app, host="127.0.0.1", port=8299, log_level="warning")443