thermograph/warm_cities.py
Emi Griffith d17ac794fd 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

59 lines
2.4 KiB
Python

"""Pre-warm the archives for the curated city set (backend/cities.json) so the
crawlable /climate pages render from cache and a search-engine crawl never bursts
the archive API quota. Run at/after deploy:
python warm_cities.py [--limit N] [--pace SECONDS]
Idempotent: a cell whose archive is already cached is skipped. Fetches are paced
(default 2s) to stay well under the archive API's rate limit. A cell that still
has no cached archive when its page is first requested self-heals via get_history,
so this is an optimization, not a hard dependency.
"""
import sys
import time
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:
todo = cities.all_cities()
if limit:
todo = todo[:limit]
fetched = skipped = failed = 0
for i, c in enumerate(todo, 1):
cell = grid.snap(c["lat"], c["lon"])
cached = climate.load_cached_history(cell)
if cached is not None and not cached.is_empty():
skipped += 1
continue
try:
climate.get_history(cell) # fetch + cache the ~45-yr archive
climate.get_recent_forecast(cell) # + the recent/forecast bundle (today block)
fetched += 1
print(f"[{i}/{len(todo)}] warmed {c['slug']} ({cell['id']})")
time.sleep(pace)
except Exception as e: # noqa: BLE001 - keep going; the page self-heals later
failed += 1
print(f"[{i}/{len(todo)}] FAILED {c['slug']}: {e}")
time.sleep(pace)
print(f"done: fetched={fetched} skipped(cached)={skipped} failed={failed}")
# Rebuild the homepage's "unusual right now" feed from whatever is now warm.
# Runs here as well as on the notifier loop so a fresh deploy has a populated
# feed immediately, and so it still refreshes when the notifier is disabled.
try:
feed = homepage.refresh()
print(f"homepage feed: {feed['considered']} cities graded, "
f"{len(feed['ranked'])} ranked")
except Exception as e: # noqa: BLE001 - the homepage renders without the feed
print(f"homepage feed: FAILED {e}")
if __name__ == "__main__":
args = sys.argv[1:]
lim = int(args[args.index("--limit") + 1]) if "--limit" in args else None
pc = float(args[args.index("--pace") + 1]) if "--pace" in args else 2.0
main(limit=lim, pace=pc)