diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 3a07804afe3..cc5a5a1c273 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -217,6 +217,18 @@ class OperationNotAllowed(ConfigError): UpdateListenerType = Callable[[HomeAssistant, "ConfigEntry"], Coroutine[Any, Any, None]] +FROZEN_CONFIG_ENTRY_ATTRS = {"entry_id", "domain", "state", "reason"} +UPDATE_ENTRY_CONFIG_ENTRY_ATTRS = { + "unique_id", + "title", + "data", + "options", + "pref_disable_new_entities", + "pref_disable_polling", + "minor_version", + "version", +} + class ConfigEntry: """Hold a configuration entry.""" @@ -252,6 +264,19 @@ class ConfigEntry: "_supports_options", ) + entry_id: str + domain: str + title: str + data: MappingProxyType[str, Any] + options: MappingProxyType[str, Any] + unique_id: str | None + state: ConfigEntryState + reason: str | None + pref_disable_new_entities: bool + pref_disable_polling: bool + version: int + minor_version: int + def __init__( self, *, @@ -270,44 +295,45 @@ class ConfigEntry: disabled_by: ConfigEntryDisabler | None = None, ) -> None: """Initialize a config entry.""" + _setter = object.__setattr__ # Unique id of the config entry - self.entry_id = entry_id or uuid_util.random_uuid_hex() + _setter(self, "entry_id", entry_id or uuid_util.random_uuid_hex()) # Version of the configuration. - self.version = version - self.minor_version = minor_version + _setter(self, "version", version) + _setter(self, "minor_version", minor_version) # Domain the configuration belongs to - self.domain = domain + _setter(self, "domain", domain) # Title of the configuration - self.title = title + _setter(self, "title", title) # Config data - self.data = MappingProxyType(data) + _setter(self, "data", MappingProxyType(data)) # Entry options - self.options = MappingProxyType(options or {}) + _setter(self, "options", MappingProxyType(options or {})) # Entry system options if pref_disable_new_entities is None: pref_disable_new_entities = False - self.pref_disable_new_entities = pref_disable_new_entities + _setter(self, "pref_disable_new_entities", pref_disable_new_entities) if pref_disable_polling is None: pref_disable_polling = False - self.pref_disable_polling = pref_disable_polling + _setter(self, "pref_disable_polling", pref_disable_polling) # Source of the configuration (user, discovery, cloud) self.source = source # State of the entry (LOADED, NOT_LOADED) - self.state = state + _setter(self, "state", state) # Unique ID of this entry. - self.unique_id = unique_id + _setter(self, "unique_id", unique_id) # Config entry is disabled if isinstance(disabled_by, str) and not isinstance( @@ -337,7 +363,7 @@ class ConfigEntry: self.update_listeners: list[UpdateListenerType] = [] # Reason why config entry is in a failed state - self.reason: str | None = None + _setter(self, "reason", None) # Function to cancel a scheduled retry self._async_cancel_retry_setup: Callable[[], Any] | None = None @@ -366,6 +392,33 @@ class ConfigEntry: f"title={self.title} state={self.state} unique_id={self.unique_id}>" ) + def __setattr__(self, key: str, value: Any) -> None: + """Set an attribute.""" + if key in UPDATE_ENTRY_CONFIG_ENTRY_ATTRS: + if key == "unique_id": + # Setting unique_id directly will corrupt internal state + # There is no deprecation period for this key + # as changing them will corrupt internal state + # so we raise an error here + raise AttributeError( + "unique_id cannot be changed directly, use async_update_entry instead" + ) + report( + f'sets "{key}" directly to update a config entry. This is deprecated and will' + " stop working in Home Assistant 2024.9, it should be updated to use" + " async_update_entry instead", + error_if_core=False, + ) + + elif key in FROZEN_CONFIG_ENTRY_ATTRS: + # These attributes are frozen and cannot be changed + # There is no deprecation period for these + # as changing them will corrupt internal state + # so we raise an error here + raise AttributeError(f"{key} cannot be changed") + + super().__setattr__(key, value) + @property def supports_options(self) -> bool: """Return if entry supports config options.""" @@ -660,8 +713,9 @@ class ConfigEntry: """Set the state of the config entry.""" if state not in NO_RESET_TRIES_STATES: self._tries = 0 - self.state = state - self.reason = reason + _setter = object.__setattr__ + _setter(self, "state", state) + _setter(self, "reason", reason) async_dispatcher_send( hass, SIGNAL_CONFIG_ENTRY_CHANGED, ConfigEntryChange.UPDATED, self ) @@ -1205,7 +1259,7 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): """ entry_id = entry.entry_id self._unindex_entry(entry_id) - entry.unique_id = new_unique_id + object.__setattr__(entry, "unique_id", new_unique_id) self._index_entry(entry) def get_entries_for_domain(self, domain: str) -> list[ConfigEntry]: @@ -1530,7 +1584,11 @@ class ConfigEntries: If the entry was not changed, the update_listeners are not fired and this function returns False """ + if entry.entry_id not in self._entries: + raise UnknownEntry(entry.entry_id) + changed = False + _setter = object.__setattr__ if unique_id is not UNDEFINED and entry.unique_id != unique_id: # Reindex the entry if the unique_id has changed @@ -1547,16 +1605,16 @@ class ConfigEntries: if value is UNDEFINED or getattr(entry, attr) == value: continue - setattr(entry, attr, value) + _setter(entry, attr, value) changed = True if data is not UNDEFINED and entry.data != data: changed = True - entry.data = MappingProxyType(data) + _setter(entry, "data", MappingProxyType(data)) if options is not UNDEFINED and entry.options != options: changed = True - entry.options = MappingProxyType(options) + _setter(entry, "options", MappingProxyType(options)) if not changed: return False diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 075f7c4e266..a3da4ac8928 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -151,10 +151,11 @@ async def test_call_async_migrate_entry( hass: HomeAssistant, major_version: int, minor_version: int ) -> None: """Test we call .async_migrate_entry when version mismatch.""" - entry = MockConfigEntry(domain="comp") + entry = MockConfigEntry( + domain="comp", version=major_version, minor_version=minor_version + ) assert not entry.supports_unload - entry.version = major_version - entry.minor_version = minor_version + entry.add_to_hass(hass) mock_migrate_entry = AsyncMock(return_value=True) @@ -185,9 +186,9 @@ async def test_call_async_migrate_entry_failure_false( hass: HomeAssistant, major_version: int, minor_version: int ) -> None: """Test migration fails if returns false.""" - entry = MockConfigEntry(domain="comp") - entry.version = major_version - entry.minor_version = minor_version + entry = MockConfigEntry( + domain="comp", version=major_version, minor_version=minor_version + ) entry.add_to_hass(hass) assert not entry.supports_unload @@ -217,9 +218,9 @@ async def test_call_async_migrate_entry_failure_exception( hass: HomeAssistant, major_version: int, minor_version: int ) -> None: """Test migration fails if exception raised.""" - entry = MockConfigEntry(domain="comp") - entry.version = major_version - entry.minor_version = minor_version + entry = MockConfigEntry( + domain="comp", version=major_version, minor_version=minor_version + ) entry.add_to_hass(hass) assert not entry.supports_unload @@ -249,9 +250,9 @@ async def test_call_async_migrate_entry_failure_not_bool( hass: HomeAssistant, major_version: int, minor_version: int ) -> None: """Test migration fails if boolean not returned.""" - entry = MockConfigEntry(domain="comp") - entry.version = major_version - entry.minor_version = minor_version + entry = MockConfigEntry( + domain="comp", version=major_version, minor_version=minor_version + ) entry.add_to_hass(hass) assert not entry.supports_unload @@ -281,9 +282,9 @@ async def test_call_async_migrate_entry_failure_not_supported( hass: HomeAssistant, major_version: int, minor_version: int ) -> None: """Test migration fails if async_migrate_entry not implemented.""" - entry = MockConfigEntry(domain="comp") - entry.version = major_version - entry.minor_version = minor_version + entry = MockConfigEntry( + domain="comp", version=major_version, minor_version=minor_version + ) entry.add_to_hass(hass) assert not entry.supports_unload @@ -304,9 +305,9 @@ async def test_call_async_migrate_entry_not_supported_minor_version( hass: HomeAssistant, major_version: int, minor_version: int ) -> None: """Test migration without async_migrate_entry and minor version changed.""" - entry = MockConfigEntry(domain="comp") - entry.version = major_version - entry.minor_version = minor_version + entry = MockConfigEntry( + domain="comp", version=major_version, minor_version=minor_version + ) entry.add_to_hass(hass) assert not entry.supports_unload @@ -2026,7 +2027,7 @@ async def test_unique_id_update_existing_entry_with_reload( # Test we don't reload if entry not started updates["host"] = "2.2.2.2" - entry.state = config_entries.ConfigEntryState.NOT_LOADED + entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None) with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch( "homeassistant.config_entries.ConfigEntries.async_reload" ) as async_reload: @@ -3380,8 +3381,7 @@ async def test_setup_raise_auth_failed( assert flows[0]["context"]["title_placeholders"] == {"name": "test_title"} caplog.clear() - entry.state = config_entries.ConfigEntryState.NOT_LOADED - entry.reason = None + entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3430,7 +3430,7 @@ async def test_setup_raise_auth_failed_from_first_coordinator_update( assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH caplog.clear() - entry.state = config_entries.ConfigEntryState.NOT_LOADED + entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -3480,7 +3480,7 @@ async def test_setup_raise_auth_failed_from_future_coordinator_update( assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH caplog.clear() - entry.state = config_entries.ConfigEntryState.NOT_LOADED + entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None) await entry.async_setup(hass) await hass.async_block_till_done() @@ -4323,3 +4323,65 @@ async def test_hashable_non_string_unique_id( del entries[entry.entry_id] assert not entries assert entries.get_entry_by_domain_and_unique_id("test", unique_id) is None + + +async def test_directly_mutating_blocked( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test directly mutating a ConfigEntry is blocked.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + + with pytest.raises(AttributeError, match="entry_id cannot be changed"): + entry.entry_id = "new_entry_id" + + with pytest.raises(AttributeError, match="domain cannot be changed"): + entry.domain = "new_domain" + + with pytest.raises(AttributeError, match="state cannot be changed"): + entry.state = config_entries.ConfigEntryState.FAILED_UNLOAD + + with pytest.raises(AttributeError, match="reason cannot be changed"): + entry.reason = "new_reason" + + with pytest.raises( + AttributeError, + match="unique_id cannot be changed directly, use async_update_entry instead", + ): + entry.unique_id = "new_id" + + +@pytest.mark.parametrize( + "field", + ( + "data", + "options", + "title", + "pref_disable_new_entities", + "pref_disable_polling", + "minor_version", + "version", + ), +) +async def test_report_direct_mutation_of_config_entry( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, field: str +) -> None: + """Test directly mutating a ConfigEntry is reported.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + + setattr(entry, field, "new_value") + + assert ( + f'Detected code that sets "{field}" directly to update a config entry. ' + "This is deprecated and will stop working in Home Assistant 2024.9, " + "it should be updated to use async_update_entry instead. Please report this issue." + ) in caplog.text + + +async def test_updating_non_added_entry_raises(hass: HomeAssistant) -> None: + """Test updating a non added entry raises UnknownEntry.""" + entry = MockConfigEntry(domain="test") + + with pytest.raises(config_entries.UnknownEntry, match=entry.entry_id): + hass.config_entries.async_update_entry(entry, unique_id="new_id")