From 241b87c7b3bb8a3abcec6ce7c619d4c96a92977b Mon Sep 17 00:00:00 2001 From: emi Date: Tue, 21 Jul 2026 16:09:35 +0000 Subject: [PATCH] Repo-split Stage 1: sever web/'s reverse imports (#9) --- accounts/api_accounts.py | 2 +- {web => accounts}/schemas.py | 0 api/__init__.py | 0 {web => api}/homepage.py | 2 +- web/views.py => api/payloads.py | 0 api/sitemap.py | 26 +++++++ data/climate_store.py | 4 +- indexnow.py | 7 +- migrate.py | 14 ++-- notifications/discord.py | 2 +- notifications/discord_interactions.py | 2 +- notifications/notify.py | 4 +- .../test_views.py => api/test_payloads.py} | 64 ++++++++--------- .../test_discord_interactions.py | 2 +- tests/web/test_api.py | 10 +-- tests/web/test_homepage.py | 2 +- warm_cities.py | 2 +- web/app.py | 72 +++++++++---------- web/content.py | 26 ++----- 19 files changed, 124 insertions(+), 117 deletions(-) rename {web => accounts}/schemas.py (100%) create mode 100644 api/__init__.py rename {web => api}/homepage.py (99%) rename web/views.py => api/payloads.py (100%) create mode 100644 api/sitemap.py rename tests/{web/test_views.py => api/test_payloads.py} (73%) diff --git a/accounts/api_accounts.py b/accounts/api_accounts.py index 106c3ed..d9ddfff 100644 --- a/accounts/api_accounts.py +++ b/accounts/api_accounts.py @@ -17,7 +17,7 @@ from data import grid from notifications import push from accounts.db import get_async_session, get_read_session from accounts.models import Notification, PushSubscription, Subscription -from web.schemas import ( +from accounts.schemas import ( NotificationList, NotificationOut, PushSubscriptionIn, diff --git a/web/schemas.py b/accounts/schemas.py similarity index 100% rename from web/schemas.py rename to accounts/schemas.py diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/homepage.py b/api/homepage.py similarity index 99% rename from web/homepage.py rename to api/homepage.py index 1921b44..27bd927 100644 --- a/web/homepage.py +++ b/api/homepage.py @@ -33,7 +33,7 @@ from data import grading from data import grid from data import store import paths -from web.views import OBS_COLS +from api.payloads import OBS_COLS # data/ is the only writable path under the hardened systemd unit # (ReadWritePaths=/opt/thermograph/data …), so the feed lives there when it's a diff --git a/web/views.py b/api/payloads.py similarity index 100% rename from web/views.py rename to api/payloads.py diff --git a/api/sitemap.py b/api/sitemap.py new file mode 100644 index 0000000..e4bf2cb --- /dev/null +++ b/api/sitemap.py @@ -0,0 +1,26 @@ +"""The indexable-page URL list — shared by the sitemap, IndexNow submission, and +(eventually) the frontend's own sitemap.xml, so all three never drift apart. +""" +from data import cities + +MONTHS = ["january", "february", "march", "april", "may", "june", + "july", "august", "september", "october", "november", "december"] + + +def sitemap_entries() -> list[tuple[str, str, str]]: + """Every indexable page as (path, changefreq, priority). Paths are relative to + BASE.""" + entries = [("/", "daily", "1.0")] + for p in ("/climate", "/about", "/glossary", "/calendar", "/compare", "/legend"): + entries.append((p, "weekly", "0.6")) + for slug in cities.all_slugs(): + entries.append((f"/climate/{slug}", "daily", "0.8")) + entries.append((f"/climate/{slug}/records", "monthly", "0.5")) + for m in MONTHS: + entries.append((f"/climate/{slug}/{m}", "monthly", "0.5")) + return entries + + +def public_paths() -> list[str]: + """BASE-relative paths of every indexable page — for IndexNow submission.""" + return [p for p, _, _ in sitemap_entries()] diff --git a/data/climate_store.py b/data/climate_store.py index d54bdcd..69d77fb 100644 --- a/data/climate_store.py +++ b/data/climate_store.py @@ -11,7 +11,7 @@ lives in two tables managed by Alembic (``backend/alembic/versions``): forecast bundle (includes *future* dates), fully rewritten each refresh. * ``climate_sync`` — per-cell freshness (epoch seconds), replacing the parquet file mtimes that used to drive the topup cadence, the forecast TTL, and the - ``recent_stamp`` token embedded in derived-payload validity (see web/views.py). + ``recent_stamp`` token embedded in derived-payload validity (see api/payloads.py). This module is the Postgres backend only. ``climate.py`` keeps the parquet cache as the backend when ``THERMOGRAPH_DATABASE_URL`` is *not* a Postgres URL (dev, @@ -44,7 +44,7 @@ COLS = ("date", "tmax", "tmin", "precip", "wind", "gust", "humid", "fmax", "fmin # Explicit polars schema so a frame read back from Postgres has the exact dtypes # the parquet path produced (pl.Date + Float64), even when the result is empty — -# so grading.py / scoring.py / web/views.py are untouched by the backend switch. +# so grading.py / scoring.py / api/payloads.py are untouched by the backend switch. _SCHEMA: dict[str, pl.DataType] = { "date": pl.Date, **{c: pl.Float64 for c in COLS if c != "date"}, diff --git a/indexnow.py b/indexnow.py index 857b4be..84bbc40 100644 --- a/indexnow.py +++ b/indexnow.py @@ -25,6 +25,7 @@ from urllib.parse import urlparse import httpx +from api import sitemap import paths log = logging.getLogger("thermograph.indexnow") @@ -100,10 +101,9 @@ def submit(urls, host: str, key_location: str, scheme: str = "https", timeout: f def submit_all(site_base_url: str, **kw) -> dict: """Submit every indexable page. ``site_base_url`` is the public origin + base path, e.g. ``https://thermograph.org`` (root) or ``https://host/thermograph``.""" - from web import content # lazy: avoids a content ↔ indexnow import cycle site = site_base_url.rstrip("/") parsed = urlparse(site) - urls = [site + path for path in content.public_paths()] + urls = [site + path for path in sitemap.public_paths()] return submit(urls, host=parsed.hostname, scheme=parsed.scheme or "https", key_location=f"{site}/{key()}.txt", **kw) @@ -112,8 +112,7 @@ def url_signature() -> str: """A stable fingerprint of the indexable URL set — changes only when pages are added or removed (e.g. a new city), not on code-only deploys. Used to skip resubmitting an unchanged set (see the --if-changed CLI flag).""" - from web import content - return hashlib.sha1("\n".join(content.public_paths()).encode()).hexdigest() + return hashlib.sha1("\n".join(sitemap.public_paths()).encode()).hexdigest() def _read_state() -> str: diff --git a/migrate.py b/migrate.py index 5e04626..824f9fc 100644 --- a/migrate.py +++ b/migrate.py @@ -24,7 +24,7 @@ import time from data import climate from data import grid from data import store -from web import views +from api import payloads def migrate() -> int: @@ -52,11 +52,11 @@ def migrate() -> int: skipped += 1 continue - token = views.history_token(history) - start_ts, end_ts = views.cal_span(history, None, None, 24) - cal_key = views.calendar_key(start_ts, end_ts, 24) + token = payloads.history_token(history) + start_ts, end_ts = payloads.cal_span(history, None, None, 24) + cal_key = payloads.calendar_key(start_ts, end_ts, 24) last = history["date"].max() - day_key = views.day_key(last) + day_key = payloads.day_key(last) have_cal = store.get_payload("calendar", cell_id, cal_key, token) is not None have_day = store.get_payload("day", cell_id, day_key, token) is not None @@ -71,10 +71,10 @@ def migrate() -> int: if not have_cal: store.put_payload("calendar", cell_id, cal_key, token, - views.build_calendar(cell, history, start_ts, end_ts, 24, place)) + payloads.build_calendar(cell, history, start_ts, end_ts, 24, place)) if not have_day: store.put_payload("day", cell_id, day_key, token, - views.build_day(cell, history, last, place)) + payloads.build_day(cell, history, last, place)) built += 1 print(f" {cell_id}: materialized ({place or 'no label'})") diff --git a/notifications/discord.py b/notifications/discord.py index bb1fdfa..85c706b 100644 --- a/notifications/discord.py +++ b/notifications/discord.py @@ -17,7 +17,7 @@ import time import httpx -from web import homepage +from api import homepage # The webhook URL IS the credential — env only, never the repo. Unset => disabled. WEBHOOK_URL = os.environ.get("THERMOGRAPH_DISCORD_WEBHOOK", "").strip() diff --git a/notifications/discord_interactions.py b/notifications/discord_interactions.py index f9b6605..a4935df 100644 --- a/notifications/discord_interactions.py +++ b/notifications/discord_interactions.py @@ -21,9 +21,9 @@ import os from nacl.exceptions import BadSignatureError from nacl.signing import VerifyKey +from api import homepage from data import cities from notifications import discord -from web import homepage # App's Ed25519 public key (Developer Portal -> General Information). Unset => # every request fails verification, which is the safe default for an unconfigured diff --git a/notifications/notify.py b/notifications/notify.py index b24d981..128c64b 100644 --- a/notifications/notify.py +++ b/notifications/notify.py @@ -29,16 +29,16 @@ import time import polars as pl from sqlalchemy import delete, select +from api import homepage +from api.payloads import OBS_COLS from data import climate from notifications import discord from data import grading from data import grid -from web import homepage from core import metrics from notifications import push from accounts.db import sync_session_maker from accounts.models import AccessToken, Notification, PushSubscription, Subscription, User -from web.views import OBS_COLS _BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") BASE = f"/{_BASE}" if _BASE else "" diff --git a/tests/web/test_views.py b/tests/api/test_payloads.py similarity index 73% rename from tests/web/test_views.py rename to tests/api/test_payloads.py index a912db2..29ab153 100644 --- a/tests/web/test_views.py +++ b/tests/api/test_payloads.py @@ -9,17 +9,17 @@ import polars as pl import pytest from data import climate -from web import views +from api import payloads CELL = {"id": "1642_-4223", "center_lat": 47.6087, "center_lon": -122.29377} -def test_views_and_migrate_import_without_the_web_stack(): +def test_payloads_and_migrate_import_without_the_web_stack(): """migrate.py must stay runnable offline: importing the payload layer may not construct the FastAPI app or start the places-index download.""" - code = ("import sys; from web import views; import migrate; " - "assert 'fastapi' not in sys.modules, 'views/migrate pulled in FastAPI'; " - "assert 'web.app' not in sys.modules, 'views/migrate imported the web app'; " + code = ("import sys; from api import payloads; import migrate; " + "assert 'fastapi' not in sys.modules, 'payloads/migrate pulled in FastAPI'; " + "assert 'web.app' not in sys.modules, 'payloads/migrate imported the web app'; " "from data import places; assert places._load_started is False") backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) subprocess.run([sys.executable, "-c", code], cwd=backend, check=True) @@ -32,23 +32,23 @@ def test_views_and_migrate_import_without_the_web_stack(): def test_cache_identity_formats_are_pinned(history): t = datetime.date(2026, 6, 15) - assert views.grade_key(t, 14, 7) == "2026-06-15:14:7" - assert views.calendar_key(t, datetime.date(2026, 6, 20), 24) == "2026-06-15:2026-06-20:24" - assert views.day_key(t) == "2026-06-15" - assert views.forecast_key(t, 7) == "2026-06-15:7" - assert views.history_token(history) == f"{views.PAYLOAD_VER}:{views.hist_end(history)}" + assert payloads.grade_key(t, 14, 7) == "2026-06-15:14:7" + assert payloads.calendar_key(t, datetime.date(2026, 6, 20), 24) == "2026-06-15:2026-06-20:24" + assert payloads.day_key(t) == "2026-06-15" + assert payloads.forecast_key(t, 7) == "2026-06-15:7" + assert payloads.history_token(history) == f"{payloads.PAYLOAD_VER}:{payloads.hist_end(history)}" def test_recent_token_composes_history_and_stamp(history, monkeypatch): monkeypatch.setattr(climate, "recent_stamp", lambda cid: "stamp") - assert views.recent_token(history, "1_2") == f"{views.history_token(history)}:stamp" + assert payloads.recent_token(history, "1_2") == f"{payloads.history_token(history)}:stamp" def test_day_token_expires_hourly_only_beyond_the_archive(history): last = history["date"].max() - assert views.day_token(history, last) == views.history_token(history) - future = views.day_token(history, last + datetime.timedelta(days=1)) - assert future.startswith(views.history_token(history) + ":h") + assert payloads.day_token(history, last) == payloads.history_token(history) + future = payloads.day_token(history, last + datetime.timedelta(days=1)) + assert future.startswith(payloads.history_token(history) + ":h") # ---- cal_span clamping --------------------------------------------------------- @@ -60,27 +60,27 @@ def _hist(start, end): def test_cal_span_defaults_to_months_back_same_day_of_month(): - start_ts, end_ts = views.cal_span(_hist("2020-01-01", "2026-06-29"), None, None, 24) + start_ts, end_ts = payloads.cal_span(_hist("2020-01-01", "2026-06-29"), None, None, 24) assert end_ts == datetime.date(2026, 6, 29) assert start_ts == datetime.date(2024, 6, 29) def test_cal_span_clamps_to_the_record(): h = _hist("2025-03-01", "2026-06-29") # record shorter than the 2-year cap - start_ts, end_ts = views.cal_span(h, "2020-01-01", None, 24) + start_ts, end_ts = payloads.cal_span(h, "2020-01-01", None, 24) assert start_ts == datetime.date(2025, 3, 1) # can't start before the record - _, end_ts = views.cal_span(h, None, "2030-01-01", 24) + _, end_ts = payloads.cal_span(h, None, "2030-01-01", 24) assert end_ts == datetime.date(2026, 6, 29) # nor end past it def test_cal_span_caps_at_two_years(): - start_ts, end_ts = views.cal_span(_hist("2018-01-01", "2026-06-29"), + start_ts, end_ts = payloads.cal_span(_hist("2018-01-01", "2026-06-29"), "2018-01-01", "2026-06-29", 24) - assert (end_ts - start_ts).days == views.CAL_MAX_SPAN_DAYS - 1 + assert (end_ts - start_ts).days == payloads.CAL_MAX_SPAN_DAYS - 1 def test_cal_span_never_inverts(): - start_ts, end_ts = views.cal_span(_hist("2020-01-01", "2026-06-29"), + start_ts, end_ts = payloads.cal_span(_hist("2020-01-01", "2026-06-29"), "2026-06-01", "2021-01-01", 24) assert start_ts == end_ts @@ -88,9 +88,9 @@ def test_cal_span_never_inverts(): def test_months_before_clamps_month_end_and_leap(): """Calendar-aware month subtraction (replaces pandas DateOffset): land on the same day-of-month, clamped to the target month's last valid day.""" - assert views._months_before(datetime.date(2026, 3, 31), 1) == datetime.date(2026, 2, 28) - assert views._months_before(datetime.date(2024, 3, 31), 1) == datetime.date(2024, 2, 29) - assert views._months_before(datetime.date(2026, 1, 15), 14) == datetime.date(2024, 11, 15) + assert payloads._months_before(datetime.date(2026, 3, 31), 1) == datetime.date(2026, 2, 28) + assert payloads._months_before(datetime.date(2024, 3, 31), 1) == datetime.date(2024, 2, 29) + assert payloads._months_before(datetime.date(2026, 1, 15), 14) == datetime.date(2024, 11, 15) def test_attach_dry_streaks_prefers_the_fresher_source_on_shared_dates(): @@ -100,7 +100,7 @@ def test_attach_dry_streaks_prefers_the_fresher_source_on_shared_dates(): hist = pl.DataFrame({"date": [d], "precip": [1.0]}) # archive: it rained (streak resets) rec = pl.DataFrame({"date": [d], "precip": [0.0]}) # fresher: dry (streak counts) graded = [{"date": d.isoformat()}] - views._attach_dry_streaks(graded, hist, rec) # archive first, recent last + payloads._attach_dry_streaks(graded, hist, rec) # archive first, recent last assert graded[0]["dsr"] == 1 # recent (dry) won @@ -108,7 +108,7 @@ def test_attach_dry_streaks_prefers_the_fresher_source_on_shared_dates(): def test_build_grade_window_and_shape(history, recent): target = datetime.date.today() - payload = views.build_grade(CELL, target, 14, history, recent, + payload = payloads.build_grade(CELL, target, 14, history, recent, {"cached": True}, "Testville") assert payload["target_date"] == target.isoformat() days = [d["date"] for d in payload["recent"]] @@ -119,7 +119,7 @@ def test_build_grade_window_and_shape(history, recent): def test_build_day_pulls_future_obs_from_recent(history, recent): today = datetime.date.today() - payload = views.build_day(CELL, history, today, "Testville", recent=recent) + payload = payloads.build_day(CELL, history, today, "Testville", recent=recent) assert payload["detail"]["date"] == today.isoformat() assert payload["detail"]["metrics"]["tmax"]["obs"] is not None @@ -129,14 +129,14 @@ def test_build_day_survives_recent_fetch_failure(history, monkeypatch): raise RuntimeError("upstream down") monkeypatch.setattr(climate, "get_recent_forecast", boom) today = datetime.date.today() - payload = views.build_day(CELL, history, today, "Testville") + payload = payloads.build_day(CELL, history, today, "Testville") assert payload["detail"]["metrics"]["tmax"]["obs"] is None # climatology only assert payload["detail"]["metrics"]["tmax"]["ladder"] is not None def test_build_forecast_only_future_days(history, recent): today = datetime.date.today() - payload = views.build_forecast(CELL, 7, history, recent, today, None) + payload = payloads.build_forecast(CELL, 7, history, recent, today, None) days = [d["date"] for d in payload["recent"]] assert days == sorted(days, reverse=True) # furthest-out first assert min(days) > today.isoformat() @@ -167,10 +167,10 @@ def _full_history(years=45, seed=5): def test_build_score_shape(): hist = _full_history() - payload = views.build_score(CELL, hist, "Testville") + payload = payloads.build_score(CELL, hist, "Testville") assert payload["api_version"] == "v2" assert payload["cell"] == CELL and payload["place"] == "Testville" - assert payload["latest"] == views.hist_end(hist) + assert payload["latest"] == payloads.hist_end(hist) s = payload["scores"] assert set(s["slices"]) == {"annual", "djf", "mam", "jja", "son"} ann = s["slices"]["annual"] @@ -180,7 +180,7 @@ def test_build_score_shape(): def test_score_key_is_the_version(): - assert views.score_key() == views.SCORE_VER + assert payloads.score_key() == payloads.SCORE_VER # Score payloads are history-only, so they ride the plain history token. hist = _full_history() - assert views.history_token(hist) == f"{views.PAYLOAD_VER}:{views.hist_end(hist)}" + assert payloads.history_token(hist) == f"{payloads.PAYLOAD_VER}:{payloads.hist_end(hist)}" diff --git a/tests/notifications/test_discord_interactions.py b/tests/notifications/test_discord_interactions.py index 35c6334..d420b41 100644 --- a/tests/notifications/test_discord_interactions.py +++ b/tests/notifications/test_discord_interactions.py @@ -8,9 +8,9 @@ import pytest from fastapi.testclient import TestClient from nacl.signing import SigningKey +from api import homepage from web import app as appmod from notifications import discord_interactions as di -from web import homepage B = "/thermograph" diff --git a/tests/web/test_api.py b/tests/web/test_api.py index 7500784..e3822ef 100644 --- a/tests/web/test_api.py +++ b/tests/web/test_api.py @@ -212,15 +212,15 @@ def test_cell_neighbors_flag_enqueues_warming(client, monkeypatch): def test_warm_cell_materializes_history_slices(client, tmp_store, monkeypatch, history): from web import app as appmod from data import grid - from web import views + from api import payloads cell = grid.snap(-33.87, 151.21) appmod._warm_cell(cell) - token = views.history_token(history) - start_ts, end_ts = views.cal_span(history, None, None, 24) + token = payloads.history_token(history) + start_ts, end_ts = payloads.cal_span(history, None, None, 24) assert tmp_store.get_payload("calendar", cell["id"], - views.calendar_key(start_ts, end_ts, 24), token) is not None + payloads.calendar_key(start_ts, end_ts, 24), token) is not None last = history["date"].max() - assert tmp_store.get_payload("day", cell["id"], views.day_key(last), token) is not None + assert tmp_store.get_payload("day", cell["id"], payloads.day_key(last), token) is not None def test_warm_cell_never_fetches_upstream(client, monkeypatch): diff --git a/tests/web/test_homepage.py b/tests/web/test_homepage.py index d31a07d..9780238 100644 --- a/tests/web/test_homepage.py +++ b/tests/web/test_homepage.py @@ -13,12 +13,12 @@ import time import pytest from fastapi.testclient import TestClient +from api import homepage from web import app as appmod from data import cities from data import climate from data import store from web import content -from web import homepage B = "/thermograph" diff --git a/warm_cities.py b/warm_cities.py index a819767..76f39f3 100644 --- a/warm_cities.py +++ b/warm_cities.py @@ -12,10 +12,10 @@ so this is an optimization, not a hard dependency. import sys import time +from api import homepage from data import cities from data import climate from data import grid -from web import homepage def main(limit: int | None = None, pace: float = 2.0) -> None: diff --git a/web/app.py b/web/app.py index dcd7cd2..81efddc 100644 --- a/web/app.py +++ b/web/app.py @@ -33,8 +33,8 @@ from data import places from core import singleton from data import store from accounts import users -from web import views -from web.schemas import UserCreate, UserRead, UserUpdate +from api import payloads +from accounts.schemas import UserCreate, UserRead, UserUpdate FRONTEND_DIR = paths.FRONTEND_DIR @@ -95,17 +95,17 @@ def _warm_cell(cell: dict) -> None: if history is None or history.is_empty(): return place = climate.reverse_geocode(cell["center_lat"], cell["center_lon"]) - token = views.history_token(history) - start_ts, end_ts = views.cal_span(history, None, None, 24) - cal_key = views.calendar_key(start_ts, end_ts, 24) + token = payloads.history_token(history) + start_ts, end_ts = payloads.cal_span(history, None, None, 24) + cal_key = payloads.calendar_key(start_ts, end_ts, 24) if store.get_payload("calendar", cell["id"], cal_key, token) is None: store.put_payload("calendar", cell["id"], cal_key, token, - views.build_calendar(cell, history, start_ts, end_ts, 24, place)) + payloads.build_calendar(cell, history, start_ts, end_ts, 24, place)) last = history["date"].max() - day_key = views.day_key(last) + day_key = payloads.day_key(last) if store.get_payload("day", cell["id"], day_key, token) is None: store.put_payload("day", cell["id"], day_key, token, - views.build_day(cell, history, last, place)) + payloads.build_day(cell, history, last, place)) def _enqueue_neighbor_warming(cell: dict) -> None: @@ -230,8 +230,8 @@ async def revalidate_static(request, call_next): # from what those return, so a cached payload expires exactly when its inputs # change and never before. The same tokens double as ETags: a client sending # If-None-Match gets an empty 304 without the payload even being loaded. -# The payloads themselves are assembled in views.py, shared with the offline -# migrate script. +# The payloads themselves are assembled in api/payloads.py, shared with the +# offline migrate script. def _etag_for(kind: str, cell_id: str, key: str, token: str) -> str: """Deterministic weak ETag from a payload's identity + validity token. Weak @@ -379,8 +379,8 @@ def api_grade( history, cache_meta, recent = _fetch_history(run, cell, recent_too=True) return _cached_response( request, run, "grade", cell, - views.grade_key(target, days, after), views.recent_token(history, cell["id"]), - lambda place: views.build_grade(cell, target, days, history, recent, + payloads.grade_key(target, days, after), payloads.recent_token(history, cell["id"]), + lambda place: payloads.build_grade(cell, target, days, history, recent, cache_meta, place, run, after=after)) @@ -412,10 +412,10 @@ def api_calendar( cell_id=cell["id"], ) as run: history, cache_meta, _ = _fetch_history(run, cell) - start_ts, end_ts = views.cal_span(history, start, end, months) + start_ts, end_ts = payloads.cal_span(history, start, end, months) def build(place): - payload = views.build_calendar(cell, history, start_ts, end_ts, months, place, run) + payload = payloads.build_calendar(cell, history, start_ts, end_ts, months, place, run) full = not cache_meta.get("cached", False) run.set(run_type="full" if full else "partial", history_source="fetch" if full else "cache") @@ -424,8 +424,8 @@ def api_calendar( # Compare loads several cells at once, so a transient reverse-geocode miss # is most likely here; cache_placeless=False keeps that miss un-persisted. return _cached_response(request, run, "calendar", cell, - views.calendar_key(start_ts, end_ts, months), - views.history_token(history), build, + payloads.calendar_key(start_ts, end_ts, months), + payloads.history_token(history), build, cache_placeless=False) @@ -457,14 +457,14 @@ def api_day( run.set(target_date=target.isoformat()) def build(place): - payload = views.build_day(cell, history, target, place, run) + payload = payloads.build_day(cell, history, target, place, run) full = not cache_meta.get("cached", False) run.set(run_type="full" if full else "partial", history_source="fetch" if full else "cache") return payload return _cached_response(request, run, "day", cell, - views.day_key(target), views.day_token(history, target), + payloads.day_key(target), payloads.day_token(history, target), build) @@ -488,14 +488,14 @@ def api_score( history, cache_meta, _ = _fetch_history(run, cell) def build(place): - payload = views.build_score(cell, history, place, run) + payload = payloads.build_score(cell, history, place, run) full = not cache_meta.get("cached", False) run.set(run_type="full" if full else "partial", history_source="fetch" if full else "cache") return payload return _cached_response(request, run, "score", cell, - views.score_key(), views.history_token(history), build) + payloads.score_key(), payloads.history_token(history), build) def api_forecast( @@ -520,13 +520,13 @@ def api_forecast( today = datetime.date.today() def build(place): - payload = views.build_forecast(cell, days, history, fc, today, place, run) + payload = payloads.build_forecast(cell, days, history, fc, today, place, run) run.set(run_type="partial") return payload return _cached_response(request, run, "forecast", cell, - views.forecast_key(today, days), - views.recent_token(history, cell["id"]), build) + payloads.forecast_key(today, days), + payloads.recent_token(history, cell["id"]), build) def api_cell( @@ -590,31 +590,31 @@ def api_cell( return {"etag": etag, "data": data} slices = {} - hist_token = views.history_token(history) + hist_token = payloads.history_token(history) # Calendar: the default last-24-months span — what the calendar view (and # the cross-view prefetch) requests first. - start_ts, end_ts = views.cal_span(history, None, None, 24) + start_ts, end_ts = payloads.cal_span(history, None, None, 24) slices["calendar"] = slice_for( - "calendar", views.calendar_key(start_ts, end_ts, 24), hist_token, - lambda: views.build_calendar(cell, history, start_ts, end_ts, 24, place, run)) + "calendar", payloads.calendar_key(start_ts, end_ts, 24), hist_token, + lambda: payloads.build_calendar(cell, history, start_ts, end_ts, 24, place, run)) if prefetch: # Latest archived day: its observation comes from history alone, so # it's buildable without the (skipped) recent bundle. slices["day"] = slice_for( - "day", views.day_key(last), hist_token, - lambda: views.build_day(cell, history, last, place, run)) + "day", payloads.day_key(last), hist_token, + lambda: payloads.build_day(cell, history, last, place, run)) else: - rf_token = views.recent_token(history, cid) + rf_token = payloads.recent_token(history, cid) slices["grade"] = slice_for( - "grade", views.grade_key(today, 14, 14), rf_token, - lambda: views.build_grade(cell, today, 14, history, recent, cache_meta, place, run, after=14)) + "grade", payloads.grade_key(today, 14, 14), rf_token, + lambda: payloads.build_grade(cell, today, 14, history, recent, cache_meta, place, run, after=14)) slices["forecast"] = slice_for( - "forecast", views.forecast_key(today, 7), rf_token, - lambda: views.build_forecast(cell, 7, history, recent, today, place, run)) + "forecast", payloads.forecast_key(today, 7), rf_token, + lambda: payloads.build_forecast(cell, 7, history, recent, today, place, run)) slices["day"] = slice_for( - "day", views.day_key(today), views.day_token(history, today), - lambda: views.build_day(cell, history, today, place, run, recent=recent)) + "day", payloads.day_key(today), payloads.day_token(history, today), + lambda: payloads.build_day(cell, history, today, place, run, recent=recent)) # The bundle's identity is the combination of its slices' identities. etag = _etag_for("cell", cid, "bundle" + (":p" if prefetch else ""), diff --git a/web/content.py b/web/content.py index 1aba1df..6499fc3 100644 --- a/web/content.py +++ b/web/content.py @@ -20,15 +20,16 @@ from fastapi.responses import PlainTextResponse from jinja2 import Environment, FileSystemLoader, select_autoescape from markupsafe import Markup +from api import homepage +from api import sitemap +from api.payloads import OBS_COLS from data import cities from data import city_events from data import climate from data import grading from data import grid from web import content_loader -from web import homepage import paths -from web.views import OBS_COLS _BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") BASE = f"/{_BASE}" if _BASE else "" @@ -336,25 +337,6 @@ def robots_txt(request: Request) -> Response: return PlainTextResponse(body) -def _sitemap_entries() -> list[tuple[str, str, str]]: - """Every indexable page as (path, changefreq, priority). Paths are relative to - BASE. Shared by the sitemap and IndexNow so the two never drift.""" - entries = [("/", "daily", "1.0")] - for p in ("/climate", "/about", "/glossary", "/calendar", "/compare", "/legend"): - entries.append((p, "weekly", "0.6")) - for slug in cities.all_slugs(): - entries.append((f"/climate/{slug}", "daily", "0.8")) - entries.append((f"/climate/{slug}/records", "monthly", "0.5")) - for m in MONTHS: - entries.append((f"/climate/{slug}/{m}", "monthly", "0.5")) - return entries - - -def public_paths() -> list[str]: - """BASE-relative paths of every indexable page — for IndexNow submission.""" - return [p for p, _, _ in _sitemap_entries()] - - @functools.lru_cache(maxsize=1) def _content_lastmod() -> str: """A stable 'content last built' date for — the newest mtime of the @@ -372,7 +354,7 @@ def sitemap_xml(request: Request) -> Response: lastmod = _content_lastmod() parts = ['', ''] - for path, cf, pr in _sitemap_entries(): + for path, cf, pr in sitemap.sitemap_entries(): parts.append( f"{base_url}{path}{lastmod}" f"{cf}{pr}"