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
-1
View File
@@ -9,7 +9,6 @@ photos/calendar/whiteboard/weather widget system to the device.
CURRENT TODO
-add more actions for buttons (i.e. change widget/layout)
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
-add Environment Canada as a weather widget provider (app/weather/ -- station/grid-lookup API, more involved than Open-Meteo/NWS)
-widget border option
-battery life widget
-sharing layouts with linked users
+17 -13
View File
@@ -227,21 +227,25 @@ which share the same `{widget_id}`-parameterized path shape).
**Providers** (`app/weather/`, a dispatch registry over pluggable
implementations mirroring `app/widgets/` itself): `WeatherWidgetConfig.
provider` selects which of `app/weather.PROVIDERS` actually fetches --
`"open_meteo"` (worldwide, no API key) or `"nws"` (api.weather.gov, US
`"open_meteo"` (worldwide, no API key), `"nws"` (api.weather.gov, US
only, no API key, approximates "current" with the first hourly forecast
period rather than a real station observation). Every provider function
returns already-normalized `{"category": ...}` entries (one of `clear`/
`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/`thunderstorm`) so
`app/weather_render.py`'s drawing code never needs to know which
provider supplied an entry. `geocode_city` (name -> lat/lon) always goes
through Open-Meteo's free geocoder regardless of which provider is
chosen to fetch with the result.
period rather than a real station observation), or `"ec"` (Environment
Canada, api.weather.gc.ca's MSC GeoMet OGC API, Canada only, no API key).
Every provider function returns already-normalized `{"category": ...}`
entries (one of `clear`/`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/
`thunderstorm`) so `app/weather_render.py`'s drawing code never needs to
know which provider supplied an entry. `geocode_city` (name -> lat/lon)
always goes through Open-Meteo's free geocoder regardless of which
provider is chosen to fetch with the result.
**Environment Canada is a deliberate gap, not an oversight** -- its free
API (the MSC GeoMet OGC service) is built around station/grid lookups,
not simple lat/lon REST like the two providers above, and would have
meaningfully expanded the initial pass. Next provider to add if this
gets revisited.
EC's `citypageweather-realtime` collection is only queryable by bounding
box (OGC API - Features), not a direct by-coordinate endpoint -- unlike
Open-Meteo/NWS's simple lat/lon REST, `app/weather/ec.py`'s
`_nearest_site` widens the box progressively and picks the closest site
by straight-line distance, rejecting anything beyond 300 km (calibrated
against a real bug caught in development: an unconditional "nearest
site, however far" matched a Miami, FL query to a site in Ontario,
1824 km away, once the box widened enough to cover the whole country).
`app/weather_render.py` holds every weather-related drawing primitive:
`draw_cloud`/`draw_weather_icon`/`draw_weather_row` (extracted out of
@@ -18,7 +18,7 @@
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 4px;">National Weather Service only covers US locations.</p>
<p class="sub" style="margin-top: 4px;">National Weather Service only covers US locations; Environment Canada only covers Canadian locations.</p>
<label>Units
<select id="weather_units">
<option value="fahrenheit" {% if weather_cfg.units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
+12 -9
View File
@@ -1,11 +1,10 @@
"""Weather provider registry -- the app/widgets/ "dispatch registry over
pluggable implementations" pattern applied to weather data sources
instead of widget types. Open-Meteo (app/weather/open_meteo.py, no API
key, worldwide) and NWS (app/weather/nws.py, no API key, US-only) are the
two providers wired up now; Environment Canada is a documented next step
(docs/widgets.md), not included yet -- its free API is built around
station/grid lookups (the MSC GeoMet OGC service), not simple lat/lon
REST like these two, and would have meaningfully expanded this pass.
instead of widget types. Three providers, all free/no API key:
Open-Meteo (app/weather/open_meteo.py, worldwide), NWS (app/weather/
nws.py, US-only), and Environment Canada (app/weather/ec.py, Canada-only,
its own bbox/nearest-site lookup shape rather than simple lat/lon REST --
see that module's own docstring).
geocode_city stays Open-Meteo-backed regardless of which provider is
chosen to actually fetch forecasts -- it's just free-text-name-to-lat/lon
@@ -33,7 +32,7 @@ class WeatherFetchError(Exception):
endpoints, get_or_refresh_*_for_widget) decide what to do."""
from . import nws, open_meteo # noqa: E402 -- after WeatherFetchError, which both submodules import
from . import ec, nws, open_meteo # noqa: E402 -- after WeatherFetchError, which all three submodules import
# Re-exported for existing call sites (routers/common.py, routers/
# api_widgets.py, calendar_render.py) -- all Open-Meteo-only and
@@ -45,8 +44,12 @@ from .open_meteo import ( # noqa: E402,F401
weather_category,
)
PROVIDERS = {"open_meteo": open_meteo, "nws": nws}
PROVIDER_LABELS = {"open_meteo": "Open-Meteo", "nws": "National Weather Service (US)"}
PROVIDERS = {"open_meteo": open_meteo, "nws": nws, "ec": ec}
PROVIDER_LABELS = {
"open_meteo": "Open-Meteo",
"nws": "National Weather Service (US)",
"ec": "Environment Canada",
}
def fetch_current(provider: str, latitude: float, longitude: float, units: str) -> dict:
+227
View File
@@ -0,0 +1,227 @@
"""Environment Canada (ECCC MSC GeoMet OGC API, api.weather.gc.ca)
provider -- Canada-only, free, no API key. Unlike NWS's grid-point
lookup or Open-Meteo's plain lat/lon REST, EC's `citypageweather-realtime`
collection (an OGC API - Features collection, the modern replacement for
the old dd.weatheroffice.gc.ca XML feed -- that host no longer resolves)
is only queryable by bounding box, not a direct by-coordinate endpoint --
this widens the box progressively until it finds at least one site, then
picks the nearest by straight-line distance. This station/bbox-lookup
shape (not simple lat/lon REST) is exactly why EC was documented as a
follow-up rather than shipped alongside Open-Meteo/NWS in the first
pass -- see docs/widgets.md.
EC's numeric icon codes are its own set, distinct from WMO's (Open-
Meteo) or NWS's icon-URL condition codes. _category_from_code_and_text
below only maps the codes actually confirmed against live data (see
this module's own tests, captured from real api.weather.gc.ca
responses), falling back to the same keyword-match-on-condition-text
safety net app/weather/nws.py uses for anything unmapped -- correctness
comes from the text fallback, the numeric table is just a fast path.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py/app/weather/open_meteo.py/nws.py.
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta
import httpx
from . import WeatherFetchError
HTTP_TIMEOUT_S = 15.0
BASE_URL = "https://api.weather.gc.ca"
COLLECTION = "citypageweather-realtime"
HEADERS = {"User-Agent": "espresso_frame-weather-widget (self-hosted photo frame project)"}
# Progressively wider bounding boxes (degrees) around the target point --
# EC's ~844 sites are dense near cities but sparse in the north, so a
# small box can come back empty even for a real Canadian location. 25
# degrees (~2000-2700 km depending on latitude) is already far beyond
# _MAX_DISTANCE_KM, so there's no point widening past it.
_BBOX_PADDINGS_DEG = (1.0, 3.0, 8.0, 25.0)
# See _nearest_site's own docstring for why this exists and how it was
# calibrated (a real Miami query matched 1824 km away without it).
_MAX_DISTANCE_KM = 300
_ICON_CODE_CATEGORIES = {
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",
}
_TEXT_CATEGORY_KEYWORDS = [
("thunderstorm", "thunderstorm"), ("tstm", "thunderstorm"), ("tornado", "thunderstorm"),
("flurr", "snow"), ("snow", "snow"), ("sleet", "snow"), ("ice pellet", "snow"), ("hail", "snow"),
("freezing", "snow"),
("rain", "rain"), ("shower", "rain"), ("drizzle", "rain"),
("fog", "fog"), ("haze", "fog"), ("mist", "fog"), ("smoke", "fog"),
("overcast", "cloudy"), ("cloudy", "cloudy"),
("clear", "clear"), ("sunny", "clear"), ("fair", "clear"),
]
def _category_from_code_and_text(code: int | None, text: str) -> str:
if code is not None and code in _ICON_CODE_CATEGORIES:
return _ICON_CODE_CATEGORIES[code]
lowered = text.lower()
for keyword, category in _TEXT_CATEGORY_KEYWORDS:
if keyword in lowered:
return category
return "cloudy" # same generic-icon fallback Open-Meteo/NWS both use
def _convert_temp(celsius: float, units: str) -> float:
"""EC's citypage feed reports temperatures in Celsius only (its
unitType is always "metric" in this feed) -- convert to the widget's
requested units, no-op if celsius was actually asked for."""
return celsius * 9 / 5 + 32 if units == "fahrenheit" else celsius
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
r = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
def _items(bbox: str) -> list[dict]:
try:
resp = httpx.get(
f"{BASE_URL}/collections/{COLLECTION}/items",
params={"f": "json", "bbox": bbox, "limit": 50},
headers=HEADERS, timeout=HTTP_TIMEOUT_S,
)
resp.raise_for_status()
return resp.json()["features"]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def _nearest_site(latitude: float, longitude: float) -> dict:
"""This location's nearest Environment Canada citypage site's
`properties` dict -- widens the bounding box until it finds one
within _MAX_DISTANCE_KM, trying every padding rather than stopping
at the first non-empty box: since each padding's box is a superset
of the previous one's, a wider box's nearest match can only be the
same distance or closer, never farther, so an early non-empty box
whose nearest site is still too far away doesn't mean a closer one
isn't waiting just outside it.
Without the distance cutoff, an unconditional "just take whatever's
nearest" happily matches a US or overseas location to some real EC
site thousands of km away (confirmed live: Miami matched to
Leamington, Ontario, 1824 km off) instead of reporting that EC
simply doesn't cover this location. 300 km is generous enough for a
legitimate rural-Canada query against EC's sparse northern coverage
(~844 sites total) while still correctly rejecting a non-Canadian
one -- a US border city like Seattle, genuinely ~100 km from the
nearest EC site in Victoria, BC, still passes. Deliberately NOT a
single whole-country query instead of progressive widening: that
collection response is ~29 MB for cheap, in-city lookups fetching a
handful of nearby sites."""
best_distance_km = None
for pad in _BBOX_PADDINGS_DEG:
bbox = f"{longitude - pad},{latitude - pad},{longitude + pad},{latitude + pad}"
features = _items(bbox)
if not features:
continue
nearest = min(
features,
key=lambda f: _haversine_km(
latitude, longitude, f["geometry"]["coordinates"][1], f["geometry"]["coordinates"][0]
),
)
best_distance_km = _haversine_km(
latitude, longitude, nearest["geometry"]["coordinates"][1], nearest["geometry"]["coordinates"][0]
)
if best_distance_km <= _MAX_DISTANCE_KM:
return nearest["properties"]
raise WeatherFetchError("No Environment Canada site found near this location (is it in Canada?)")
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
props = _nearest_site(latitude, longitude)
cc = props["currentConditions"]
temp_c = cc["temperature"]["value"]["en"]
code = (cc.get("iconCode") or {}).get("value")
text = (cc.get("condition") or {}).get("en") or ""
return {"temp": _convert_temp(temp_c, units), "category": _category_from_code_and_text(code, text)}
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
"""EC's hourlyForecastGroup is a fixed 24-hour window (unlike Open-
Meteo/NWS's own longer hourly ranges) -- `hours` just caps how much
of it gets returned, same as the other providers."""
props = _nearest_site(latitude, longitude)
entries = props["hourlyForecastGroup"]["hourlyForecasts"]
result = []
for h in entries[:hours]:
temp_c = h["temperature"]["value"]["en"]
code = (h.get("iconCode") or {}).get("value")
text = (h.get("condition") or {}).get("en") or ""
result.append({
"time": h["timestamp"], "temp": _convert_temp(temp_c, units),
"category": _category_from_code_and_text(code, text),
})
return result
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
"""Pairs EC's named day/night periods ("Today"/"Tonight"/"Tuesday"/
"Tuesday night"/...) into calendar dates by walking them in issued
order -- unlike NWS's periods (which carry a real startTime), EC's
forecast periods are named relative to "today", not dated, so the
date is inferred: a "night" period shares its preceding day period's
date, any other period starts the calendar day after the previous
one (the forecastGroup's own issued-at timestamp anchors day 0)."""
props = _nearest_site(latitude, longitude)
group = props["forecastGroup"]
issued = datetime.fromisoformat(group["timestamp"]["en"].replace("Z", "+00:00")).date()
by_date: dict[str, dict] = {}
order: list[str] = []
current_day = None
for period in group["forecasts"]:
name = period["period"]["textForecastName"]["en"].strip().lower()
if "night" in name:
day_date = current_day or issued
else:
day_date = issued if current_day is None else current_day + timedelta(days=1)
current_day = day_date
key = day_date.isoformat()
entry = by_date.setdefault(key, {"category": None})
if key not in order:
order.append(key)
temps = period.get("temperatures", {}).get("temperature") or []
if temps:
temp = _convert_temp(temps[0]["value"]["en"], units)
if temps[0]["class"]["en"] == "high":
entry["high"] = temp
else:
entry["low"] = temp
if entry["category"] is None:
icon = (period.get("abbreviatedForecast") or {}).get("icon") or {}
text = (period.get("abbreviatedForecast") or {}).get("textSummary", {}).get("en") or ""
entry["category"] = _category_from_code_and_text(icon.get("value"), text)
result = {}
for key in order[:max(1, days)]:
entry = by_date[key]
if "high" not in entry and "low" not in entry:
continue
result[key] = {
"high": entry.get("high", entry.get("low")),
"low": entry.get("low", entry.get("high")),
"category": entry["category"] or "cloudy",
}
return result
+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")