Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion tests/test_weather_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1111,7 +1111,47 @@ def handler(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"Unexpected request: {request.url}")

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
with pytest.raises(WeatherContextError, match="weather forecast failed: TypeError"):
with pytest.raises(WeatherContextError, match="weather forecast parsing failed: TypeError"):
await QWeatherProvider(
client,
authenticator=StaticAuthenticator(),
base_url="https://api.example.invalid",
).fetch(1, 2)


@pytest.mark.parametrize(
"missing_field",
(
"fxDate",
"textDay",
"textNight",
"tempMin",
"tempMax",
"windDirDay",
"windScaleDay",
"humidity",
"precip",
),
)
async def test_qweather_forecast_identifies_missing_required_field(missing_field: str) -> None:
incomplete_forecast = {key: value for key, value in _QWEATHER_DAILY_ITEM.items() if key != missing_field}

def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/v7/weather/3d"
return httpx.Response(
200,
json={
"code": "200",
"updateTime": "2026-07-13T08:00",
"daily": [incomplete_forecast],
},
)

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
with pytest.raises(
WeatherContextError,
match=f"weather forecast parsing failed: daily forecast missing required field: {missing_field}",
):
await QWeatherProvider(
client,
authenticator=StaticAuthenticator(),
Expand Down
30 changes: 28 additions & 2 deletions weather_briefing/weather_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from collections.abc import Callable
from contextlib import suppress
from dataclasses import replace
from typing import Any, Protocol, runtime_checkable
from typing import Any, Protocol, TypeGuard, runtime_checkable

import httpx
import jwt
Expand All @@ -30,6 +30,10 @@ class WeatherContextError(RuntimeError):
"""Raised when a weather source is unavailable or violates its contract."""


class _QWeatherResponseError(ValueError):
"""Raised for safe, code-defined QWeather response contract errors."""


class WeatherContextProvider(Protocol):
async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: ...

Expand Down Expand Up @@ -181,6 +185,7 @@ async def fetch(
"QWeather returned a non-success weather status "
f"code={_safe_api_status(weather_payload.get('code'))}"
)
operation = "weather forecast parsing"
daily_forecasts = weather_payload.get("daily", ())
first_forecast_date = next(
(
Expand Down Expand Up @@ -243,6 +248,8 @@ async def fetch(
)
except WeatherContextError:
raise
except _QWeatherResponseError as exc:
raise WeatherContextError(f"QWeather {operation} failed: {exc}") from None
except (httpx.HTTPError, jwt.PyJWTError, KeyError, TypeError, ValueError) as exc:
detail = _safe_provider_error(exc)
raise WeatherContextError(f"QWeather {operation} failed: {detail}") from None
Expand Down Expand Up @@ -695,7 +702,26 @@ def _format_qweather_lifestyle(item: dict[str, object]) -> str:
return f"{name}({category}):{text}"


def _format_qweather_day(item: dict[str, object]) -> str:
def _is_string_keyed_dict(value: object) -> TypeGuard[dict[str, object]]:
return isinstance(value, dict) and all(isinstance(key, str) for key in value)


def _format_qweather_day(item: object) -> str:
if not _is_string_keyed_dict(item):
raise TypeError("daily forecast entries must be objects")
required_fields = (
"fxDate",
"textDay",
"textNight",
"tempMin",
"tempMax",
"windDirDay",
"windScaleDay",
"humidity",
"precip",
)
if missing_field := next((field for field in required_fields if field not in item), None):
raise _QWeatherResponseError(f"daily forecast missing required field: {missing_field}")
return (
f"{item['fxDate']}:{item['textDay']}转{item['textNight']},"
f"{item['tempMin']}~{item['tempMax']}℃,"
Expand Down