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_air_quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import pytest

from weather_briefing.air_quality import AirQualityError, AQICNProvider, air_quality_to_document, health_guidance
from weather_briefing.data.resources import ReferenceDataError
from weather_briefing.models import AirQualitySnapshot, AirQualityTimeKind
from weather_briefing.reference_data import ReferenceDataError


async def test_aqicn_provider_labels_aqi_standard_without_converting_pm25() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_allergen.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
allergen_to_document,
pollen_type_names,
)
from weather_briefing.data.resources import ReferenceDataError
from weather_briefing.models import AllergenLevel, AllergenSnapshot
from weather_briefing.reference_data import ReferenceDataError


def test_allergen_guidance_zero_is_none() -> None:
Expand Down
88 changes: 70 additions & 18 deletions tests/test_reference_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@

from weather_briefing import __version__
from weather_briefing.air_quality import health_guidance
from weather_briefing.data.service_endpoints import NOMINATIM_USER_AGENT
from weather_briefing.reference_data import (
from weather_briefing.data.resources import (
ReferenceDataError,
load_reference_data,
localization_table,
open_meteo_weather_code_descriptions,
reference_string,
reference_string_tuple,
reference_value,
)
from weather_briefing.data.service_endpoints import NOMINATIM_USER_AGENT
from weather_briefing.localization import localization_table
from weather_briefing.reference_data import (
open_meteo_weather_code_descriptions,
telegram_error_classification,
)

Expand Down Expand Up @@ -157,7 +159,7 @@ def test_reference_string_tuple_rejects_non_list_value() -> None:

@pytest.mark.parametrize("value", [None, "", " ", 7])
def test_reference_string_rejects_invalid_value(monkeypatch, value) -> None:
monkeypatch.setattr("weather_briefing.reference_data.reference_value", lambda *args: value)
monkeypatch.setattr("weather_briefing.data.resources.reference_value", lambda *args: value)

with pytest.raises(ReferenceDataError, match="non-empty string"):
reference_string("provider_defaults.json", "qweather_allergen_index_type")
Expand Down Expand Up @@ -220,7 +222,7 @@ def test_telegram_error_classification_rejects_invalid_data(monkeypatch, value,


def test_load_reference_data_rejects_non_dict_root(monkeypatch) -> None:
from weather_briefing.reference_data import load_reference_data
from weather_briefing.data.resources import load_reference_data

class FakeResource:
def joinpath(self, filename):
Expand All @@ -230,16 +232,66 @@ def read_text(self, encoding=None):
return "42"

monkeypatch.setattr(
"weather_briefing.reference_data.resources.files",
"weather_briefing.data.resources.resources.files",
lambda package: FakeResource(),
)

load_reference_data.cache_clear()

with pytest.raises(ReferenceDataError, match="must be an object"):
load_reference_data("test.json")


def test_load_reference_data_returns_independent_values() -> None:
first = load_reference_data("localization.json")
tables = first["tables"]
assert isinstance(tables, dict)
tables.clear()

second = load_reference_data("localization.json")

assert second["tables"]


def test_reference_value_copies_only_the_selected_value(monkeypatch) -> None:
import weather_briefing.data.resources as resources_module

class UnselectedValue:
def __deepcopy__(self, memo):
raise AssertionError( # pragma: no cover - reached only if root copying regresses
"reference_value must not copy the cached root"
)

selected = {"items": ["one"]}
root = {"selected": selected, "unselected": UnselectedValue()}

monkeypatch.setattr(resources_module, "_load_reference_data", lambda filename: root)

first = reference_value("test.json", "selected")
assert isinstance(first, dict)
items = first["items"]
assert isinstance(items, list)
items.append("changed")

second = reference_value("test.json", "selected")

assert second == {"items": ["one"]}
assert first is not selected


def test_reference_string_tuple_does_not_copy_the_cached_list(monkeypatch) -> None:
import weather_briefing.data.resources as resources_module

class CachedStrings(list[str]):
def __deepcopy__(self, memo):
raise AssertionError( # pragma: no cover - reached only if list copying regresses
"reference_string_tuple must not copy the cached list"
)

cached = CachedStrings(["one", "two"])
monkeypatch.setattr(resources_module, "_load_reference_data", lambda filename: {"selected": cached})

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
assert reference_string_tuple("test.json", "selected") == ("one", "two")


@pytest.mark.parametrize(
("value", "message"),
[
Expand All @@ -257,7 +309,7 @@ def read_text(self, encoding=None):
],
)
def test_localization_table_rejects_incomplete_data(monkeypatch, value, message) -> None:
monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
monkeypatch.setattr("weather_briefing.data.resources._load_reference_data", lambda filename: value)
localization_table.cache_clear()

with pytest.raises(ReferenceDataError, match=message):
Expand All @@ -268,7 +320,7 @@ def test_localization_table_rejects_missing_fields(monkeypatch) -> None:
value, tables, _ = _mutable_localization_data()
english = _localization_language(tables, "briefing", "en")
del english["weather"]
monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
monkeypatch.setattr("weather_briefing.data.resources._load_reference_data", lambda filename: value)
localization_table.cache_clear()

with pytest.raises(ReferenceDataError, match="Invalid localization fields: briefing:en"):
Expand All @@ -280,7 +332,7 @@ def test_localization_table_rejects_unknown_alias_target(monkeypatch) -> None:
briefing = aliases["briefing"]
assert _is_string_object_dict(briefing)
briefing["zh-Hans"] = "missing"
monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
monkeypatch.setattr("weather_briefing.data.resources._load_reference_data", lambda filename: value)
localization_table.cache_clear()

with pytest.raises(ReferenceDataError, match="Invalid localization alias: briefing:zh-Hans"):
Expand All @@ -290,7 +342,7 @@ def test_localization_table_rejects_unknown_alias_target(monkeypatch) -> None:
def test_localization_table_rejects_whitespace_values(monkeypatch) -> None:
value, tables, _ = _mutable_localization_data()
_localization_language(tables, "briefing", "en")["weather"] = " "
monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
monkeypatch.setattr("weather_briefing.data.resources._load_reference_data", lambda filename: value)
localization_table.cache_clear()

with pytest.raises(ReferenceDataError, match="Invalid localization fields: briefing:en"):
Expand All @@ -312,7 +364,7 @@ def test_localization_tables_are_immutable() -> None:
def test_localization_table_rejects_invalid_alias_root(monkeypatch, aliases) -> None:
value, _, _ = _mutable_localization_data()
value["aliases"] = aliases
monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
monkeypatch.setattr("weather_briefing.data.resources._load_reference_data", lambda filename: value)
localization_table.cache_clear()

with pytest.raises(ReferenceDataError, match="every supported table"):
Expand All @@ -329,7 +381,7 @@ def test_localization_table_rejects_unknown_table() -> None:
def test_localization_table_rejects_non_object_table(monkeypatch) -> None:
value, tables, _ = _mutable_localization_data()
tables["briefing"] = []
monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
monkeypatch.setattr("weather_briefing.data.resources._load_reference_data", lambda filename: value)
localization_table.cache_clear()

with pytest.raises(ReferenceDataError, match="Unknown localization table: briefing"):
Expand All @@ -341,7 +393,7 @@ def test_localization_table_rejects_non_object_labels(monkeypatch) -> None:
briefing = tables["briefing"]
assert _is_string_object_dict(briefing)
briefing["en"] = []
monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
monkeypatch.setattr("weather_briefing.data.resources._load_reference_data", lambda filename: value)
localization_table.cache_clear()

with pytest.raises(ReferenceDataError, match="Invalid localization fields: briefing:en"):
Expand All @@ -351,7 +403,7 @@ def test_localization_table_rejects_non_object_labels(monkeypatch) -> None:
def test_localization_table_rejects_non_object_table_aliases(monkeypatch) -> None:
value, _, aliases = _mutable_localization_data()
aliases["briefing"] = []
monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
monkeypatch.setattr("weather_briefing.data.resources._load_reference_data", lambda filename: value)
localization_table.cache_clear()

with pytest.raises(ReferenceDataError, match="Localization aliases must be an object: briefing"):
Expand All @@ -365,7 +417,7 @@ def test_localization_table_rejects_non_object_table_aliases(monkeypatch) -> Non
def test_localization_table_rejects_invalid_aliases(monkeypatch, alias, target) -> None:
value, _, aliases = _mutable_localization_data()
aliases["briefing"] = {alias: target}
monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
monkeypatch.setattr("weather_briefing.data.resources._load_reference_data", lambda filename: value)
localization_table.cache_clear()

with pytest.raises(ReferenceDataError, match="Invalid localization alias: briefing"):
Expand Down
3 changes: 2 additions & 1 deletion weather_briefing/air_quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
import pendulum

from .api_client import api_call_extensions
from .data.resources import ReferenceDataError, reference_value
from .data.service_endpoints import AQICN_BASE_URL
from .languages import localized_labels
from .localization import localization_table
from .models import AirQualitySnapshot, AirQualityTimeKind, SourceDocument
from .reference_data import ReferenceDataError, localization_table, reference_value
from .time_utils import parse_datetime_with_default_timezone

_AIR_QUALITY_FORMATS = localization_table("air_quality")
Expand Down
3 changes: 2 additions & 1 deletion weather_briefing/allergen.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
from functools import cache
from math import isfinite

from .data.resources import ReferenceDataError, reference_value
from .languages import localized_labels
from .localization import localization_table
from .models import AllergenSnapshot, SourceDocument
from .reference_data import ReferenceDataError, localization_table, reference_value

_ALLERGEN_FORMATS = localization_table("allergen")

Expand Down
2 changes: 1 addition & 1 deletion weather_briefing/content_cleaners.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from bs4 import BeautifulSoup, Comment
from soupsieve import SelectorSyntaxError

from .reference_data import reference_string_tuple
from .data.resources import reference_string_tuple


class ContentCleaningError(ValueError):
Expand Down
74 changes: 74 additions & 0 deletions weather_briefing/data/resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Validated access to packaged JSON resources."""

from __future__ import annotations

import json
from copy import deepcopy
from functools import cache
from importlib import resources
from pathlib import PurePath
from typing import Any

from .. import data


class ReferenceDataError(RuntimeError):
"""Raised when packaged domain reference data is missing or malformed."""


def _validate_reference_data_filename(filename: str) -> None:
if PurePath(filename).name != filename or not filename.endswith(".json"):
raise ReferenceDataError("Reference data filename must identify one JSON file")


def load_reference_data(filename: str) -> dict[str, object]:
"""Load and validate one packaged JSON reference-data object."""
_validate_reference_data_filename(filename)
return deepcopy(_load_reference_data(filename))


@cache
def _load_reference_data(filename: str) -> dict[str, object]:
try:
text = resources.files(data).joinpath(filename).read_text(encoding="utf-8")
value = json.loads(text)
except (FileNotFoundError, OSError, json.JSONDecodeError) as exc:
raise ReferenceDataError(f"Unable to load reference data: {filename}") from exc
if not isinstance(value, dict):
raise ReferenceDataError(f"Reference data root must be an object: {filename}")
return value


def _cached_reference_value(filename: str, *path: str) -> Any:
_validate_reference_data_filename(filename)
value: Any = _load_reference_data(filename)
try:
for key in path:
value = value[key]
except (KeyError, TypeError) as exc:
joined_path = ".".join(path)
raise ReferenceDataError(f"Missing reference data field: {filename}:{joined_path}") from exc
return value


def reference_value(filename: str, *path: str) -> Any:
"""Read an isolated nested value from a packaged reference-data file."""
return deepcopy(_cached_reference_value(filename, *path))


def reference_string(filename: str, *path: str) -> str:
"""Read a non-empty string from packaged reference data."""
value = reference_value(filename, *path)
if not isinstance(value, str) or not value.strip():
joined_path = ".".join(path)
raise ReferenceDataError(f"Reference data field must be a non-empty string: {filename}:{joined_path}")
return value


def reference_string_tuple(filename: str, *path: str) -> tuple[str, ...]:
"""Read a non-empty string sequence from packaged reference data."""
value = _cached_reference_value(filename, *path)
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item.strip() for item in value):
joined_path = ".".join(path)
raise ReferenceDataError(f"Reference data field must be a non-empty string list: {filename}:{joined_path}")
return tuple(value)
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Loading