From e15cf8b633b658731013046c470ace15767b7412 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 May 2024 10:27:27 +0200 Subject: [PATCH 01/13] Add support for subentries to config entries --- .../components/config/config_entries.py | 110 ++++++ homeassistant/config_entries.py | 197 +++++++++- tests/common.py | 2 + .../components/config/test_config_entries.py | 357 ++++++++++++++++++ tests/snapshots/test_config_entries.ambr | 2 + tests/test_config_entries.py | 189 +++++++++- 6 files changed, 846 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index da50f7e93a1..944d02bf6f7 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -46,6 +46,13 @@ def async_setup(hass: HomeAssistant) -> bool: hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) + hass.http.register_view( + SubentryManagerFlowIndexView(hass.config_entries.subentries) + ) + hass.http.register_view( + SubentryManagerFlowResourceView(hass.config_entries.subentries) + ) + websocket_api.async_register_command(hass, config_entries_get) websocket_api.async_register_command(hass, config_entry_disable) websocket_api.async_register_command(hass, config_entry_get_single) @@ -54,6 +61,9 @@ def async_setup(hass: HomeAssistant) -> bool: websocket_api.async_register_command(hass, config_entries_progress) websocket_api.async_register_command(hass, ignore_config_flow) + websocket_api.async_register_command(hass, config_subentry_delete) + websocket_api.async_register_command(hass, config_subentry_list) + return True @@ -285,6 +295,48 @@ class OptionManagerFlowResourceView( return await super().post(request, flow_id) +class SubentryManagerFlowIndexView( + FlowManagerIndexView[config_entries.ConfigSubentryFlowManager] +): + """View to create subentry flows.""" + + url = "/api/config/config_entries/subentries/flow" + name = "api:config:config_entries:subentries:flow" + + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) + ) + async def post(self, request: web.Request) -> web.Response: + """Handle a POST request. + + handler in request is entry_id. + """ + return await super().post(request) + + +class SubentryManagerFlowResourceView( + FlowManagerResourceView[config_entries.ConfigSubentryFlowManager] +): + """View to interact with the subentry flow manager.""" + + url = "/api/config/config_entries/subentries/flow/{flow_id}" + name = "api:config:config_entries:subentries:flow:resource" + + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) + ) + async def get(self, request: web.Request, /, flow_id: str) -> web.Response: + """Get the current state of a data_entry_flow.""" + return await super().get(request, flow_id) + + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) + ) + async def post(self, request: web.Request, flow_id: str) -> web.Response: + """Handle a POST request.""" + return await super().post(request, flow_id) + + @websocket_api.require_admin @websocket_api.websocket_command({"type": "config_entries/flow/progress"}) def config_entries_progress( @@ -588,3 +640,61 @@ async def _async_matching_config_entries_json_fragments( ) or (filter_is_not_helper and entry.domain not in integrations) ] + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + "type": "config_entries/subentries/list", + "entry_id": str, + } +) +@websocket_api.async_response +async def config_subentry_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List subentries of a config entry.""" + entry = get_entry(hass, connection, msg["entry_id"], msg["id"]) + if entry is None: + return + + result = { + "subentries": { + subentry_id: {"title": subentry.title} + for subentry_id, subentry in entry.subentries.items() + } + } + connection.send_result(msg["id"], result) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + "type": "config_entries/subentries/delete", + "entry_id": str, + "subentry_id": str, + } +) +@websocket_api.async_response +async def config_subentry_delete( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Delete a subentry of a config entry.""" + entry = get_entry(hass, connection, msg["entry_id"], msg["id"]) + if entry is None: + return + + hass.config_entries.async_update_entry( + entry, + subentries={ + subentry_id: subentry + for subentry_id, subentry in entry.subentries.items() + if subentry_id != msg["subentry_id"] + }, + ) + + connection.send_result(msg["id"]) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a13225c4dfe..af41d44012b 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -15,6 +15,7 @@ from collections.abc import ( ) from contextvars import ContextVar from copy import deepcopy +from dataclasses import dataclass from datetime import datetime from enum import Enum, StrEnum import functools @@ -22,7 +23,7 @@ from functools import cache import logging from random import randint from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Generic, Self, cast +from typing import TYPE_CHECKING, Any, Generic, Self, TypedDict, cast from async_interrupt import interrupt from propcache import cached_property @@ -123,7 +124,7 @@ HANDLERS: Registry[str, type[ConfigFlow]] = Registry() STORAGE_KEY = "core.config_entries" STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 4 +STORAGE_VERSION_MINOR = 5 SAVE_DELAY = 1 @@ -295,6 +296,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False): minor_version: int options: Mapping[str, Any] + subentries: Mapping[str, ConfigSubentryData] version: int @@ -308,6 +310,31 @@ def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> N ) +class ConfigSubentryData(TypedDict): + """Container for configuration subentry data.""" + + data: Mapping[str, Any] + title: str + + +class SubentryFlowResult(FlowResult, total=False): + """Typed result dict for subentry flow.""" + + subentry_id: str + + +@dataclass(frozen=True) +class ConfigSubentry: + """Container for a configuration subentry.""" + + data: MappingProxyType[str, Any] + title: str + + def as_dict(self) -> dict[str, Any]: + """Return dictionary version of this subentry.""" + return {"data": dict(self.data), "title": self.title} + + class ConfigEntry(Generic[_DataT]): """Hold a configuration entry.""" @@ -317,6 +344,7 @@ class ConfigEntry(Generic[_DataT]): data: MappingProxyType[str, Any] runtime_data: _DataT options: MappingProxyType[str, Any] + subentries: MappingProxyType[str, ConfigSubentry] unique_id: str | None state: ConfigEntryState reason: str | None @@ -332,6 +360,7 @@ class ConfigEntry(Generic[_DataT]): supports_remove_device: bool | None _supports_options: bool | None _supports_reconfigure: bool | None + _supports_subentries: bool | None update_listeners: list[UpdateListenerType] _async_cancel_retry_setup: Callable[[], Any] | None _on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None @@ -361,6 +390,7 @@ class ConfigEntry(Generic[_DataT]): pref_disable_polling: bool | None = None, source: str, state: ConfigEntryState = ConfigEntryState.NOT_LOADED, + subentries: Mapping[str, ConfigSubentryData] | None, title: str, unique_id: str | None, version: int, @@ -386,6 +416,16 @@ class ConfigEntry(Generic[_DataT]): # Entry options _setter(self, "options", MappingProxyType(options or {})) + # Subentries + subentries = subentries or {} + _subentries = { + subentry_id: ConfigSubentry( + MappingProxyType(subentry["data"]), subentry["title"] + ) + for subentry_id, subentry in subentries.items() + } + _setter(self, "subentries", MappingProxyType(_subentries)) + # Entry system options if pref_disable_new_entities is None: pref_disable_new_entities = False @@ -422,6 +462,9 @@ class ConfigEntry(Generic[_DataT]): # Supports reconfigure _setter(self, "_supports_reconfigure", None) + # Supports subentries + _setter(self, "_supports_subentries", None) + # Listeners to call on update _setter(self, "update_listeners", []) @@ -494,6 +537,16 @@ class ConfigEntry(Generic[_DataT]): ) return self._supports_reconfigure or False + @property + def supports_subentries(self) -> bool: + """Return if entry supports adding and removing sub entries.""" + if self._supports_subentries is None and (handler := HANDLERS.get(self.domain)): + # work out if handler has support for sub entries + object.__setattr__( + self, "_supports_subentries", handler.async_supports_subentries(self) + ) + return self._supports_subentries or False + def clear_state_cache(self) -> None: """Clear cached properties that are included in as_json_fragment.""" self.__dict__.pop("as_json_fragment", None) @@ -513,6 +566,7 @@ class ConfigEntry(Generic[_DataT]): "supports_remove_device": self.supports_remove_device or False, "supports_unload": self.supports_unload or False, "supports_reconfigure": self.supports_reconfigure, + "supports_subentries": self.supports_subentries, "pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_polling": self.pref_disable_polling, "disabled_by": self.disabled_by, @@ -1013,6 +1067,10 @@ class ConfigEntry(Generic[_DataT]): "pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_polling": self.pref_disable_polling, "source": self.source, + "subentries": { + subentry_id: subentry.as_dict() + for subentry_id, subentry in self.subentries.items() + }, "title": self.title, "unique_id": self.unique_id, "version": self.version, @@ -1502,6 +1560,7 @@ class ConfigEntriesFlowManager( minor_version=result["minor_version"], options=result["options"], source=flow.context["source"], + subentries=result["subentries"], title=result["title"], unique_id=flow.unique_id, version=result["version"], @@ -1786,6 +1845,11 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]): for entry in data["entries"]: entry["discovery_keys"] = {} + if old_minor_version < 5: + # Version 1.4 adds config subentries + for entry in data["entries"]: + entry.setdefault("subentries", entry.get("subentries", {})) + if old_major_version > 1: raise NotImplementedError return data @@ -1802,6 +1866,7 @@ class ConfigEntries: self.hass = hass self.flow = ConfigEntriesFlowManager(hass, self, hass_config) self.options = OptionsFlowManager(hass) + self.subentries = ConfigSubentryFlowManager(hass) self._hass_config = hass_config self._entries = ConfigEntryItems(hass) self._store = ConfigEntryStore(hass) @@ -1981,6 +2046,7 @@ class ConfigEntries: pref_disable_new_entities=entry["pref_disable_new_entities"], pref_disable_polling=entry["pref_disable_polling"], source=entry["source"], + subentries=entry["subentries"], title=entry["title"], unique_id=entry["unique_id"], version=entry["version"], @@ -2136,6 +2202,7 @@ class ConfigEntries: options: Mapping[str, Any] | UndefinedType = UNDEFINED, pref_disable_new_entities: bool | UndefinedType = UNDEFINED, pref_disable_polling: bool | UndefinedType = UNDEFINED, + subentries: Mapping[str, ConfigSubentry] | UndefinedType = UNDEFINED, title: str | UndefinedType = UNDEFINED, unique_id: str | None | UndefinedType = UNDEFINED, version: int | UndefinedType = UNDEFINED, @@ -2202,6 +2269,10 @@ class ConfigEntries: changed = True _setter(entry, "options", MappingProxyType(options)) + if subentries is not UNDEFINED and entry.subentries != subentries: + changed = True + _setter(entry, "subentries", MappingProxyType(subentries)) + if not changed: return False @@ -2539,6 +2610,18 @@ class ConfigFlow(ConfigEntryBaseFlow): """Return options flow support for this handler.""" return cls.async_get_options_flow is not ConfigFlow.async_get_options_flow + @staticmethod + @callback + def async_get_subentry_flow(config_entry: ConfigEntry) -> ConfigSubentryFlow: + """Get the subentry flow for this handler.""" + raise data_entry_flow.UnknownHandler + + @classmethod + @callback + def async_supports_subentries(cls, config_entry: ConfigEntry) -> bool: + """Return subentry support for this handler.""" + return cls.async_get_subentry_flow is not ConfigFlow.async_get_subentry_flow + @callback def _async_abort_entries_match( self, match_dict: dict[str, Any] | None = None @@ -2847,6 +2930,7 @@ class ConfigFlow(ConfigEntryBaseFlow): description: str | None = None, description_placeholders: Mapping[str, str] | None = None, options: Mapping[str, Any] | None = None, + subentries: Mapping[str, ConfigSubentryData] | None = None, ) -> ConfigFlowResult: """Finish config flow and create a config entry.""" if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: @@ -2872,6 +2956,7 @@ class ConfigFlow(ConfigEntryBaseFlow): result["minor_version"] = self.MINOR_VERSION result["options"] = options or {} + result["subentries"] = subentries or {} result["version"] = self.VERSION return result @@ -2990,12 +3075,10 @@ class ConfigFlow(ConfigEntryBaseFlow): raise UnknownEntry -class OptionsFlowManager( - data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult] -): - """Flow to set options for a configuration entry.""" +class _ConfigSubFlowManager: + """Mixin class for flow managers which manage flows tied to a config entry.""" - _flow_result = ConfigFlowResult + hass: HomeAssistant def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry: """Return config entry or raise if not found.""" @@ -3005,6 +3088,106 @@ class OptionsFlowManager( return entry + +class ConfigSubentryFlowManager( + data_entry_flow.FlowManager[FlowContext, SubentryFlowResult], _ConfigSubFlowManager +): + """Manage all the config subentry flows that are in progress.""" + + _flow_result = SubentryFlowResult + + async def async_create_flow( + self, + handler_key: str, + *, + context: FlowContext | None = None, + data: dict[str, Any] | None = None, + ) -> ConfigSubentryFlow: + """Create a subentry flow for a config entry. + + Entry_id and flow.handler is the same thing to map entry with flow. + """ + entry = self._async_get_config_entry(handler_key) + handler = await _async_get_flow_handler(self.hass, entry.domain, {}) + return handler.async_get_subentry_flow(entry) + + async def async_finish_flow( + self, + flow: data_entry_flow.FlowHandler[FlowContext, SubentryFlowResult], + result: SubentryFlowResult, + ) -> SubentryFlowResult: + """Finish a subentry flow and add a new subentry to the configuration entry. + + Flow.handler and entry_id is the same thing to map flow with entry. + """ + flow = cast(ConfigSubentryFlow, flow) + + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: + return result + + entry = self.hass.config_entries.async_get_entry(flow.handler) + if entry is None: + raise UnknownEntry(flow.handler) + + if "subentry_id" not in result or not isinstance( + subentry_id := result["subentry_id"], str + ): + raise HomeAssistantError("subentry_id must be a string") + + if subentry_id in entry.subentries: + raise data_entry_flow.AbortFlow("already_configured") + + self.hass.config_entries.async_update_entry( + entry, + subentries=entry.subentries + | { + subentry_id: ConfigSubentry( + MappingProxyType(result["data"]), result["title"] + ) + }, + ) + + result["result"] = True + return result + + +class ConfigSubentryFlow(data_entry_flow.FlowHandler[FlowContext, SubentryFlowResult]): + """Base class for config options flows.""" + + _flow_result = SubentryFlowResult + handler: str + + @callback + def async_create_entry( # type: ignore[override] + self, + *, + title: str | None = None, + data: Mapping[str, Any], + description: str | None = None, + description_placeholders: Mapping[str, str] | None = None, + subentry_id: str, + ) -> SubentryFlowResult: + """Finish config flow and create a config entry.""" + result = super().async_create_entry( + title=title, + data=data, + description=description, + description_placeholders=description_placeholders, + ) + + result["subentry_id"] = subentry_id + + return result + + +class OptionsFlowManager( + data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult], + _ConfigSubFlowManager, +): + """Manage all the config entry option flows that are in progress.""" + + _flow_result = ConfigFlowResult + async def async_create_flow( self, handler_key: str, diff --git a/tests/common.py b/tests/common.py index 8bd45e4d7f8..610b5833267 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1000,6 +1000,7 @@ class MockConfigEntry(config_entries.ConfigEntry): reason=None, source=config_entries.SOURCE_USER, state=None, + subentries=None, title="Mock Title", unique_id=None, version=1, @@ -1016,6 +1017,7 @@ class MockConfigEntry(config_entries.ConfigEntry): "options": options or {}, "pref_disable_new_entities": pref_disable_new_entities, "pref_disable_polling": pref_disable_polling, + "subentries": subentries or {}, "title": title, "unique_id": unique_id, "version": version, diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index b96aa9ae006..092cef7c300 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -145,6 +145,7 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "supports_options": True, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": True, "title": "Test 1", }, @@ -163,6 +164,7 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 2", }, @@ -181,6 +183,7 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 3", }, @@ -199,6 +202,7 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 4", }, @@ -217,6 +221,7 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 5", }, @@ -583,6 +588,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test Entry", }, @@ -590,6 +596,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: "description_placeholders": None, "options": {}, "minor_version": 1, + "subentries": {}, } @@ -666,6 +673,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "user-title", }, @@ -673,6 +681,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "description_placeholders": None, "options": {}, "minor_version": 1, + "subentries": {}, } @@ -1092,6 +1101,244 @@ async def test_options_flow_with_invalid_data( assert data == {"errors": {"choices": "invalid is not a valid option"}} +async def test_subentry_flow(hass: HomeAssistant, client) -> None: + """Test we can start a subentry flow.""" + + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_subentry_flow(config_entry): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + schema = OrderedDict() + schema[vol.Required("enabled")] = bool + return self.async_show_form( + step_id="user", + data_schema=schema, + description_placeholders={"enabled": "Set to true to be true"}, + ) + + async def async_step_user(self, user_input=None): + raise NotImplementedError + + return SubentryFlowHandler() + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with patch.dict(HANDLERS, {"test": TestFlow}): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": entry.entry_id}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + data.pop("flow_id") + assert data == { + "type": "form", + "handler": "test1", + "step_id": "user", + "data_schema": [{"name": "enabled", "required": True, "type": "boolean"}], + "description_placeholders": {"enabled": "Set to true to be true"}, + "errors": None, + "last_step": None, + "preview": None, + } + + +@pytest.mark.parametrize( + ("endpoint", "method"), + [ + ("/api/config/config_entries/subentries/flow", "post"), + ("/api/config/config_entries/subentries/flow/1", "get"), + ("/api/config/config_entries/subentries/flow/1", "post"), + ], +) +async def test_subentry_flow_unauth( + hass: HomeAssistant, client, hass_admin_user: MockUser, endpoint: str, method: str +) -> None: + """Test unauthorized on subentry flow.""" + + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_subentry_flow(config_entry): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + schema = OrderedDict() + schema[vol.Required("enabled")] = bool + return self.async_show_form( + step_id="user", + data_schema=schema, + description_placeholders={"enabled": "Set to true to be true"}, + ) + + return SubentryFlowHandler() + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + hass_admin_user.groups = [] + + with patch.dict(HANDLERS, {"test": TestFlow}): + resp = await getattr(client, method)(endpoint, json={"handler": entry.entry_id}) + + assert resp.status == HTTPStatus.UNAUTHORIZED + + +async def test_two_step_subentry_flow(hass: HomeAssistant, client) -> None: + """Test we can finish a two step subentry flow.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_subentry_flow(config_entry): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + return self.async_show_form( + step_id="finish", data_schema=vol.Schema({"enabled": bool}) + ) + + async def async_step_finish(self, user_input=None): + return self.async_create_entry( + title="Mock title", data=user_input, subentry_id="test" + ) + + return SubentryFlowHandler() + + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with patch.dict(HANDLERS, {"test": TestFlow}): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": entry.entry_id}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + flow_id = data.pop("flow_id") + assert data == { + "type": "form", + "handler": "test1", + "step_id": "finish", + "data_schema": [{"name": "enabled", "type": "boolean"}], + "description_placeholders": None, + "errors": None, + "last_step": None, + "preview": None, + } + + with patch.dict(HANDLERS, {"test": TestFlow}): + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"enabled": True}, + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + data.pop("flow_id") + assert data == { + "handler": "test1", + "type": "create_entry", + "title": "Mock title", + "description": None, + "description_placeholders": None, + "subentry_id": "test", + } + + +async def test_subentry_flow_with_invalid_data(hass: HomeAssistant, client) -> None: + """Test a subentry flow with invalid_data.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_subentry_flow(config_entry): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + return self.async_show_form( + step_id="finish", + data_schema=vol.Schema( + { + vol.Required( + "choices", default=["invalid", "valid"] + ): cv.multi_select({"valid": "Valid"}) + } + ), + ) + + async def async_step_finish(self, user_input=None): + return self.async_create_entry( + title="Enable disable", data=user_input + ) + + return SubentryFlowHandler() + + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with patch.dict(HANDLERS, {"test": TestFlow}): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": entry.entry_id}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + flow_id = data.pop("flow_id") + assert data == { + "type": "form", + "handler": "test1", + "step_id": "finish", + "data_schema": [ + { + "default": ["invalid", "valid"], + "name": "choices", + "options": {"valid": "Valid"}, + "required": True, + "type": "multi_select", + } + ], + "description_placeholders": None, + "errors": None, + "last_step": None, + "preview": None, + } + + with patch.dict(HANDLERS, {"test": TestFlow}): + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"choices": ["valid", "invalid"]}, + ) + assert resp.status == HTTPStatus.BAD_REQUEST + data = await resp.json() + assert data == {"errors": {"choices": "invalid is not a valid option"}} + + @pytest.mark.usefixtures("freezer") async def test_get_single( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -1132,6 +1379,7 @@ async def test_get_single( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Mock Title", } @@ -1496,6 +1744,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 1", }, @@ -1515,6 +1764,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 2", }, @@ -1534,6 +1784,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 3", }, @@ -1553,6 +1804,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 4", }, @@ -1572,6 +1824,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 5", }, @@ -1602,6 +1855,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 1", } @@ -1631,6 +1885,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 4", }, @@ -1650,6 +1905,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 5", }, @@ -1679,6 +1935,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 1", }, @@ -1698,6 +1955,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 3", }, @@ -1733,6 +1991,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 1", }, @@ -1752,6 +2011,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 2", }, @@ -1771,6 +2031,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 3", }, @@ -1790,6 +2051,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 4", }, @@ -1809,6 +2071,7 @@ async def test_get_matching_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 5", }, @@ -1916,6 +2179,7 @@ async def test_subscribe_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 1", }, @@ -1938,6 +2202,7 @@ async def test_subscribe_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 2", }, @@ -1960,6 +2225,7 @@ async def test_subscribe_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 3", }, @@ -1988,6 +2254,7 @@ async def test_subscribe_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "changed", }, @@ -2017,6 +2284,7 @@ async def test_subscribe_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "changed", }, @@ -2045,6 +2313,7 @@ async def test_subscribe_entries_ws( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "changed", }, @@ -2135,6 +2404,7 @@ async def test_subscribe_entries_ws_filtered( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 1", }, @@ -2157,6 +2427,7 @@ async def test_subscribe_entries_ws_filtered( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "Test 3", }, @@ -2187,6 +2458,7 @@ async def test_subscribe_entries_ws_filtered( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "changed", }, @@ -2213,6 +2485,7 @@ async def test_subscribe_entries_ws_filtered( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "changed too", }, @@ -2243,6 +2516,7 @@ async def test_subscribe_entries_ws_filtered( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "changed", }, @@ -2271,6 +2545,7 @@ async def test_subscribe_entries_ws_filtered( "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, + "supports_subentries": False, "supports_unload": False, "title": "changed", }, @@ -2478,3 +2753,85 @@ async def test_does_not_support_reconfigure( response == '{"message":"Handler ConfigEntriesFlowManager doesn\'t support step reconfigure"}' ) + + +async def test_list_subentries( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that we can list subentries.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry( + domain="test", + state=core_ce.ConfigEntryState.LOADED, + subentries={ + "test": core_ce.ConfigSubentryData( + data={"test": "test"}, title="Mock title" + ) + }, + ) + entry.add_to_hass(hass) + + assert entry.pref_disable_new_entities is False + assert entry.pref_disable_polling is False + + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/list", + "entry_id": entry.entry_id, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] == { + "subentries": {"test": {"title": "Mock title"}}, + } + + +async def test_delete_subentry( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that we can delete a subentry.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry( + domain="test", + state=core_ce.ConfigEntryState.LOADED, + subentries={ + "test": core_ce.ConfigSubentryData( + data={"test": "test"}, title="Mock title" + ) + }, + ) + entry.add_to_hass(hass) + + assert entry.pref_disable_new_entities is False + assert entry.pref_disable_polling is False + + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/delete", + "entry_id": entry.entry_id, + "subentry_id": "test", + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] is None + + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/list", + "entry_id": entry.entry_id, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] == { + "subentries": {}, + } diff --git a/tests/snapshots/test_config_entries.ambr b/tests/snapshots/test_config_entries.ambr index 51e56f4874e..b8340338d4c 100644 --- a/tests/snapshots/test_config_entries.ambr +++ b/tests/snapshots/test_config_entries.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': dict({ + }), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 3e3f3b4c504..00887959635 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1416,6 +1416,32 @@ async def test_update_entry_options_and_trigger_listener( assert len(update_listener_calls) == 1 +async def test_update_subentry_and_trigger_listener( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can update subentry and trigger listener.""" + entry = MockConfigEntry(domain="test", options={"first": True}) + entry.add_to_manager(manager) + update_listener_calls = [] + + subentries = {"test": config_entries.ConfigSubentry({"test": "test"}, "Mock title")} + + async def update_listener( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Test function.""" + assert entry.subentries == subentries + update_listener_calls.append(None) + + entry.add_update_listener(update_listener) + + assert manager.async_update_entry(entry, subentries=subentries) is True + + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.subentries == subentries + assert len(update_listener_calls) == 1 + + async def test_setup_raise_not_ready( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -1742,16 +1768,163 @@ async def test_entry_options_unknown_config_entry( mock_integration(hass, MockModule("test")) mock_platform(hass, "test.config_flow", None) - class TestFlow: + with pytest.raises(config_entries.UnknownEntry): + await manager.options.async_create_flow( + "blah", context={"source": "test"}, data=None + ) + + +async def test_create_entry_subentries( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test a config entry being created with subentries.""" + + subentrydata = config_entries.ConfigSubentryData( + data={"test": "test"}, + title="Mock title", + ) + subentry = config_entries.ConfigSubentry( + subentrydata["data"], + subentrydata["title"], + ) + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Mock setup.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_IMPORT}, + data={"data": "data", "subentry": subentrydata}, + ) + ) + return True + + async_setup_entry = AsyncMock(return_value=True) + mock_integration( + hass, + MockModule( + "comp", async_setup=mock_async_setup, async_setup_entry=async_setup_entry + ), + ) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_import(self, user_input): + """Test import step creating entry, with subentry.""" + return self.async_create_entry( + title="title", + data={"example": user_input["data"]}, + subentries={"test": user_input["subentry"]}, + ) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + assert await async_setup_component(hass, "comp", {}) + + await hass.async_block_till_done() + + assert len(async_setup_entry.mock_calls) == 1 + + entries = hass.config_entries.async_entries("comp") + assert len(entries) == 1 + assert entries[0].supports_subentries is False + assert entries[0].data == {"example": "data"} + assert entries[0].subentries == {"test": subentry} + + +async def test_entry_subentry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can add a subentry to en entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): """Test flow.""" @staticmethod @callback - def async_get_options_flow(config_entry): - """Test options flow.""" + def async_get_subentry_flow(config_entry): + """Test subentry flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + return SubentryFlowHandler() + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + entry.entry_id, context={"source": "test"}, data=None + ) + + flow.handler = entry.entry_id # Used to keep reference to config entry + + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "subentry_id": "test", + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + }, + ) + + assert entry.data == {"first": True} + assert entry.options == {} + assert entry.subentries == { + "test": config_entries.ConfigSubentry({"second": True}, "Mock title") + } + assert entry.supports_subentries is True + + +async def test_entry_subentry_abort( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can abort subentry flow.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_subentry_flow(config_entry): + """Test subentry flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + return SubentryFlowHandler() + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + entry.entry_id, context={"source": "test"}, data=None + ) + + flow.handler = entry.entry_id # Used to keep reference to config entry + + assert await manager.subentries.async_finish_flow( + flow, {"type": data_entry_flow.FlowResultType.ABORT, "reason": "test"} + ) + + +async def test_entry_subentry_unknown_config_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to start a subentry flow for an unknown config entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) with pytest.raises(config_entries.UnknownEntry): - await manager.options.async_create_flow( + await manager.subentries.async_create_flow( "blah", context={"source": "test"}, data=None ) @@ -3918,6 +4091,11 @@ async def test_updating_entry_with_and_without_changes( {"options": {"hello": True}}, {"pref_disable_new_entities": True}, {"pref_disable_polling": True}, + { + "subentries": { + "test": config_entries.ConfigSubentry({"test": "test"}, "Mock title") + } + }, {"title": "sometitle"}, {"unique_id": "abcd1234"}, {"version": 2}, @@ -5439,6 +5617,7 @@ async def test_unhashable_unique_id_fails( minor_version=1, options={}, source="test", + subentries={}, title="title", unique_id=unique_id, version=1, @@ -5546,6 +5725,7 @@ async def test_hashable_unique_id( minor_version=1, options={}, source="test", + subentries={}, title="title", unique_id=unique_id, version=1, @@ -6468,6 +6648,7 @@ async def test_migration_from_1_2( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "import", + "subentries": {}, "title": "Sun", "unique_id": None, "version": 1, From a3077513e42375ca604ebac3ab109181220a82b3 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 May 2024 14:45:30 +0200 Subject: [PATCH 02/13] Improve error handling and test coverage --- .../components/config/config_entries.py | 9 +- .../components/config/test_config_entries.py | 88 ++++++++-- tests/test_config_entries.py | 152 +++++++++++++++++- 3 files changed, 231 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 944d02bf6f7..7de80f8127e 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -688,12 +688,19 @@ async def config_subentry_delete( if entry is None: return + subentry_id_to_delete = msg["subentry_id"] + if subentry_id_to_delete not in entry.subentries: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config subentry not found" + ) + return + hass.config_entries.async_update_entry( entry, subentries={ subentry_id: subentry for subentry_id, subentry in entry.subentries.items() - if subentry_id != msg["subentry_id"] + if subentry_id != subentry_id_to_delete }, ) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 092cef7c300..99dcbb4ce76 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1211,13 +1211,16 @@ async def test_two_step_subentry_flow(hass: HomeAssistant, client) -> None: def async_get_subentry_flow(config_entry): class SubentryFlowHandler(core_ce.ConfigSubentryFlow): async def async_step_init(self, user_input=None): - return self.async_show_form( - step_id="finish", data_schema=vol.Schema({"enabled": bool}) - ) + return await self.async_step_finish() async def async_step_finish(self, user_input=None): - return self.async_create_entry( - title="Mock title", data=user_input, subentry_id="test" + if user_input: + return self.async_create_entry( + title="Mock title", data=user_input, subentry_id="test" + ) + + return self.async_show_form( + step_id="finish", data_schema=vol.Schema({"enabled": bool}) ) return SubentryFlowHandler() @@ -1235,33 +1238,39 @@ async def test_two_step_subentry_flow(hass: HomeAssistant, client) -> None: assert resp.status == HTTPStatus.OK data = await resp.json() - flow_id = data.pop("flow_id") - assert data == { - "type": "form", - "handler": "test1", - "step_id": "finish", + flow_id = data["flow_id"] + expected_data = { "data_schema": [{"name": "enabled", "type": "boolean"}], "description_placeholders": None, "errors": None, + "flow_id": flow_id, + "handler": "test1", "last_step": None, "preview": None, + "step_id": "finish", + "type": "form", } + assert data == expected_data + + resp = await client.get(f"/api/config/config_entries/subentries/flow/{flow_id}") + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data == expected_data - with patch.dict(HANDLERS, {"test": TestFlow}): resp = await client.post( f"/api/config/config_entries/subentries/flow/{flow_id}", json={"enabled": True}, ) assert resp.status == HTTPStatus.OK data = await resp.json() - data.pop("flow_id") assert data == { - "handler": "test1", - "type": "create_entry", - "title": "Mock title", - "description": None, "description_placeholders": None, + "description": None, + "flow_id": flow_id, + "handler": "test1", "subentry_id": "test", + "title": "Mock title", + "type": "create_entry", } @@ -2789,6 +2798,21 @@ async def test_list_subentries( "subentries": {"test": {"title": "Mock title"}}, } + # Try listing subentries for an unknown entry + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/list", + "entry_id": "no_such_entry", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config entry not found", + } + async def test_delete_subentry( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -2835,3 +2859,35 @@ async def test_delete_subentry( assert response["result"] == { "subentries": {}, } + + # Try deleting the subentry again + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/delete", + "entry_id": entry.entry_id, + "subentry_id": "test", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config subentry not found", + } + + # Try deleting subentry from an unknown entry + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/delete", + "entry_id": "no_such_entry", + "subentry_id": "test", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config entry not found", + } diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 00887959635..3e3614f6a40 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1838,7 +1838,7 @@ async def test_create_entry_subentries( async def test_entry_subentry( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: - """Test that we can add a subentry to en entry.""" + """Test that we can add a subentry to an entry.""" mock_integration(hass, MockModule("test")) mock_platform(hass, "test.config_flow", None) entry = MockConfigEntry(domain="test", data={"first": True}) @@ -1882,6 +1882,92 @@ async def test_entry_subentry( assert entry.supports_subentries is True +async def test_entry_subentry_non_string( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test adding an invalid subentry to an entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_subentry_flow(config_entry): + """Test subentry flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + return SubentryFlowHandler() + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + entry.entry_id, context={"source": "test"}, data=None + ) + + flow.handler = entry.entry_id # Used to keep reference to config entry + + with pytest.raises(HomeAssistantError): + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "subentry_id": 123, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + }, + ) + + +async def test_entry_subentry_duplicate( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test adding a duplicated subentry to an entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry( + domain="test", + data={"first": True}, + subentries={"test": {"data": {}, "title": "Mock title"}}, + ) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_subentry_flow(config_entry): + """Test subentry flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + return SubentryFlowHandler() + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + entry.entry_id, context={"source": "test"}, data=None + ) + + flow.handler = entry.entry_id # Used to keep reference to config entry + + with pytest.raises(HomeAssistantError): + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "subentry_id": "test", + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + }, + ) + + async def test_entry_subentry_abort( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -1929,6 +2015,70 @@ async def test_entry_subentry_unknown_config_entry( ) +async def test_entry_subentry_deleted_config_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to finish a subentry flow for a deleted config entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_subentry_flow(config_entry): + """Test subentry flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + return SubentryFlowHandler() + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + entry.entry_id, context={"source": "test"}, data=None + ) + + flow.handler = entry.entry_id # Used to keep reference to config entry + + await hass.config_entries.async_remove(entry.entry_id) + + with pytest.raises(config_entries.UnknownEntry): + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "subentry_id": "test", + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + }, + ) + + +async def test_entry_subentry_unsupported( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to start a subentry flow for a config entry without support.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + with ( + mock_config_flow("test", TestFlow), + pytest.raises(data_entry_flow.UnknownHandler), + ): + await manager.subentries.async_create_flow( + entry.entry_id, context={"source": "test"}, data=None + ) + + async def test_entry_setup_succeed( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: From 40d1c164c470036ddaaed3fa2710be2116c5ad7a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 14 May 2024 08:46:33 +0200 Subject: [PATCH 03/13] Include subentry_id in subentry containers --- .../components/config/config_entries.py | 10 ++-- homeassistant/config_entries.py | 47 ++++++++++-------- tests/common.py | 2 +- .../components/config/test_config_entries.py | 20 ++++---- tests/snapshots/test_config_entries.ambr | 4 +- tests/test_config_entries.py | 49 ++++++++++--------- 6 files changed, 69 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 7de80f8127e..c6d34207197 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -697,11 +697,11 @@ async def config_subentry_delete( hass.config_entries.async_update_entry( entry, - subentries={ - subentry_id: subentry - for subentry_id, subentry in entry.subentries.items() - if subentry_id != subentry_id_to_delete - }, + subentries=[ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_id != subentry_id_to_delete + ], ) connection.send_result(msg["id"]) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index af41d44012b..9e8c691582f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -296,7 +296,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False): minor_version: int options: Mapping[str, Any] - subentries: Mapping[str, ConfigSubentryData] + subentries: Iterable[ConfigSubentryData] version: int @@ -314,6 +314,7 @@ class ConfigSubentryData(TypedDict): """Container for configuration subentry data.""" data: Mapping[str, Any] + subentry_id: str title: str @@ -328,6 +329,7 @@ class ConfigSubentry: """Container for a configuration subentry.""" data: MappingProxyType[str, Any] + subentry_id: str title: str def as_dict(self) -> dict[str, Any]: @@ -390,7 +392,7 @@ class ConfigEntry(Generic[_DataT]): pref_disable_polling: bool | None = None, source: str, state: ConfigEntryState = ConfigEntryState.NOT_LOADED, - subentries: Mapping[str, ConfigSubentryData] | None, + subentries: Iterable[ConfigSubentryData] | None, title: str, unique_id: str | None, version: int, @@ -419,10 +421,12 @@ class ConfigEntry(Generic[_DataT]): # Subentries subentries = subentries or {} _subentries = { - subentry_id: ConfigSubentry( - MappingProxyType(subentry["data"]), subentry["title"] + subentry["subentry_id"]: ConfigSubentry( + MappingProxyType(subentry["data"]), + subentry["subentry_id"], + subentry["title"], ) - for subentry_id, subentry in subentries.items() + for subentry in subentries } _setter(self, "subentries", MappingProxyType(_subentries)) @@ -1067,10 +1071,7 @@ class ConfigEntry(Generic[_DataT]): "pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_polling": self.pref_disable_polling, "source": self.source, - "subentries": { - subentry_id: subentry.as_dict() - for subentry_id, subentry in self.subentries.items() - }, + "subentries": [subentry.as_dict() for subentry in self.subentries.values()], "title": self.title, "unique_id": self.unique_id, "version": self.version, @@ -2202,7 +2203,7 @@ class ConfigEntries: options: Mapping[str, Any] | UndefinedType = UNDEFINED, pref_disable_new_entities: bool | UndefinedType = UNDEFINED, pref_disable_polling: bool | UndefinedType = UNDEFINED, - subentries: Mapping[str, ConfigSubentry] | UndefinedType = UNDEFINED, + subentries: Iterable[ConfigSubentry] | UndefinedType = UNDEFINED, title: str | UndefinedType = UNDEFINED, unique_id: str | None | UndefinedType = UNDEFINED, version: int | UndefinedType = UNDEFINED, @@ -2269,9 +2270,13 @@ class ConfigEntries: changed = True _setter(entry, "options", MappingProxyType(options)) - if subentries is not UNDEFINED and entry.subentries != subentries: - changed = True - _setter(entry, "subentries", MappingProxyType(subentries)) + if subentries is not UNDEFINED: + subentry_dict = MappingProxyType( + {subentry.subentry_id: subentry for subentry in subentries} + ) + if entry.subentries != subentry_dict: + changed = True + _setter(entry, "subentries", subentry_dict) if not changed: return False @@ -2930,7 +2935,7 @@ class ConfigFlow(ConfigEntryBaseFlow): description: str | None = None, description_placeholders: Mapping[str, str] | None = None, options: Mapping[str, Any] | None = None, - subentries: Mapping[str, ConfigSubentryData] | None = None, + subentries: Iterable[ConfigSubentryData] | None = None, ) -> ConfigFlowResult: """Finish config flow and create a config entry.""" if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: @@ -2956,7 +2961,7 @@ class ConfigFlow(ConfigEntryBaseFlow): result["minor_version"] = self.MINOR_VERSION result["options"] = options or {} - result["subentries"] = subentries or {} + result["subentries"] = subentries or () result["version"] = self.VERSION return result @@ -3139,12 +3144,12 @@ class ConfigSubentryFlowManager( self.hass.config_entries.async_update_entry( entry, - subentries=entry.subentries - | { - subentry_id: ConfigSubentry( - MappingProxyType(result["data"]), result["title"] - ) - }, + subentries=[ + *entry.subentries.values(), + ConfigSubentry( + MappingProxyType(result["data"]), subentry_id, result["title"] + ), + ], ) result["result"] = True diff --git a/tests/common.py b/tests/common.py index 610b5833267..523cb5dd353 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1017,7 +1017,7 @@ class MockConfigEntry(config_entries.ConfigEntry): "options": options or {}, "pref_disable_new_entities": pref_disable_new_entities, "pref_disable_polling": pref_disable_polling, - "subentries": subentries or {}, + "subentries": subentries or (), "title": title, "unique_id": unique_id, "version": version, diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 99dcbb4ce76..775b21ea097 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -596,7 +596,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: "description_placeholders": None, "options": {}, "minor_version": 1, - "subentries": {}, + "subentries": [], } @@ -681,7 +681,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "description_placeholders": None, "options": {}, "minor_version": 1, - "subentries": {}, + "subentries": [], } @@ -2774,11 +2774,11 @@ async def test_list_subentries( entry = MockConfigEntry( domain="test", state=core_ce.ConfigEntryState.LOADED, - subentries={ - "test": core_ce.ConfigSubentryData( - data={"test": "test"}, title="Mock title" + subentries=[ + core_ce.ConfigSubentryData( + data={"test": "test"}, subentry_id="test", title="Mock title" ) - }, + ], ) entry.add_to_hass(hass) @@ -2824,11 +2824,11 @@ async def test_delete_subentry( entry = MockConfigEntry( domain="test", state=core_ce.ConfigEntryState.LOADED, - subentries={ - "test": core_ce.ConfigSubentryData( - data={"test": "test"}, title="Mock title" + subentries=[ + core_ce.ConfigSubentryData( + data={"test": "test"}, subentry_id="test", title="Mock title" ) - }, + ], ) entry.add_to_hass(hass) diff --git a/tests/snapshots/test_config_entries.ambr b/tests/snapshots/test_config_entries.ambr index b8340338d4c..08b532677f4 100644 --- a/tests/snapshots/test_config_entries.ambr +++ b/tests/snapshots/test_config_entries.ambr @@ -16,8 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', - 'subentries': dict({ - }), + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 3e3614f6a40..47a72eff2a5 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1424,21 +1424,21 @@ async def test_update_subentry_and_trigger_listener( entry.add_to_manager(manager) update_listener_calls = [] - subentries = {"test": config_entries.ConfigSubentry({"test": "test"}, "Mock title")} + subentry = config_entries.ConfigSubentry({"test": "test"}, "test", "Mock title") async def update_listener( hass: HomeAssistant, entry: config_entries.ConfigEntry ) -> None: """Test function.""" - assert entry.subentries == subentries + assert entry.subentries == {subentry.subentry_id: subentry} update_listener_calls.append(None) entry.add_update_listener(update_listener) - assert manager.async_update_entry(entry, subentries=subentries) is True + assert manager.async_update_entry(entry, subentries=[subentry]) is True await hass.async_block_till_done(wait_background_tasks=True) - assert entry.subentries == subentries + assert entry.subentries == {subentry.subentry_id: subentry} assert len(update_listener_calls) == 1 @@ -1781,10 +1781,12 @@ async def test_create_entry_subentries( subentrydata = config_entries.ConfigSubentryData( data={"test": "test"}, + subentry_id="test", title="Mock title", ) subentry = config_entries.ConfigSubentry( subentrydata["data"], + "test", subentrydata["title"], ) @@ -1818,7 +1820,7 @@ async def test_create_entry_subentries( return self.async_create_entry( title="title", data={"example": user_input["data"]}, - subentries={"test": user_input["subentry"]}, + subentries=[user_input["subentry"]], ) with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): @@ -1877,7 +1879,9 @@ async def test_entry_subentry( assert entry.data == {"first": True} assert entry.options == {} assert entry.subentries == { - "test": config_entries.ConfigSubentry({"second": True}, "Mock title") + "test": config_entries.ConfigSubentry( + {"second": True}, "test", "Mock title" + ) } assert entry.supports_subentries is True @@ -1932,7 +1936,7 @@ async def test_entry_subentry_duplicate( entry = MockConfigEntry( domain="test", data={"first": True}, - subentries={"test": {"data": {}, "title": "Mock title"}}, + subentries=[{"data": {}, "subentry_id": "test", "title": "Mock title"}], ) entry.add_to_manager(manager) @@ -4234,26 +4238,23 @@ async def test_updating_entry_with_and_without_changes( assert manager.async_update_entry(entry) is False - for change in ( - {"data": {"second": True, "third": 456}}, - {"data": {"second": True}}, - {"minor_version": 2}, - {"options": {"hello": True}}, - {"pref_disable_new_entities": True}, - {"pref_disable_polling": True}, - { - "subentries": { - "test": config_entries.ConfigSubentry({"test": "test"}, "Mock title") - } - }, - {"title": "sometitle"}, - {"unique_id": "abcd1234"}, - {"version": 2}, + subentry = config_entries.ConfigSubentry({"test": "test"}, "test", "Mock title") + + for change, expected_value in ( + ({"data": {"second": True, "third": 456}}, {"second": True, "third": 456}), + ({"data": {"second": True}}, {"second": True}), + ({"minor_version": 2}, 2), + ({"options": {"hello": True}}, {"hello": True}), + ({"pref_disable_new_entities": True}, True), + ({"pref_disable_polling": True}, True), + ({"subentries": [subentry]}, {subentry.subentry_id: subentry}), + ({"title": "sometitle"}, "sometitle"), + ({"unique_id": "abcd1234"}, "abcd1234"), + ({"version": 2}, 2), ): assert manager.async_update_entry(entry, **change) is True key = next(iter(change)) - value = next(iter(change.values())) - assert getattr(entry, key) == value + assert getattr(entry, key) == expected_value assert manager.async_update_entry(entry, **change) is False assert manager.async_entry_for_domain_unique_id("test", "abc123") is None From 977c8311c9c2b2cb0438d360297073bf4e02ae52 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 May 2024 11:25:59 +0200 Subject: [PATCH 04/13] Auto-generate subentry_id and add optional unique_id --- .../components/config/config_entries.py | 12 +- homeassistant/config_entries.py | 66 ++++--- tests/common.py | 4 +- .../components/config/test_config_entries.py | 31 +-- tests/test_config_entries.py | 179 ++++++++++++++++-- 5 files changed, 224 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index c6d34207197..d1853ff266c 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -660,12 +660,14 @@ async def config_subentry_list( if entry is None: return - result = { - "subentries": { - subentry_id: {"title": subentry.title} - for subentry_id, subentry in entry.subentries.items() + result = [ + { + "subentry_id": subentry.subentry_id, + "title": subentry.title, + "unique_id": subentry.unique_id, } - } + for subentry_id, subentry in entry.subentries.items() + ] connection.send_result(msg["id"], result) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 9e8c691582f..3845da4f0f1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -15,7 +15,7 @@ from collections.abc import ( ) from contextvars import ContextVar from copy import deepcopy -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from enum import Enum, StrEnum import functools @@ -316,25 +316,32 @@ class ConfigSubentryData(TypedDict): data: Mapping[str, Any] subentry_id: str title: str + unique_id: str | None class SubentryFlowResult(FlowResult, total=False): """Typed result dict for subentry flow.""" - subentry_id: str + unique_id: str | None -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ConfigSubentry: """Container for a configuration subentry.""" data: MappingProxyType[str, Any] - subentry_id: str + subentry_id: str = field(default_factory=ulid_util.ulid_now) title: str + unique_id: str | None def as_dict(self) -> dict[str, Any]: """Return dictionary version of this subentry.""" - return {"data": dict(self.data), "title": self.title} + return { + "data": dict(self.data), + "subentry_id": self.subentry_id, + "title": self.title, + "unique_id": self.unique_id, + } class ConfigEntry(Generic[_DataT]): @@ -392,7 +399,7 @@ class ConfigEntry(Generic[_DataT]): pref_disable_polling: bool | None = None, source: str, state: ConfigEntryState = ConfigEntryState.NOT_LOADED, - subentries: Iterable[ConfigSubentryData] | None, + subentries_data: Iterable[ConfigSubentryData] | None, title: str, unique_id: str | None, version: int, @@ -419,16 +426,20 @@ class ConfigEntry(Generic[_DataT]): _setter(self, "options", MappingProxyType(options or {})) # Subentries - subentries = subentries or {} - _subentries = { - subentry["subentry_id"]: ConfigSubentry( - MappingProxyType(subentry["data"]), - subentry["subentry_id"], - subentry["title"], - ) - for subentry in subentries - } - _setter(self, "subentries", MappingProxyType(_subentries)) + subentries_data = subentries_data or {} + subentries = {} + for subentry_data in subentries_data: + subentry_kwargs = { + "data": MappingProxyType(subentry_data["data"]), + "title": subentry_data["title"], + "unique_id": subentry_data.get("unique_id"), + } + if "subentry_id" in subentry_data: + subentry_kwargs["subentry_id"] = subentry_data["subentry_id"] + subentry = ConfigSubentry(**subentry_kwargs) # type: ignore[arg-type] + subentries[subentry.subentry_id] = subentry + + _setter(self, "subentries", MappingProxyType(subentries)) # Entry system options if pref_disable_new_entities is None: @@ -1561,7 +1572,7 @@ class ConfigEntriesFlowManager( minor_version=result["minor_version"], options=result["options"], source=flow.context["source"], - subentries=result["subentries"], + subentries_data=result["subentries"], title=result["title"], unique_id=flow.unique_id, version=result["version"], @@ -2047,7 +2058,7 @@ class ConfigEntries: pref_disable_new_entities=entry["pref_disable_new_entities"], pref_disable_polling=entry["pref_disable_polling"], source=entry["source"], - subentries=entry["subentries"], + subentries_data=entry["subentries"], title=entry["title"], unique_id=entry["unique_id"], version=entry["version"], @@ -3134,12 +3145,13 @@ class ConfigSubentryFlowManager( if entry is None: raise UnknownEntry(flow.handler) - if "subentry_id" not in result or not isinstance( - subentry_id := result["subentry_id"], str - ): - raise HomeAssistantError("subentry_id must be a string") + unique_id = result.get("unique_id") + if "unique_id" in result and not isinstance(unique_id, str): + raise HomeAssistantError("unique_id must be a string") - if subentry_id in entry.subentries: + if any( + subentry.unique_id == unique_id for subentry in entry.subentries.values() + ): raise data_entry_flow.AbortFlow("already_configured") self.hass.config_entries.async_update_entry( @@ -3147,7 +3159,9 @@ class ConfigSubentryFlowManager( subentries=[ *entry.subentries.values(), ConfigSubentry( - MappingProxyType(result["data"]), subentry_id, result["title"] + data=MappingProxyType(result["data"]), + title=result["title"], + unique_id=unique_id, ), ], ) @@ -3170,7 +3184,7 @@ class ConfigSubentryFlow(data_entry_flow.FlowHandler[FlowContext, SubentryFlowRe data: Mapping[str, Any], description: str | None = None, description_placeholders: Mapping[str, str] | None = None, - subentry_id: str, + unique_id: str, ) -> SubentryFlowResult: """Finish config flow and create a config entry.""" result = super().async_create_entry( @@ -3180,7 +3194,7 @@ class ConfigSubentryFlow(data_entry_flow.FlowHandler[FlowContext, SubentryFlowRe description_placeholders=description_placeholders, ) - result["subentry_id"] = subentry_id + result["unique_id"] = unique_id return result diff --git a/tests/common.py b/tests/common.py index 523cb5dd353..b05358fb415 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1000,7 +1000,7 @@ class MockConfigEntry(config_entries.ConfigEntry): reason=None, source=config_entries.SOURCE_USER, state=None, - subentries=None, + subentries_data=None, title="Mock Title", unique_id=None, version=1, @@ -1017,7 +1017,7 @@ class MockConfigEntry(config_entries.ConfigEntry): "options": options or {}, "pref_disable_new_entities": pref_disable_new_entities, "pref_disable_polling": pref_disable_polling, - "subentries": subentries or (), + "subentries_data": subentries_data or (), "title": title, "unique_id": unique_id, "version": version, diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 775b21ea097..c8655a2abd7 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1216,7 +1216,7 @@ async def test_two_step_subentry_flow(hass: HomeAssistant, client) -> None: async def async_step_finish(self, user_input=None): if user_input: return self.async_create_entry( - title="Mock title", data=user_input, subentry_id="test" + title="Mock title", data=user_input, unique_id="test" ) return self.async_show_form( @@ -1268,9 +1268,9 @@ async def test_two_step_subentry_flow(hass: HomeAssistant, client) -> None: "description": None, "flow_id": flow_id, "handler": "test1", - "subentry_id": "test", "title": "Mock title", "type": "create_entry", + "unique_id": "test", } @@ -2774,9 +2774,12 @@ async def test_list_subentries( entry = MockConfigEntry( domain="test", state=core_ce.ConfigEntryState.LOADED, - subentries=[ + subentries_data=[ core_ce.ConfigSubentryData( - data={"test": "test"}, subentry_id="test", title="Mock title" + data={"test": "test"}, + subentry_id="mock_id", + title="Mock title", + unique_id="test", ) ], ) @@ -2794,9 +2797,9 @@ async def test_list_subentries( response = await ws_client.receive_json() assert response["success"] - assert response["result"] == { - "subentries": {"test": {"title": "Mock title"}}, - } + assert response["result"] == [ + {"subentry_id": "mock_id", "title": "Mock title", "unique_id": "test"}, + ] # Try listing subentries for an unknown entry await ws_client.send_json_auto_id( @@ -2824,9 +2827,9 @@ async def test_delete_subentry( entry = MockConfigEntry( domain="test", state=core_ce.ConfigEntryState.LOADED, - subentries=[ + subentries_data=[ core_ce.ConfigSubentryData( - data={"test": "test"}, subentry_id="test", title="Mock title" + data={"test": "test"}, subentry_id="mock_id", title="Mock title" ) ], ) @@ -2839,7 +2842,7 @@ async def test_delete_subentry( { "type": "config_entries/subentries/delete", "entry_id": entry.entry_id, - "subentry_id": "test", + "subentry_id": "mock_id", } ) response = await ws_client.receive_json() @@ -2856,16 +2859,14 @@ async def test_delete_subentry( response = await ws_client.receive_json() assert response["success"] - assert response["result"] == { - "subentries": {}, - } + assert response["result"] == [] # Try deleting the subentry again await ws_client.send_json_auto_id( { "type": "config_entries/subentries/delete", "entry_id": entry.entry_id, - "subentry_id": "test", + "subentry_id": "mock_id", } ) response = await ws_client.receive_json() @@ -2881,7 +2882,7 @@ async def test_delete_subentry( { "type": "config_entries/subentries/delete", "entry_id": "no_such_entry", - "subentry_id": "test", + "subentry_id": "mock_id", } ) response = await ws_client.receive_json() diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 47a72eff2a5..ac1a4aba18b 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -905,7 +905,7 @@ async def test_entries_excludes_ignore_and_disabled( async def test_saving_and_loading( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any] ) -> None: """Test that we're saving and loading correctly.""" mock_integration( @@ -922,7 +922,17 @@ async def test_saving_and_loading( async def async_step_user(self, user_input=None): """Test user step.""" await self.async_set_unique_id("unique") - return self.async_create_entry(title="Test Title", data={"token": "abcd"}) + subentries = [ + config_entries.ConfigSubentryData( + data={"foo": "bar"}, title="subentry 1" + ), + config_entries.ConfigSubentryData( + data={"sun": "moon"}, title="subentry 2", unique_id="very_unique" + ), + ] + return self.async_create_entry( + title="Test Title", data={"token": "abcd"}, subentries=subentries + ) with mock_config_flow("test", TestFlow): await hass.config_entries.flow.async_init( @@ -971,6 +981,98 @@ async def test_saving_and_loading( # To execute the save await hass.async_block_till_done() + stored_data = hass_storage["core.config_entries"] + assert stored_data == { + "data": { + "entries": [ + { + "created_at": ANY, + "data": { + "token": "abcd", + }, + "disabled_by": None, + "discovery_keys": {}, + "domain": "test", + "entry_id": ANY, + "minor_version": 1, + "modified_at": ANY, + "options": {}, + "pref_disable_new_entities": True, + "pref_disable_polling": True, + "source": "user", + "subentries": [ + { + "data": {"foo": "bar"}, + "subentry_id": ANY, + "title": "subentry 1", + "unique_id": None, + }, + { + "data": {"sun": "moon"}, + "subentry_id": ANY, + "title": "subentry 2", + "unique_id": "very_unique", + }, + ], + "title": "Test Title", + "unique_id": "unique", + "version": 5, + }, + { + "created_at": ANY, + "data": { + "username": "bla", + }, + "disabled_by": None, + "discovery_keys": { + "test": [ + {"domain": "test", "key": "blah", "version": 1}, + ], + }, + "domain": "test", + "entry_id": ANY, + "minor_version": 1, + "modified_at": ANY, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "subentries": [], + "title": "Test 2 Title", + "unique_id": None, + "version": 3, + }, + { + "created_at": ANY, + "data": { + "username": "bla", + }, + "disabled_by": None, + "discovery_keys": { + "test": [ + {"domain": "test", "key": ["a", "b"], "version": 1}, + ], + }, + "domain": "test", + "entry_id": ANY, + "minor_version": 1, + "modified_at": ANY, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "subentries": [], + "title": "Test 2 Title", + "unique_id": None, + "version": 3, + }, + ], + }, + "key": "core.config_entries", + "minor_version": 5, + "version": 1, + } + # Now load written data in new config manager manager = config_entries.ConfigEntries(hass, {}) await manager.async_initialize() @@ -983,6 +1085,25 @@ async def test_saving_and_loading( ): assert orig.as_dict() == loaded.as_dict() + hass.config_entries.async_update_entry( + entry_1, + pref_disable_polling=False, + pref_disable_new_entities=False, + ) + + # To trigger the call_later + freezer.tick(1.0) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + + # Assert no data is lost when storing again + expected_stored_data = stored_data + expected_stored_data["data"]["entries"][0]["modified_at"] = ANY + expected_stored_data["data"]["entries"][0]["pref_disable_new_entities"] = False + expected_stored_data["data"]["entries"][0]["pref_disable_polling"] = False + assert hass_storage["core.config_entries"] == expected_stored_data | {} + @freeze_time("2024-02-14 12:00:00") async def test_as_dict(snapshot: SnapshotAssertion) -> None: @@ -1424,7 +1545,9 @@ async def test_update_subentry_and_trigger_listener( entry.add_to_manager(manager) update_listener_calls = [] - subentry = config_entries.ConfigSubentry({"test": "test"}, "test", "Mock title") + subentry = config_entries.ConfigSubentry( + data={"test": "test"}, unique_id="test", title="Mock title" + ) async def update_listener( hass: HomeAssistant, entry: config_entries.ConfigEntry @@ -1781,13 +1904,8 @@ async def test_create_entry_subentries( subentrydata = config_entries.ConfigSubentryData( data={"test": "test"}, - subentry_id="test", title="Mock title", - ) - subentry = config_entries.ConfigSubentry( - subentrydata["data"], - "test", - subentrydata["title"], + unique_id="test", ) async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -1834,7 +1952,15 @@ async def test_create_entry_subentries( assert len(entries) == 1 assert entries[0].supports_subentries is False assert entries[0].data == {"example": "data"} - assert entries[0].subentries == {"test": subentry} + assert len(entries[0].subentries) == 1 + subentry_id = list(entries[0].subentries)[0] + subentry = config_entries.ConfigSubentry( + data=subentrydata["data"], + subentry_id=subentry_id, + title=subentrydata["title"], + unique_id="test", + ) + assert entries[0].subentries == {subentry_id: subentry} async def test_entry_subentry( @@ -1870,17 +1996,21 @@ async def test_entry_subentry( flow, { "data": {"second": True}, - "subentry_id": "test", "title": "Mock title", "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": "test", }, ) assert entry.data == {"first": True} assert entry.options == {} + subentry_id = list(entry.subentries)[0] assert entry.subentries == { - "test": config_entries.ConfigSubentry( - {"second": True}, "test", "Mock title" + subentry_id: config_entries.ConfigSubentry( + data={"second": True}, + subentry_id=subentry_id, + title="Mock title", + unique_id="test", ) } assert entry.supports_subentries is True @@ -1920,9 +2050,9 @@ async def test_entry_subentry_non_string( flow, { "data": {"second": True}, - "subentry_id": 123, "title": "Mock title", "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": 123, }, ) @@ -1936,7 +2066,14 @@ async def test_entry_subentry_duplicate( entry = MockConfigEntry( domain="test", data={"first": True}, - subentries=[{"data": {}, "subentry_id": "test", "title": "Mock title"}], + subentries_data=[ + { + "data": {}, + "subentry_id": "blabla", + "title": "Mock title", + "unique_id": "test", + } + ], ) entry.add_to_manager(manager) @@ -1965,9 +2102,9 @@ async def test_entry_subentry_duplicate( flow, { "data": {"second": True}, - "subentry_id": "test", "title": "Mock title", "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": "test", }, ) @@ -2055,9 +2192,9 @@ async def test_entry_subentry_deleted_config_entry( flow, { "data": {"second": True}, - "subentry_id": "test", "title": "Mock title", "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": "test", }, ) @@ -4238,7 +4375,9 @@ async def test_updating_entry_with_and_without_changes( assert manager.async_update_entry(entry) is False - subentry = config_entries.ConfigSubentry({"test": "test"}, "test", "Mock title") + subentry = config_entries.ConfigSubentry( + data={"test": "test"}, title="Mock title", unique_id="test" + ) for change, expected_value in ( ({"data": {"second": True, "third": 456}}, {"second": True, "third": 456}), @@ -5768,7 +5907,7 @@ async def test_unhashable_unique_id_fails( minor_version=1, options={}, source="test", - subentries={}, + subentries_data={}, title="title", unique_id=unique_id, version=1, @@ -5876,7 +6015,7 @@ async def test_hashable_unique_id( minor_version=1, options={}, source="test", - subentries={}, + subentries_data={}, title="title", unique_id=unique_id, version=1, From a298091968a67d92560c4d0f8ffb2a3b642a775f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 May 2024 13:58:14 +0200 Subject: [PATCH 05/13] Tweak --- homeassistant/config_entries.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 3845da4f0f1..7a4ae934a10 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -23,7 +23,7 @@ from functools import cache import logging from random import randint from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Generic, Self, TypedDict, cast +from typing import TYPE_CHECKING, Any, Generic, NotRequired, Self, TypedDict, cast from async_interrupt import interrupt from propcache import cached_property @@ -314,7 +314,7 @@ class ConfigSubentryData(TypedDict): """Container for configuration subentry data.""" data: Mapping[str, Any] - subentry_id: str + subentry_id: NotRequired[str] title: str unique_id: str | None @@ -429,14 +429,15 @@ class ConfigEntry(Generic[_DataT]): subentries_data = subentries_data or {} subentries = {} for subentry_data in subentries_data: - subentry_kwargs = { - "data": MappingProxyType(subentry_data["data"]), - "title": subentry_data["title"], - "unique_id": subentry_data.get("unique_id"), - } + subentry_kwargs = {} if "subentry_id" in subentry_data: subentry_kwargs["subentry_id"] = subentry_data["subentry_id"] - subentry = ConfigSubentry(**subentry_kwargs) # type: ignore[arg-type] + subentry = ConfigSubentry( + data=MappingProxyType(subentry_data["data"]), + title=subentry_data["title"], + unique_id=subentry_data.get("unique_id"), + **subentry_kwargs, + ) subentries[subentry.subentry_id] = subentry _setter(self, "subentries", MappingProxyType(subentries)) From df13bed5b54fc04c55afcb2236e1661ce632b8fd Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 May 2024 12:38:28 +0200 Subject: [PATCH 06/13] Update tests --- .../aemet/snapshots/test_diagnostics.ambr | 2 ++ .../airly/snapshots/test_diagnostics.ambr | 2 ++ .../airnow/snapshots/test_diagnostics.ambr | 2 ++ .../airvisual/snapshots/test_diagnostics.ambr | 2 ++ .../snapshots/test_diagnostics.ambr | 2 ++ .../airzone/snapshots/test_diagnostics.ambr | 2 ++ .../snapshots/test_diagnostics.ambr | 2 ++ .../snapshots/test_diagnostics.ambr | 2 ++ .../axis/snapshots/test_diagnostics.ambr | 2 ++ .../blink/snapshots/test_diagnostics.ambr | 2 ++ .../braviatv/snapshots/test_diagnostics.ambr | 2 ++ .../co2signal/snapshots/test_diagnostics.ambr | 2 ++ .../coinbase/snapshots/test_diagnostics.ambr | 2 ++ .../deconz/snapshots/test_diagnostics.ambr | 2 ++ .../snapshots/test_diagnostics.ambr | 2 ++ .../snapshots/test_diagnostics.ambr | 2 ++ .../snapshots/test_diagnostics.ambr | 2 ++ .../ecovacs/snapshots/test_diagnostics.ambr | 4 ++++ .../elgato/snapshots/test_config_flow.ambr | 12 +++++++++++ .../snapshots/test_config_flow.ambr | 4 ++++ .../snapshots/test_diagnostics.ambr | 6 ++++++ .../esphome/snapshots/test_diagnostics.ambr | 2 ++ tests/components/esphome/test_diagnostics.py | 1 + .../forecast_solar/snapshots/test_init.ambr | 2 ++ .../fritz/snapshots/test_diagnostics.ambr | 2 ++ .../fronius/snapshots/test_diagnostics.ambr | 2 ++ .../fyta/snapshots/test_diagnostics.ambr | 2 ++ .../snapshots/test_config_flow.ambr | 8 ++++++++ .../gios/snapshots/test_diagnostics.ambr | 2 ++ .../goodwe/snapshots/test_diagnostics.ambr | 2 ++ .../snapshots/test_diagnostics.ambr | 2 ++ tests/components/guardian/test_diagnostics.py | 1 + .../snapshots/test_config_flow.ambr | 16 +++++++++++++++ .../snapshots/test_diagnostics.ambr | 2 ++ .../imgw_pib/snapshots/test_diagnostics.ambr | 2 ++ .../iqvia/snapshots/test_diagnostics.ambr | 2 ++ .../kostal_plenticore/test_diagnostics.py | 1 + .../snapshots/test_diagnostics.ambr | 2 ++ .../snapshots/test_diagnostics.ambr | 2 ++ .../madvr/snapshots/test_diagnostics.ambr | 2 ++ .../melcloud/snapshots/test_diagnostics.ambr | 2 ++ .../snapshots/test_diagnostics.ambr | 2 ++ .../snapshots/test_diagnostics.ambr | 2 ++ .../netatmo/snapshots/test_diagnostics.ambr | 2 ++ .../nextdns/snapshots/test_diagnostics.ambr | 2 ++ .../nice_go/snapshots/test_diagnostics.ambr | 2 ++ tests/components/notion/test_diagnostics.py | 1 + .../onvif/snapshots/test_diagnostics.ambr | 2 ++ tests/components/openuv/test_diagnostics.py | 1 + .../snapshots/test_diagnostics.ambr | 2 ++ .../components/philips_js/test_config_flow.py | 1 + .../pi_hole/snapshots/test_diagnostics.ambr | 2 ++ .../proximity/snapshots/test_diagnostics.ambr | 2 ++ tests/components/ps4/test_init.py | 1 + .../components/purpleair/test_diagnostics.py | 1 + .../snapshots/test_diagnostics.ambr | 4 ++++ .../recollect_waste/test_diagnostics.py | 1 + .../ridwell/snapshots/test_diagnostics.ambr | 2 ++ .../components/samsungtv/test_diagnostics.py | 3 +++ .../snapshots/test_diagnostics.ambr | 2 ++ .../components/simplisafe/test_diagnostics.py | 1 + .../solarlog/snapshots/test_diagnostics.ambr | 2 ++ tests/components/subaru/test_config_flow.py | 2 ++ .../switcher_kis/test_diagnostics.py | 1 + .../snapshots/test_diagnostics.ambr | 2 ++ .../tailwind/snapshots/test_config_flow.ambr | 8 ++++++++ .../snapshots/test_diagnostics.ambr | 2 ++ .../tractive/snapshots/test_diagnostics.ambr | 2 ++ .../tuya/snapshots/test_config_flow.ambr | 8 ++++++++ .../snapshots/test_config_flow.ambr | 8 ++++++++ .../twinkly/snapshots/test_diagnostics.ambr | 2 ++ .../unifi/snapshots/test_diagnostics.ambr | 2 ++ .../uptime/snapshots/test_config_flow.ambr | 4 ++++ .../snapshots/test_diagnostics.ambr | 2 ++ .../v2c/snapshots/test_diagnostics.ambr | 2 ++ .../vicare/snapshots/test_diagnostics.ambr | 2 ++ .../watttime/snapshots/test_diagnostics.ambr | 2 ++ .../webmin/snapshots/test_diagnostics.ambr | 2 ++ tests/components/webostv/test_diagnostics.py | 1 + .../whirlpool/snapshots/test_diagnostics.ambr | 2 ++ .../whois/snapshots/test_config_flow.ambr | 20 +++++++++++++++++++ .../workday/snapshots/test_diagnostics.ambr | 2 ++ .../wyoming/snapshots/test_config_flow.ambr | 12 +++++++++++ .../zha/snapshots/test_diagnostics.ambr | 2 ++ 84 files changed, 245 insertions(+) diff --git a/tests/components/aemet/snapshots/test_diagnostics.ambr b/tests/components/aemet/snapshots/test_diagnostics.ambr index 54546507dfa..1e09a372352 100644 --- a/tests/components/aemet/snapshots/test_diagnostics.ambr +++ b/tests/components/aemet/snapshots/test_diagnostics.ambr @@ -21,6 +21,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/airly/snapshots/test_diagnostics.ambr b/tests/components/airly/snapshots/test_diagnostics.ambr index ec501b2fd7e..1c760eaec52 100644 --- a/tests/components/airly/snapshots/test_diagnostics.ambr +++ b/tests/components/airly/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Home', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 3dd4788dc61..73ba6a7123f 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -35,6 +35,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/airvisual/snapshots/test_diagnostics.ambr b/tests/components/airvisual/snapshots/test_diagnostics.ambr index 606d6082351..0dbdef1d508 100644 --- a/tests/components/airvisual/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual/snapshots/test_diagnostics.ambr @@ -47,6 +47,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 3, diff --git a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr index cb1d3a7aee7..113db6e3b96 100644 --- a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr @@ -101,6 +101,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'XXXXXXX', 'version': 1, diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index fb4f6530b1e..39668e3d19f 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -287,6 +287,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index c6ad36916bf..4bd7bfaccdd 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -101,6 +101,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'installation1', 'version': 1, diff --git a/tests/components/ambient_station/snapshots/test_diagnostics.ambr b/tests/components/ambient_station/snapshots/test_diagnostics.ambr index 2f90b09d39f..07db19101ab 100644 --- a/tests/components/ambient_station/snapshots/test_diagnostics.ambr +++ b/tests/components/ambient_station/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/axis/snapshots/test_diagnostics.ambr b/tests/components/axis/snapshots/test_diagnostics.ambr index ebd0061f416..b475c796d2b 100644 --- a/tests/components/axis/snapshots/test_diagnostics.ambr +++ b/tests/components/axis/snapshots/test_diagnostics.ambr @@ -47,6 +47,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 3, diff --git a/tests/components/blink/snapshots/test_diagnostics.ambr b/tests/components/blink/snapshots/test_diagnostics.ambr index edc2879a66b..54df2b48cdb 100644 --- a/tests/components/blink/snapshots/test_diagnostics.ambr +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -48,6 +48,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 3, diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr index cd29c647df7..de76c00cd23 100644 --- a/tests/components/braviatv/snapshots/test_diagnostics.ambr +++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'very_unique_string', 'version': 1, diff --git a/tests/components/co2signal/snapshots/test_diagnostics.ambr b/tests/components/co2signal/snapshots/test_diagnostics.ambr index 9218e7343ec..4159c8ec1a1 100644 --- a/tests/components/co2signal/snapshots/test_diagnostics.ambr +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr index 51bd946f140..3eab18fb9f3 100644 --- a/tests/components/coinbase/snapshots/test_diagnostics.ambr +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -44,6 +44,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, diff --git a/tests/components/deconz/snapshots/test_diagnostics.ambr b/tests/components/deconz/snapshots/test_diagnostics.ambr index 1ca674a4fbe..20558b4bbbd 100644 --- a/tests/components/deconz/snapshots/test_diagnostics.ambr +++ b/tests/components/deconz/snapshots/test_diagnostics.ambr @@ -21,6 +21,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr index 6a7ef1fc6d3..0a8f6243d96 100644 --- a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr @@ -48,6 +48,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '123456', 'version': 1, diff --git a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr index 3da8c76c2b4..8fe6c7c2293 100644 --- a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr @@ -32,6 +32,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr index d407fe2dc5b..0a46dd7f476 100644 --- a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr +++ b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'dsmr_reader', 'unique_id': 'UNIQUE_TEST_ID', 'version': 1, diff --git a/tests/components/ecovacs/snapshots/test_diagnostics.ambr b/tests/components/ecovacs/snapshots/test_diagnostics.ambr index 38c8a9a5ab9..f9540e06038 100644 --- a/tests/components/ecovacs/snapshots/test_diagnostics.ambr +++ b/tests/components/ecovacs/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, @@ -70,6 +72,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, diff --git a/tests/components/elgato/snapshots/test_config_flow.ambr b/tests/components/elgato/snapshots/test_config_flow.ambr index d5d005cff9c..83df934a895 100644 --- a/tests/components/elgato/snapshots/test_config_flow.ambr +++ b/tests/components/elgato/snapshots/test_config_flow.ambr @@ -34,10 +34,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'CN11A1A00001', 'unique_id': 'CN11A1A00001', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'CN11A1A00001', 'type': , 'version': 1, @@ -79,10 +83,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'CN11A1A00001', 'unique_id': 'CN11A1A00001', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'CN11A1A00001', 'type': , 'version': 1, @@ -123,10 +131,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'CN11A1A00001', 'unique_id': 'CN11A1A00001', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'CN11A1A00001', 'type': , 'version': 1, diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr index 72e504c97c8..88b0af6dc7b 100644 --- a/tests/components/energyzero/snapshots/test_config_flow.ambr +++ b/tests/components/energyzero/snapshots/test_config_flow.ambr @@ -28,10 +28,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'EnergyZero', 'unique_id': 'energyzero', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'EnergyZero', 'type': , 'version': 1, diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 76835098f27..3cacd3a8518 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -20,6 +20,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, @@ -454,6 +456,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, @@ -928,6 +932,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 4f7ea679b20..8f1711e829e 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -20,6 +20,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'ESPHome Device', 'unique_id': '11:22:33:44:55:aa', 'version': 1, diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 832e7d6572f..0beeae71df3 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -79,6 +79,7 @@ async def test_diagnostics_with_bluetooth( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "11:22:33:44:55:aa", "version": 1, diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr index 6ae4c2f6198..c0db54c2d4e 100644 --- a/tests/components/forecast_solar/snapshots/test_init.ambr +++ b/tests/components/forecast_solar/snapshots/test_init.ambr @@ -23,6 +23,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Green House', 'unique_id': 'unique', 'version': 2, diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index 53f7093a21b..9b5b8c9353a 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -61,6 +61,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/fronius/snapshots/test_diagnostics.ambr b/tests/components/fronius/snapshots/test_diagnostics.ambr index 010de06e276..b112839835a 100644 --- a/tests/components/fronius/snapshots/test_diagnostics.ambr +++ b/tests/components/fronius/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index 2af616c6412..d3d802ddeb6 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'fyta_user', 'unique_id': None, 'version': 1, diff --git a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index 6d521b1f2c8..10f23759fae 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -66,10 +66,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'bluetooth', + 'subentries': list([ + ]), 'title': 'Gardena Water Computer', 'unique_id': '00000000-0000-0000-0000-000000000001', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Gardena Water Computer', 'type': , 'version': 1, @@ -223,10 +227,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Gardena Water Computer', 'unique_id': '00000000-0000-0000-0000-000000000001', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Gardena Water Computer', 'type': , 'version': 1, diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 71e0afdc495..890edc00482 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Home', 'unique_id': '123', 'version': 1, diff --git a/tests/components/goodwe/snapshots/test_diagnostics.ambr b/tests/components/goodwe/snapshots/test_diagnostics.ambr index f52e47688e8..40ed22195d5 100644 --- a/tests/components/goodwe/snapshots/test_diagnostics.ambr +++ b/tests/components/goodwe/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index edbbdb1ba28..1ecedbd1173 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -15,6 +15,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'import', + 'subentries': list([ + ]), 'title': '1234', 'unique_id': '1234', 'version': 1, diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index faba2103000..4487d0b6ac6 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -42,6 +42,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "valve_controller": { diff --git a/tests/components/homewizard/snapshots/test_config_flow.ambr b/tests/components/homewizard/snapshots/test_config_flow.ambr index c3852a8c3fa..1d2aa7fb346 100644 --- a/tests/components/homewizard/snapshots/test_config_flow.ambr +++ b/tests/components/homewizard/snapshots/test_config_flow.ambr @@ -30,10 +30,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'P1 meter', 'unique_id': 'HWE-P1_aabbccddeeff', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'P1 meter', 'type': , 'version': 1, @@ -74,10 +78,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'P1 meter', 'unique_id': 'HWE-P1_aabbccddeeff', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'P1 meter', 'type': , 'version': 1, @@ -118,10 +126,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'Energy Socket', 'unique_id': 'HWE-SKT_aabbccddeeff', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Energy Socket', 'type': , 'version': 1, @@ -158,10 +170,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'P1 meter', 'unique_id': 'HWE-P1_3c39e7aabbcc', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'P1 meter', 'type': , 'version': 1, diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index ee9b7510770..f6bda19a8d8 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -190,6 +190,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Husqvarna Automower of Erika Mustermann', 'unique_id': '123', 'version': 1, diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 494980ba4ce..f15fc706d7e 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -15,6 +15,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'River Name (Station Name)', 'unique_id': '123', 'version': 1, diff --git a/tests/components/iqvia/snapshots/test_diagnostics.ambr b/tests/components/iqvia/snapshots/test_diagnostics.ambr index f2fa656cb0f..41cfedb0e29 100644 --- a/tests/components/iqvia/snapshots/test_diagnostics.ambr +++ b/tests/components/iqvia/snapshots/test_diagnostics.ambr @@ -358,6 +358,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index 08f06684d9a..3a99a7f681d 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -57,6 +57,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "client": { "version": "api_version='0.2.0' hostname='scb' name='PUCK RESTful API' sw_version='01.16.05025'", diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr index 201bbbc971e..640726e2355 100644 --- a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -25,6 +25,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr index c689d04949a..db82f41eb73 100644 --- a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr +++ b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr @@ -73,6 +73,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'test-site-name', 'unique_id': None, 'version': 1, diff --git a/tests/components/madvr/snapshots/test_diagnostics.ambr b/tests/components/madvr/snapshots/test_diagnostics.ambr index 3a281391860..92d0578dba8 100644 --- a/tests/components/madvr/snapshots/test_diagnostics.ambr +++ b/tests/components/madvr/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'envy', 'unique_id': '00:11:22:33:44:55', 'version': 1, diff --git a/tests/components/melcloud/snapshots/test_diagnostics.ambr b/tests/components/melcloud/snapshots/test_diagnostics.ambr index e6a432de07e..671f5afcc52 100644 --- a/tests/components/melcloud/snapshots/test_diagnostics.ambr +++ b/tests/components/melcloud/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'melcloud', 'unique_id': 'UNIQUE_TEST_ID', 'version': 1, diff --git a/tests/components/modern_forms/snapshots/test_diagnostics.ambr b/tests/components/modern_forms/snapshots/test_diagnostics.ambr index 75794aaca12..c1bbf1e2175 100644 --- a/tests/components/modern_forms/snapshots/test_diagnostics.ambr +++ b/tests/components/modern_forms/snapshots/test_diagnostics.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr index 5b4b169c0fe..d042dc02ac3 100644 --- a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr +++ b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr @@ -28,6 +28,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 463556ec657..4ea7e30bcf9 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -646,6 +646,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'netatmo', 'version': 1, diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index 827d6aeb6e5..23f42fee077 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Fake Profile', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/nice_go/snapshots/test_diagnostics.ambr b/tests/components/nice_go/snapshots/test_diagnostics.ambr index f4ba363a421..b33726d2b72 100644 --- a/tests/components/nice_go/snapshots/test_diagnostics.ambr +++ b/tests/components/nice_go/snapshots/test_diagnostics.ambr @@ -60,6 +60,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index 890ce2dfc4a..c1d1bd1bb2e 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -37,6 +37,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "bridges": [ diff --git a/tests/components/onvif/snapshots/test_diagnostics.ambr b/tests/components/onvif/snapshots/test_diagnostics.ambr index c8a9ff75d62..c3938efcbb6 100644 --- a/tests/components/onvif/snapshots/test_diagnostics.ambr +++ b/tests/components/onvif/snapshots/test_diagnostics.ambr @@ -24,6 +24,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'aa:bb:cc:dd:ee:ff', 'version': 1, diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index 61b68b5ad90..03b392b3e7b 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -39,6 +39,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "protection_window": { diff --git a/tests/components/philips_js/snapshots/test_diagnostics.ambr b/tests/components/philips_js/snapshots/test_diagnostics.ambr index 4f7a6176634..53db95f0534 100644 --- a/tests/components/philips_js/snapshots/test_diagnostics.ambr +++ b/tests/components/philips_js/snapshots/test_diagnostics.ambr @@ -94,6 +94,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index c08885634db..cfb4b84c45f 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -155,6 +155,7 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) "version": 1, "options": {}, "minor_version": 1, + "subentries": (), } await hass.async_block_till_done() diff --git a/tests/components/pi_hole/snapshots/test_diagnostics.ambr b/tests/components/pi_hole/snapshots/test_diagnostics.ambr index 3094fcef24b..2d6f6687d04 100644 --- a/tests/components/pi_hole/snapshots/test_diagnostics.ambr +++ b/tests/components/pi_hole/snapshots/test_diagnostics.ambr @@ -33,6 +33,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr index 3d9673ffd90..42ec74710f9 100644 --- a/tests/components/proximity/snapshots/test_diagnostics.ambr +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -102,6 +102,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'home', 'unique_id': 'proximity_home', 'version': 1, diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index d14f367b2bd..24d45fee5b9 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -52,6 +52,7 @@ MOCK_FLOW_RESULT = { "title": "test_ps4", "data": MOCK_DATA, "options": {}, + "subentries": (), } MOCK_ENTRY_ID = "SomeID" diff --git a/tests/components/purpleair/test_diagnostics.py b/tests/components/purpleair/test_diagnostics.py index ae4b28567be..6271a63d652 100644 --- a/tests/components/purpleair/test_diagnostics.py +++ b/tests/components/purpleair/test_diagnostics.py @@ -38,6 +38,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "fields": [ diff --git a/tests/components/rainmachine/snapshots/test_diagnostics.ambr b/tests/components/rainmachine/snapshots/test_diagnostics.ambr index acd5fd165b4..681805996f1 100644 --- a/tests/components/rainmachine/snapshots/test_diagnostics.ambr +++ b/tests/components/rainmachine/snapshots/test_diagnostics.ambr @@ -1144,6 +1144,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 2, @@ -2275,6 +2277,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/recollect_waste/test_diagnostics.py b/tests/components/recollect_waste/test_diagnostics.py index 24c690bcb37..a57e289ec04 100644 --- a/tests/components/recollect_waste/test_diagnostics.py +++ b/tests/components/recollect_waste/test_diagnostics.py @@ -34,6 +34,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": [ { diff --git a/tests/components/ridwell/snapshots/test_diagnostics.ambr b/tests/components/ridwell/snapshots/test_diagnostics.ambr index b03d87c7a89..4b4dda7227d 100644 --- a/tests/components/ridwell/snapshots/test_diagnostics.ambr +++ b/tests/components/ridwell/snapshots/test_diagnostics.ambr @@ -44,6 +44,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index 0319d5dd8dd..e8e0b699a7e 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -51,6 +51,7 @@ async def test_entry_diagnostics( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "any", "version": 2, @@ -91,6 +92,7 @@ async def test_entry_diagnostics_encrypted( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "any", "version": 2, @@ -130,6 +132,7 @@ async def test_entry_diagnostics_encrypte_offline( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "any", "version": 2, diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr index 237d3eab257..c7db7a33959 100644 --- a/tests/components/screenlogic/snapshots/test_diagnostics.ambr +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -18,6 +18,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Pentair: DD-EE-FF', 'unique_id': 'aa:bb:cc:dd:ee:ff', 'version': 1, diff --git a/tests/components/simplisafe/test_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py index d5479f00b06..13c1e28aa36 100644 --- a/tests/components/simplisafe/test_diagnostics.py +++ b/tests/components/simplisafe/test_diagnostics.py @@ -32,6 +32,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "subscription_data": { "12345": { diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr index 4b37ea63dce..db74bfbcc89 100644 --- a/tests/components/solarlog/snapshots/test_diagnostics.ambr +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'solarlog', 'unique_id': None, 'version': 1, diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index d930aafbdfb..2520d0be408 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -136,6 +136,7 @@ async def test_user_form_pin_not_required( "data": deepcopy(TEST_CONFIG), "options": {}, "minor_version": 1, + "subentries": (), } expected["data"][CONF_PIN] = None @@ -345,6 +346,7 @@ async def test_pin_form_success(hass: HomeAssistant, pin_form) -> None: "data": TEST_CONFIG, "options": {}, "minor_version": 1, + "subentries": (), } result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID assert result == expected diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py index 53572085f9b..f59958420c4 100644 --- a/tests/components/switcher_kis/test_diagnostics.py +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -69,5 +69,6 @@ async def test_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, } diff --git a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr index 75d942fc601..4ea02d8bb94 100644 --- a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr +++ b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr @@ -56,6 +56,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'System Monitor', 'unique_id': None, 'version': 1, diff --git a/tests/components/tailwind/snapshots/test_config_flow.ambr b/tests/components/tailwind/snapshots/test_config_flow.ambr index 09bf25cb96e..6e8964bd89b 100644 --- a/tests/components/tailwind/snapshots/test_config_flow.ambr +++ b/tests/components/tailwind/snapshots/test_config_flow.ambr @@ -32,10 +32,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Tailwind iQ3', 'unique_id': '3c:e9:0e:6d:21:84', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Tailwind iQ3', 'type': , 'version': 1, @@ -78,10 +82,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'Tailwind iQ3', 'unique_id': '3c:e9:0e:6d:21:84', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Tailwind iQ3', 'type': , 'version': 1, diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr index 3180c7c0b1d..b5b33d7c246 100644 --- a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -37,6 +37,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/tractive/snapshots/test_diagnostics.ambr b/tests/components/tractive/snapshots/test_diagnostics.ambr index 11427a84801..3613f7e5997 100644 --- a/tests/components/tractive/snapshots/test_diagnostics.ambr +++ b/tests/components/tractive/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': 'very_unique_string', 'version': 1, diff --git a/tests/components/tuya/snapshots/test_config_flow.ambr b/tests/components/tuya/snapshots/test_config_flow.ambr index a5a68a12a22..90d83d69814 100644 --- a/tests/components/tuya/snapshots/test_config_flow.ambr +++ b/tests/components/tuya/snapshots/test_config_flow.ambr @@ -24,6 +24,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '12345', 'unique_id': '12345', 'version': 1, @@ -54,6 +56,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Old Tuya configuration entry', 'unique_id': '12345', 'version': 1, @@ -107,10 +111,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'mocked_username', 'unique_id': None, 'version': 1, }), + 'subentries': tuple( + ), 'title': 'mocked_username', 'type': , 'version': 1, diff --git a/tests/components/twentemilieu/snapshots/test_config_flow.ambr b/tests/components/twentemilieu/snapshots/test_config_flow.ambr index a98119e81c9..29470fbfc0c 100644 --- a/tests/components/twentemilieu/snapshots/test_config_flow.ambr +++ b/tests/components/twentemilieu/snapshots/test_config_flow.ambr @@ -36,10 +36,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '12345', 'unique_id': '12345', 'version': 1, }), + 'subentries': tuple( + ), 'title': '12345', 'type': , 'version': 1, @@ -82,10 +86,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '12345', 'unique_id': '12345', 'version': 1, }), + 'subentries': tuple( + ), 'title': '12345', 'type': , 'version': 1, diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index 28ec98cf572..e52f76634fd 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -37,6 +37,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Twinkly', 'unique_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', 'version': 1, diff --git a/tests/components/unifi/snapshots/test_diagnostics.ambr b/tests/components/unifi/snapshots/test_diagnostics.ambr index 4ba90a00113..aa7337be0ba 100644 --- a/tests/components/unifi/snapshots/test_diagnostics.ambr +++ b/tests/components/unifi/snapshots/test_diagnostics.ambr @@ -42,6 +42,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '1', 'version': 1, diff --git a/tests/components/uptime/snapshots/test_config_flow.ambr b/tests/components/uptime/snapshots/test_config_flow.ambr index 38312667375..93b1da60998 100644 --- a/tests/components/uptime/snapshots/test_config_flow.ambr +++ b/tests/components/uptime/snapshots/test_config_flow.ambr @@ -27,10 +27,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Uptime', 'unique_id': None, 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Uptime', 'type': , 'version': 1, diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr index c69164264da..ec28f0d8411 100644 --- a/tests/components/utility_meter/snapshots/test_diagnostics.ambr +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -25,6 +25,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Energy Bill', 'unique_id': None, 'version': 2, diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index 96567b80c54..780a00acd64 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': 'ABC123', 'version': 1, diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index ae9b05389c7..0b1dcef5a29 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4731,6 +4731,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'ViCare', 'version': 1, diff --git a/tests/components/watttime/snapshots/test_diagnostics.ambr b/tests/components/watttime/snapshots/test_diagnostics.ambr index 0c137acc36b..3cc5e1d6f66 100644 --- a/tests/components/watttime/snapshots/test_diagnostics.ambr +++ b/tests/components/watttime/snapshots/test_diagnostics.ambr @@ -27,6 +27,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/webmin/snapshots/test_diagnostics.ambr b/tests/components/webmin/snapshots/test_diagnostics.ambr index 8299b0eafba..c64fa212a98 100644 --- a/tests/components/webmin/snapshots/test_diagnostics.ambr +++ b/tests/components/webmin/snapshots/test_diagnostics.ambr @@ -253,6 +253,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index 3d7cb00e021..7f54e940966 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -61,5 +61,6 @@ async def test_diagnostics( "created_at": entry.created_at.isoformat(), "modified_at": entry.modified_at.isoformat(), "discovery_keys": {}, + "subentries": [], }, } diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index c60ce17b952..ee8abe04bf1 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -38,6 +38,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr index 937502d4d6c..0d99b0596e3 100644 --- a/tests/components/whois/snapshots/test_config_flow.ambr +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -30,10 +30,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, @@ -70,10 +74,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, @@ -110,10 +118,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, @@ -150,10 +162,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, @@ -190,10 +206,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, diff --git a/tests/components/workday/snapshots/test_diagnostics.ambr b/tests/components/workday/snapshots/test_diagnostics.ambr index f41b86b7f6d..e7331b911a8 100644 --- a/tests/components/workday/snapshots/test_diagnostics.ambr +++ b/tests/components/workday/snapshots/test_diagnostics.ambr @@ -40,6 +40,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index bdead0f2028..d288c531407 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -36,10 +36,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'hassio', + 'subentries': list([ + ]), 'title': 'Piper', 'unique_id': '1234', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Piper', 'type': , 'version': 1, @@ -82,10 +86,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'hassio', + 'subentries': list([ + ]), 'title': 'Piper', 'unique_id': '1234', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Piper', 'type': , 'version': 1, @@ -127,10 +135,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'Test Satellite', 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Test Satellite', 'type': , 'version': 1, diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index f46a06e84b8..08807f65d5d 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -113,6 +113,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 4, From 55bf543648a3e6e074b9786892f83eec03bd99aa Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 9 Oct 2024 10:06:13 +0200 Subject: [PATCH 07/13] Fix stale docstring --- homeassistant/config_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7a4ae934a10..4bc1c1ee0c4 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3172,7 +3172,7 @@ class ConfigSubentryFlowManager( class ConfigSubentryFlow(data_entry_flow.FlowHandler[FlowContext, SubentryFlowResult]): - """Base class for config options flows.""" + """Base class for config subentry flows.""" _flow_result = SubentryFlowResult handler: str From 1a06a2b6a6f9e69e68b02b1e237f883a3abcaa2b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 11 Oct 2024 10:48:57 +0200 Subject: [PATCH 08/13] Address review comments --- homeassistant/config_entries.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 4bc1c1ee0c4..4fe4c759f0e 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -334,7 +334,7 @@ class ConfigSubentry: title: str unique_id: str | None - def as_dict(self) -> dict[str, Any]: + def as_dict(self) -> ConfigSubentryData: """Return dictionary version of this subentry.""" return { "data": dict(self.data), @@ -2283,12 +2283,12 @@ class ConfigEntries: _setter(entry, "options", MappingProxyType(options)) if subentries is not UNDEFINED: - subentry_dict = MappingProxyType( + subentries_dict = MappingProxyType( {subentry.subentry_id: subentry for subentry in subentries} ) - if entry.subentries != subentry_dict: + if entry.subentries != subentries_dict: changed = True - _setter(entry, "subentries", subentry_dict) + _setter(entry, "subentries", subentries_dict) if not changed: return False @@ -3122,7 +3122,7 @@ class ConfigSubentryFlowManager( ) -> ConfigSubentryFlow: """Create a subentry flow for a config entry. - Entry_id and flow.handler is the same thing to map entry with flow. + The entry_id and the flow.handler is the same thing to map entry with flow. """ entry = self._async_get_config_entry(handler_key) handler = await _async_get_flow_handler(self.hass, entry.domain, {}) @@ -3135,7 +3135,7 @@ class ConfigSubentryFlowManager( ) -> SubentryFlowResult: """Finish a subentry flow and add a new subentry to the configuration entry. - Flow.handler and entry_id is the same thing to map flow with entry. + The flow.handler and the entry_id is the same thing to map flow with entry. """ flow = cast(ConfigSubentryFlow, flow) @@ -3217,7 +3217,7 @@ class OptionsFlowManager( ) -> OptionsFlow: """Create an options flow for a config entry. - Entry_id and flow.handler is the same thing to map entry with flow. + The entry_id and the flow.handler is the same thing to map entry with flow. """ entry = self._async_get_config_entry(handler_key) handler = await _async_get_flow_handler(self.hass, entry.domain, {}) @@ -3233,7 +3233,7 @@ class OptionsFlowManager( This method is called when a flow step returns FlowResultType.ABORT or FlowResultType.CREATE_ENTRY. - Flow.handler and entry_id is the same thing to map flow with entry. + The flow.handler and the entry_id is the same thing to map flow with entry. """ flow = cast(OptionsFlow, flow) From b2922334aa3ff1a70a50627966f6b80473597047 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 11 Oct 2024 10:53:22 +0200 Subject: [PATCH 09/13] Typing tweaks --- homeassistant/config_entries.py | 2 +- tests/test_config_entries.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 4fe4c759f0e..181af7fe3c8 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -426,7 +426,7 @@ class ConfigEntry(Generic[_DataT]): _setter(self, "options", MappingProxyType(options or {})) # Subentries - subentries_data = subentries_data or {} + subentries_data = subentries_data or () subentries = {} for subentry_data in subentries_data: subentry_kwargs = {} diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index ac1a4aba18b..c6a41dfdf30 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2067,12 +2067,12 @@ async def test_entry_subentry_duplicate( domain="test", data={"first": True}, subentries_data=[ - { - "data": {}, - "subentry_id": "blabla", - "title": "Mock title", - "unique_id": "test", - } + config_entries.ConfigSubentryData( + data={}, + subentry_id="blabla", + title="Mock title", + unique_id="test", + ) ], ) entry.add_to_manager(manager) @@ -5907,7 +5907,7 @@ async def test_unhashable_unique_id_fails( minor_version=1, options={}, source="test", - subentries_data={}, + subentries_data=(), title="title", unique_id=unique_id, version=1, @@ -6015,7 +6015,7 @@ async def test_hashable_unique_id( minor_version=1, options={}, source="test", - subentries_data={}, + subentries_data=(), title="title", unique_id=unique_id, version=1, From 498773bad0519f98690737cb847c69723b9e8089 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 11 Oct 2024 11:01:37 +0200 Subject: [PATCH 10/13] Add methods to ConfigEntries to add and remove subentry --- .../components/config/config_entries.py | 14 +--- homeassistant/config_entries.py | 84 +++++++++++++++---- tests/test_config_entries.py | 19 +++-- 3 files changed, 83 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index d1853ff266c..db41b917093 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -690,20 +690,12 @@ async def config_subentry_delete( if entry is None: return - subentry_id_to_delete = msg["subentry_id"] - if subentry_id_to_delete not in entry.subentries: + try: + hass.config_entries.async_remove_subentry(entry, msg["subentry_id"]) + except config_entries.UnknownSubEntry: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config subentry not found" ) return - hass.config_entries.async_update_entry( - entry, - subentries=[ - subentry - for subentry in entry.subentries.values() - if subentry.subentry_id != subentry_id_to_delete - ], - ) - connection.send_result(msg["id"]) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 181af7fe3c8..2c6de3e34d1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -252,6 +252,10 @@ class UnknownEntry(ConfigError): """Unknown entry specified.""" +class UnknownSubEntry(ConfigError): + """Unknown subentry specified.""" + + class OperationNotAllowed(ConfigError): """Raised when a config entry operation is not allowed.""" @@ -2215,7 +2219,44 @@ class ConfigEntries: options: Mapping[str, Any] | UndefinedType = UNDEFINED, pref_disable_new_entities: bool | UndefinedType = UNDEFINED, pref_disable_polling: bool | UndefinedType = UNDEFINED, - subentries: Iterable[ConfigSubentry] | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + unique_id: str | None | UndefinedType = UNDEFINED, + version: int | UndefinedType = UNDEFINED, + ) -> bool: + """Update a config entry. + + If the entry was changed, the update_listeners are + fired and this function returns True + + If the entry was not changed, the update_listeners are + not fired and this function returns False + """ + return self._async_update_entry( + entry, + data=data, + discovery_keys=discovery_keys, + minor_version=minor_version, + options=options, + pref_disable_new_entities=pref_disable_new_entities, + pref_disable_polling=pref_disable_polling, + title=title, + unique_id=unique_id, + version=version, + ) + + @callback + def _async_update_entry( + self, + entry: ConfigEntry, + *, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]] + | UndefinedType = UNDEFINED, + minor_version: int | UndefinedType = UNDEFINED, + options: Mapping[str, Any] | UndefinedType = UNDEFINED, + pref_disable_new_entities: bool | UndefinedType = UNDEFINED, + pref_disable_polling: bool | UndefinedType = UNDEFINED, + subentries: dict[str, ConfigSubentry] | UndefinedType = UNDEFINED, title: str | UndefinedType = UNDEFINED, unique_id: str | None | UndefinedType = UNDEFINED, version: int | UndefinedType = UNDEFINED, @@ -2283,12 +2324,9 @@ class ConfigEntries: _setter(entry, "options", MappingProxyType(options)) if subentries is not UNDEFINED: - subentries_dict = MappingProxyType( - {subentry.subentry_id: subentry for subentry in subentries} - ) - if entry.subentries != subentries_dict: + if entry.subentries != subentries: changed = True - _setter(entry, "subentries", subentries_dict) + _setter(entry, "subentries", MappingProxyType(subentries)) if not changed: return False @@ -2307,6 +2345,25 @@ class ConfigEntries: self._async_dispatch(ConfigEntryChange.UPDATED, entry) return True + @callback + def async_add_subentry(self, entry: ConfigEntry, subentry: ConfigSubentry) -> bool: + """Add a subentry to a config entry.""" + return self._async_update_entry( + entry, + subentries=entry.subentries | {subentry.subentry_id: subentry}, + ) + + @callback + def async_remove_subentry(self, entry: ConfigEntry, subentry_id: str) -> bool: + """Remove a subentry from a config entry.""" + subentries = dict(entry.subentries) + try: + subentries.pop(subentry_id) + except KeyError as err: + raise UnknownSubEntry from err + + return self._async_update_entry(entry, subentries=subentries) + @callback def _async_dispatch( self, change_type: ConfigEntryChange, entry: ConfigEntry @@ -3155,16 +3212,13 @@ class ConfigSubentryFlowManager( ): raise data_entry_flow.AbortFlow("already_configured") - self.hass.config_entries.async_update_entry( + self.hass.config_entries.async_add_subentry( entry, - subentries=[ - *entry.subentries.values(), - ConfigSubentry( - data=MappingProxyType(result["data"]), - title=result["title"], - unique_id=unique_id, - ), - ], + ConfigSubentry( + data=MappingProxyType(result["data"]), + title=result["title"], + unique_id=unique_id, + ), ) result["result"] = True diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index c6a41dfdf30..e6a54c59be8 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1553,17 +1553,25 @@ async def test_update_subentry_and_trigger_listener( hass: HomeAssistant, entry: config_entries.ConfigEntry ) -> None: """Test function.""" - assert entry.subentries == {subentry.subentry_id: subentry} + assert entry.subentries == expected_subentries update_listener_calls.append(None) entry.add_update_listener(update_listener) - assert manager.async_update_entry(entry, subentries=[subentry]) is True + expected_subentries = {subentry.subentry_id: subentry} + assert manager.async_add_subentry(entry, subentry) is True await hass.async_block_till_done(wait_background_tasks=True) - assert entry.subentries == {subentry.subentry_id: subentry} + assert entry.subentries == expected_subentries assert len(update_listener_calls) == 1 + expected_subentries = {} + assert manager.async_remove_subentry(entry, subentry.subentry_id) is True + + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.subentries == expected_subentries + assert len(update_listener_calls) == 2 + async def test_setup_raise_not_ready( hass: HomeAssistant, @@ -4375,10 +4383,6 @@ async def test_updating_entry_with_and_without_changes( assert manager.async_update_entry(entry) is False - subentry = config_entries.ConfigSubentry( - data={"test": "test"}, title="Mock title", unique_id="test" - ) - for change, expected_value in ( ({"data": {"second": True, "third": 456}}, {"second": True, "third": 456}), ({"data": {"second": True}}, {"second": True}), @@ -4386,7 +4390,6 @@ async def test_updating_entry_with_and_without_changes( ({"options": {"hello": True}}, {"hello": True}), ({"pref_disable_new_entities": True}, True), ({"pref_disable_polling": True}, True), - ({"subentries": [subentry]}, {subentry.subentry_id: subentry}), ({"title": "sometitle"}, "sometitle"), ({"unique_id": "abcd1234"}, "abcd1234"), ({"version": 2}, 2), From 02a5fed2e4e24fc33a83143c97786b1d9b1a7baf Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 11 Oct 2024 11:12:23 +0200 Subject: [PATCH 11/13] Improve ConfigSubentryData typed dict --- homeassistant/config_entries.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2c6de3e34d1..0b50abe5ed6 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -23,7 +23,7 @@ from functools import cache import logging from random import randint from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Generic, NotRequired, Self, TypedDict, cast +from typing import TYPE_CHECKING, Any, Generic, Self, TypedDict, cast from async_interrupt import interrupt from propcache import cached_property @@ -315,14 +315,25 @@ def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> N class ConfigSubentryData(TypedDict): - """Container for configuration subentry data.""" + """Container for configuration subentry data. + + Returned by integrations, a subentry_id will be assigned automatically. + """ data: Mapping[str, Any] - subentry_id: NotRequired[str] title: str unique_id: str | None +class ConfigSubentryDataWithId(ConfigSubentryData): + """Container for configuration subentry data. + + This type is used when loading existing subentries from storage. + """ + + subentry_id: str + + class SubentryFlowResult(FlowResult, total=False): """Typed result dict for subentry flow.""" @@ -338,7 +349,7 @@ class ConfigSubentry: title: str unique_id: str | None - def as_dict(self) -> ConfigSubentryData: + def as_dict(self) -> ConfigSubentryDataWithId: """Return dictionary version of this subentry.""" return { "data": dict(self.data), @@ -403,7 +414,7 @@ class ConfigEntry(Generic[_DataT]): pref_disable_polling: bool | None = None, source: str, state: ConfigEntryState = ConfigEntryState.NOT_LOADED, - subentries_data: Iterable[ConfigSubentryData] | None, + subentries_data: Iterable[ConfigSubentryData | ConfigSubentryDataWithId] | None, title: str, unique_id: str | None, version: int, @@ -435,7 +446,8 @@ class ConfigEntry(Generic[_DataT]): for subentry_data in subentries_data: subentry_kwargs = {} if "subentry_id" in subentry_data: - subentry_kwargs["subentry_id"] = subentry_data["subentry_id"] + # If subentry_data has key "subentry_id", we're loading from storage + subentry_kwargs["subentry_id"] = subentry_data["subentry_id"] # type: ignore[typeddict-item] subentry = ConfigSubentry( data=MappingProxyType(subentry_data["data"]), title=subentry_data["title"], From 2246f86d4b3f567f497f1b3016a8a06dc78e2bab Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 7 Nov 2024 12:17:43 +0100 Subject: [PATCH 12/13] Update test snapshots --- tests/components/comelit/snapshots/test_diagnostics.ambr | 4 ++++ tests/components/p1_monitor/snapshots/test_init.ambr | 4 ++++ tests/components/pegel_online/snapshots/test_diagnostics.ambr | 2 ++ .../rainforest_raven/snapshots/test_diagnostics.ambr | 4 ++++ .../components/systemmonitor/snapshots/test_diagnostics.ambr | 2 ++ .../vodafone_station/snapshots/test_diagnostics.ambr | 2 ++ 6 files changed, 18 insertions(+) diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 58ce74035f9..877f48a4611 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -71,6 +71,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, @@ -135,6 +137,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/p1_monitor/snapshots/test_init.ambr b/tests/components/p1_monitor/snapshots/test_init.ambr index d0a676fce1b..83684e153c9 100644 --- a/tests/components/p1_monitor/snapshots/test_init.ambr +++ b/tests/components/p1_monitor/snapshots/test_init.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'unique_thingy', 'version': 2, @@ -38,6 +40,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'unique_thingy', 'version': 2, diff --git a/tests/components/pegel_online/snapshots/test_diagnostics.ambr b/tests/components/pegel_online/snapshots/test_diagnostics.ambr index 1e55805f867..d0fdc81acb4 100644 --- a/tests/components/pegel_online/snapshots/test_diagnostics.ambr +++ b/tests/components/pegel_online/snapshots/test_diagnostics.ambr @@ -31,6 +31,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '70272185-xxxx-xxxx-xxxx-43bea330dcae', 'version': 1, diff --git a/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr b/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr index e131bf3d952..abf8e380916 100644 --- a/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr +++ b/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, @@ -84,6 +86,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr index 4ea02d8bb94..afa508cc004 100644 --- a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr +++ b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr @@ -113,6 +113,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'System Monitor', 'unique_id': None, 'version': 1, diff --git a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr index c258b14dc2d..dd268f4ed1a 100644 --- a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr +++ b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr @@ -35,6 +35,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, From 2462dfbc473232aa40ebcaf5861e0bedec6c256e Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 7 Nov 2024 12:21:38 +0100 Subject: [PATCH 13/13] Adjust tests --- tests/test_config_entries.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index e6a54c59be8..6415f25f9e5 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5946,6 +5946,7 @@ async def test_unhashable_unique_id_fails_on_update( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id="123", version=1, @@ -5976,6 +5977,7 @@ async def test_string_unique_id_no_warning( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id="123", version=1, @@ -6053,6 +6055,7 @@ async def test_no_unique_id_no_warning( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id=None, version=1, @@ -6941,7 +6944,6 @@ async def test_migration_from_1_2( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "import", - "subentries": {}, "title": "Sun", "unique_id": None, "version": 1, @@ -6978,6 +6980,7 @@ async def test_migration_from_1_2( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "import", + "subentries": {}, "title": "Sun", "unique_id": None, "version": 1,