From 757fa1b567df7b6d0b215106aa6ff63f64132004 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Mon, 24 Nov 2025 22:40:07 +0100 Subject: [PATCH 01/21] Format tests --- tests/test_config_flow.py | 217 +++++++++++++++++++++++-- tests/test_options_flow.py | 130 +++++++++++++++ tests/test_sensor.py | 208 +++++++++++++++++++++++- tests/test_translations.py | 318 +++++++++++++++++++++++++++++++++++++ 4 files changed, 856 insertions(+), 17 deletions(-) create mode 100644 tests/test_options_flow.py create mode 100644 tests/test_translations.py diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 92ca125f..83a4e5d1 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -4,12 +4,14 @@ from __future__ import annotations +import ast import asyncio -import json import sys from pathlib import Path from types import ModuleType, SimpleNamespace +import pytest + ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) @@ -121,6 +123,7 @@ def __init__(self, error_message=""): vol_mod.All = lambda *args, **kwargs: None vol_mod.Coerce = lambda *args, **kwargs: None vol_mod.Range = lambda *args, **kwargs: None +vol_mod.In = lambda *args, **kwargs: None sys.modules.setdefault("voluptuous", vol_mod) from custom_components.pollenlevels import config_flow as cf @@ -139,6 +142,91 @@ def __init__(self, error_message=""): ) +class _StubResponse: + def __init__(self, status: int, body: bytes | None = None) -> None: + self.status = status + self._body = body or b"{}" + + async def __aenter__(self): # pragma: no cover - trivial + return self + + async def __aexit__(self, exc_type, exc, tb): # pragma: no cover - trivial + return None + + async def read(self) -> bytes: + return self._body + + +class _StubSession: + def __init__(self, responses: list[_StubResponse]) -> None: + self.responses = responses + self.calls: list[tuple[tuple, dict]] = [] + + def get(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return self.responses.pop(0) + + +def _collect_error_keys_from_config_flow() -> set[str]: + """Parse the config flow to extract all error keys used in forms.""" + + source = (ROOT / "custom_components" / "pollenlevels" / "config_flow.py").read_text( + encoding="utf-8" + ) + tree = ast.parse(source) + + language_error_returns: set[str] = set() + + class _LanguageErrorVisitor(ast.NodeVisitor): + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: N802 + if node.name == "_language_error_to_form_key": + for child in ast.walk(node): + if ( + isinstance(child, ast.Return) + and isinstance(child.value, ast.Constant) + and isinstance(child.value.value, str) + ): + language_error_returns.add(child.value.value) + + _LanguageErrorVisitor().visit(tree) + + error_keys: set[str] = set() + + def _extract_error_values(value: ast.AST) -> set[str]: + values: set[str] = set() + if isinstance(value, ast.Constant) and isinstance(value.value, str): + values.add(value.value) + elif ( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Name) + and value.func.id == "_language_error_to_form_key" + ): + values.update(language_error_returns) + return values + + class _ErrorsVisitor(ast.NodeVisitor): + def visit_Assign(self, node: ast.Assign) -> None: # noqa: N802 + for target in node.targets: + self._record_errors(target, node.value) + self.generic_visit(node) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: # noqa: N802 + self._record_errors(node.target, node.value) + self.generic_visit(node) + + def _record_errors(self, target: ast.AST, value: ast.AST | None) -> None: + if ( + isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Name) + and target.value.id == "errors" + and value is not None + ): + error_keys.update(_extract_error_values(value)) + + _ErrorsVisitor().visit(tree) + return error_keys + + def test_validate_input_invalid_language_key_mapping() -> None: """Invalid language formats should surface the translation key.""" @@ -213,21 +301,122 @@ def test_validate_input_out_of_range_coordinates() -> None: assert normalized is None -def test_translations_define_required_error_keys() -> None: - """Every translation must expose the custom error messages.""" +def _patch_client_session(monkeypatch: pytest.MonkeyPatch, response: _StubResponse): + session = _StubSession([response]) + monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: session) + return session + + +def _base_user_input() -> dict: + return { + CONF_API_KEY: "test-key", + CONF_LATITUDE: "1.0", + CONF_LONGITUDE: "2.0", + } + + +def test_validate_input_http_403_sets_invalid_auth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HTTP 403 during validation should map to invalid_auth.""" + + session = _patch_client_session(monkeypatch, _StubResponse(403)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + errors, normalized = asyncio.run( + flow._async_validate_input(_base_user_input(), check_unique_id=False) + ) + + assert session.calls + assert errors == {"base": "invalid_auth"} + assert normalized is None + + +def test_validate_input_http_429_sets_quota_exceeded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HTTP 429 during validation should map to quota_exceeded.""" + + session = _patch_client_session(monkeypatch, _StubResponse(429)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + errors, normalized = asyncio.run( + flow._async_validate_input(_base_user_input(), check_unique_id=False) + ) + + assert session.calls + assert errors == {"base": "quota_exceeded"} + assert normalized is None + + +def test_validate_input_http_500_sets_cannot_connect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unexpected HTTP failures should map to cannot_connect.""" - translations_dir = ROOT / "custom_components" / "pollenlevels" / "translations" - required_errors = {"invalid_language_format", "invalid_coordinates"} + session = _patch_client_session(monkeypatch, _StubResponse(500)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + errors, normalized = asyncio.run( + flow._async_validate_input(_base_user_input(), check_unique_id=False) + ) + + assert session.calls + assert errors == {"base": "cannot_connect"} + assert normalized is None + + +def test_validate_input_happy_path_sets_unique_id_and_normalizes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Successful validation should normalize data and set unique ID.""" + + body = b'{"dailyInfo": [{"day": "D0"}]}' + session = _patch_client_session(monkeypatch, _StubResponse(200, body)) + + class _TrackingFlow(PollenLevelsConfigFlow): + def __init__(self) -> None: + super().__init__() + self.unique_ids: list[str] = [] + self.abort_calls = 0 + + async def async_set_unique_id(self, uid: str, raise_on_progress: bool = False): + self.unique_ids.append(uid) + return None + + def _abort_if_unique_id_configured(self): + self.abort_calls += 1 + return None + + flow = _TrackingFlow() + flow.hass = SimpleNamespace( + config=SimpleNamespace(), + ) + + user_input = { + **_base_user_input(), + CONF_LANGUAGE_CODE: " es ", + CONF_ENTRY_NAME: "Name", + } + + errors, normalized = asyncio.run( + flow._async_validate_input(user_input, check_unique_id=True) + ) - for path in translations_dir.glob("*.json"): - content = json.loads(path.read_text(encoding="utf-8")) - for section in ("config", "options"): - errors = content.get(section, {}).get("error", {}) - for key in required_errors: - assert key in errors, f"missing {key} in {path.name} ({section})" - assert errors[ - key - ].strip(), f"empty {key} message in {path.name} ({section})" + assert session.calls + assert errors == {} + assert normalized is not None + assert normalized[CONF_LATITUDE] == pytest.approx(1.0) + assert normalized[CONF_LONGITUDE] == pytest.approx(2.0) + assert normalized[CONF_LANGUAGE_CODE] == "es" + assert flow.unique_ids == ["1.0000_2.0000"] + assert flow.abort_calls == 1 def test_reauth_confirm_updates_and_reloads_entry() -> None: diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py new file mode 100644 index 00000000..81afa947 --- /dev/null +++ b/tests/test_options_flow.py @@ -0,0 +1,130 @@ +"""Options flow validation tests for Pollen Levels.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from custom_components.pollenlevels.const import ( + CONF_API_KEY, + CONF_CREATE_FORECAST_SENSORS, + CONF_FORECAST_DAYS, + CONF_LANGUAGE_CODE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UPDATE_INTERVAL, +) +from tests import test_config_flow as base + +PollenLevelsOptionsFlow = base.cf.PollenLevelsOptionsFlow +_StubConfigEntry = base._StubConfigEntry + + +def _flow(entry_data: dict | None = None, options: dict | None = None): + entry = _StubConfigEntry( + data=entry_data + or { + CONF_API_KEY: "key", + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 2.0, + CONF_LANGUAGE_CODE: "en", + CONF_UPDATE_INTERVAL: 6, + CONF_FORECAST_DAYS: 2, + CONF_CREATE_FORECAST_SENSORS: "none", + }, + options=options, + ) + flow = PollenLevelsOptionsFlow(entry) + flow.hass = SimpleNamespace(config=SimpleNamespace(language="en")) + flow.async_show_form = lambda **kwargs: kwargs + flow.async_create_entry = lambda *, title, data: {"title": title, "data": data} + return flow + + +def test_options_flow_invalid_language_sets_error() -> None: + """Invalid language in options should map to invalid_language_format.""" + + flow = _flow() + + result = asyncio.run( + flow.async_step_init( + { + CONF_LANGUAGE_CODE: "bad code", + CONF_FORECAST_DAYS: 2, + CONF_CREATE_FORECAST_SENSORS: "none", + CONF_UPDATE_INTERVAL: 6, + } + ) + ) + + assert result["errors"] == {CONF_LANGUAGE_CODE: "invalid_language_format"} + + +def test_options_flow_forecast_days_below_min_sets_error() -> None: + """Forecast days below allowed range should error.""" + + flow = _flow() + + result = asyncio.run( + flow.async_step_init( + { + CONF_LANGUAGE_CODE: "en", + CONF_FORECAST_DAYS: 0, + CONF_CREATE_FORECAST_SENSORS: "none", + CONF_UPDATE_INTERVAL: 6, + } + ) + ) + + assert result["errors"] == { + CONF_FORECAST_DAYS: "invalid_option_combo", + CONF_CREATE_FORECAST_SENSORS: "invalid_option_combo", + } + + +@pytest.mark.parametrize( + "mode,days", + [("D+1", 1), ("D+1+2", 2)], +) +def test_options_flow_per_day_sensor_requires_enough_days(mode: str, days: int) -> None: + """Per-day sensor modes should enforce minimum forecast days.""" + + flow = _flow() + + result = asyncio.run( + flow.async_step_init( + { + CONF_LANGUAGE_CODE: "en", + CONF_FORECAST_DAYS: days, + CONF_CREATE_FORECAST_SENSORS: mode, + CONF_UPDATE_INTERVAL: 6, + } + ) + ) + + assert result["errors"] == {CONF_CREATE_FORECAST_SENSORS: "invalid_option_combo"} + + +def test_options_flow_valid_submission_returns_entry_data() -> None: + """A valid options submission should return the data unchanged.""" + + flow = _flow() + + user_input = { + CONF_LANGUAGE_CODE: " es ", + CONF_FORECAST_DAYS: 3, + CONF_CREATE_FORECAST_SENSORS: "D+1", + CONF_UPDATE_INTERVAL: 8, + } + + result = asyncio.run(flow.async_step_init(dict(user_input))) + + assert result == { + "title": "", + "data": { + **user_input, + CONF_LANGUAGE_CODE: "es", + }, + } diff --git a/tests/test_sensor.py b/tests/test_sensor.py index b3d94783..4c14a46f 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -7,7 +7,7 @@ import sys import types from pathlib import Path -from typing import Any +from typing import Any, NamedTuple import pytest @@ -208,10 +208,16 @@ def __init__( class FakeResponse: """Async context manager returning a static payload.""" - def __init__(self, payload: dict[str, Any], *, status: int = 200) -> None: + def __init__( + self, + payload: dict[str, Any], + *, + status: int = 200, + headers: dict[str, str] | None = None, + ) -> None: self._payload = payload self.status = status - self.headers: dict[str, str] = {} + self.headers: dict[str, str] = headers or {} async def json(self) -> dict[str, Any]: return self._payload @@ -239,6 +245,33 @@ def get(self, *_args, **_kwargs) -> FakeResponse: return FakeResponse(self._payload, status=self._status) +class ResponseSpec(NamedTuple): + """Describe a fake HTTP response to return from the coordinator session.""" + + status: int + payload: dict[str, Any] + headers: dict[str, str] | None = None + + +class SequenceSession: + """Session that returns a sequence of responses or raises exceptions.""" + + def __init__(self, sequence: list[ResponseSpec | Exception]): + self.sequence = sequence + self.calls = 0 + + def get(self, *_args, **_kwargs): + item = self.sequence[self.calls] + self.calls += 1 + + if isinstance(item, Exception): + raise item + + return FakeResponse( + item.payload, status=item.status, headers=item.headers or {} + ) + + class RegistryEntry: """Simple stub representing an Entity Registry entry.""" @@ -621,6 +654,175 @@ def test_coordinator_raises_auth_failed(monkeypatch: pytest.MonkeyPatch) -> None loop.close() +def test_coordinator_retries_then_raises_on_rate_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """429 responses are retried once then raise UpdateFailed with quota message.""" + + session = SequenceSession( + [ + ResponseSpec(status=429, payload={}, headers={"Retry-After": "3"}), + ResponseSpec(status=429, payload={}, headers={"Retry-After": "3"}), + ] + ) + delays: list[float] = [] + + async def _fast_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: session) + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + ) + + try: + with pytest.raises(sensor.UpdateFailed, match="Quota exceeded"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert session.calls == 2 + assert delays == [3.0] + + +def test_coordinator_retries_then_raises_on_server_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """5xx responses retry once before raising UpdateFailed with status.""" + + session = SequenceSession( + [ResponseSpec(status=500, payload={}), ResponseSpec(status=502, payload={})] + ) + delays: list[float] = [] + + async def _fast_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: session) + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + ) + + try: + with pytest.raises(sensor.UpdateFailed, match="HTTP 502"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert session.calls == 2 + assert delays == [0.8] + + +def test_coordinator_retries_then_wraps_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Timeout errors retry once then surface as UpdateFailed with context.""" + + session = SequenceSession([TimeoutError("boom"), TimeoutError("boom")]) + delays: list[float] = [] + + async def _fast_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: session) + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + ) + + try: + with pytest.raises(sensor.UpdateFailed, match="Timeout"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert session.calls == 2 + assert delays == [0.8] + + +def test_coordinator_retries_then_wraps_client_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Client errors retry once then raise UpdateFailed with redacted message.""" + + session = SequenceSession( + [sensor.aiohttp.ClientError("net down"), sensor.aiohttp.ClientError("net down")] + ) + delays: list[float] = [] + + async def _fast_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: session) + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="secret", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + ) + + try: + with pytest.raises(sensor.UpdateFailed, match="net down"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert session.calls == 2 + assert delays == [0.8] + + def test_async_setup_entry_missing_api_key_triggers_reauth() -> None: """A missing API key results in ConfigEntryAuthFailed during setup.""" diff --git a/tests/test_translations.py b/tests/test_translations.py new file mode 100644 index 00000000..5ab1eda0 --- /dev/null +++ b/tests/test_translations.py @@ -0,0 +1,318 @@ +"""Translation coverage tests for the Pollen Levels integration.""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path +from typing import Any + +# 🔧 Adjust this per repository +INTEGRATION_DOMAIN = "pollenlevels" + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMPONENT_DIR = PROJECT_ROOT / "custom_components" / INTEGRATION_DOMAIN +TRANSLATIONS_DIR = COMPONENT_DIR / "translations" +CONFIG_FLOW_PATH = COMPONENT_DIR / "config_flow.py" +CONST_PATH = COMPONENT_DIR / "const.py" + + +def _flatten_keys(data: dict[str, Any], prefix: str = "") -> set[str]: + """Flatten nested dict keys into dotted paths.""" + + keys: set[str] = set() + for key, value in data.items(): + path = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + keys.update(_flatten_keys(value, path)) + else: + keys.add(path) + return keys + + +def _load_translation(path: Path) -> dict[str, Any]: + """Load a translation JSON file.""" + + if not path.is_file(): + raise AssertionError(f"Missing translation file: {path}") + with path.open("r", encoding="utf-8") as file: + return json.load(file) + + +def test_translations_match_english_keyset() -> None: + """Verify all locale files mirror the English translation keyset.""" + + en_path = TRANSLATIONS_DIR / "en.json" + english_keys = _flatten_keys(_load_translation(en_path)) + + problems: list[str] = [] + for translation_path in TRANSLATIONS_DIR.glob("*.json"): + if translation_path.name == "en.json": + continue + locale_keys = _flatten_keys(_load_translation(translation_path)) + missing = english_keys - locale_keys + extra = locale_keys - english_keys + if missing or extra: + problems.append( + f"{translation_path.name}: " + f"missing {sorted(missing)} extra {sorted(extra)}" + ) + assert not problems, "Translation keys mismatch: " + "; ".join(problems) + + +def test_config_flow_translation_keys_present() -> None: + """Ensure config/options flow keys referenced in code exist in English JSON.""" + + english = _flatten_keys(_load_translation(TRANSLATIONS_DIR / "en.json")) + flow_keys = _extract_config_flow_keys() + missing = flow_keys - english + assert not missing, f"Missing config_flow translation keys: {sorted(missing)}" + + +def _extract_constant_assignments(tree: ast.AST) -> dict[str, str]: + """Collect string literal assignments from an AST. + + Only handles simple cases like: + NAME = "literal" + """ + + constants: dict[str, str] = {} + for node in ast.walk(tree): + if isinstance(node, (ast.Assign, ast.AnnAssign)): + target_name: str | None = None + if isinstance(node, ast.Assign): + if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name): + continue + target_name = node.targets[0].id + value = node.value + else: + if not isinstance(node.target, ast.Name): + continue + target_name = node.target.id + value = node.value + if ( + target_name + and isinstance(value, ast.Constant) + and isinstance(value.value, str) + ): + constants[target_name] = value.value + return constants + + +def _resolve_name(name: str, mapping: dict[str, str]) -> str | None: + """Resolve a variable name to its string value if known.""" + + return mapping.get(name) + + +def _fields_from_schema_call(call: ast.Call, mapping: dict[str, str]) -> set[str]: + """Extract field keys from a vol.Schema(...) call. + + Looks for patterns like: + vol.Schema({vol.Required(CONF_USERNAME): str, ...}) + """ + + if not call.args: + return set() + arg = call.args[0] + if not isinstance(arg, ast.Dict): + return set() + + fields: set[str] = set() + for key in arg.keys: + if not isinstance(key, ast.Call): + continue + if not isinstance(key.func, ast.Attribute): + continue + if key.func.attr not in {"Required", "Optional"}: + continue + if not key.args: + continue + selector = key.args[0] + if isinstance(selector, ast.Constant) and isinstance(selector.value, str): + fields.add(selector.value) + elif isinstance(selector, ast.Name): + resolved = _resolve_name(selector.id, mapping) + if resolved: + fields.add(resolved) + return fields + + +def _extract_schema_fields( + tree: ast.AST, mapping: dict[str, str] +) -> dict[str, set[str]]: + """Map schema helper names to their field keys. + + Collects: + - Functions like _user_schema / _options_schema returning vol.Schema(...) + - Top-level assignments like USER_SCHEMA = vol.Schema(...) + """ + + fields: dict[str, set[str]] = {} + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name in { + "_user_schema", + "_options_schema", + }: + returns = [ + child for child in ast.walk(node) if isinstance(child, ast.Return) + ] + for ret in returns: + if isinstance(ret.value, ast.Call): + fields.setdefault(node.name, set()).update( + _fields_from_schema_call(ret.value, mapping) + ) + if isinstance(node, ast.Assign): + if ( + isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Attribute) + and node.value.func.attr == "Schema" + ): + name = node.targets[0].id + fields.setdefault(name, set()).update( + _fields_from_schema_call(node.value, mapping) + ) + return fields + + +def _is_options_flow_class(name: str) -> bool: + """Heuristic to decide if a class represents an options flow. + + Works for names like: + AirzoneOptionsFlow + PollenLevelsOptionsFlowHandler + MyIntegrationOptionsFlow + """ + + lower = name.lower() + return "optionsflow" in lower or "options_flow" in lower + + +def _extract_config_flow_keys() -> set[str]: + """Parse config_flow.py to derive translation keys used in flows. + + This covers: + - config.step..title + - config.step..description + - config.step..data. + - config.error. + - config.abort. + And the equivalent options.* keys for options flows. + """ + + if not CONFIG_FLOW_PATH.is_file(): + raise AssertionError(f"Missing config_flow.py at {CONFIG_FLOW_PATH}") + + config_tree = ast.parse(CONFIG_FLOW_PATH.read_text(encoding="utf-8")) + const_tree: ast.AST | None = None + if CONST_PATH.is_file(): + const_tree = ast.parse(CONST_PATH.read_text(encoding="utf-8")) + + manual_mapping: dict[str, str] = { + "CONF_USERNAME": "username", + "CONF_PASSWORD": "password", + "CONF_API_KEY": "api_key", + "CONF_LATITUDE": "latitude", + "CONF_LONGITUDE": "longitude", + "CONF_LANGUAGE": "language", + "CONF_SCAN_INTERVAL": "scan_interval", + } + + mapping: dict[str, str] = dict(manual_mapping) + if const_tree is not None: + mapping.update(_extract_constant_assignments(const_tree)) + mapping.update(_extract_constant_assignments(config_tree)) + + schema_fields = _extract_schema_fields(config_tree, mapping) + keys: set[str] = set() + + class FlowVisitor(ast.NodeVisitor): + def __init__(self) -> None: + self.class_stack: list[str] = [] + self.local_schema_vars: dict[str, set[str]] = dict(schema_fields) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: N802 + self.class_stack.append(node.name) + self.generic_visit(node) + self.class_stack.pop() + + def visit_Assign(self, node: ast.Assign) -> None: # noqa: N802 + if ( + isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Attribute) + and node.value.func.attr == "Schema" + ): + name = node.targets[0].id + self.local_schema_vars[name] = _fields_from_schema_call( + node.value, mapping + ) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: # noqa: N802 + prefix = "config" + if self.class_stack and _is_options_flow_class(self.class_stack[-1]): + prefix = "options" + + func_attr: str | None = None + if isinstance(node.func, ast.Attribute): + func_attr = node.func.attr + + if func_attr == "async_show_form": + self._handle_show_form(node, prefix) + if func_attr == "async_abort": + self._handle_abort(node, prefix) + + self.generic_visit(node) + + def _handle_show_form(self, node: ast.Call, prefix: str) -> None: + step_id: str | None = None + schema_name: str | None = None + inline_schema_fields: set[str] = set() + + for kw in node.keywords: + if kw.arg == "step_id" and isinstance(kw.value, ast.Constant): + step_id = str(kw.value.value) + if kw.arg == "data_schema": + if isinstance(kw.value, ast.Name): + schema_name = kw.value.id + elif isinstance(kw.value, ast.Call): + if ( + isinstance(kw.value.func, ast.Attribute) + and kw.value.func.attr == "Schema" + ): + inline_schema_fields.update( + _fields_from_schema_call(kw.value, mapping) + ) + if kw.arg == "errors" and isinstance(kw.value, ast.Dict): + for err_value in kw.value.values: + if isinstance(err_value, ast.Constant) and isinstance( + err_value.value, str + ): + keys.add(f"{prefix}.error.{err_value.value}") + + if not step_id: + return + + keys.add(f"{prefix}.step.{step_id}.title") + keys.add(f"{prefix}.step.{step_id}.description") + + if schema_name and schema_name in self.local_schema_vars: + for field in self.local_schema_vars[schema_name]: + keys.add(f"{prefix}.step.{step_id}.data.{field}") + + for field in inline_schema_fields: + keys.add(f"{prefix}.step.{step_id}.data.{field}") + + def _handle_abort(self, node: ast.Call, prefix: str) -> None: + for kw in node.keywords: + if ( + kw.arg == "reason" + and isinstance(kw.value, ast.Constant) + and isinstance(kw.value.value, str) + ): + keys.add(f"{prefix}.abort.{kw.value.value}") + + FlowVisitor().visit(config_tree) + return keys From 3a79e872fbf0c1ce5d663ccc2934d4b91c8658ab Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 26 Nov 2025 07:26:11 +0100 Subject: [PATCH 02/21] Restrict workflow push triggers to main --- .github/workflows/hassfest.yml | 1 + .github/workflows/lint.yml | 1 + .github/workflows/tests.yml | 2 +- .github/workflows/validate.yml | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/hassfest.yml b/.github/workflows/hassfest.yml index 9ecc28ed..ba9929a0 100644 --- a/.github/workflows/hassfest.yml +++ b/.github/workflows/hassfest.yml @@ -2,6 +2,7 @@ name: Validate with hassfest on: push: + branches: [main] pull_request: schedule: - cron: "0 0 * * *" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8f229db5..03182319 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,7 @@ name: Lint on: push: + branches: [main] pull_request: workflow_dispatch: inputs: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 51066d9f..48c75532 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,7 @@ name: tests on: push: - branches: [main, master] + branches: [main] pull_request: concurrency: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6699b081..0fb500a3 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -2,6 +2,7 @@ name: Validate with HACS on: push: + branches: [main] pull_request: schedule: - cron: "0 0 * * *" From edce7a9759fac3881d8aaffae284e5f23fa1cc46 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 26 Nov 2025 07:26:16 +0100 Subject: [PATCH 03/21] Expand workflow push branches --- .github/workflows/hassfest.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/validate.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/hassfest.yml b/.github/workflows/hassfest.yml index ba9929a0..5763eb5d 100644 --- a/.github/workflows/hassfest.yml +++ b/.github/workflows/hassfest.yml @@ -2,7 +2,7 @@ name: Validate with hassfest on: push: - branches: [main] + branches: [main, master] pull_request: schedule: - cron: "0 0 * * *" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 03182319..74e53a2f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,7 +2,7 @@ name: Lint on: push: - branches: [main] + branches: [main, master] pull_request: workflow_dispatch: inputs: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 48c75532..51066d9f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,7 @@ name: tests on: push: - branches: [main] + branches: [main, master] pull_request: concurrency: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 0fb500a3..efa2d283 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -2,7 +2,7 @@ name: Validate with HACS on: push: - branches: [main] + branches: [main, master] pull_request: schedule: - cron: "0 0 * * *" From 7aca5eef5fe128b56e02066969b46435ea9cfc48 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:41:21 +0100 Subject: [PATCH 04/21] Install package during test workflow --- .github/workflows/tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 51066d9f..af8bee4c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,6 +37,10 @@ jobs: python -m pip install -r requirements_test.txt fi + - name: Install package + run: | + python -m pip install . + - name: Run tests run: | pytest -q From d6d555ac8705dc938dc7e15ff6da3d4c365e3f23 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:41:26 +0100 Subject: [PATCH 05/21] Add happy path setup/unload test --- tests/test_init.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/test_init.py b/tests/test_init.py index 6d1dc505..4ac49a95 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -63,15 +63,29 @@ class _StubConfigEntryAuthFailed(Exception): class _FakeConfigEntries: - def __init__(self, forward_exception: Exception | None = None): + def __init__( + self, + forward_exception: Exception | None = None, + unload_result: bool = True, + ): self._forward_exception = forward_exception + self._unload_result = unload_result self.forward_calls: list[tuple[object, list[str]]] = [] + self.unload_calls: list[tuple[object, list[str]]] = [] + self.reload_calls: list[str] = [] async def async_forward_entry_setups(self, entry, platforms): self.forward_calls.append((entry, platforms)) if self._forward_exception is not None: raise self._forward_exception + async def async_unload_platforms(self, entry, platforms): + self.unload_calls.append((entry, platforms)) + return self._unload_result + + async def async_reload(self, entry_id: str): # pragma: no cover - used in tests + self.reload_calls.append(entry_id) + class _FakeEntry: def __init__(self, *, entry_id: str = "entry-1", title: str = "Pollen Levels"): @@ -116,3 +130,24 @@ class _Boom(Exception): with pytest.raises(integration.ConfigEntryNotReady): asyncio.run(integration.async_setup_entry(hass, entry)) + + +def test_setup_entry_success_and_unload() -> None: + """Happy path should forward setup, register listener, and unload cleanly.""" + + hass = _FakeHass() + entry = _FakeEntry() + hass.data[integration.DOMAIN] = {entry.entry_id: "coordinator"} + + assert asyncio.run(integration.async_setup_entry(hass, entry)) is True + + assert hass.config_entries.forward_calls == [(entry, ["sensor"])] + assert entry._update_listener is integration._update_listener # noqa: SLF001 + assert entry._on_unload is entry._update_listener # noqa: SLF001 + + asyncio.run(entry._update_listener(hass, entry)) # noqa: SLF001 + assert hass.config_entries.reload_calls == [entry.entry_id] + + assert asyncio.run(integration.async_unload_entry(hass, entry)) is True + assert hass.config_entries.unload_calls == [(entry, ["sensor"])] + assert hass.data[integration.DOMAIN] == {} From 01efaab7f954e510dbaa23a472158218271e6faa Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:23:37 +0100 Subject: [PATCH 06/21] Fix packaging metadata and translation key collection --- pyproject.toml | 2 + tests/test_translations.py | 114 +++++++++++++++++++++++++++++-------- 2 files changed, 92 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eb750135..536e37e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,8 @@ # - CI: pip install "black==" "ruff==" [project] +name = "pollenlevels" +version = "1.8.3" # Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" diff --git a/tests/test_translations.py b/tests/test_translations.py index 5ab1eda0..e097fe88 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -78,24 +78,24 @@ def _extract_constant_assignments(tree: ast.AST) -> dict[str, str]: constants: dict[str, str] = {} for node in ast.walk(tree): - if isinstance(node, (ast.Assign, ast.AnnAssign)): - target_name: str | None = None - if isinstance(node, ast.Assign): - if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name): - continue - target_name = node.targets[0].id - value = node.value - else: - if not isinstance(node.target, ast.Name): - continue - target_name = node.target.id - value = node.value - if ( - target_name - and isinstance(value, ast.Constant) - and isinstance(value.value, str) - ): - constants[target_name] = value.value + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + + target = None + if isinstance(node, ast.Assign): + if len(node.targets) == 1: + target = node.targets[0] + else: # AnnAssign + target = node.target + + value = node.value + if ( + isinstance(target, ast.Name) + and value is not None + and isinstance(value, ast.Constant) + and isinstance(value.value, str) + ): + constants[target.id] = value.value return constants @@ -225,6 +225,58 @@ def _extract_config_flow_keys() -> set[str]: mapping.update(_extract_constant_assignments(config_tree)) schema_fields = _extract_schema_fields(config_tree, mapping) + + language_error_returns: set[str] = set() + + class _LanguageErrorVisitor(ast.NodeVisitor): + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: N802 + if node.name != "_language_error_to_form_key": + return + for child in ast.walk(node): + if ( + isinstance(child, ast.Return) + and isinstance(child.value, ast.Constant) + and isinstance(child.value.value, str) + ): + language_error_returns.add(child.value.value) + + _LanguageErrorVisitor().visit(config_tree) + + def _extract_error_values(value: ast.AST) -> set[str]: + values: set[str] = set() + if isinstance(value, ast.Constant) and isinstance(value.value, str): + values.add(value.value) + elif ( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Name) + and value.func.id == "_language_error_to_form_key" + ): + values.update(language_error_returns) + return values + + error_keys_from_assignments: set[str] = set() + + class _ErrorsVisitor(ast.NodeVisitor): + def visit_Assign(self, node: ast.Assign) -> None: # noqa: N802 + for target in node.targets: + self._record_errors(target, node.value) + self.generic_visit(node) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: # noqa: N802 + self._record_errors(node.target, node.value) + self.generic_visit(node) + + def _record_errors(self, target: ast.AST, value: ast.AST | None) -> None: + if ( + isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Name) + and target.value.id == "errors" + and value is not None + ): + error_keys_from_assignments.update(_extract_error_values(value)) + + _ErrorsVisitor().visit(config_tree) + keys: set[str] = set() class FlowVisitor(ast.NodeVisitor): @@ -285,12 +337,26 @@ def _handle_show_form(self, node: ast.Call, prefix: str) -> None: inline_schema_fields.update( _fields_from_schema_call(kw.value, mapping) ) - if kw.arg == "errors" and isinstance(kw.value, ast.Dict): - for err_value in kw.value.values: - if isinstance(err_value, ast.Constant) and isinstance( - err_value.value, str - ): - keys.add(f"{prefix}.error.{err_value.value}") + if kw.arg == "errors": + if isinstance(kw.value, ast.Dict): + for err_value in kw.value.values: + err_key: str | None = None + if isinstance(err_value, ast.Constant) and isinstance( + err_value.value, str + ): + err_key = err_value.value + elif isinstance(err_value, ast.Name): + err_key = _resolve_name(err_value.id, mapping) + if err_key: + keys.add(f"{prefix}.error.{err_key}") + elif isinstance(kw.value, ast.Name): + if kw.value.id == "errors": + for err_key in error_keys_from_assignments: + keys.add(f"{prefix}.error.{err_key}") + else: + resolved = _resolve_name(kw.value.id, mapping) + if resolved: + keys.add(f"{prefix}.error.{resolved}") if not step_id: return From 6320a0c06c9b17b22d047f6c9d3976d0f4d99c31 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:41:16 +0100 Subject: [PATCH 07/21] Lower Python floor to 3.13 --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 536e37e3..486e4338 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,9 @@ [project] name = "pollenlevels" version = "1.8.3" -# Enforce the runtime floor aligned with upcoming HA Python 3.14 images. -requires-python = ">=3.14" +# Enforce the runtime floor aligned with Home Assistant (Python 3.13+), while CI +# tooling runs on Python 3.14. +requires-python = ">=3.13" [tool.black] line-length = 88 From 1b5d2ca37fe925efa0cf1995a9d9ce5894f20b38 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:05:58 +0100 Subject: [PATCH 08/21] Gate package install in tests workflow --- .github/workflows/tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index af8bee4c..082a7d74 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,9 +38,12 @@ jobs: fi - name: Install package + if: ${{ matrix.python-version >= '3.14' }} run: | python -m pip install . - name: Run tests run: | pytest -q + env: + PYTHONPATH: . From b6eba9293430d59b7a286788bc3e8795d96dfd0a Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:25:28 +0100 Subject: [PATCH 09/21] Raise Python requirement to 3.14 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 486e4338..737da42f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,9 +7,9 @@ [project] name = "pollenlevels" version = "1.8.3" -# Enforce the runtime floor aligned with Home Assistant (Python 3.13+), while CI +# Enforce the runtime floor aligned with Home Assistant (Python 3.14+), while CI # tooling runs on Python 3.14. -requires-python = ">=3.13" +requires-python = ">=3.14" [tool.black] line-length = 88 From 386ea09f78e32a527288cb6dcd6798316392a9fb Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:46:01 +0100 Subject: [PATCH 10/21] Clarify Python floor note in pyproject --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 737da42f..536e37e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,7 @@ [project] name = "pollenlevels" version = "1.8.3" -# Enforce the runtime floor aligned with Home Assistant (Python 3.14+), while CI -# tooling runs on Python 3.14. +# Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" [tool.black] From 4ee12b51cbc7a633b2865e9da36722a0a290457e Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Wed, 26 Nov 2025 23:24:35 +0100 Subject: [PATCH 11/21] Adjust test stubs for async session and utcnow --- tests/test_config_flow.py | 47 ++++++++++++++++++++++++++++++++------- tests/test_sensor.py | 6 +++-- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 83a4e5d1..40bedacf 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -78,14 +78,45 @@ def __init__(self, data=None, options=None, entry_id="stub-entry"): sys.modules.setdefault("homeassistant.helpers.config_validation", config_validation_mod) aiohttp_client_mod = ModuleType("homeassistant.helpers.aiohttp_client") -aiohttp_client_mod.async_get_clientsession = lambda hass: SimpleNamespace( - get=lambda *args, **kwargs: SimpleNamespace( - __aenter__=lambda self: self, - __aexit__=lambda self, exc_type, exc, tb: None, - read=lambda: b"{}", - status=200, - ) -) + + +class _StubResponse: + """Async response stub matching aiohttp.ClientResponse for tests.""" + + def __init__(self, *, status: int = 200, body: bytes = b"{}") -> None: + self.status = status + self._body = body + + async def read(self) -> bytes: + """Return the fake response body.""" + + return self._body + + async def __aenter__(self) -> _StubResponse: + """Support the async context manager protocol.""" + + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + """Support the async context manager protocol.""" + + return None + + +class _StubSession: + """Async session stub exposing a get() method.""" + + def __init__(self, *, status: int = 200, body: bytes = b"{}") -> None: + self._status = status + self._body = body + + def get(self, *args, **kwargs) -> _StubResponse: + """Return an async context manager response stub.""" + + return _StubResponse(status=self._status, body=self._body) + + +aiohttp_client_mod.async_get_clientsession = lambda hass: _StubSession() sys.modules.setdefault("homeassistant.helpers.aiohttp_client", aiohttp_client_mod) ha_mod.helpers = helpers_mod diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 4c14a46f..8a2db991 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -138,9 +138,11 @@ def __init__(self, coordinator): def _stub_utcnow(): - from datetime import datetime + """Return a timezone-aware UTC datetime, similar to Home Assistant.""" - return datetime.utcnow() + from datetime import UTC, datetime + + return datetime.now(UTC) dt_mod.utcnow = _stub_utcnow From 995b3250880516d6025cb78ec3b0da88df68f36c Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 27 Nov 2025 20:57:13 +0100 Subject: [PATCH 12/21] Defer per-day cleanup until coordinator refresh --- custom_components/pollenlevels/sensor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 91183cef..7d639b08 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -208,11 +208,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): allow_d1 = create_d1 and forecast_days >= 2 allow_d2 = create_d2 and forecast_days >= 3 - # Proactively remove stale D+ entities from the Entity Registry - await _cleanup_per_day_entities( - hass, config_entry.entry_id, allow_d1=allow_d1, allow_d2=allow_d2 - ) - coordinator = PollenDataUpdateCoordinator( hass=hass, api_key=api_key, @@ -236,6 +231,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): _LOGGER.warning(message) raise ConfigEntryNotReady(message) + # Proactively remove stale D+ entities from the Entity Registry + await _cleanup_per_day_entities( + hass, config_entry.entry_id, allow_d1=allow_d1, allow_d2=allow_d2 + ) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator sensors: list[CoordinatorEntity] = [] From 953b7750629865deeda7b9e4656da2d9108563e5 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 27 Nov 2025 21:28:44 +0100 Subject: [PATCH 13/21] Add SequenceSession guard and bump version 1.8.4 --- CHANGELOG.md | 5 +++++ custom_components/pollenlevels/manifest.json | 18 +++++++++--------- pyproject.toml | 2 +- tests/test_sensor.py | 2 ++ 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97afffe0..8c0c746f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # Changelog +## [1.8.4] - 2025-11-27 +### Fixed +- Added a bounds check to the sensor test SequenceSession helper so exhausting the + fake response list raises a clear AssertionError instead of an IndexError. + ## [1.8.3] - 2025-11-24 ### Fixed - Stop logging coordinate values through the invalid-coordinate exception message in the config diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index 1a209e85..43b94477 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -1,11 +1,11 @@ { - "domain": "pollenlevels", - "name": "Pollen Levels", - "codeowners": ["@eXPerience83"], - "config_flow": true, - "documentation": "https://github.com/eXPerience83/pollenlevels", - "integration_type": "service", - "iot_class": "cloud_polling", - "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", - "version": "1.8.3" + "domain": "pollenlevels", + "name": "Pollen Levels", + "codeowners": ["@eXPerience83"], + "config_flow": true, + "documentation": "https://github.com/eXPerience83/pollenlevels", + "integration_type": "service", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", + "version": "1.8.4", } diff --git a/pyproject.toml b/pyproject.toml index 536e37e3..8a411fad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ [project] name = "pollenlevels" -version = "1.8.3" +version = "1.8.4" # Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 8a2db991..327cb9d5 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -263,6 +263,8 @@ def __init__(self, sequence: list[ResponseSpec | Exception]): self.calls = 0 def get(self, *_args, **_kwargs): + if self.calls >= len(self.sequence): + raise AssertionError("SequenceSession exhausted; no more responses.") item = self.sequence[self.calls] self.calls += 1 From d49ea549d0e27c2964a43c02e08f8fd720f66a8e Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Thu, 27 Nov 2025 21:28:51 +0100 Subject: [PATCH 14/21] Add metadata consistency test and fix manifest JSON --- custom_components/pollenlevels/manifest.json | 2 +- tests/test_metadata.py | 40 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/test_metadata.py diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index 43b94477..a9285bdf 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", - "version": "1.8.4", + "version": "1.8.4" } diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 00000000..b7f19581 --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,40 @@ +"""Project metadata consistency tests.""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PYPROJECT_PATH = ROOT / "pyproject.toml" +MANIFEST_PATH = ROOT / "custom_components" / "pollenlevels" / "manifest.json" + + +def _load_pyproject() -> dict: + with PYPROJECT_PATH.open("rb") as file: + return tomllib.load(file) + + +def _load_manifest() -> dict: + with MANIFEST_PATH.open("r", encoding="utf-8") as file: + return json.load(file) + + +def test_pyproject_declares_name_and_version() -> None: + """Ensure pyproject defines package metadata required for installs.""" + project = _load_pyproject().get("project") + assert project, "[project] section missing in pyproject.toml" + + assert project.get("name"), "Project name must be defined for packaging" + assert project.get("version"), "Project version must be defined for packaging" + + +def test_manifest_version_matches_pyproject() -> None: + """Manifest version should stay aligned with the package metadata.""" + project = _load_pyproject().get("project", {}) + manifest = _load_manifest() + + assert manifest.get("version") == project.get( + "version" + ), "Manifest version must match pyproject version" From ca1a2702470c0543a90969996ad31dc6e45d5d81 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 20:29:11 +0000 Subject: [PATCH 15/21] Add renovate.json --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..5db72dd6 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} From c70a15f0b5f53a054165474f46bbb3e1d97ff8c6 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 28 Nov 2025 07:28:16 +0100 Subject: [PATCH 16/21] Update changelog for 1.8.4 changes --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c0c746f..08333874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ### Fixed - Added a bounds check to the sensor test SequenceSession helper so exhausting the fake response list raises a clear AssertionError instead of an IndexError. +- Added metadata consistency tests to keep the package name/version aligned with + the manifest and catch missing fields early in CI. +- Corrected the manifest JSON formatting so metadata parses cleanly without + trailing separators. ## [1.8.3] - 2025-11-24 ### Fixed From ce8a859b2d110b127a773ea2fc4a0bebfc6734b3 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 28 Nov 2025 08:22:41 +0100 Subject: [PATCH 17/21] Fix config flow stub session --- tests/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 40bedacf..8d8355fb 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -188,7 +188,7 @@ async def read(self) -> bytes: return self._body -class _StubSession: +class _SequenceSession: def __init__(self, responses: list[_StubResponse]) -> None: self.responses = responses self.calls: list[tuple[tuple, dict]] = [] @@ -333,7 +333,7 @@ def test_validate_input_out_of_range_coordinates() -> None: def _patch_client_session(monkeypatch: pytest.MonkeyPatch, response: _StubResponse): - session = _StubSession([response]) + session = _SequenceSession([response]) monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: session) return session From 5b066c714488d006fbb16652a94497488154a9d7 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 28 Nov 2025 08:29:36 +0100 Subject: [PATCH 18/21] Improve SequenceSession exhaustion message --- tests/test_sensor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 327cb9d5..152db35e 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -264,7 +264,10 @@ def __init__(self, sequence: list[ResponseSpec | Exception]): def get(self, *_args, **_kwargs): if self.calls >= len(self.sequence): - raise AssertionError("SequenceSession exhausted; no more responses.") + raise AssertionError( + "SequenceSession exhausted; no more responses " + f"(calls={self.calls}, sequence_len={len(self.sequence)})." + ) item = self.sequence[self.calls] self.calls += 1 From ea6e376cf4bf5e30f8c34e5bf2410861de9e8c9c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 07:35:22 +0000 Subject: [PATCH 19/21] Update actions/checkout action to v6 --- .github/workflows/format.yml | 2 +- .github/workflows/hassfest.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index b5716a2b..053a7cdf 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ github.event.inputs.target_branch || github.ref }} diff --git a/.github/workflows/hassfest.yml b/.github/workflows/hassfest.yml index 5763eb5d..5f4fd925 100644 --- a/.github/workflows/hassfest.yml +++ b/.github/workflows/hassfest.yml @@ -18,7 +18,7 @@ jobs: steps: # Checkout so hassfest can read manifests and structure - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 # Official hassfest action (Home Assistant recommends @master) - name: Run hassfest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 74e53a2f..0b3843fb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ github.event.inputs.ref || github.ref }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 082a7d74..4cd90fe3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: python-version: ["3.13", "3.14"] steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v5 From 4a7558853e31eda292ab6f56d28cef4d1f6623a8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 07:35:25 +0000 Subject: [PATCH 20/21] Update actions/setup-python action to v6 --- .github/workflows/format.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index b5716a2b..5237cf5d 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -26,7 +26,7 @@ jobs: ref: ${{ github.event.inputs.target_branch || github.ref }} - name: Setup Python 3.14 (latest patch) - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.14" cache: "pip" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 74e53a2f..2d7895a1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,7 +29,7 @@ jobs: ref: ${{ github.event.inputs.ref || github.ref }} - name: Setup Python 3.14 (latest patch) - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.14" cache: "pip" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 082a7d74..32c3ea3b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: 'pip' From 9aa3575a638461efc87ca5e79f371527763f64e6 Mon Sep 17 00:00:00 2001 From: eXPerience83 <16572400+eXPerience83@users.noreply.github.com> Date: Fri, 28 Nov 2025 09:48:21 +0100 Subject: [PATCH 21/21] Clarify AST test failures --- custom_components/pollenlevels/config_flow.py | 2 +- custom_components/pollenlevels/sensor.py | 4 ++ tests/test_translations.py | 39 +++++++++++++------ 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index dfb26a0a..a56d5501 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -214,7 +214,7 @@ async def _async_validate_input( errors["base"] = "cannot_connect" except Exception as err: # defensive _LOGGER.exception( - "Unexpected error: %s", + "Unexpected error in Pollen Levels config flow while validating input: %s", redact_api_key(err, user_input.get(CONF_API_KEY)), ) errors["base"] = "cannot_connect" diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 7d639b08..e4451612 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -463,6 +463,8 @@ async def _async_update_data(self): await asyncio.sleep(delay) continue msg = redact_api_key(err, self.api_key) + if not msg: + msg = "Google Pollen API call timed out" raise UpdateFailed(f"Timeout: {msg}") from err except aiohttp.ClientError as err: @@ -478,6 +480,8 @@ async def _async_update_data(self): await asyncio.sleep(delay) continue msg = redact_api_key(err, self.api_key) + if not msg: + msg = "Network error while calling the Google Pollen API" raise UpdateFailed(msg) from err except Exception as err: # Keep previous behavior for unexpected errors diff --git a/tests/test_translations.py b/tests/test_translations.py index e097fe88..6cc8684c 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -1,4 +1,10 @@ -"""Translation coverage tests for the Pollen Levels integration.""" +"""Translation coverage tests for the Pollen Levels integration. + +These tests parse ``config_flow.py`` with a simple AST walker to ensure every +translation key used in the config/options flows exists in each locale file. +If the flow code changes structure, update the helper below rather than +changing the assertions to keep the guarantees intact. +""" from __future__ import annotations @@ -7,6 +13,8 @@ from pathlib import Path from typing import Any +import pytest + # 🔧 Adjust this per repository INTEGRATION_DOMAIN = "pollenlevels" @@ -17,6 +25,15 @@ CONST_PATH = COMPONENT_DIR / "const.py" +def _fail_unexpected_ast(context: str) -> None: + """Fail with a consistent, actionable message for unsupported AST shapes.""" + + pytest.fail( + "Unexpected AST layout while extracting translation keys from " + f"config_flow.py ({context}); please update the helper in test_translations.py", + ) + + def _flatten_keys(data: dict[str, Any], prefix: str = "") -> set[str]: """Flatten nested dict keys into dotted paths.""" @@ -112,22 +129,18 @@ def _fields_from_schema_call(call: ast.Call, mapping: dict[str, str]) -> set[str vol.Schema({vol.Required(CONF_USERNAME): str, ...}) """ - if not call.args: - return set() + if not call.args or not isinstance(call.args[0], ast.Dict): + _fail_unexpected_ast("schema call arguments") arg = call.args[0] - if not isinstance(arg, ast.Dict): - return set() fields: set[str] = set() for key in arg.keys: - if not isinstance(key, ast.Call): - continue - if not isinstance(key.func, ast.Attribute): - continue + if not isinstance(key, ast.Call) or not isinstance(key.func, ast.Attribute): + _fail_unexpected_ast("schema key wrapper") if key.func.attr not in {"Required", "Optional"}: - continue + _fail_unexpected_ast(f"unexpected schema call {key.func.attr}") if not key.args: - continue + _fail_unexpected_ast("schema key args") selector = key.args[0] if isinstance(selector, ast.Constant) and isinstance(selector.value, str): fields.add(selector.value) @@ -135,6 +148,10 @@ def _fields_from_schema_call(call: ast.Call, mapping: dict[str, str]) -> set[str resolved = _resolve_name(selector.id, mapping) if resolved: fields.add(resolved) + else: + _fail_unexpected_ast(f"unmapped selector {selector.id}") + else: + _fail_unexpected_ast("selector type") return fields