From 11872d7e640272cb566d587ccc8c96c317b973f5 Mon Sep 17 00:00:00 2001 From: emi Date: Tue, 21 Jul 2026 20:01:30 +0000 Subject: [PATCH] Repo-split Stage 4: cut prod traffic to backend + frontend, dual-service (#14) --- api_client.py | 11 ++- app.py | 28 ++---- tests/conftest.py | 30 +++++- tests/test_content.py | 11 +++ tests/test_content_loader.py | 141 ++++++++++++++++++++++++++++ tests/test_homepage.py | 176 +++++++++++++++++++++++++++++++++++ 6 files changed, 377 insertions(+), 20 deletions(-) create mode 100644 tests/test_content_loader.py create mode 100644 tests/test_homepage.py diff --git a/api_client.py b/api_client.py index ac29271..c40cb2a 100644 --- a/api_client.py +++ b/api_client.py @@ -20,6 +20,15 @@ API_BASE = os.environ.get("THERMOGRAPH_API_BASE_INTERNAL") if not API_BASE: raise RuntimeError("THERMOGRAPH_API_BASE_INTERNAL must be set (e.g. http://backend:8137)") +# Backend's own routes (including the content API this client calls) sit +# under ITS OWN THERMOGRAPH_BASE, exactly like content.py computes for +# frontend's own routes -- both processes are always configured with the same +# value in every real deployment (compose, run.sh, CI all set it identically +# for both), so reading it here too keeps this client correct under a +# sub-path deployment instead of only working when BASE is empty/root. +_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") +_BASE_PREFIX = f"/{_BASE}" if _BASE else "" + _TTL = float(os.environ.get("THERMOGRAPH_SSR_CACHE_TTL", "600") or 600) # 10 min default _cache: dict[str, tuple[float, object]] = {} @@ -46,7 +55,7 @@ def _get(path: str, *, origin: str | None = None) -> object: if origin: proto, _, host = origin.partition("://") headers = {"host": host, "x-forwarded-proto": proto} - resp = httpx.get(f"{API_BASE}{path}", timeout=10.0, headers=headers) + resp = httpx.get(f"{API_BASE}{_BASE_PREFIX}{path}", timeout=10.0, headers=headers) resp.raise_for_status() data = resp.json() _cache[cache_key] = (now, data) diff --git a/app.py b/app.py index 789373a..366a6bd 100644 --- a/app.py +++ b/app.py @@ -1,26 +1,20 @@ -"""SSR frontend service: server-rendered content pages (content.py) plus the -static JS/CSS/PWA asset bundle. All climate data comes from the backend's -content API over HTTP (api_client.py) -- this process holds no data of its -own and does no polars/DB work. -""" -import mimetypes +"""SSR frontend service: server-rendered content pages only (content.py). All +climate data comes from the backend's content API over HTTP (api_client.py) -- +this process holds no data of its own and does no polars/DB work. +No static-asset mount here (repo-split Stage 4): every real topology routes +JS/CSS/images to backend's own StaticFiles mount -- prod/beta's Caddy never +sends those paths here, and backend's own dev/bare-metal reverse-proxy fallback +(THERMOGRAPH_FRONTEND_BASE_INTERNAL) only forwards the specific content paths +content.register() claims. A static mount here would just be unreachable dead +code in every deployment. +""" from fastapi import FastAPI -from fastapi.middleware.gzip import GZipMiddleware -from fastapi.staticfiles import StaticFiles import content -import paths - -# The PWA manifest is served as a static file; register its media type so -# StaticFiles labels it correctly (not in Python's default mimetypes map). -mimetypes.add_type("application/manifest+json", ".webmanifest") app = FastAPI(title="Thermograph SSR", version="0.1.0") -# Compress every sizeable response, same threshold as the backend. -app.add_middleware(GZipMiddleware, minimum_size=1024) - @app.get("/healthz") def healthz(): @@ -32,5 +26,3 @@ def healthz(): # NOTE: "/" is not here -- the homepage is server-rendered by content.register(). content.register(app) - -app.mount(content.BASE or "/", StaticFiles(directory=paths.STATIC_DIR), name="static") diff --git a/tests/conftest.py b/tests/conftest.py index d212709..21319ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,9 @@ FRONTEND_SSR = os.path.dirname(_HERE) REPO_ROOT = os.path.dirname(FRONTEND_SSR) BACKEND = os.path.join(REPO_ROOT, "backend") -sys.path.insert(0, FRONTEND_SSR) +# BACKEND first: content_payloads.py's own dependency chain (data/cities.py, +# data/store.py, ...) does a bare `import paths` that must resolve to +# backend/paths.py while THAT chain loads below. sys.path.insert(0, BACKEND) os.environ.setdefault("THERMOGRAPH_API_BASE_INTERNAL", "http://backend.invalid") @@ -50,9 +52,35 @@ from api import content_payloads # noqa: E402 from api import sitemap as sitemap_mod # noqa: E402 from data import cities # noqa: E402 +# frontend_ssr/ has its OWN paths.py and app.py -- genuinely different modules +# that happen to share backend's bare top-level names. data/cities.py etc. +# above already bound their own `paths` name to backend's module object +# (permanent, unaffected by anything below); swap sys.modules["paths"] to +# frontend_ssr's version just for the span of importing content.py (which +# cascades into content_loader.py, the other consumer), then restore it so any +# LATER lazy backend import (api.homepage's own `import paths`, first +# triggered whenever a test actually calls content_payloads.home_payload()) +# still sees backend's version, not frontend's. +_backend_paths_module = sys.modules.get("paths") +_frontend_paths_spec = importlib.util.spec_from_file_location("paths", os.path.join(FRONTEND_SSR, "paths.py")) +_frontend_paths_module = importlib.util.module_from_spec(_frontend_paths_spec) +sys.modules["paths"] = _frontend_paths_module +_frontend_paths_spec.loader.exec_module(_frontend_paths_module) + +# Reprioritize sys.path so "app" -- imported lazily, per-test, by the `client` +# fixture below -- resolves to frontend_ssr/app.py rather than backend's +# app.py (the `app:app` launch shim). Nothing triggers a bare `import app` +# before that point, so ordering (not a swap) is enough for this one name. +sys.path.insert(0, FRONTEND_SSR) + import api_client # noqa: E402 import content # noqa: E402 +if _backend_paths_module is not None: + sys.modules["paths"] = _backend_paths_module +else: + del sys.modules["paths"] + B = content.BASE SLUG = "london-england-gb" # present in cities.json; GB -> renders in Celsius FAKE_INDEXNOW_KEY = "test0000indexnow0000key000000000" diff --git a/tests/test_content.py b/tests/test_content.py index d476a28..c2b1188 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -405,3 +405,14 @@ def test_brand_lockup_links_home(client, path): assert href in ("./", f"{B}/"), f"{path}: brand links to {href!r}, not home" assert '