fix: adopt OptionsFlowWithConfigEntry for HA 2024.6 compatibility and use voluptuous schema
This commit is contained in:
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.core import callback
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
@@ -15,6 +16,7 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@callback
|
||||||
def async_get_options_flow(
|
def async_get_options_flow(
|
||||||
config_entry: config_entries.ConfigEntry,
|
config_entry: config_entries.ConfigEntry,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
|
||||||
@@ -15,11 +16,6 @@ from .const import (
|
|||||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
import voluptuous as vol
|
|
||||||
except ImportError:
|
|
||||||
vol = None
|
|
||||||
|
|
||||||
MIN_HORIZON_DAYS = 1
|
MIN_HORIZON_DAYS = 1
|
||||||
MIN_PANEL_IDLE_TIMEOUT = 30
|
MIN_PANEL_IDLE_TIMEOUT = 30
|
||||||
_TIME_PART_LENGTH = 2
|
_TIME_PART_LENGTH = 2
|
||||||
@@ -65,30 +61,24 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def _build_schema(current: dict[str, Any]) -> Any:
|
def _build_schema(current: dict[str, Any]) -> vol.Schema:
|
||||||
"""Build the form schema, pre-filled with the current values."""
|
"""Build the form schema, pre-filled with the current values."""
|
||||||
if vol is not None:
|
return vol.Schema(
|
||||||
return vol.Schema(
|
{
|
||||||
{
|
vol.Required(
|
||||||
vol.Required(
|
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
|
||||||
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
|
): int,
|
||||||
): int,
|
vol.Required(
|
||||||
vol.Required(
|
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
|
||||||
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
|
): str,
|
||||||
): str,
|
vol.Required(
|
||||||
vol.Required(
|
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
||||||
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
): int,
|
||||||
): int,
|
}
|
||||||
}
|
)
|
||||||
)
|
|
||||||
return {
|
|
||||||
CONF_HORIZON_DAYS: current[CONF_HORIZON_DAYS],
|
|
||||||
CONF_DAY_ROLLOVER_TIME: current[CONF_DAY_ROLLOVER_TIME],
|
|
||||||
CONF_PANEL_IDLE_TIMEOUT: current[CONF_PANEL_IDLE_TIMEOUT],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class NestQuestOptionsFlow(config_entries.OptionsFlow):
|
class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||||
"""Handle the options flow for NestQuest."""
|
"""Handle the options flow for NestQuest."""
|
||||||
|
|
||||||
async def async_step_init(
|
async def async_step_init(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ dependencies = []
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
|
"voluptuous>=0.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|||||||
+153
-16
@@ -3,13 +3,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ast
|
import ast
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import importlib
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
# Mock homeassistant modules if not installed in environment
|
|
||||||
|
# Mock homeassistant modules if not installed in environment.
|
||||||
|
# The mock mirrors the REAL Home Assistant 2024.6 contract:
|
||||||
|
# config_entries.OptionsFlowWithConfigEntry accepts a config_entry kwarg and
|
||||||
|
# exposes .options / .config_entry; homeassistant.core.callback exists as a
|
||||||
|
# decorator; data_entry_flow.RESULT_TYPE_* constants and FlowResult exist.
|
||||||
for _mod in (
|
for _mod in (
|
||||||
"homeassistant",
|
"homeassistant",
|
||||||
"homeassistant.core",
|
"homeassistant.core",
|
||||||
@@ -24,8 +31,8 @@ sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_
|
|||||||
sys.modules["homeassistant"].data_entry_flow = sys.modules["homeassistant.data_entry_flow"]
|
sys.modules["homeassistant"].data_entry_flow = sys.modules["homeassistant.data_entry_flow"]
|
||||||
|
|
||||||
|
|
||||||
class _OptionsFlowBase:
|
class _OptionsFlowWithConfigEntryBase:
|
||||||
"""Minimal stand-in for homeassistant OptionsFlow."""
|
"""Stand-in mirroring HA 2024.6 config_entries.OptionsFlowWithConfigEntry."""
|
||||||
|
|
||||||
def __init__(self, config_entry=None):
|
def __init__(self, config_entry=None):
|
||||||
self.config_entry = config_entry
|
self.config_entry = config_entry
|
||||||
@@ -80,8 +87,42 @@ class _ConfigFlowBase:
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
sys.modules["homeassistant.config_entries"].OptionsFlow = _OptionsFlowBase
|
def _callback_decorator(fn):
|
||||||
sys.modules["homeassistant.config_entries"].ConfigFlow = _ConfigFlowBase
|
"""Mirror homeassistant.core.callback: flags the fn and returns it unchanged."""
|
||||||
|
fn.__ha_callback__ = True
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
_config_entries_mock = sys.modules["homeassistant.config_entries"]
|
||||||
|
_core_mock = sys.modules["homeassistant.core"]
|
||||||
|
_data_entry_flow_mock = sys.modules["homeassistant.data_entry_flow"]
|
||||||
|
|
||||||
|
# The concrete 2024.6-shaped surface the integration is written against:
|
||||||
|
# plain OptionsFlow has no config_entry constructor (NQ-001); only
|
||||||
|
# OptionsFlowWithConfigEntry does.
|
||||||
|
_config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase
|
||||||
|
_config_entries_mock.ConfigFlow = _ConfigFlowBase
|
||||||
|
_core_mock.callback = _callback_decorator
|
||||||
|
|
||||||
|
# data_entry_flow constants/annotations referenced by the production modules.
|
||||||
|
_data_entry_flow_mock.RESULT_TYPE_FORM = "form"
|
||||||
|
_data_entry_flow_mock.RESULT_TYPE_CREATE_ENTRY = "create_entry"
|
||||||
|
_data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
|
||||||
|
_data_entry_flow_mock.FlowResult = dict
|
||||||
|
|
||||||
|
# test_config_flow.py may run first and import the integration modules against a
|
||||||
|
# bare MagicMock surface. Force a clean re-import now that the 2024.6-shaped
|
||||||
|
# mocks are installed, so the concrete classes bind to them.
|
||||||
|
for _name in [
|
||||||
|
name
|
||||||
|
for name in sys.modules
|
||||||
|
if name.startswith("custom_components.nestquest")
|
||||||
|
]:
|
||||||
|
del sys.modules[_name]
|
||||||
|
|
||||||
|
importlib.import_module("custom_components.nestquest")
|
||||||
|
importlib.import_module("custom_components.nestquest.config_flow")
|
||||||
|
importlib.import_module("custom_components.nestquest.options_flow")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -168,6 +209,15 @@ def _make_flow(entry) -> options_flow.NestQuestOptionsFlow:
|
|||||||
return flow
|
return flow
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_values(schema) -> dict:
|
||||||
|
"""Extract the per-field default values from a real voluptuous schema."""
|
||||||
|
return {
|
||||||
|
marker.schema: marker.default()
|
||||||
|
for marker in schema.schema
|
||||||
|
if hasattr(marker, "schema")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_reachable_via_configure_button() -> None:
|
def test_options_flow_reachable_via_configure_button() -> None:
|
||||||
"""The config flow exposes async_get_options_flow returning the options flow."""
|
"""The config flow exposes async_get_options_flow returning the options flow."""
|
||||||
entry = _make_entry()
|
entry = _make_entry()
|
||||||
@@ -176,6 +226,32 @@ def test_options_flow_reachable_via_configure_button() -> None:
|
|||||||
assert handler.config_entry is entry
|
assert handler.config_entry is entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_get_options_flow_is_ha_callback() -> None:
|
||||||
|
"""async_get_options_flow is wrapped by the homeassistant.core.callback decorator."""
|
||||||
|
assert getattr(
|
||||||
|
config_flow.NestQuestConfigFlow.async_get_options_flow, "__ha_callback__", False
|
||||||
|
) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_constructs_through_2024_6_surface() -> None:
|
||||||
|
"""The handler constructs against the mocked 2024.6 base and inherits its contract."""
|
||||||
|
entry = _make_entry(options={CONF_HORIZON_DAYS: 21})
|
||||||
|
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
||||||
|
assert isinstance(flow, sys.modules["homeassistant.config_entries"].OptionsFlowWithConfigEntry)
|
||||||
|
assert flow.config_entry is entry
|
||||||
|
assert flow.options == {CONF_HORIZON_DAYS: 21}
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_submits_through_2024_6_surface() -> None:
|
||||||
|
"""The handler can be submitted through the mocked 2024.6-shaped surface."""
|
||||||
|
entry = _make_entry()
|
||||||
|
handler = config_flow.NestQuestConfigFlow.async_get_options_flow(entry)
|
||||||
|
result = _run(handler.async_step_init(dict(VALID_INPUT)))
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["title"] == "NestQuest"
|
||||||
|
assert result["data"] == VALID_INPUT
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_shows_form_initially() -> None:
|
def test_options_flow_shows_form_initially() -> None:
|
||||||
"""The init step shows a form."""
|
"""The init step shows a form."""
|
||||||
flow = _make_flow(_make_entry())
|
flow = _make_flow(_make_entry())
|
||||||
@@ -184,14 +260,23 @@ def test_options_flow_shows_form_initially() -> None:
|
|||||||
assert result["step_id"] == "init"
|
assert result["step_id"] == "init"
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_schema_is_voluptuous() -> None:
|
||||||
|
"""The form's data_schema is a real voluptuous Schema (no dict fallback)."""
|
||||||
|
flow = _make_flow(_make_entry())
|
||||||
|
result = _run(flow.async_step_init(None))
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
assert isinstance(result["data_schema"], vol.Schema)
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_prefills_from_defaults() -> None:
|
def test_options_flow_prefills_from_defaults() -> None:
|
||||||
"""Without stored options, the form pre-fills with the constant defaults."""
|
"""Without stored options, the form pre-fills with the constant defaults."""
|
||||||
flow = _make_flow(_make_entry())
|
flow = _make_flow(_make_entry())
|
||||||
result = _run(flow.async_step_init(None))
|
result = _run(flow.async_step_init(None))
|
||||||
schema = result["data_schema"]
|
values = _schema_values(result["data_schema"])
|
||||||
assert schema[CONF_HORIZON_DAYS] == DEFAULT_HORIZON_DAYS
|
assert values[CONF_HORIZON_DAYS] == DEFAULT_HORIZON_DAYS
|
||||||
assert schema[CONF_DAY_ROLLOVER_TIME] == DEFAULT_DAY_ROLLOVER_TIME
|
assert values[CONF_DAY_ROLLOVER_TIME] == DEFAULT_DAY_ROLLOVER_TIME
|
||||||
assert schema[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT
|
assert values[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_prefills_from_entry_options() -> None:
|
def test_options_flow_prefills_from_entry_options() -> None:
|
||||||
@@ -209,10 +294,10 @@ def test_options_flow_prefills_from_entry_options() -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
result = _run(_make_flow(entry).async_step_init(None))
|
result = _run(_make_flow(entry).async_step_init(None))
|
||||||
schema = result["data_schema"]
|
values = _schema_values(result["data_schema"])
|
||||||
assert schema[CONF_HORIZON_DAYS] == 21
|
assert values[CONF_HORIZON_DAYS] == 21
|
||||||
assert schema[CONF_DAY_ROLLOVER_TIME] == "05:45"
|
assert values[CONF_DAY_ROLLOVER_TIME] == "05:45"
|
||||||
assert schema[CONF_PANEL_IDLE_TIMEOUT] == 120
|
assert values[CONF_PANEL_IDLE_TIMEOUT] == 120
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_falls_back_to_entry_data() -> None:
|
def test_options_flow_falls_back_to_entry_data() -> None:
|
||||||
@@ -225,10 +310,38 @@ def test_options_flow_falls_back_to_entry_data() -> None:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = _run(_make_flow(entry).async_step_init(None))
|
result = _run(_make_flow(entry).async_step_init(None))
|
||||||
|
values = _schema_values(result["data_schema"])
|
||||||
|
assert values[CONF_HORIZON_DAYS] == 10
|
||||||
|
assert values[CONF_DAY_ROLLOVER_TIME] == "01:15"
|
||||||
|
assert values[CONF_PANEL_IDLE_TIMEOUT] == 90
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||||
|
"""A real voluptuous validate({}) fills every field from the stored defaults."""
|
||||||
|
entry = _make_entry(
|
||||||
|
options={
|
||||||
|
CONF_HORIZON_DAYS: 21,
|
||||||
|
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||||
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = _run(_make_flow(entry).async_step_init(None))
|
||||||
|
assert result["data_schema"]({}) == {
|
||||||
|
CONF_HORIZON_DAYS: 21,
|
||||||
|
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||||
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_schema_coerces_field_types() -> None:
|
||||||
|
"""The schema validates the expected field types via voluptuous."""
|
||||||
|
flow = _make_flow(_make_entry())
|
||||||
|
result = _run(flow.async_step_init(None))
|
||||||
schema = result["data_schema"]
|
schema = result["data_schema"]
|
||||||
assert schema[CONF_HORIZON_DAYS] == 10
|
validators = {marker.schema: validator for marker, validator in schema.schema.items()}
|
||||||
assert schema[CONF_DAY_ROLLOVER_TIME] == "01:15"
|
assert validators[CONF_HORIZON_DAYS] is int
|
||||||
assert schema[CONF_PANEL_IDLE_TIMEOUT] == 90
|
assert validators[CONF_DAY_ROLLOVER_TIME] is str
|
||||||
|
assert validators[CONF_PANEL_IDLE_TIMEOUT] is int
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad_horizon", [0, -5])
|
@pytest.mark.parametrize("bad_horizon", [0, -5])
|
||||||
@@ -371,6 +484,30 @@ def test_options_strings_no_raw_keys(strings: dict, translations_en: dict) -> No
|
|||||||
check(translations_en["options"])
|
check(translations_en["options"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_no_plain_dict_schema_fallback() -> None:
|
||||||
|
"""No try/except ImportError fallback for voluptuous in the options flow module."""
|
||||||
|
py_file = Path(options_flow.__file__)
|
||||||
|
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Try):
|
||||||
|
handlers = node.handlers or []
|
||||||
|
has_import_error_handler = any(
|
||||||
|
getattr(handler.type, "id", getattr(handler.type, "attr", None))
|
||||||
|
== "ImportError"
|
||||||
|
for handler in handlers
|
||||||
|
)
|
||||||
|
assert not has_import_error_handler, (
|
||||||
|
f"Plain-dict schema fallback (try/except ImportError) found in {py_file.name}:{node.lineno}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_subclasses_options_flow_with_config_entry() -> None:
|
||||||
|
"""NestQuestOptionsFlow subclasses config_entries.OptionsFlowWithConfigEntry."""
|
||||||
|
base = sys.modules["homeassistant.config_entries"].OptionsFlowWithConfigEntry
|
||||||
|
assert issubclass(options_flow.NestQuestOptionsFlow, base)
|
||||||
|
assert options_flow.NestQuestOptionsFlow.__bases__ == (base,)
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_no_hardcoded_domain_or_db_filename() -> None:
|
def test_options_flow_no_hardcoded_domain_or_db_filename() -> None:
|
||||||
"""The options flow module does not hardcode the domain or db filename."""
|
"""The options flow module does not hardcode the domain or db filename."""
|
||||||
py_file = Path(options_flow.__file__)
|
py_file = Path(options_flow.__file__)
|
||||||
|
|||||||
Reference in New Issue
Block a user