diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 4004078be5f..03b031fc1f1 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -21,6 +21,7 @@ from homeassistant.helpers.data_entry_flow import ( FlowManagerResourceView, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.json import json_fragment from homeassistant.loader import ( Integration, IntegrationNotFound, @@ -69,7 +70,10 @@ class ConfigManagerEntryIndexView(HomeAssistantView): type_filter = None if "type" in request.query: type_filter = [request.query["type"]] - return self.json(await async_matching_config_entries(hass, type_filter, domain)) + fragments = await _async_matching_config_entries_json_fragments( + hass, type_filter, domain + ) + return self.json(fragments) class ConfigManagerEntryResourceView(HomeAssistantView): @@ -129,7 +133,8 @@ def _prepare_config_flow_result_json( return prepare_result_json(result) data = result.copy() - data["result"] = entry_json(result["result"]) + entry: config_entries.ConfigEntry = data["result"] + data["result"] = entry.as_json_fragment data.pop("data") data.pop("context") return data @@ -312,7 +317,7 @@ async def config_entry_get_single( if entry is None: return - result = {"config_entry": entry_json(entry)} + result = {"config_entry": entry.as_json_fragment} connection.send_result(msg["id"], result) @@ -347,7 +352,7 @@ async def config_entry_update( hass.config_entries.async_update_entry(entry, **changes) result = { - "config_entry": entry_json(entry), + "config_entry": entry.as_json_fragment, "require_restart": False, } @@ -453,12 +458,10 @@ async def config_entries_get( msg: dict[str, Any], ) -> None: """Return matching config entries by type and/or domain.""" - connection.send_result( - msg["id"], - await async_matching_config_entries( - hass, msg.get("type_filter"), msg.get("domain") - ), + fragments = await _async_matching_config_entries_json_fragments( + hass, msg.get("type_filter"), msg.get("domain") ) + connection.send_result(msg["id"], fragments) @websocket_api.websocket_command( @@ -491,13 +494,15 @@ async def config_entries_subscribe( [ { "type": change, - "entry": entry_json(entry), + "entry": entry.as_json_fragment, } ], ) ) - current_entries = await async_matching_config_entries(hass, type_filter, None) + current_entries = await _async_matching_config_entries_json_fragments( + hass, type_filter, None + ) connection.subscriptions[msg["id"]] = async_dispatcher_connect( hass, config_entries.SIGNAL_CONFIG_ENTRY_CHANGED, @@ -511,9 +516,9 @@ async def config_entries_subscribe( ) -async def async_matching_config_entries( +async def _async_matching_config_entries_json_fragments( hass: HomeAssistant, type_filter: list[str] | None, domain: str | None -) -> list[dict[str, Any]]: +) -> list[json_fragment]: """Return matching config entries by type and/or domain.""" if domain: entries = hass.config_entries.async_entries(domain) @@ -521,7 +526,7 @@ async def async_matching_config_entries( entries = hass.config_entries.async_entries() if not type_filter: - return [entry_json(entry) for entry in entries] + return [entry.as_json_fragment for entry in entries] integrations: dict[str, Integration] = {} # Fetch all the integrations so we can check their type @@ -541,7 +546,7 @@ async def async_matching_config_entries( filter_is_not_helper = type_filter != ["helper"] filter_set = set(type_filter) return [ - entry_json(entry) + entry.as_json_fragment for entry in entries # If the filter is not 'helper', we still include the integration # even if its not returned from async_get_integrations for backwards @@ -552,22 +557,3 @@ async def async_matching_config_entries( ) or (filter_is_not_helper and entry.domain not in integrations) ] - - -@callback -def entry_json(entry: config_entries.ConfigEntry) -> dict[str, Any]: - """Return JSON value of a config entry.""" - return { - "entry_id": entry.entry_id, - "domain": entry.domain, - "title": entry.title, - "source": entry.source, - "state": entry.state.value, - "supports_options": entry.supports_options, - "supports_remove_device": entry.supports_remove_device or False, - "supports_unload": entry.supports_unload or False, - "pref_disable_new_entities": entry.pref_disable_new_entities, - "pref_disable_polling": entry.pref_disable_polling, - "disabled_by": entry.disabled_by, - "reason": entry.reason, - } diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 049f521fcf9..e5f8d20578a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -12,6 +12,7 @@ from collections.abc import ( Mapping, ValuesView, ) +import contextlib from contextvars import ContextVar from copy import deepcopy from enum import Enum, StrEnum @@ -49,6 +50,7 @@ from .helpers.event import ( async_call_later, ) from .helpers.frame import report +from .helpers.json import json_bytes, json_fragment from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType from .loader import async_suggest_report_issue from .setup import DATA_SETUP_DONE, async_process_deps_reqs, async_setup_component @@ -56,6 +58,8 @@ from .util import uuid as uuid_util from .util.decorator import Registry if TYPE_CHECKING: + from functools import cached_property + from .components.bluetooth import BluetoothServiceInfoBleak from .components.dhcp import DhcpServiceInfo from .components.hassio import HassioServiceInfo @@ -63,6 +67,8 @@ if TYPE_CHECKING: from .components.usb import UsbServiceInfo from .components.zeroconf import ZeroconfServiceInfo from .helpers.service_info.mqtt import MqttServiceInfo +else: + from .backports.functools import cached_property _LOGGER = logging.getLogger(__name__) @@ -233,37 +239,6 @@ UPDATE_ENTRY_CONFIG_ENTRY_ATTRS = { class ConfigEntry: """Hold a configuration entry.""" - __slots__ = ( - "entry_id", - "version", - "minor_version", - "domain", - "title", - "data", - "options", - "unique_id", - "supports_unload", - "supports_remove_device", - "pref_disable_new_entities", - "pref_disable_polling", - "source", - "state", - "disabled_by", - "_setup_lock", - "update_listeners", - "reason", - "_async_cancel_retry_setup", - "_on_unload", - "reload_lock", - "_reauth_lock", - "_tasks", - "_background_tasks", - "_integration_for_domain", - "_tries", - "_setup_again_job", - "_supports_options", - ) - entry_id: str domain: str title: str @@ -418,15 +393,42 @@ class ConfigEntry: raise AttributeError(f"{key} cannot be changed") super().__setattr__(key, value) + self.clear_cache() @property def supports_options(self) -> bool: """Return if entry supports config options.""" if self._supports_options is None and (handler := HANDLERS.get(self.domain)): # work out if handler has support for options flow - self._supports_options = handler.async_supports_options_flow(self) + object.__setattr__( + self, "_supports_options", handler.async_supports_options_flow(self) + ) return self._supports_options or False + def clear_cache(self) -> None: + """Clear cached properties.""" + with contextlib.suppress(AttributeError): + delattr(self, "as_json_fragment") + + @cached_property + def as_json_fragment(self) -> json_fragment: + """Return JSON fragment of a config entry.""" + json_repr = { + "entry_id": self.entry_id, + "domain": self.domain, + "title": self.title, + "source": self.source, + "state": self.state.value, + "supports_options": self.supports_options, + "supports_remove_device": self.supports_remove_device or False, + "supports_unload": self.supports_unload or False, + "pref_disable_new_entities": self.pref_disable_new_entities, + "pref_disable_polling": self.pref_disable_polling, + "disabled_by": self.disabled_by, + "reason": self.reason, + } + return json_fragment(json_bytes(json_repr)) + async def async_setup( self, hass: HomeAssistant, @@ -716,6 +718,7 @@ class ConfigEntry: _setter = object.__setattr__ _setter(self, "state", state) _setter(self, "reason", reason) + self.clear_cache() async_dispatcher_send( hass, SIGNAL_CONFIG_ENTRY_CHANGED, ConfigEntryChange.UPDATED, self ) @@ -1261,6 +1264,7 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): self._unindex_entry(entry_id) object.__setattr__(entry, "unique_id", new_unique_id) self._index_entry(entry) + entry.clear_cache() def get_entries_for_domain(self, domain: str) -> list[ConfigEntry]: """Get entries for a domain.""" @@ -1642,6 +1646,7 @@ class ConfigEntries: ) self._async_schedule_save() + entry.clear_cache() self._async_dispatch(ConfigEntryChange.UPDATED, entry) return True diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index c5eb8d18efe..71334b753fa 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -12,6 +12,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries, data_entry_flow, loader +from homeassistant.backports.functools import cached_property from homeassistant.components import dhcp from homeassistant.components.hassio import HassioServiceInfo from homeassistant.const import ( @@ -834,7 +835,14 @@ async def test_as_dict(snapshot: SnapshotAssertion) -> None: # Make sure the expected keys are present dict_repr = entry.as_dict() - for key in config_entries.ConfigEntry.__slots__: + for key in config_entries.ConfigEntry.__dict__: + func = getattr(config_entries.ConfigEntry, key) + if ( + key.startswith("__") + or callable(func) + or type(func) in (cached_property, property) + ): + continue assert key in dict_repr or key in excluded_from_dict assert not (key in dict_repr and key in excluded_from_dict)