diff --git a/tests/test_weather_context.py b/tests/test_weather_context.py index 87fd0986..7c3517db 100644 --- a/tests/test_weather_context.py +++ b/tests/test_weather_context.py @@ -1,4 +1,5 @@ import base64 +import logging from typing import TypeGuard import httpx @@ -21,6 +22,7 @@ OpenMeteoProvider, QWeatherJWTAuthenticator, QWeatherProvider, + UnsupportedForecastDateError, WeatherContextError, _format_qweather_day, _format_qweather_lifestyle, @@ -834,6 +836,56 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh assert "sensitive upstream detail" not in caplog.text +async def test_logged_provider_logs_unsupported_forecast_date_as_skip(caplog) -> None: + class UndatedProvider: + async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: + raise AssertionError("Dated requests must not call the current-weather method") # pragma: no cover + + provider = LoggedWeatherContextProvider("nea-sg", UndatedProvider()) + + with ( + caplog.at_level("INFO", logger="weather_briefing.weather_context"), + pytest.raises(UnsupportedForecastDateError, match="does not support target forecast dates"), + ): + await provider.fetch(1, 2, forecast_date=pendulum.date(2026, 7, 21)) + + assert "Weather API call skipped provider=nea-sg" in caplog.text + assert "forecast_date=2026-07-21" in caplog.text + assert "reason=unsupported-forecast-date" in caplog.text + assert "Weather API call failed provider=nea-sg" not in caplog.text + + +@pytest.mark.parametrize("secondary_failure", ("elapsed", "logger")) +async def test_logged_provider_preserves_unsupported_date_when_skip_logging_fails( + monkeypatch, + secondary_failure: str, +) -> None: + class UndatedProvider: + async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: + raise AssertionError("Dated requests must not call the current-weather method") # pragma: no cover + + if secondary_failure == "elapsed": + + def fail_elapsed(started_at: float) -> int: + raise RuntimeError("secondary failure") + + monkeypatch.setattr("weather_briefing.weather_context._elapsed_milliseconds", fail_elapsed) + else: + original_info = logging.getLogger("weather_briefing.weather_context").info + + def fail_skip_log(message: str, *args: object) -> None: + if "skipped" in message: + raise RuntimeError("secondary failure") + original_info(message, *args) + + monkeypatch.setattr("weather_briefing.weather_context._LOGGER.info", fail_skip_log) + + provider = LoggedWeatherContextProvider("nea-sg", UndatedProvider()) + + with pytest.raises(UnsupportedForecastDateError, match="does not support target forecast dates"): + await provider.fetch(1, 2, forecast_date=pendulum.date(2026, 7, 21)) + + async def test_missing_weather_air_quality_requires_optional_aqicn_configuration() -> None: snapshot = WeatherContextSnapshot( source_id="weather:test", diff --git a/weather_briefing/weather_context.py b/weather_briefing/weather_context.py index cb2bdd13..ff87b305 100644 --- a/weather_briefing/weather_context.py +++ b/weather_briefing/weather_context.py @@ -129,6 +129,10 @@ class WeatherContextError(RuntimeError): """Raised when a weather source is unavailable or violates its contract.""" +class UnsupportedForecastDateError(WeatherContextError): + """Raised when a provider cannot fetch an explicit forecast date.""" + + class _QWeatherResponseError(ValueError): """Raised for safe, code-defined QWeather response contract errors.""" @@ -179,6 +183,16 @@ async def fetch( _LOGGER.info("Weather API call started provider=%s", self._name) try: snapshot = await fetch_weather_context(self._provider, latitude, longitude, forecast_date) + except UnsupportedForecastDateError: + with suppress(Exception): + _LOGGER.info( + "Weather API call skipped provider=%s duration_ms=%d forecast_date=%s " + "reason=unsupported-forecast-date", + self._name, + _elapsed_milliseconds(started_at), + forecast_date, + ) + raise except WeatherContextError as exc: _LOGGER.warning( "Weather API call failed provider=%s duration_ms=%d reason=%s", @@ -785,10 +799,10 @@ async def fetch_weather_context( if forecast_date is None: return await provider.fetch(latitude, longitude) if not isinstance(provider, DatedWeatherContextProvider): - raise WeatherContextError(f"{type(provider).__name__} does not support target forecast dates") + raise UnsupportedForecastDateError(f"{type(provider).__name__} does not support target forecast dates") fetch_for_date = provider.fetch_for_date if not callable(fetch_for_date): - raise WeatherContextError(f"{type(provider).__name__} does not support target forecast dates") + raise UnsupportedForecastDateError(f"{type(provider).__name__} does not support target forecast dates") return await fetch_for_date(latitude, longitude, forecast_date)