From f7925763a46a52cf7f6191285a309d7d2030b906 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:20:51 +0200 Subject: [PATCH] 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 --- homeassistant/components/imap/config_flow.py | 67 ++++++-------- homeassistant/config_entries.py | 58 +++++++++--- tests/test_config_entries.py | 93 ++++++++++++++++++++ 3 files changed, 167 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index c855d099b4a..8dd3019878f 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME 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 .const import ( @@ -148,50 +148,39 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): """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( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the options.""" - errors: dict[str, str] = self._async_abort_entries_match( - { - 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 - ) + errors: dict[str, str] | None = None entry_data: dict[str, Any] = dict(self._config_entry.data) - if not errors and user_input is not None: - 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 + if user_input is not None: + try: + self._async_abort_entries_match( + { + 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 ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(self.config_entry.entry_id) - ) - return self.async_create_entry(data={}) + except AbortFlow as err: + errors = {"base": err.reason} + else: + 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) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b21ae391e2a..454cfeade27 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1468,6 +1468,28 @@ async def _old_conf_migrator(old_config: dict[str, Any]) -> dict[str, Any]: 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): """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. """ - if match_dict is None: - match_dict = {} # Match any entry - 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") + _async_abort_entries_match( + self._async_current_entries(include_ignore=False), match_dict + ) @callback def _abort_if_unique_id_configured( @@ -1858,6 +1871,27 @@ class OptionsFlow(data_entry_flow.FlowHandler): 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): """Base class for options flows with config entry and options.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index c8cdc561985..60b9a250c17 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -40,6 +40,7 @@ from .common import ( MockModule, MockPlatform, async_fire_time_changed, + mock_config_flow, mock_coro, mock_entity_platform, mock_integration, @@ -3388,6 +3389,98 @@ async def test__async_abort_entries_match( 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( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: