Make abort_entries_match available in options flow (#90406)

* Make abort_entries_match available in options flow

* Add tests

* Exclude ignore entries and add test

* Move to OptionsFlow

* Adjust tests

* Use mock_config_flow

* Use AbortFlow

* Remove duplicate code
This commit is contained in:
epenet 2023-03-29 17:20:51 +02:00 committed by GitHub
parent a1c94919de
commit f7925763a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 167 additions and 51 deletions

View File

@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import AbortFlow, FlowResult
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from .const import ( from .const import (
@ -148,50 +148,39 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): class OptionsFlow(config_entries.OptionsFlowWithConfigEntry):
"""Option flow handler.""" """Option flow handler."""
def _async_abort_entries_match(
self, match_dict: dict[str, Any] | None
) -> dict[str, str]:
"""Validate the user input against other config entries."""
if match_dict is None:
return {}
errors: dict[str, str] = {}
for entry in [
entry
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry is not self.config_entry
]:
if all(item in entry.data.items() for item in match_dict.items()):
errors["base"] = "already_configured"
break
return errors
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
"""Manage the options.""" """Manage the options."""
errors: dict[str, str] = self._async_abort_entries_match( errors: dict[str, str] | None = None
{
CONF_SERVER: self._config_entry.data[CONF_SERVER],
CONF_USERNAME: self._config_entry.data[CONF_USERNAME],
CONF_FOLDER: user_input[CONF_FOLDER],
CONF_SEARCH: user_input[CONF_SEARCH],
}
if user_input
else None
)
entry_data: dict[str, Any] = dict(self._config_entry.data) entry_data: dict[str, Any] = dict(self._config_entry.data)
if not errors and user_input is not None: if user_input is not None:
entry_data.update(user_input) try:
errors = await validate_input(entry_data) self._async_abort_entries_match(
if not errors: {
self.hass.config_entries.async_update_entry( CONF_SERVER: self._config_entry.data[CONF_SERVER],
self.config_entry, data=entry_data CONF_USERNAME: self._config_entry.data[CONF_USERNAME],
CONF_FOLDER: user_input[CONF_FOLDER],
CONF_SEARCH: user_input[CONF_SEARCH],
}
if user_input
else None
) )
self.hass.async_create_task( except AbortFlow as err:
self.hass.config_entries.async_reload(self.config_entry.entry_id) errors = {"base": err.reason}
) else:
return self.async_create_entry(data={}) entry_data.update(user_input)
errors = await validate_input(entry_data)
if not errors:
self.hass.config_entries.async_update_entry(
self.config_entry, data=entry_data
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(
self.config_entry.entry_id
)
)
return self.async_create_entry(data={})
schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data) schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data)

View File

@ -1468,6 +1468,28 @@ async def _old_conf_migrator(old_config: dict[str, Any]) -> dict[str, Any]:
return {"entries": old_config} return {"entries": old_config}
@callback
def _async_abort_entries_match(
other_entries: list[ConfigEntry], match_dict: dict[str, Any] | None = None
) -> None:
"""Abort if current entries match all data.
Requires `already_configured` in strings.json in user visible flows.
"""
if match_dict is None:
match_dict = {} # Match any entry
for entry in other_entries:
if all(
item
in ChainMap(
entry.options, # type: ignore[arg-type]
entry.data, # type: ignore[arg-type]
).items()
for item in match_dict.items()
):
raise data_entry_flow.AbortFlow("already_configured")
class ConfigFlow(data_entry_flow.FlowHandler): class ConfigFlow(data_entry_flow.FlowHandler):
"""Base class for config flows with some helpers.""" """Base class for config flows with some helpers."""
@ -1505,18 +1527,9 @@ class ConfigFlow(data_entry_flow.FlowHandler):
Requires `already_configured` in strings.json in user visible flows. Requires `already_configured` in strings.json in user visible flows.
""" """
if match_dict is None: _async_abort_entries_match(
match_dict = {} # Match any entry self._async_current_entries(include_ignore=False), match_dict
for entry in self._async_current_entries(include_ignore=False): )
if all(
item
in ChainMap(
entry.options, # type: ignore[arg-type]
entry.data, # type: ignore[arg-type]
).items()
for item in match_dict.items()
):
raise data_entry_flow.AbortFlow("already_configured")
@callback @callback
def _abort_if_unique_id_configured( def _abort_if_unique_id_configured(
@ -1858,6 +1871,27 @@ class OptionsFlow(data_entry_flow.FlowHandler):
handler: str handler: str
@callback
def _async_abort_entries_match(
self, match_dict: dict[str, Any] | None = None
) -> None:
"""Abort if another current entry matches all data.
Requires `already_configured` in strings.json in user visible flows.
"""
config_entry = cast(
ConfigEntry, self.hass.config_entries.async_get_entry(self.handler)
)
_async_abort_entries_match(
[
entry
for entry in self.hass.config_entries.async_entries(config_entry.domain)
if entry is not config_entry and entry.source != SOURCE_IGNORE
],
match_dict,
)
class OptionsFlowWithConfigEntry(OptionsFlow): class OptionsFlowWithConfigEntry(OptionsFlow):
"""Base class for options flows with config entry and options.""" """Base class for options flows with config entry and options."""

View File

@ -40,6 +40,7 @@ from .common import (
MockModule, MockModule,
MockPlatform, MockPlatform,
async_fire_time_changed, async_fire_time_changed,
mock_config_flow,
mock_coro, mock_coro,
mock_entity_platform, mock_entity_platform,
mock_integration, mock_integration,
@ -3388,6 +3389,98 @@ async def test__async_abort_entries_match(
assert result["reason"] == reason assert result["reason"] == reason
@pytest.mark.parametrize(
("matchers", "reason"),
[
({}, "already_configured"),
({"host": "3.3.3.3"}, "no_match"),
({"vendor": "no_match"}, "no_match"),
({"host": "3.4.5.6"}, "already_configured"),
({"host": "3.4.5.6", "ip": "3.4.5.6"}, "no_match"),
({"host": "3.4.5.6", "ip": "1.2.3.4"}, "already_configured"),
({"host": "3.4.5.6", "ip": "1.2.3.4", "port": 23}, "already_configured"),
(
{"host": "9.9.9.9", "ip": "6.6.6.6", "port": 12, "vendor": "zoo"},
"already_configured",
),
({"vendor": "zoo"}, "already_configured"),
({"ip": "9.9.9.9"}, "already_configured"),
({"ip": "7.7.7.7"}, "no_match"), # ignored
({"vendor": "data"}, "no_match"),
(
{"vendor": "options"},
"already_configured",
), # ensure options takes precedence over data
],
)
async def test__async_abort_entries_match_options_flow(
hass: HomeAssistant,
manager: config_entries.ConfigEntries,
matchers: dict[str, str],
reason: str,
) -> None:
"""Test aborting if matching config entries exist."""
MockConfigEntry(
domain="test_abort", data={"ip": "1.2.3.4", "host": "4.5.6.7", "port": 23}
).add_to_hass(hass)
MockConfigEntry(
domain="test_abort", data={"ip": "9.9.9.9", "host": "4.5.6.7", "port": 23}
).add_to_hass(hass)
MockConfigEntry(
domain="test_abort", data={"ip": "1.2.3.4", "host": "3.4.5.6", "port": 23}
).add_to_hass(hass)
MockConfigEntry(
domain="test_abort",
source=config_entries.SOURCE_IGNORE,
data={"ip": "7.7.7.7", "host": "4.5.6.7", "port": 23},
).add_to_hass(hass)
MockConfigEntry(
domain="test_abort",
data={"ip": "6.6.6.6", "host": "9.9.9.9", "port": 12},
options={"vendor": "zoo"},
).add_to_hass(hass)
MockConfigEntry(
domain="test_abort",
data={"vendor": "data"},
options={"vendor": "options"},
).add_to_hass(hass)
original_entry = MockConfigEntry(domain="test_abort", data={})
original_entry.add_to_hass(hass)
mock_setup_entry = AsyncMock(return_value=True)
mock_integration(hass, MockModule("test_abort", async_setup_entry=mock_setup_entry))
mock_entity_platform(hass, "config_flow.test_abort", None)
class TestFlow(config_entries.ConfigFlow):
"""Test flow."""
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Test options flow."""
class _OptionsFlow(config_entries.OptionsFlow):
"""Test flow."""
async def async_step_init(self, user_input=None):
"""Test user step."""
if errors := self._async_abort_entries_match(user_input):
return self.async_abort(reason=errors["base"])
return self.async_abort(reason="no_match")
return _OptionsFlow()
with mock_config_flow("test_abort", TestFlow):
result = await hass.config_entries.options.async_init(
original_entry.entry_id, data=matchers
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == reason
async def test_loading_old_data( async def test_loading_old_data(
hass: HomeAssistant, hass_storage: dict[str, Any] hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None: ) -> None: