Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
covering grid snapping/round-trips, grading percentiles/bands/windows/
dry streaks, the places index (norm, one-edit matchers, search,
corrections), the derived store (token validity, cache=False, degraded
mode), and route-level API tests over a faked climate layer — routing,
validation, ETag/304 revalidation, store replay, the /cell bundle, and
the v1/v2 aliases. The API tests would have caught the /place
AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
and an API health probe (page-only curl can't catch route wiring
faults); deploy-dev.yml now runs the same build gate before deploying
direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
both workflows (previously per-PR groups could interleave two deploys
to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
|
|
|
"""Route-level tests over the FastAPI app with the weather/geocode layer faked —
|
|
|
|
|
they exercise the real routing, validation, derived-store and ETag plumbing, and
|
|
|
|
|
would catch wiring regressions (e.g. a handler calling a deleted helper)."""
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
from web import app as appmod
|
|
|
|
|
from data import climate
|
2026-07-23 14:34:51 +00:00
|
|
|
from data import places
|
Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
covering grid snapping/round-trips, grading percentiles/bands/windows/
dry streaks, the places index (norm, one-edit matchers, search,
corrections), the derived store (token validity, cache=False, degraded
mode), and route-level API tests over a faked climate layer — routing,
validation, ETag/304 revalidation, store replay, the /cell bundle, and
the v1/v2 aliases. The API tests would have caught the /place
AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
and an API health probe (page-only curl can't catch route wiring
faults); deploy-dev.yml now runs the same build gate before deploying
direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
both workflows (previously per-PR groups could interleave two deploys
to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def client(monkeypatch, history, recent):
|
|
|
|
|
monkeypatch.setattr(climate, "get_history",
|
Migrate backend dataframe layer from pandas to polars (#90)
* Migrate backend dataframe layer from pandas to polars
Replace pandas with polars across the backend, dropping both pandas and its
pyarrow parquet engine from the dependency set. numpy stays (the grading
percentile math is unchanged).
- climate.py: parquet IO, source→frame mappings, cache read/topup on polars.
New _normalize_read casts the cached `date` column to pl.Date (older files
were written by pandas as datetime64[ns]); frames now unify missing values as
null so the grading boundary drops them consistently across sources.
- grading.py: keep the numpy percentile core; swap the frame→numpy bridge to
.to_numpy()/.drop_nulls(), day-of-year/year to polars dt expressions, and the
per-row loop to iter_rows(named=True).
- views.py: filter/anti-join/concat replace boolean-mask, isin and pd.concat;
scalar dates are stdlib datetime.date; a local _months_before helper replaces
DateOffset(months=) for the calendar-range default.
- app.py, migrate.py: request-date parsing uses datetime.date, removing pandas
from the endpoint and migrate layers entirely.
- The date column is pl.Date end to end, eliminating the pandas normalize() calls
and comparing cleanly against stdlib dates.
Payloads are unchanged: calendar, day, grade and forecast responses are
byte-for-byte identical to the pandas implementation on the same cached record.
Tests ported to polars fixtures, with added coverage for the combined feels-like
fallback, calendar month-offset (month-end/leap), and the concat/dedup
"fresher source wins" rule.
* Port notify.py to polars after merging dev's account system
Merge origin/dev (accounts + notification subscriptions) and carry the pandas→
polars migration into the newly added notify.py, which the merge brought in still
using pandas — with pandas removed from requirements this broke its import.
- notify.py: _candidate_rows filters/sorts the recent bundle with polars
expressions and returns iter_rows dicts; date scalars are datetime.date;
history/recent emptiness via is_empty().
- test_notify.py: synthetic history/rows built with polars + datetime.
2026-07-15 19:07:38 +00:00
|
|
|
lambda cell: (history.clone(), {"cached": True, "cache_age_days": 3}))
|
|
|
|
|
monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.clone())
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone())
|
Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
covering grid snapping/round-trips, grading percentiles/bands/windows/
dry streaks, the places index (norm, one-edit matchers, search,
corrections), the derived store (token validity, cache=False, degraded
mode), and route-level API tests over a faked climate layer — routing,
validation, ETag/304 revalidation, store replay, the /cell bundle, and
the v1/v2 aliases. The API tests would have caught the /place
AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
and an API health probe (page-only curl can't catch route wiring
faults); deploy-dev.yml now runs the same build gate before deploying
direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
both workflows (previously per-PR groups could interleave two deploys
to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
|
|
|
monkeypatch.setattr(climate, "recent_stamp", lambda cell_id: "rs-test")
|
|
|
|
|
monkeypatch.setattr(climate, "reverse_geocode", lambda lat, lon: "Testville, Washington")
|
|
|
|
|
return TestClient(appmod.app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Q = {"lat": 47.6062, "lon": -122.3321}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_place_serves_any_point_worldwide(client):
|
|
|
|
|
for q in (Q, {"lat": 48.8566, "lon": 2.3522}, {"lat": -33.8688, "lon": 151.2093}):
|
|
|
|
|
r = client.get("/thermograph/api/v2/place", params=q)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json()["place"] == "Testville, Washington"
|
|
|
|
|
assert set(r.json()["cell"]) == {"center_lat", "center_lon"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_grade_shape_and_conditional_revalidation(client, history):
|
|
|
|
|
r = client.get("/thermograph/api/v2/grade", params=Q)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["place"] == "Testville, Washington"
|
|
|
|
|
assert body["climatology"]["tmax"] is not None
|
|
|
|
|
days = [d["date"] for d in body["recent"]]
|
|
|
|
|
assert days == sorted(days, reverse=True) # newest first
|
|
|
|
|
assert body["recent"][0]["tmax"]["grade"]
|
|
|
|
|
|
|
|
|
|
etag = r.headers["etag"]
|
|
|
|
|
r304 = client.get("/thermograph/api/v2/grade", params=Q,
|
|
|
|
|
headers={"If-None-Match": etag})
|
|
|
|
|
assert r304.status_code == 304 and r304.headers["etag"] == etag
|
|
|
|
|
|
|
|
|
|
# Without the validator the derived store replays the exact same bytes.
|
|
|
|
|
r2 = client.get("/thermograph/api/v2/grade", params=Q)
|
|
|
|
|
assert r2.status_code == 200 and r2.content == r.content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_grade_is_aliased_across_api_versions(client):
|
|
|
|
|
for prefix in ("api", "api/v1", "api/v2"):
|
|
|
|
|
assert client.get(f"/thermograph/{prefix}/grade", params=Q).status_code == 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_query_validation_rejects_out_of_range(client):
|
|
|
|
|
assert client.get("/thermograph/api/v2/grade",
|
|
|
|
|
params={"lat": 999, "lon": 0}).status_code == 422
|
2026-07-22 18:42:22 +00:00
|
|
|
assert client.get("/thermograph/api/v2/grade",
|
|
|
|
|
params={**Q, "days": 0}).status_code == 422
|
2026-07-22 18:41:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_version_reports_backend_contract(client):
|
|
|
|
|
r = client.get("/thermograph/api/version")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert {"backend_version", "min_frontend", "payload_ver"} <= body.keys()
|
|
|
|
|
assert body["backend_version"] == "2"
|
Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
covering grid snapping/round-trips, grading percentiles/bands/windows/
dry streaks, the places index (norm, one-edit matchers, search,
corrections), the derived store (token validity, cache=False, degraded
mode), and route-level API tests over a faked climate layer — routing,
validation, ETag/304 revalidation, store replay, the /cell bundle, and
the v1/v2 aliases. The API tests would have caught the /place
AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
and an API health probe (page-only curl can't catch route wiring
faults); deploy-dev.yml now runs the same build gate before deploying
direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
both workflows (previously per-PR groups could interleave two deploys
to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
|
|
|
|
|
|
|
|
|
2026-07-24 23:13:36 +00:00
|
|
|
def test_malformed_date_is_rejected(client):
|
|
|
|
|
# A malformed or non-calendar date must be a 422, not a 500: fromisoformat
|
|
|
|
|
# raises ValueError on these and the handler used to leak it as a crash.
|
|
|
|
|
for bad in ("notadate", "2026-13-40", "2026-02-30"):
|
|
|
|
|
for route in ("grade", "day"):
|
|
|
|
|
r = client.get(f"/thermograph/api/v2/{route}", params={**Q, "date": bad})
|
|
|
|
|
assert r.status_code == 422, (route, bad, r.status_code)
|
|
|
|
|
# A valid date still works.
|
|
|
|
|
assert client.get("/thermograph/api/v2/grade",
|
|
|
|
|
params={**Q, "date": "2026-07-11"}).status_code == 200
|
|
|
|
|
|
|
|
|
|
|
Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
covering grid snapping/round-trips, grading percentiles/bands/windows/
dry streaks, the places index (norm, one-edit matchers, search,
corrections), the derived store (token validity, cache=False, degraded
mode), and route-level API tests over a faked climate layer — routing,
validation, ETag/304 revalidation, store replay, the /cell bundle, and
the v1/v2 aliases. The API tests would have caught the /place
AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
and an API health probe (page-only curl can't catch route wiring
faults); deploy-dev.yml now runs the same build gate before deploying
direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
both workflows (previously per-PR groups could interleave two deploys
to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
|
|
|
def test_day_detail_and_ladders(client, history):
|
|
|
|
|
r = client.get("/thermograph/api/v2/day", params=Q)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
Migrate backend dataframe layer from pandas to polars (#90)
* Migrate backend dataframe layer from pandas to polars
Replace pandas with polars across the backend, dropping both pandas and its
pyarrow parquet engine from the dependency set. numpy stays (the grading
percentile math is unchanged).
- climate.py: parquet IO, source→frame mappings, cache read/topup on polars.
New _normalize_read casts the cached `date` column to pl.Date (older files
were written by pandas as datetime64[ns]); frames now unify missing values as
null so the grading boundary drops them consistently across sources.
- grading.py: keep the numpy percentile core; swap the frame→numpy bridge to
.to_numpy()/.drop_nulls(), day-of-year/year to polars dt expressions, and the
per-row loop to iter_rows(named=True).
- views.py: filter/anti-join/concat replace boolean-mask, isin and pd.concat;
scalar dates are stdlib datetime.date; a local _months_before helper replaces
DateOffset(months=) for the calendar-range default.
- app.py, migrate.py: request-date parsing uses datetime.date, removing pandas
from the endpoint and migrate layers entirely.
- The date column is pl.Date end to end, eliminating the pandas normalize() calls
and comparing cleanly against stdlib dates.
Payloads are unchanged: calendar, day, grade and forecast responses are
byte-for-byte identical to the pandas implementation on the same cached record.
Tests ported to polars fixtures, with added coverage for the combined feels-like
fallback, calendar month-offset (month-end/leap), and the concat/dedup
"fresher source wins" rule.
* Port notify.py to polars after merging dev's account system
Merge origin/dev (accounts + notification subscriptions) and carry the pandas→
polars migration into the newly added notify.py, which the merge brought in still
using pandas — with pandas removed from requirements this broke its import.
- notify.py: _candidate_rows filters/sorts the recent bundle with polars
expressions and returns iter_rows dicts; date scalars are datetime.date;
history/recent emptiness via is_empty().
- test_notify.py: synthetic history/rows built with polars + datetime.
2026-07-15 19:07:38 +00:00
|
|
|
assert body["latest"] == history["date"].max().isoformat()
|
Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
covering grid snapping/round-trips, grading percentiles/bands/windows/
dry streaks, the places index (norm, one-edit matchers, search,
corrections), the derived store (token validity, cache=False, degraded
mode), and route-level API tests over a faked climate layer — routing,
validation, ETag/304 revalidation, store replay, the /cell bundle, and
the v1/v2 aliases. The API tests would have caught the /place
AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
and an API health probe (page-only curl can't catch route wiring
faults); deploy-dev.yml now runs the same build gate before deploying
direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
both workflows (previously per-PR groups could interleave two deploys
to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
|
|
|
tmax = body["detail"]["metrics"]["tmax"]
|
|
|
|
|
assert tmax["ladder"]["tiers"][0]["c"] == "rec-hot"
|
|
|
|
|
assert tmax["obs"]["grade"]
|
|
|
|
|
|
|
|
|
|
|
Single-source cache identity; shared fetch preamble and cache flow (#43)
The derived store's key/token formats existed in three places — each
endpoint, api_cell's slice assembly, and migrate.py — where any drift
would silently split the cache (endpoints missing rows the bundle wrote,
migrate materializing rows nobody reads). They are now defined once in
views.py (grade_key/calendar_key/day_key/forecast_key, history_token/
recent_token/day_token) and consumed everywhere, with a pinning test so
a format change is always deliberate.
The four data endpoints shared two copy-pasted sequences, now helpers:
- _fetch_history: history (+ optional recent bundle) fetch with upstream
failures mapped to clean HTTP errors and an empty record to 404.
- _cached_response: If-None-Match 304 / token-valid store replay /
build + persist + serve, with calendar's dont-persist-placeless rule
as an explicit flag.
Each endpoint is now its audit run + identity + a build callback (~10
lines); api_day's hourly-token special case moved into day_token. New
tests: identity format pins, rate-limit 503 parametrized across all five
data routes, prefetch=1 never touching upstream (cold 204, warm
history-only slices), and calendar's placeless-payload retry behavior.
2026-07-11 19:49:15 +00:00
|
|
|
@pytest.mark.parametrize("path", ["grade", "calendar", "day", "forecast", "cell"])
|
|
|
|
|
def test_every_data_route_maps_rate_limit_to_503(client, monkeypatch, path):
|
Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
covering grid snapping/round-trips, grading percentiles/bands/windows/
dry streaks, the places index (norm, one-edit matchers, search,
corrections), the derived store (token validity, cache=False, degraded
mode), and route-level API tests over a faked climate layer — routing,
validation, ETag/304 revalidation, store replay, the /cell bundle, and
the v1/v2 aliases. The API tests would have caught the /place
AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
and an API health probe (page-only curl can't catch route wiring
faults); deploy-dev.yml now runs the same build gate before deploying
direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
both workflows (previously per-PR groups could interleave two deploys
to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
|
|
|
def rate_limited(cell):
|
2026-07-11 19:53:46 +00:00
|
|
|
raise climate.WeatherUnavailable(climate.limit_message(False))
|
Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
covering grid snapping/round-trips, grading percentiles/bands/windows/
dry streaks, the places index (norm, one-edit matchers, search,
corrections), the derived store (token validity, cache=False, degraded
mode), and route-level API tests over a faked climate layer — routing,
validation, ETag/304 revalidation, store replay, the /cell bundle, and
the v1/v2 aliases. The API tests would have caught the /place
AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
and an API health probe (page-only curl can't catch route wiring
faults); deploy-dev.yml now runs the same build gate before deploying
direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
both workflows (previously per-PR groups could interleave two deploys
to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
|
|
|
monkeypatch.setattr(climate, "get_history", rate_limited)
|
Single-source cache identity; shared fetch preamble and cache flow (#43)
The derived store's key/token formats existed in three places — each
endpoint, api_cell's slice assembly, and migrate.py — where any drift
would silently split the cache (endpoints missing rows the bundle wrote,
migrate materializing rows nobody reads). They are now defined once in
views.py (grade_key/calendar_key/day_key/forecast_key, history_token/
recent_token/day_token) and consumed everywhere, with a pinning test so
a format change is always deliberate.
The four data endpoints shared two copy-pasted sequences, now helpers:
- _fetch_history: history (+ optional recent bundle) fetch with upstream
failures mapped to clean HTTP errors and an empty record to 404.
- _cached_response: If-None-Match 304 / token-valid store replay /
build + persist + serve, with calendar's dont-persist-placeless rule
as an explicit flag.
Each endpoint is now its audit run + identity + a build callback (~10
lines); api_day's hourly-token special case moved into day_token. New
tests: identity format pins, rate-limit 503 parametrized across all five
data routes, prefetch=1 never touching upstream (cold 204, warm
history-only slices), and calendar's placeless-payload retry behavior.
2026-07-11 19:49:15 +00:00
|
|
|
# A distinct spot per route: a store row cached by an earlier test would
|
|
|
|
|
# otherwise be checked only after the (failing) history fetch anyway.
|
|
|
|
|
r = client.get(f"/thermograph/api/v2/{path}", params={"lat": 51.5, "lon": -0.1})
|
Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
covering grid snapping/round-trips, grading percentiles/bands/windows/
dry streaks, the places index (norm, one-edit matchers, search,
corrections), the derived store (token validity, cache=False, degraded
mode), and route-level API tests over a faked climate layer — routing,
validation, ETag/304 revalidation, store replay, the /cell bundle, and
the v1/v2 aliases. The API tests would have caught the /place
AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
and an API health probe (page-only curl can't catch route wiring
faults); deploy-dev.yml now runs the same build gate before deploying
direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
both workflows (previously per-PR groups could interleave two deploys
to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
|
|
|
assert r.status_code == 503
|
|
|
|
|
assert "rate-limited" in r.json()["detail"]
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 19:53:46 +00:00
|
|
|
def test_upstream_failure_classification(client, monkeypatch):
|
|
|
|
|
q = {"lat": 52.52, "lon": 13.4}
|
|
|
|
|
|
|
|
|
|
def daily_quota(cell):
|
|
|
|
|
raise climate.WeatherUnavailable(climate.limit_message(True), daily=True)
|
|
|
|
|
monkeypatch.setattr(climate, "get_history", daily_quota)
|
|
|
|
|
r = client.get("/thermograph/api/v2/grade", params=q)
|
|
|
|
|
assert r.status_code == 503 and "tomorrow" in r.json()["detail"]
|
|
|
|
|
|
|
|
|
|
# A raw upstream 429 that no fetcher classified still maps to a clean 503.
|
|
|
|
|
def raw_429(cell):
|
|
|
|
|
e = RuntimeError("upstream said no")
|
|
|
|
|
e.response = type("R", (), {"status_code": 429})()
|
|
|
|
|
raise e
|
|
|
|
|
monkeypatch.setattr(climate, "get_history", raw_429)
|
|
|
|
|
r = client.get("/thermograph/api/v2/grade", params=q)
|
|
|
|
|
assert r.status_code == 503 and "rate-limited" in r.json()["detail"]
|
|
|
|
|
|
|
|
|
|
# Anything else is a genuine upstream fault: 502 with the raw error.
|
|
|
|
|
def boom(cell):
|
|
|
|
|
raise RuntimeError("parquet cache corrupted")
|
|
|
|
|
monkeypatch.setattr(climate, "get_history", boom)
|
|
|
|
|
r = client.get("/thermograph/api/v2/grade", params=q)
|
|
|
|
|
assert r.status_code == 502 and "parquet cache corrupted" in r.json()["detail"]
|
|
|
|
|
|
|
|
|
|
|
Single-source cache identity; shared fetch preamble and cache flow (#43)
The derived store's key/token formats existed in three places — each
endpoint, api_cell's slice assembly, and migrate.py — where any drift
would silently split the cache (endpoints missing rows the bundle wrote,
migrate materializing rows nobody reads). They are now defined once in
views.py (grade_key/calendar_key/day_key/forecast_key, history_token/
recent_token/day_token) and consumed everywhere, with a pinning test so
a format change is always deliberate.
The four data endpoints shared two copy-pasted sequences, now helpers:
- _fetch_history: history (+ optional recent bundle) fetch with upstream
failures mapped to clean HTTP errors and an empty record to 404.
- _cached_response: If-None-Match 304 / token-valid store replay /
build + persist + serve, with calendar's dont-persist-placeless rule
as an explicit flag.
Each endpoint is now its audit run + identity + a build callback (~10
lines); api_day's hourly-token special case moved into day_token. New
tests: identity format pins, rate-limit 503 parametrized across all five
data routes, prefetch=1 never touching upstream (cold 204, warm
history-only slices), and calendar's placeless-payload retry behavior.
2026-07-11 19:49:15 +00:00
|
|
|
def test_cell_prefetch_never_fetches_upstream(client, monkeypatch):
|
|
|
|
|
def boom(cell):
|
|
|
|
|
raise AssertionError("prefetch=1 must never fetch weather upstream")
|
|
|
|
|
monkeypatch.setattr(climate, "get_history", boom)
|
|
|
|
|
monkeypatch.setattr(climate, "get_recent_forecast", boom)
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
|
|
|
|
|
r = client.get("/thermograph/api/v2/cell", params={**Q, "prefetch": 1})
|
|
|
|
|
assert r.status_code == 204 # cold cell: no body, no quota spent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cell_prefetch_builds_history_slices_only(client):
|
|
|
|
|
r = client.get("/thermograph/api/v2/cell", params={"lat": 10.0, "lon": 10.0, "prefetch": 1})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert set(r.json()["slices"]) == {"calendar", "day"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_calendar_placeless_payload_is_not_persisted(client, monkeypatch):
|
|
|
|
|
q = {"lat": -10.0, "lon": 20.0, "months": 2}
|
|
|
|
|
monkeypatch.setattr(climate, "reverse_geocode", lambda lat, lon: None)
|
|
|
|
|
r = client.get("/thermograph/api/v2/calendar", params=q)
|
|
|
|
|
assert r.status_code == 200 and r.json()["place"] is None
|
|
|
|
|
# The placeless payload wasn't cached, so the next request retries the
|
|
|
|
|
# label instead of replaying bare coordinates for the life of the token.
|
|
|
|
|
monkeypatch.setattr(climate, "reverse_geocode", lambda lat, lon: "Resolved, Now")
|
|
|
|
|
assert client.get("/thermograph/api/v2/calendar", params=q).json()["place"] == "Resolved, Now"
|
|
|
|
|
|
|
|
|
|
|
Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
covering grid snapping/round-trips, grading percentiles/bands/windows/
dry streaks, the places index (norm, one-edit matchers, search,
corrections), the derived store (token validity, cache=False, degraded
mode), and route-level API tests over a faked climate layer — routing,
validation, ETag/304 revalidation, store replay, the /cell bundle, and
the v1/v2 aliases. The API tests would have caught the /place
AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
and an API health probe (page-only curl can't catch route wiring
faults); deploy-dev.yml now runs the same build gate before deploying
direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
both workflows (previously per-PR groups could interleave two deploys
to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
|
|
|
def test_calendar_compact_range(client, history):
|
|
|
|
|
r = client.get("/thermograph/api/v2/calendar", params={**Q, "months": 2})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
Migrate backend dataframe layer from pandas to polars (#90)
* Migrate backend dataframe layer from pandas to polars
Replace pandas with polars across the backend, dropping both pandas and its
pyarrow parquet engine from the dependency set. numpy stays (the grading
percentile math is unchanged).
- climate.py: parquet IO, source→frame mappings, cache read/topup on polars.
New _normalize_read casts the cached `date` column to pl.Date (older files
were written by pandas as datetime64[ns]); frames now unify missing values as
null so the grading boundary drops them consistently across sources.
- grading.py: keep the numpy percentile core; swap the frame→numpy bridge to
.to_numpy()/.drop_nulls(), day-of-year/year to polars dt expressions, and the
per-row loop to iter_rows(named=True).
- views.py: filter/anti-join/concat replace boolean-mask, isin and pd.concat;
scalar dates are stdlib datetime.date; a local _months_before helper replaces
DateOffset(months=) for the calendar-range default.
- app.py, migrate.py: request-date parsing uses datetime.date, removing pandas
from the endpoint and migrate layers entirely.
- The date column is pl.Date end to end, eliminating the pandas normalize() calls
and comparing cleanly against stdlib dates.
Payloads are unchanged: calendar, day, grade and forecast responses are
byte-for-byte identical to the pandas implementation on the same cached record.
Tests ported to polars fixtures, with added coverage for the combined feels-like
fallback, calendar month-offset (month-end/leap), and the concat/dedup
"fresher source wins" rule.
* Port notify.py to polars after merging dev's account system
Merge origin/dev (accounts + notification subscriptions) and carry the pandas→
polars migration into the newly added notify.py, which the merge brought in still
using pandas — with pandas removed from requirements this broke its import.
- notify.py: _candidate_rows filters/sorts the recent bundle with polars
expressions and returns iter_rows dicts; date scalars are datetime.date;
history/recent emptiness via is_empty().
- test_notify.py: synthetic history/rows built with polars + datetime.
2026-07-15 19:07:38 +00:00
|
|
|
assert body["range"]["end"] == history["date"].max().isoformat()
|
Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
covering grid snapping/round-trips, grading percentiles/bands/windows/
dry streaks, the places index (norm, one-edit matchers, search,
corrections), the derived store (token validity, cache=False, degraded
mode), and route-level API tests over a faked climate layer — routing,
validation, ETag/304 revalidation, store replay, the /cell bundle, and
the v1/v2 aliases. The API tests would have caught the /place
AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
and an API health probe (page-only curl can't catch route wiring
faults); deploy-dev.yml now runs the same build gate before deploying
direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
both workflows (previously per-PR groups could interleave two deploys
to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
|
|
|
day = body["days"][0]
|
|
|
|
|
assert {"date", "dsr", "tmax", "tmin", "precip"} <= set(day)
|
|
|
|
|
assert set(day["tmax"]) == {"v", "pct", "c", "g"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_forecast_grades_future_days(client):
|
|
|
|
|
r = client.get("/thermograph/api/v2/forecast", params=Q)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["forecast"] is True
|
|
|
|
|
days = [d["date"] for d in body["recent"]]
|
|
|
|
|
assert days and days == sorted(days, reverse=True) # furthest-out first
|
|
|
|
|
assert min(days) > body["target_date"] # strictly future
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cell_bundle_matches_per_view_payloads(client):
|
|
|
|
|
r = client.get("/thermograph/api/v2/cell", params=Q)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
slices = r.json()["slices"]
|
|
|
|
|
assert set(slices) == {"calendar", "grade", "forecast", "day"}
|
|
|
|
|
for s in slices.values():
|
|
|
|
|
assert s["etag"] and s["data"]
|
|
|
|
|
# The grade slice must be byte-for-byte what /grade serves (same store row).
|
|
|
|
|
grade = client.get("/thermograph/api/v2/grade", params=Q)
|
|
|
|
|
assert grade.json() == slices["grade"]["data"]
|
|
|
|
|
assert grade.headers["etag"] == slices["grade"]["etag"]
|
|
|
|
|
|
|
|
|
|
r304 = client.get("/thermograph/api/v2/cell", params=Q,
|
|
|
|
|
headers={"If-None-Match": r.headers["etag"]})
|
|
|
|
|
assert r304.status_code == 304
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 14:34:51 +00:00
|
|
|
_LOCAL_HIT = [{"name": "Seattle", "admin1": "Washington", "country": "United States",
|
|
|
|
|
"country_code": "US", "lat": 47.6, "lon": -122.33, "population": 737015,
|
|
|
|
|
"match": "prefix"}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_geocode_local_first_skips_network(client, monkeypatch):
|
|
|
|
|
"""A local GeoNames hit answers /geocode with no outbound Nominatim call, and
|
|
|
|
|
the internal prefix/fuzzy tag is stripped from the wire shape."""
|
|
|
|
|
monkeypatch.setattr(places, "search", lambda q, limit=5: list(_LOCAL_HIT))
|
|
|
|
|
|
|
|
|
|
def _boom(*a, **k):
|
|
|
|
|
raise AssertionError("Nominatim must not be called when the local index hits")
|
|
|
|
|
monkeypatch.setattr(climate, "geocode_nominatim", _boom)
|
|
|
|
|
|
|
|
|
|
r = client.get("/thermograph/api/v2/geocode", params={"q": "seattle"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
res = r.json()["results"]
|
|
|
|
|
assert res[0]["name"] == "Seattle"
|
|
|
|
|
assert "match" not in res[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_geocode_falls_back_to_nominatim_on_local_miss(client, monkeypatch):
|
|
|
|
|
"""A local miss (or a still-loading index) falls back to Nominatim /search —
|
|
|
|
|
the path that covers neighbourhoods/postcodes the cities dump lacks."""
|
|
|
|
|
monkeypatch.setattr(places, "search", lambda q, limit=5: []) # genuine miss
|
|
|
|
|
nom = [{"name": "West Seattle", "admin1": "Washington", "country": "United States",
|
|
|
|
|
"country_code": "US", "lat": 47.57, "lon": -122.38, "population": None}]
|
|
|
|
|
called = []
|
|
|
|
|
monkeypatch.setattr(climate, "geocode_nominatim",
|
|
|
|
|
lambda q, count=5: (called.append(q), nom)[1])
|
|
|
|
|
|
|
|
|
|
r = client.get("/thermograph/api/v2/geocode", params={"q": "west seattle"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json()["results"][0]["name"] == "West Seattle"
|
|
|
|
|
assert called == ["west seattle"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_suggest_is_local_only_no_network(client, monkeypatch):
|
|
|
|
|
"""Autocomplete is served purely from the local index — even a reachable
|
|
|
|
|
Nominatim must never be hit per keystroke."""
|
|
|
|
|
monkeypatch.setattr(places, "search", lambda q, limit=5: list(_LOCAL_HIT))
|
|
|
|
|
|
|
|
|
|
def _boom(*a, **k):
|
|
|
|
|
raise AssertionError("/suggest must not make an outbound geocoder call")
|
|
|
|
|
monkeypatch.setattr(climate, "geocode_nominatim", _boom)
|
|
|
|
|
|
|
|
|
|
r = client.get("/thermograph/api/v2/suggest", params={"q": "seattle"})
|
Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
covering grid snapping/round-trips, grading percentiles/bands/windows/
dry streaks, the places index (norm, one-edit matchers, search,
corrections), the derived store (token validity, cache=False, degraded
mode), and route-level API tests over a faked climate layer — routing,
validation, ETag/304 revalidation, store replay, the /cell bundle, and
the v1/v2 aliases. The API tests would have caught the /place
AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
and an API health probe (page-only curl can't catch route wiring
faults); deploy-dev.yml now runs the same build gate before deploying
direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
both workflows (previously per-PR groups could interleave two deploys
to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["results"][0]["name"] == "Seattle"
|
|
|
|
|
assert body["corrected"] is None
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 20:47:31 +00:00
|
|
|
def test_cell_neighbors_flag_enqueues_warming(client, monkeypatch):
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
from web import app as appmod
|
2026-07-11 20:47:31 +00:00
|
|
|
monkeypatch.setattr(appmod, "_WARM_SEEN", {})
|
|
|
|
|
# No lifespan in TestClient without a context manager, so no worker drains
|
|
|
|
|
# the queue — enqueued cells just accumulate for inspection.
|
|
|
|
|
monkeypatch.setattr(appmod, "_warm_queue", __import__("queue").Queue())
|
|
|
|
|
r = client.get("/thermograph/api/v2/cell", params={"lat": 35.0, "lon": 25.0, "neighbors": 1})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert appmod._warm_queue.qsize() == 8
|
|
|
|
|
assert len(appmod._WARM_SEEN) == 8
|
|
|
|
|
# Same spot again within the TTL: nothing new enqueued.
|
|
|
|
|
client.get("/thermograph/api/v2/cell", params={"lat": 35.0, "lon": 25.0, "neighbors": 1},
|
|
|
|
|
headers={"If-None-Match": r.headers["etag"]})
|
|
|
|
|
assert appmod._warm_queue.qsize() == 8
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_warm_cell_materializes_history_slices(client, tmp_store, monkeypatch, history):
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
from web import app as appmod
|
|
|
|
|
from data import grid
|
2026-07-21 16:09:35 +00:00
|
|
|
from api import payloads
|
2026-07-11 20:47:31 +00:00
|
|
|
cell = grid.snap(-33.87, 151.21)
|
|
|
|
|
appmod._warm_cell(cell)
|
2026-07-21 16:09:35 +00:00
|
|
|
token = payloads.history_token(history)
|
|
|
|
|
start_ts, end_ts = payloads.cal_span(history, None, None, 24)
|
2026-07-11 20:47:31 +00:00
|
|
|
assert tmp_store.get_payload("calendar", cell["id"],
|
2026-07-21 16:09:35 +00:00
|
|
|
payloads.calendar_key(start_ts, end_ts, 24), token) is not None
|
Migrate backend dataframe layer from pandas to polars (#90)
* Migrate backend dataframe layer from pandas to polars
Replace pandas with polars across the backend, dropping both pandas and its
pyarrow parquet engine from the dependency set. numpy stays (the grading
percentile math is unchanged).
- climate.py: parquet IO, source→frame mappings, cache read/topup on polars.
New _normalize_read casts the cached `date` column to pl.Date (older files
were written by pandas as datetime64[ns]); frames now unify missing values as
null so the grading boundary drops them consistently across sources.
- grading.py: keep the numpy percentile core; swap the frame→numpy bridge to
.to_numpy()/.drop_nulls(), day-of-year/year to polars dt expressions, and the
per-row loop to iter_rows(named=True).
- views.py: filter/anti-join/concat replace boolean-mask, isin and pd.concat;
scalar dates are stdlib datetime.date; a local _months_before helper replaces
DateOffset(months=) for the calendar-range default.
- app.py, migrate.py: request-date parsing uses datetime.date, removing pandas
from the endpoint and migrate layers entirely.
- The date column is pl.Date end to end, eliminating the pandas normalize() calls
and comparing cleanly against stdlib dates.
Payloads are unchanged: calendar, day, grade and forecast responses are
byte-for-byte identical to the pandas implementation on the same cached record.
Tests ported to polars fixtures, with added coverage for the combined feels-like
fallback, calendar month-offset (month-end/leap), and the concat/dedup
"fresher source wins" rule.
* Port notify.py to polars after merging dev's account system
Merge origin/dev (accounts + notification subscriptions) and carry the pandas→
polars migration into the newly added notify.py, which the merge brought in still
using pandas — with pandas removed from requirements this broke its import.
- notify.py: _candidate_rows filters/sorts the recent bundle with polars
expressions and returns iter_rows dicts; date scalars are datetime.date;
history/recent emptiness via is_empty().
- test_notify.py: synthetic history/rows built with polars + datetime.
2026-07-15 19:07:38 +00:00
|
|
|
last = history["date"].max()
|
2026-07-21 16:09:35 +00:00
|
|
|
assert tmp_store.get_payload("day", cell["id"], payloads.day_key(last), token) is not None
|
2026-07-11 20:47:31 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_warm_cell_never_fetches_upstream(client, monkeypatch):
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
from web import app as appmod
|
2026-07-11 20:47:31 +00:00
|
|
|
def boom(cell):
|
|
|
|
|
raise AssertionError("warming must never fetch weather upstream")
|
|
|
|
|
monkeypatch.setattr(climate, "get_history", boom)
|
|
|
|
|
monkeypatch.setattr(climate, "get_recent_forecast", boom)
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
|
|
|
|
|
appmod._warm_cell({"id": "1_1", "center_lat": 0.03, "center_lon": 0.03}) # cold: no-op
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _score_history(years=45, seed=5):
|
|
|
|
|
import datetime
|
|
|
|
|
import numpy as np
|
|
|
|
|
import polars as pl
|
|
|
|
|
end = datetime.date(2026, 7, 11)
|
|
|
|
|
start = datetime.date(end.year - years, end.month, end.day)
|
|
|
|
|
dates = [start + datetime.timedelta(days=i) for i in range((end - start).days + 1)]
|
|
|
|
|
n = len(dates)
|
|
|
|
|
rng = np.random.default_rng(seed)
|
|
|
|
|
doy = np.array([d.timetuple().tm_yday for d in dates])
|
|
|
|
|
tmax = 55 + 30 * np.sin((doy - 100) / 366.0 * 2 * np.pi) + rng.normal(0, 8, n)
|
|
|
|
|
return pl.DataFrame({
|
|
|
|
|
"date": dates, "tmax": np.round(tmax, 1), "tmin": np.round(tmax - 15, 1),
|
|
|
|
|
"feels": np.round(tmax + 1, 1), "humid": np.round(np.clip(12 + rng.normal(0, 3, n), 1, None), 1),
|
|
|
|
|
"wetbulb": np.round(tmax - 12, 1), "wind": np.round(np.clip(8 + rng.normal(0, 3, n), 0, None), 1),
|
|
|
|
|
"gust": np.round(np.clip(16 + rng.normal(0, 5, n), 0, None), 1),
|
|
|
|
|
"precip": np.where(rng.random(n) < 0.3, 0.2, 0.0),
|
|
|
|
|
}).with_columns(pl.col("date").dt.ordinal_day().cast(pl.Int16).alias("doy"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def score_client(monkeypatch):
|
|
|
|
|
hist = _score_history()
|
|
|
|
|
monkeypatch.setattr(climate, "get_history",
|
|
|
|
|
lambda cell: (hist.clone(), {"cached": True, "cache_age_days": 3}))
|
|
|
|
|
monkeypatch.setattr(climate, "reverse_geocode", lambda lat, lon: "Testville, Washington")
|
|
|
|
|
return TestClient(appmod.app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_score_shape_and_conditional_revalidation(score_client):
|
|
|
|
|
r = score_client.get("/thermograph/api/v2/score", params=Q)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["place"] == "Testville, Washington"
|
|
|
|
|
s = body["scores"]
|
|
|
|
|
assert set(s["slices"]) == {"annual", "djf", "mam", "jja", "son"}
|
|
|
|
|
assert s["slices"]["annual"]["overall"]["score"] is not None
|
|
|
|
|
|
|
|
|
|
etag = r.headers["etag"]
|
|
|
|
|
r304 = score_client.get("/thermograph/api/v2/score", params=Q,
|
|
|
|
|
headers={"If-None-Match": etag})
|
|
|
|
|
assert r304.status_code == 304 and r304.headers["etag"] == etag
|
|
|
|
|
|
|
|
|
|
# Second GET replays the exact same bytes from the derived store.
|
|
|
|
|
r2 = score_client.get("/thermograph/api/v2/score", params=Q)
|
|
|
|
|
assert r2.status_code == 200 and r2.content == r.content
|