From 5fc2e6ed53d857bc94ca906351bbd7c805f3cd77 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 13 Aug 2025 11:59:37 +0200 Subject: [PATCH] Add async_update_reload_and_abort to config entry subentries (#149768) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/kitchen_sink/config_flow.py | 2 +- homeassistant/config_entries.py | 71 ++++- tests/test_config_entries.py | 269 ++++++++++++++++++ 3 files changed, 336 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 056ace7011c..27a10738f48 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -146,7 +146,7 @@ class SubentryFlowHandler(ConfigSubentryFlow): """Reconfigure a sensor.""" if user_input is not None: title = user_input.pop("name") - return self.async_update_and_abort( + return self.async_update_reload_and_abort( self._get_entry(), self._get_reconfigure_subentry(), data=user_input, diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index da8e73d9566..f5ccf9c3143 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3385,6 +3385,34 @@ class ConfigSubentryFlow( return result + @callback + def _async_update( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, + ) -> bool: + """Update config subentry and return result. + + Internal to be used by update_and_abort and update_reload_and_abort methods only. + """ + + if data_updates is not UNDEFINED: + if data is not UNDEFINED: + raise ValueError("Cannot set both data and data_updates") + data = subentry.data | data_updates + return self.hass.config_entries.async_update_subentry( + entry=entry, + subentry=subentry, + unique_id=unique_id, + title=title, + data=data, + ) + @callback def async_update_and_abort( self, @@ -3404,19 +3432,52 @@ class ConfigSubentryFlow( :param title: replace the title of the subentry :param unique_id: replace the unique_id of the subentry """ - if data_updates is not UNDEFINED: - if data is not UNDEFINED: - raise ValueError("Cannot set both data and data_updates") - data = subentry.data | data_updates - self.hass.config_entries.async_update_subentry( + self._async_update( entry=entry, subentry=subentry, unique_id=unique_id, title=title, data=data, + data_updates=data_updates, ) return self.async_abort(reason="reconfigure_successful") + @callback + def async_update_reload_and_abort( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, + reload_even_if_entry_is_unchanged: bool = True, + ) -> SubentryFlowResult: + """Update config subentry, reload config entry and finish subentry flow. + + :param data: replace the subentry data with new data + :param data_updates: add items from data_updates to subentry data - existing + keys are overridden + :param title: replace the title of the subentry + :param unique_id: replace the unique_id of the subentry + :param reload_even_if_entry_is_unchanged: set this to `False` if the entry + should not be reloaded if it is unchanged + """ + result = self._async_update( + entry=entry, + subentry=subentry, + unique_id=unique_id, + title=title, + data=data, + data_updates=data_updates, + ) + if reload_even_if_entry_is_unchanged or result: + if entry.update_listeners: + raise ValueError("Cannot update and reload entry with update listeners") + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + @property def _entry_id(self) -> str: """Return config entry id.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 833d28ecdd9..9a62fd421b7 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6519,6 +6519,275 @@ async def test_update_subentry_and_abort( assert result["reason"] == "reconfigure_successful" +@pytest.mark.parametrize( + ( + "kwargs", + "expected_title", + "expected_unique_id", + "expected_data", + "raises", + "reload", # True is default + "setup_call_count", + "expected_result", + ), + [ + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + }, + "Updated title", + "5678", + {"vendor": "data2"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "unique_id": "1234", + "title": "Test", + "data": {"vendor": "data"}, + }, + "Test", + "1234", + {"vendor": "data"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "unique_id": "1234", + "title": "Test", + "data": {"vendor": "data"}, + }, + "Test", + "1234", + {"vendor": "data"}, + does_not_raise(), + False, + 1, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + {}, + "Test", + "1234", + {"vendor": "data"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "data": {"buyer": "me"}, + }, + "Test", + "1234", + {"buyer": "me"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + {"data_updates": {"buyer": "me"}}, + "Test", + "1234", + {"vendor": "data", "buyer": "me"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + "data_updates": {"buyer": "me"}, + }, + "Test", + "1234", + {"vendor": "data"}, + pytest.raises(ValueError), + True, + 1, + {}, + ), + ], + ids=[ + "changed_entry_default", + "unchanged_entry_default", + "unchanged_entry_no_reload", + "no_kwargs", + "replace_data", + "update_data", + "update_and_data_raises", + ], +) +async def test_update_subentry_reload_and_abort( + hass: HomeAssistant, + expected_title: str, + expected_unique_id: str, + expected_data: dict[str, Any], + kwargs: dict[str, Any], + raises: AbstractContextManager, + reload: bool, + setup_call_count: int, + expected_result: dict[str, Any], +) -> None: + """Test updating an entry and reloading.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + subentry = entry.subentries[subentry_id] + + setup_entry = AsyncMock(return_value=True) + + comp = MockModule( + "comp", + async_setup_entry=setup_entry, + async_unload_entry=AsyncMock(return_value=True), + ) + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_update_reload_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + **kwargs, + reload_even_if_entry_is_unchanged=reload, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("comp", TestFlow), raises: + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) + + await hass.async_block_till_done() + + subentry = entry.subentries[subentry_id] + assert subentry.title == expected_title + assert subentry.unique_id == expected_unique_id + assert subentry.data == expected_data + assert setup_entry.call_count == setup_call_count + for k, v in expected_result.items(): + assert result[k] == v + + +async def test_update_subentry_reload_with_listener(hass: HomeAssistant) -> None: + """Test updating an entry and reloading fails with update listener.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + entry.add_update_listener(AsyncMock()) + + setup_entry = AsyncMock(return_value=True) + + comp = MockModule( + "comp", + async_setup_entry=setup_entry, + async_unload_entry=AsyncMock(return_value=True), + ) + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_update_reload_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data={}, + reload_even_if_entry_is_unchanged=True, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with ( + mock_config_flow("comp", TestFlow), + pytest.raises( + ValueError, match="Cannot update and reload entry with update listeners" + ), + ): + await entry.start_subentry_reconfigure_flow(hass, subentry_id) + + async def test_reconfigure_subentry_create_subentry(hass: HomeAssistant) -> None: """Test it's not allowed to create a subentry from a subentry reconfigure flow.""" subentry_id = "blabla"