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
2 changes: 1 addition & 1 deletion tests/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from weather_briefing.air_quality import AirQualityError
from weather_briefing.capabilities import CapabilityName, CapabilityProviderSet, ProviderCapabilities
from weather_briefing.models import AirQualitySnapshot, AirQualityTimeKind, WeatherContextSnapshot
from weather_briefing.weather_context import WeatherContextError
from weather_briefing.weather import WeatherContextError


def _weather(*, air_quality: AirQualitySnapshot | None = None) -> WeatherContextSnapshot:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from weather_briefing.languages import LanguageSupport, localized_labels, normalize_language_tag
from weather_briefing.models import BriefingResult, LocationSpec, ResolvedLocation, SourceDocument
from weather_briefing.weather_context import OPEN_METEO_LANGUAGE_SUPPORT, QWEATHER_LANGUAGE_SUPPORT
from weather_briefing.weather import OPEN_METEO_LANGUAGE_SUPPORT, QWEATHER_LANGUAGE_SUPPORT


def test_language_tags_are_normalized() -> None:
Expand Down
6 changes: 2 additions & 4 deletions tests/test_reference_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@
from weather_briefing.data.service_endpoints import NOMINATIM_USER_AGENT
from weather_briefing.delivery.telegram_reference import telegram_error_classification
from weather_briefing.localization import localization_table
from weather_briefing.reference_data import (
open_meteo_weather_code_descriptions,
)
from weather_briefing.weather.open_meteo_reference import open_meteo_weather_code_descriptions


def _is_string_object_dict(value: object) -> TypeGuard[dict[str, object]]:
Expand Down Expand Up @@ -119,7 +117,7 @@ def test_nominatim_user_agent_identifies_current_version() -> None:
),
)
def test_open_meteo_weather_codes_reject_invalid_data(monkeypatch, value) -> None:
monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
monkeypatch.setattr("weather_briefing.weather.open_meteo_reference.load_reference_data", lambda filename: value)
open_meteo_weather_code_descriptions.cache_clear()

with pytest.raises(ReferenceDataError, match="Open-Meteo weather codes"):
Expand Down
10 changes: 7 additions & 3 deletions tests/test_regional_weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@
import pendulum
import pytest

