From feaefba03c8f3e3bd195758d39c993260808b89e Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 14 Mar 2026 09:27:56 +0000 Subject: [PATCH] Limit weather API calls to cron only, increase cache TTL to 24h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web view was calling the OpenWeatherMap API directly on every request (home + all trip locations), causing >1000 calls/day. Now web_view.py uses cache_only=True everywhere so it never triggers live API calls — update.py (cron) is the sole API caller. Also warms home location cache in update_weather(), and increases TTL from 6h to 24h. Co-Authored-By: Claude Sonnet 4.6 --- agenda/weather.py | 32 +++++++++++++++++++++++++++----- update.py | 16 +++++++++++++++- web_view.py | 3 +++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/agenda/weather.py b/agenda/weather.py index 20a9817..ad0fc0c 100644 --- a/agenda/weather.py +++ b/agenda/weather.py @@ -14,7 +14,7 @@ def _cache_path(data_dir: str, lat: float, lon: float) -> str: return os.path.join(weather_dir, f"{lat:.2f}_{lon:.2f}.json") -def _is_fresh(path: str, max_age_hours: int = 6) -> bool: +def _is_fresh(path: str, max_age_hours: int = 24) -> bool: """Return True if the cache file exists and is recent enough.""" if not os.path.exists(path): return False @@ -22,14 +22,30 @@ def _is_fresh(path: str, max_age_hours: int = 6) -> bool: return age < max_age_hours * 3600 -def get_forecast(data_dir: str, api_key: str, lat: float, lon: float) -> list[dict]: - """Return 8-day daily forecast for lat/lon, caching results for 6 hours.""" +def get_forecast( + data_dir: str, + api_key: str, + lat: float, + lon: float, + cache_only: bool = False, +) -> list[dict]: + """Return 8-day daily forecast for lat/lon, caching results for 24 hours. + + If cache_only=True, return cached data if available (even if stale) and + never call the API. Returns [] if no cache exists. + """ cache_file = _cache_path(data_dir, lat, lon) if _is_fresh(cache_file): with open(cache_file) as f: return json.load(f) # type: ignore[no-any-return] + if cache_only: + if os.path.exists(cache_file): + with open(cache_file) as f: + return json.load(f) # type: ignore[no-any-return] + return [] + owm = pyowm.OWM(api_key) mgr = owm.weather_manager() result = mgr.one_call(lat=lat, lon=lon) @@ -67,17 +83,23 @@ def trip_latlon(trip: object) -> tuple[float, float] | None: return None -def get_trip_weather(data_dir: str, api_key: str, trip: object) -> dict[str, dict]: +def get_trip_weather( + data_dir: str, + api_key: str, + trip: object, + cache_only: bool = False, +) -> dict[str, dict]: """Return forecast for a trip keyed by date ISO string. Returns an empty dict if no location is known or the API call fails. + If cache_only=True, never call the API (returns stale or empty data). """ latlon = trip_latlon(trip) if not latlon: return {} lat, lon = latlon try: - forecasts = get_forecast(data_dir, api_key, lat, lon) + forecasts = get_forecast(data_dir, api_key, lat, lon, cache_only=cache_only) except Exception: return {} return {f["date"]: f for f in forecasts} diff --git a/update.py b/update.py index f6e5ef3..00460c2 100755 --- a/update.py +++ b/update.py @@ -410,7 +410,7 @@ def update_gandi(config: flask.config.Config) -> None: def update_weather(config: flask.config.Config) -> None: - """Refresh weather cache for all upcoming trips.""" + """Refresh weather cache for home and all upcoming trips.""" from datetime import date, timedelta today = date.today() @@ -425,6 +425,20 @@ def update_weather(config: flask.config.Config) -> None: seen: set[tuple[float, float]] = set() count = 0 + + # Always include home location + home_lat = config.get("HOME_LATITUDE") + home_lon = config.get("HOME_LONGITUDE") + if home_lat is not None and home_lon is not None: + seen.add((home_lat, home_lon)) + try: + agenda.weather.get_forecast( + config["DATA_DIR"], config["OPENWEATHERMAP_API_KEY"], home_lat, home_lon + ) + count += 1 + except Exception as exc: + print(f"weather update failed for home {home_lat},{home_lon}: {exc}") + for trip in upcoming: latlon = agenda.weather.trip_latlon(trip) if not latlon or latlon in seen: diff --git a/web_view.py b/web_view.py index 18a62dc..4bd5203 100755 --- a/web_view.py +++ b/web_view.py @@ -627,6 +627,7 @@ def get_home_weather() -> list[StrDict]: app.config.get("OPENWEATHERMAP_API_KEY", ""), app.config["HOME_LATITUDE"], app.config["HOME_LONGITUDE"], + cache_only=True, ) for f in forecasts: f["date_obj"] = date_type.fromisoformat(f["date"]) @@ -729,6 +730,7 @@ def trip_future_list() -> str: app.config["DATA_DIR"], app.config.get("OPENWEATHERMAP_API_KEY", ""), trip, + cache_only=True, ) for trip in shown } @@ -1049,6 +1051,7 @@ def trip_page(start: str) -> str: app.config["DATA_DIR"], app.config.get("OPENWEATHERMAP_API_KEY", ""), trip, + cache_only=True, ) return flask.render_template(