Add Environment Canada as a third weather provider
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 4m32s
Build and push server image / deploy (push) Successful in 49s

app/weather/ec.py -- api.weather.gc.ca's MSC GeoMet OGC API
(citypageweather-realtime collection), the modern replacement for the
old dd.weatheroffice.gc.ca XML feed (that host no longer resolves).
Unlike Open-Meteo/NWS's simple lat/lon REST, this collection is only
queryable by bounding box, so _nearest_site widens the box
progressively and picks the closest of the ~844 sites by straight-line
distance -- capped at 300km, calibrated against a real bug caught in
development where an unconditional "nearest site, however far" matched
a Miami, FL query to a site in Ontario 1824km away once the box widened
to cover the whole country.

EC's own numeric icon codes get a small confirmed-against-live-data
mapping table plus the same keyword-on-condition-text fallback NWS
already uses for anything unmapped. Daily periods are named ("Today"/
"Tonight"/"Tuesday"/...) rather than dated, so dates are inferred by
walking them in issued order.

Verified end-to-end against the real live API (Toronto, rural
Saskatchewan, a US border city, and a rejected far-away match) and
through the browser (daily mode, composited panel preview). Test
fixtures mirror the actual response shapes captured live. docs/
widgets.md and CLAUDE.md's TODO updated -- EC is no longer a documented
gap.
This commit is contained in:
2026-07-27 16:50:40 +00:00
parent 52ebafab78
commit 270979949f
6 changed files with 425 additions and 28 deletions
+168 -4
View File
@@ -1,15 +1,17 @@
"""app/weather/'s provider modules -- pure-logic, no HTTP/DB: monkeypatches
httpx.get with canned responses. Covers category normalization (Open-
Meteo's WMO codes, NWS's icon-URL/text shapes) landing on the same shared
category set, and each provider's fetch_current/fetch_hourly/fetch_daily
shape."""
Meteo's WMO codes, NWS's icon-URL/text shapes, EC's numeric icon codes)
landing on the same shared category set, and each provider's
fetch_current/fetch_hourly/fetch_daily shape. The EC fixtures below are
trimmed-down real response shapes captured live against
api.weather.gc.ca (Toronto, 2026-07-27), not guessed."""
from __future__ import annotations
import httpx
import pytest
from app.weather import WeatherFetchError, nws, open_meteo
from app.weather import WeatherFetchError, ec, nws, open_meteo
class _FakeResponse:
@@ -200,3 +202,165 @@ def test_nws_raises_weather_fetch_error_when_points_lookup_fails(monkeypatch):
monkeypatch.setattr(httpx, "get", _raise)
with pytest.raises(WeatherFetchError):
nws.fetch_current(45.5, -122.6, "fahrenheit")
# --- ec ---------------------------------------------------------------------
def _ec_feature(lon: float, lat: float, properties: dict) -> dict:
return {"type": "Feature", "geometry": {"type": "Point", "coordinates": [lon, lat]}, "properties": properties}
def _ec_current_properties(temp_c=28.8, icon_code=3, condition="Mostly Cloudy") -> dict:
return {
"currentConditions": {
"temperature": {"value": {"en": temp_c}},
"iconCode": {"value": icon_code},
"condition": {"en": condition},
},
"hourlyForecastGroup": {"hourlyForecasts": []},
"forecastGroup": {"timestamp": {"en": "2026-07-27T15:00:00Z"}, "forecasts": []},
}
def _ec_items_response(features: list[dict]) -> _FakeResponse:
return _FakeResponse({"features": features})
@pytest.mark.parametrize("code,expected", [
(0, "clear"), (1, "clear"), (30, "clear"),
(2, "partly_cloudy"), (5, "partly_cloudy"), (31, "partly_cloudy"), (32, "partly_cloudy"),
(3, "cloudy"), (4, "cloudy"), (10, "cloudy"), (33, "cloudy"),
(6, "rain"), (12, "rain"), (28, "rain"), (36, "rain"),
(9, "thunderstorm"), (19, "thunderstorm"), (39, "thunderstorm"),
(24, "fog"),
])
def test_ec_category_from_known_codes(code, expected):
assert ec._category_from_code_and_text(code, "irrelevant text") == expected
@pytest.mark.parametrize("text,expected", [
("Periods of snow", "snow"), ("Flurries", "snow"), ("Freezing rain", "snow"),
("Periods of rain", "rain"), ("Chance of showers", "rain"), ("Drizzle", "rain"),
("Risk of thunderstorms", "thunderstorm"), ("Tornado warning", "thunderstorm"),
("Patchy fog", "fog"), ("Mainly sunny", "clear"), ("Something unrelated", "cloudy"),
])
def test_ec_category_from_text_fallback_for_unmapped_code(text, expected):
assert ec._category_from_code_and_text(9999, text) == expected
def test_ec_convert_temp_celsius_to_fahrenheit():
assert ec._convert_temp(0, "fahrenheit") == 32
assert ec._convert_temp(0, "celsius") == 0
def test_ec_fetch_current_converts_and_categorizes(monkeypatch):
props = _ec_current_properties(temp_c=28.8, icon_code=3, condition="Mostly Cloudy")
monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
[_ec_feature(-79.38, 43.65, props)]
))
result = ec.fetch_current(43.65, -79.38, "fahrenheit")
assert result == {"temp": pytest.approx(83.84), "category": "cloudy"}
def test_ec_fetch_hourly_respects_hours_limit(monkeypatch):
hourly = [
{"timestamp": f"2026-07-27T{h:02d}:00:00Z", "temperature": {"value": {"en": 20 + h}},
"iconCode": {"value": 2}, "condition": {"en": "A mix of sun and cloud"}}
for h in range(24)
]
props = _ec_current_properties()
props["hourlyForecastGroup"] = {"hourlyForecasts": hourly}
monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
[_ec_feature(-79.38, 43.65, props)]
))
result = ec.fetch_hourly(43.65, -79.38, "celsius", hours=5)
assert len(result) == 5
assert result[0]["category"] == "partly_cloudy"
def _ec_forecast_period(name: str, temp_class: str, temp_c: float, icon_code: int, summary: str) -> dict:
return {
"period": {"textForecastName": {"en": name}},
"temperatures": {"temperature": [{"value": {"en": temp_c}, "class": {"en": temp_class}}]},
"abbreviatedForecast": {"icon": {"value": icon_code}, "textSummary": {"en": summary}},
}
def test_ec_fetch_daily_pairs_day_night_periods_by_walking_order(monkeypatch):
props = _ec_current_properties()
props["forecastGroup"] = {
"timestamp": {"en": "2026-07-27T15:00:00Z"},
"forecasts": [
_ec_forecast_period("Today", "high", 29, 9, "Chance of showers"),
_ec_forecast_period("Tonight", "low", 15, 30, "Clear"),
_ec_forecast_period("Tuesday", "high", 25, 2, "Partly cloudy"),
_ec_forecast_period("Tuesday night", "low", 17, 32, "Cloudy periods"),
],
}
monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
[_ec_feature(-79.38, 43.65, props)]
))
result = ec.fetch_daily(43.65, -79.38, "celsius", days=2)
assert result == {
"2026-07-27": {"high": 29, "low": 15, "category": "thunderstorm"},
"2026-07-28": {"high": 25, "low": 17, "category": "partly_cloudy"},
}
def test_ec_fetch_daily_clamps_to_however_many_dates_came_back(monkeypatch):
props = _ec_current_properties()
props["forecastGroup"] = {
"timestamp": {"en": "2026-07-27T15:00:00Z"},
"forecasts": [_ec_forecast_period("Today", "high", 29, 9, "Chance of showers")],
}
monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
[_ec_feature(-79.38, 43.65, props)]
))
result = ec.fetch_daily(43.65, -79.38, "celsius", days=7)
assert len(result) == 1
def test_ec_nearest_site_widens_bbox_until_within_max_distance(monkeypatch):
"""A site right at the query point should be picked up by the very
first (smallest) bounding box -- no need to widen."""
props = _ec_current_properties()
calls = []
def _get(url, params, headers, timeout):
calls.append(params["bbox"])
return _ec_items_response([_ec_feature(-79.38, 43.65, props)])
monkeypatch.setattr(httpx, "get", _get)
result = ec.fetch_current(43.65, -79.38, "celsius")
assert result["temp"] == pytest.approx(28.8)
assert len(calls) == 1 # first (smallest) padding already found it
def test_ec_nearest_site_rejects_a_match_beyond_max_distance(monkeypatch):
"""Regression test: without a distance cutoff, progressively
widening the bbox until non-empty eventually matches ANY location on
Earth to some real EC site once the box is big enough (confirmed
live: a Miami, FL query matched Leamington, Ontario, 1824 km away).
A site ~785 km from the query point must be rejected as "not
covered", not returned as if it were a legitimate nearby match."""
far_props = _ec_current_properties()
widest_bbox = f"{-ec._BBOX_PADDINGS_DEG[-1]},{-ec._BBOX_PADDINGS_DEG[-1]},{ec._BBOX_PADDINGS_DEG[-1]},{ec._BBOX_PADDINGS_DEG[-1]}"
def _get(url, params, headers, timeout):
# Only the widest padding's box actually reaches the far site --
# every narrower one comes back empty, same as a genuine gap.
if params["bbox"] == widest_bbox:
return _ec_items_response([_ec_feature(5.0, 5.0, far_props)])
return _ec_items_response([])
monkeypatch.setattr(httpx, "get", _get)
with pytest.raises(WeatherFetchError):
ec.fetch_current(0.0, 0.0, "celsius")
def test_ec_raises_weather_fetch_error_on_http_failure(monkeypatch):
def _raise(*a, **kw):
raise httpx.ConnectError("boom")
monkeypatch.setattr(httpx, "get", _raise)
with pytest.raises(WeatherFetchError):
ec.fetch_current(43.65, -79.38, "fahrenheit")