from weather_briefing.regional_weather import (
from weather_briefing.weather import (
NEA_LANGUAGE_SUPPORT,
JMAJapanForecastProvider,
NEASingaporeNowcastProvider,
RegionalWeatherProviderError,
_first_item,
snapshot_to_documents,
)
from weather_briefing.weather.jma import (
_jma_forecast_lines,
_parse_japan_time,
)
from weather_briefing.weather.nea import (
_first_item,
_parse_singapore_time,
)
from weather_briefing.weather_context import snapshot_to_documents


async def test_nea_nowcast_provider_normalizes_v2_response() -> None:
Expand Down
109 changes: 97 additions & 12 deletions tests/test_weather_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
import pendulum
import pytest

from weather_briefing.data.resources import ReferenceDataError
from weather_briefing.models import (
AirQualitySnapshot,
AirQualityTimeKind,
AllergenSnapshot,
WeatherContextSnapshot,
)
from weather_briefing.reference_data import ReferenceDataError
from weather_briefing.time_utils import parse_aware_datetime
from weather_briefing.weather_context import (
from weather_briefing.weather import (
AirQualitySupplementingWeatherProvider,
FallbackWeatherContextProvider,
LoggedWeatherContextProvider,
Expand All @@ -24,11 +24,15 @@
QWeatherProvider,
UnsupportedForecastDateError,
WeatherContextError,
_format_qweather_day,
_format_qweather_lifestyle,
snapshot_to_documents,
)
from weather_briefing.weather.open_meteo import (
_open_meteo_daily_peak_values,
_open_meteo_weather_description,
snapshot_to_documents,
)
from weather_briefing.weather.qweather import (
_format_qweather_day,
_format_qweather_lifestyle,
)


Expand Down Expand Up @@ -551,7 +555,7 @@ def test_open_meteo_weather_code_uses_readable_description() -> None:

def test_open_meteo_weather_code_lookup_uses_cached_loader_boundary(monkeypatch) -> None:
monkeypatch.setattr(
"weather_briefing.weather_context.open_meteo_weather_code_descriptions",
"weather_briefing.weather.open_meteo_reference.open_meteo_weather_code_descriptions",
lambda: {53: "Reloaded description"},
)
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

Expand Down Expand Up @@ -656,6 +660,38 @@ async def test_open_meteo_future_enrichment_rejects_non_object_hourly_payload()
assert allergen is None


@pytest.mark.parametrize(
("payload", "reason"),
(
([], "air-quality response must be an object"),
({"current": []}, "current air quality must be an object"),
),
)
async def test_open_meteo_current_enrichment_rejects_non_object_payload(
caplog,
payload: object,
reason: str,
) -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(lambda request: httpx.Response(200, json=payload))
) as client:
provider = OpenMeteoProvider(
client,
air_quality_base_url="https://air.example.invalid",
)

with caplog.at_level("WARNING", logger="weather_briefing.weather_context"):
air_quality, allergen = await provider._fetch_air_quality_and_allergen(
39.9,
116.3,
forecast_date=None,
)

assert air_quality is None
assert allergen is None
assert f"operation=air-quality reason={reason}" in caplog.text


Comment on lines +663 to +694

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Use synthetic coordinates in the mocked test.

The test passes the real location 39.9, 116.3, although the mocked response does not depend on geography. Replace it with neutral fixture values such as the existing 1, 2 inputs.

As per coding guidelines, “Never commit real credentials, locations, coordinates, private source URLs, generated content, or runtime state; use runtime configuration, ignored state paths, and public test data.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_weather_context.py` around lines 663 - 694, Update
test_open_meteo_current_enrichment_rejects_non_object_payload to use neutral
synthetic coordinates, preferably the existing fixture values 1 and 2, when
calling _fetch_air_quality_and_allergen; leave the mocked payload and assertions
unchanged.

Source: Coding guidelines

def test_open_meteo_daily_peaks_skip_invalid_hourly_values() -> None:
hourly: dict[str, object] = {
"time": ["2026-07-15T06:00", "2026-07-15T09:00", "2026-07-15T18:00"],
Expand Down Expand Up @@ -846,7 +882,8 @@ async def fetch_for_date(

assert "Weather API call started provider=qweather" in caplog.text
assert "Weather API call failed provider=qweather" in caplog.text
assert "reason=QWeather weather forecast failed: HTTP 401" in caplog.text
assert "reason=WeatherContextError" in caplog.text
assert "QWeather weather forecast failed: HTTP 401" not in caplog.text
assert "Weather API call succeeded provider=open-meteo" in caplog.text
assert "source_id=weather:fallback" in caplog.text

Expand Down Expand Up @@ -902,7 +939,7 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh
def fail_elapsed(started_at: float) -> int:
raise RuntimeError("secondary failure")

monkeypatch.setattr("weather_briefing.weather_context._elapsed_milliseconds", fail_elapsed)
monkeypatch.setattr("weather_briefing.weather.base._elapsed_milliseconds", fail_elapsed)
else:
original_info = logging.getLogger("weather_briefing.weather_context").info

Expand All @@ -911,7 +948,7 @@ def fail_skip_log(message: str, *args: object) -> None:
raise RuntimeError("secondary failure")
original_info(message, *args)

monkeypatch.setattr("weather_briefing.weather_context._LOGGER.info", fail_skip_log)
monkeypatch.setattr("weather_briefing.weather.base._LOGGER.info", fail_skip_log)

provider = LoggedWeatherContextProvider("nea-sg", UndatedProvider())

Expand Down Expand Up @@ -1044,6 +1081,25 @@ def handler(request: httpx.Request) -> httpx.Response:
).fetch(1, 2)


@pytest.mark.parametrize(
("payload", "message"),
(
([], "weather response must be an object"),
({"code": "200", "daily": {}}, "daily forecast must be an array"),
),
)
async def test_qweather_rejects_invalid_weather_response_shape(payload: object, message: str) -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(lambda request: httpx.Response(200, json=payload))
) as client:
with pytest.raises(WeatherContextError, match=message):
await QWeatherProvider(
client,
authenticator=StaticAuthenticator(),
base_url="https://api.example.invalid",
).fetch(1, 2)


async def test_qweather_does_not_log_untrusted_api_status(caplog) -> None:
untrusted_status = "400\nforged-log-entry"

Expand All @@ -1066,7 +1122,7 @@ def handler(request: httpx.Request) -> httpx.Response:
await provider.fetch(1, 2)

assert untrusted_status not in caplog.text
assert "reason=QWeather returned a non-success weather status code=invalid" in caplog.text
assert "reason=WeatherContextError" in caplog.text


async def test_qweather_rejects_empty_daily_forecast() -> None:
Expand Down Expand Up @@ -1212,7 +1268,7 @@ def handler(request: httpx.Request) -> httpx.Response:


async def test_fallback_weather_provider_requires_at_least_one_provider() -> None:
from weather_briefing.weather_context import FallbackWeatherContextProvider
from weather_briefing.weather import FallbackWeatherContextProvider

with pytest.raises(ValueError, match="At least one"):
FallbackWeatherContextProvider()
Expand Down Expand Up @@ -1303,6 +1359,35 @@ def handler(request: httpx.Request) -> httpx.Response:
assert "operation=lifestyle-indices reason=non-success indices status code=400" in caplog.text


@pytest.mark.parametrize(
("payload", "reason"),
(
([], "indices response must be an object"),
({"code": "200", "daily": {}}, "daily indices must be an array"),
),
)
async def test_qweather_invalid_indices_response_shape_is_optional(caplog, payload: object, reason: str) -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/v7/weather/3d":
return _qweather_weather_response()
if request.url.path == "/v7/indices/1d":
return httpx.Response(200, json=payload)
assert request.url.path == "/airquality/v1/current/1.00/2.00"
return httpx.Response(500)

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
with caplog.at_level("WARNING", logger="weather_briefing.weather_context"):
snapshot = await QWeatherProvider(
client,
authenticator=StaticAuthenticator(),
base_url="https://api.example.invalid",
).fetch(1, 2)

assert snapshot.weather_forecast
assert snapshot.lifestyle_advice == ()
assert f"operation=lifestyle-indices reason={reason}" in caplog.text


async def test_qweather_rejects_http_error() -> None:
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(500))) as client:
with pytest.raises(WeatherContextError, match="weather forecast failed: HTTP 500"):
Expand Down Expand Up @@ -2016,7 +2101,7 @@ def handler(request: httpx.Request) -> httpx.Response:
)

monkeypatch.setattr(
"weather_briefing.weather_context.pollen_type_names",
"weather_briefing.allergen.pollen_type_names",
fail_to_load_pollen_types,
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
Expand Down
8 changes: 4 additions & 4 deletions weather_briefing/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ async def fetch(
if snapshot.air_quality is not None or forecast_date is not None:
return snapshot
if self.air_quality is None:
from .weather_context import WeatherContextError
from .weather import WeatherContextError

raise WeatherContextError("Weather source did not provide air quality; configure AQICN_API_TOKEN")
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
try:
Expand All @@ -84,7 +84,7 @@ async def fetch(
datetime_timezone_specifier(snapshot.observed_at, context="Weather snapshot time"),
)
except AirQualityError:
from .weather_context import WeatherContextError
from .weather import WeatherContextError

raise WeatherContextError("Weather source did not provide air quality and AQICN fallback failed") from None
return replace(snapshot, air_quality=air_quality)
Expand All @@ -107,7 +107,7 @@ async def fetch_all(
) -> tuple[WeatherContextSnapshot, ...]:
"""Fetch primary context and skip expected supplement failures."""
snapshots = [await self.fetch(latitude, longitude, forecast_date=forecast_date)]
from .weather_context import WeatherContextError
from .weather import WeatherContextError

for provider in self.supplements:
try:
Expand All @@ -124,6 +124,6 @@ async def _fetch_context(
forecast_date: pendulum.Date | None,
) -> WeatherContextSnapshot:
"""Route current or dated context through the shared provider boundary."""
from .weather_context import fetch_weather_context
from .weather import fetch_weather_context

return await fetch_weather_context(provider, latitude, longitude, forecast_date)
36 changes: 2 additions & 34 deletions weather_briefing/reference_data.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
"""Compatibility exports for reference data awaiting weather migration."""

from __future__ import annotations

from collections.abc import Mapping
from functools import cache
from types import MappingProxyType
"""Compatibility exports for packaged reference data."""

from .data.resources import (
ReferenceDataError,
Expand All @@ -14,33 +8,7 @@
reference_value,
)
from .localization import localization_table


@cache
def open_meteo_weather_code_descriptions() -> Mapping[int, str]:
"""Return validated English descriptions for Open-Meteo WMO weather codes."""
value = load_reference_data("open_meteo_weather_codes.json")
descriptions = value.get("descriptions_en")
if set(value) != {"descriptions_en"} or not isinstance(descriptions, dict) or not descriptions:
raise ReferenceDataError("Open-Meteo weather codes must contain English descriptions")

validated: dict[int, str] = {}
for code, description in descriptions.items():
if (
not isinstance(code, str)
or not code.isascii()
or not code.isdigit()
or len(code) > 2
or not isinstance(description, str)
or not description.strip()
):
raise ReferenceDataError("Open-Meteo weather codes must map numeric codes to descriptions")
numeric_code = int(code)
if str(numeric_code) != code:
raise ReferenceDataError("Open-Meteo weather codes must map numeric codes to descriptions")
validated[numeric_code] = description
return MappingProxyType(validated)

from .weather.open_meteo_reference import open_meteo_weather_code_descriptions

__all__ = [
"ReferenceDataError",
Expand Down
Loading