"""Open-Meteo provider (see app/weather/__init__.py's PROVIDERS registry) -- free geocoding + forecast APIs, no API key, no signup, no per-request quota to manage. Backs both the calendar widget's embedded weather strip (fetch_daily_forecast/weather_category, its original shape, untouched) and the standalone weather widget's per-mode fetches below (fetch_current/ fetch_hourly/fetch_daily, which return already-normalized {"category": ...} entries from the shared category set instead of a raw WMO code). Pure functions -- no ORM, no FastAPI Depends -- same testability philosophy as calendar_feed.py/caldav_client.py. """ from __future__ import annotations import httpx from . import WeatherFetchError HTTP_TIMEOUT_S = 15.0 GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" FORECAST_URL = "https://api.open-meteo.com/v1/forecast" CHECK_INTERVAL_S = 3 * 60 * 60 # weather doesn't need calendar_feed's 20-minute cadence FORECAST_DAYS = 14 # comfortably covers the week view's furthest browse-forward # Open-Meteo's WMO weather codes (https://open-meteo.com/en/docs), grouped # into the handful of icon categories calendar_render.py actually draws. _CODE_CATEGORIES = { 0: "clear", 1: "partly_cloudy", 2: "partly_cloudy", 3: "cloudy", 45: "fog", 48: "fog", 51: "rain", 53: "rain", 55: "rain", 56: "rain", 57: "rain", 61: "rain", 63: "rain", 65: "rain", 66: "rain", 67: "rain", 80: "rain", 81: "rain", 82: "rain", 71: "snow", 73: "snow", 75: "snow", 77: "snow", 85: "snow", 86: "snow", 95: "thunderstorm", 96: "thunderstorm", 99: "thunderstorm", } def weather_category(code: int) -> str: """Falls back to "cloudy" for any WMO code Open-Meteo might add later that isn't in the table above -- an unrecognized code shouldn't drop a day's weather entirely, just render with a generic icon.""" return _CODE_CATEGORIES.get(code, "cloudy") # Open-Meteo's geocoder matches on the bare place name only -- "Portland, # OR" returns zero results even though "Portland" alone returns three # (OR/ME/IN, disambiguated by population-ranked order). So a ", " # or ", " qualifier is split off client-side and used to filter # among the candidates instead of being sent as part of the search term. _US_STATE_ABBREVIATIONS = { "al": "alabama", "ak": "alaska", "az": "arizona", "ar": "arkansas", "ca": "california", "co": "colorado", "ct": "connecticut", "de": "delaware", "fl": "florida", "ga": "georgia", "hi": "hawaii", "id": "idaho", "il": "illinois", "in": "indiana", "ia": "iowa", "ks": "kansas", "ky": "kentucky", "la": "louisiana", "me": "maine", "md": "maryland", "ma": "massachusetts", "mi": "michigan", "mn": "minnesota", "ms": "mississippi", "mo": "missouri", "mt": "montana", "ne": "nebraska", "nv": "nevada", "nh": "new hampshire", "nj": "new jersey", "nm": "new mexico", "ny": "new york", "nc": "north carolina", "nd": "north dakota", "oh": "ohio", "ok": "oklahoma", "or": "oregon", "pa": "pennsylvania", "ri": "rhode island", "sc": "south carolina", "sd": "south dakota", "tn": "tennessee", "tx": "texas", "ut": "utah", "vt": "vermont", "va": "virginia", "wa": "washington", "wv": "west virginia", "wi": "wisconsin", "wy": "wyoming", "dc": "district of columbia", } def _matches_qualifier(result: dict, qualifier: str) -> bool: q = qualifier.strip().lower() expanded = _US_STATE_ABBREVIATIONS.get(q, q) admin1 = (result.get("admin1") or "").lower() country = (result.get("country") or "").lower() country_code = (result.get("country_code") or "").lower() return expanded in admin1 or expanded in country or q == country_code def geocode_city(name: str) -> dict: """Best-match {"label", "latitude", "longitude"} for a free-text city name, optionally qualified with a state/country (e.g. "Portland, OR") via Open-Meteo's geocoder. label is the resolved place name (city + admin1/country when available), not necessarily what the user typed -- shown back so they can confirm it found the right place before it's saved.""" query, _, qualifier = name.partition(",") query, qualifier = query.strip(), qualifier.strip() try: resp = httpx.get(GEOCODE_URL, params={"name": query, "count": 10}, timeout=HTTP_TIMEOUT_S) resp.raise_for_status() data = resp.json() except httpx.HTTPError as e: raise WeatherFetchError(str(e)) from e results = data.get("results") or [] if not results: raise WeatherFetchError(f"No location found matching {name!r}") if qualifier: qualified = [r for r in results if _matches_qualifier(r, qualifier)] if not qualified: raise WeatherFetchError(f"No location found matching {name!r}") results = qualified r = results[0] parts = [r["name"]] if r.get("admin1"): parts.append(r["admin1"]) if r.get("country"): parts.append(r["country"]) return {"label": ", ".join(parts), "latitude": r["latitude"], "longitude": r["longitude"]} def fetch_daily_forecast(latitude: float, longitude: float, units: str) -> dict[str, dict]: """{"YYYY-MM-DD": {"code": int, "high": float, "low": float}, ...} for the next FORECAST_DAYS days, already in `units` ("fahrenheit"/"celsius") -- Open-Meteo converts server-side, so there's no client-side unit math to get wrong.""" try: resp = httpx.get(FORECAST_URL, params={ "latitude": latitude, "longitude": longitude, "daily": "weathercode,temperature_2m_max,temperature_2m_min", "temperature_unit": units, "timezone": "auto", "forecast_days": FORECAST_DAYS, }, timeout=HTTP_TIMEOUT_S) resp.raise_for_status() daily = resp.json()["daily"] return { day: {"code": code, "high": high, "low": low} for day, code, high, low in zip( daily["time"], daily["weathercode"], daily["temperature_2m_max"], daily["temperature_2m_min"] ) } except (httpx.HTTPError, KeyError) as e: raise WeatherFetchError(str(e)) from e # --- Standalone weather widget fetches (app/widgets/weather.py) -------- # # Unlike fetch_daily_forecast above (kept as-is for the calendar widget's # embedded strip, which does its own weather_category(code) lookup), # these return already-normalized {"category": ...} entries so # app/weather_render.py's build_* functions never need to know which # provider supplied the data (see app/weather/nws.py, which normalizes # its own icon/text shape to the same category set). def fetch_current(latitude: float, longitude: float, units: str) -> dict: """{"temp": float, "category": str} for right now.""" try: resp = httpx.get(FORECAST_URL, params={ "latitude": latitude, "longitude": longitude, "current": "temperature_2m,weathercode", "temperature_unit": units, "timezone": "auto", }, timeout=HTTP_TIMEOUT_S) resp.raise_for_status() current = resp.json()["current"] return {"temp": current["temperature_2m"], "category": weather_category(current["weathercode"])} except (httpx.HTTPError, KeyError) as e: raise WeatherFetchError(str(e)) from e def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]: """[{"time": ISO 8601 string, "temp": float, "category": str}, ...], one entry per hour, for the next `hours` hours (Open-Meteo's hourly data is always 1-hour resolution regardless of how far apart the ticks a caller actually wants to display are -- see app/weather_render.py's build_hourly, which samples every Nth entry).""" forecast_days = max(1, -(-hours // 24)) # ceil division -- enough days to cover `hours` try: resp = httpx.get(FORECAST_URL, params={ "latitude": latitude, "longitude": longitude, "hourly": "temperature_2m,weathercode", "temperature_unit": units, "timezone": "auto", "forecast_days": forecast_days, }, timeout=HTTP_TIMEOUT_S) resp.raise_for_status() hourly = resp.json()["hourly"] return [ {"time": t, "temp": temp, "category": weather_category(code)} for t, temp, code in zip(hourly["time"], hourly["temperature_2m"], hourly["weathercode"]) ][:hours] except (httpx.HTTPError, KeyError) as e: raise WeatherFetchError(str(e)) from e def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]: """{"YYYY-MM-DD": {"high": float, "low": float, "category": str}, ...} -- same request as fetch_daily_forecast, just normalized to a category instead of a raw WMO code, and clamped to `days` (Open- Meteo's own real max is FORECAST_DAYS).""" clamped = max(1, min(days, FORECAST_DAYS)) raw = fetch_daily_forecast(latitude, longitude, units) return { day: {"high": d["high"], "low": d["low"], "category": weather_category(d["code"])} for day, d in list(raw.items())[:clamped] }