From 43fbb2ab7b23c06c05744228846551ba222ba13f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 6 Dec 2024 16:18:54 +0000 Subject: [PATCH] Don't allow entities without platform --- homeassistant/helpers/entity.py | 41 +++------- tests/helpers/test_entity.py | 133 +++++++++++++++++++++----------- 2 files changed, 97 insertions(+), 77 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9e8fe40c6b0..38a2c43db49 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -64,7 +64,7 @@ from .event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, ) -from .frame import report_non_thread_safe_operation +from .frame import ReportBehavior, report_non_thread_safe_operation, report_usage from .typing import UNDEFINED, StateType, UndefinedType timer = time.time @@ -464,9 +464,6 @@ class Entity( # it should be using async_write_ha_state. _async_update_ha_state_reported = False - # If we reported this entity was added without its platform set - _no_platform_reported = False - # If we reported the name translation placeholders do not match the name _name_translation_placeholders_reported = False @@ -722,9 +719,6 @@ class Entity( # value. type.__getattribute__(self.__class__, "name") is type.__getattribute__(Entity, "name") - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - and self.platform ): name = self._name_internal( self._object_id_device_class_name, @@ -737,10 +731,6 @@ class Entity( @cached_property def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - if not self.platform: - return self._name_internal(None, {}) return self._name_internal( self._device_class_name, self.platform.platform_translations, @@ -983,21 +973,13 @@ class Entity( if self.hass is None: raise RuntimeError(f"Attribute hass is None for {self}") - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - if self.platform is None and not self._no_platform_reported: # type: ignore[unreachable] - report_issue = self._suggest_report_issue() # type: ignore[unreachable] - _LOGGER.warning( - ( - "Entity %s (%s) does not have a platform, this may be caused by " - "adding it manually instead of with an EntityComponent helper" - ", please %s" - ), - self.entity_id, - type(self), - report_issue, + # Break if entity is not loaded using EntityComponent, introduced in 2025.1 + if self.platform is None: + report_usage( # type: ignore[unreachable] + f"Entity {self.entity_id} ({type(self)}) does not have a platform," + "this may be caused by adding it manually instead of with an EntityComponent helper", + core_behavior=ReportBehavior.ERROR, ) - self._no_platform_reported = True if self.entity_id is None: raise NoEntitySpecifiedError( @@ -1499,10 +1481,7 @@ class Entity( Not to be extended by integrations. """ - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - if self.platform: - del entity_sources(self.hass)[self.entity_id] + del entity_sources(self.hass)[self.entity_id] @callback def _async_registry_updated( @@ -1632,9 +1611,7 @@ class Entity( def _suggest_report_issue(self) -> str: """Suggest to report an issue.""" - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - platform_name = self.platform.platform_name if self.platform else None + platform_name = self.platform.platform_name return async_suggest_report_issue( self.hass, integration_domain=platform_name, module=type(self).__module__ ) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 2bf441f70fd..1c94eabf872 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -6,6 +6,7 @@ import dataclasses from datetime import timedelta from enum import IntFlag import logging +import re import threading from typing import Any from unittest.mock import MagicMock, PropertyMock, patch @@ -100,6 +101,8 @@ async def test_async_update_support(hass: HomeAssistant) -> None: ent = AsyncEntity() ent.hass = hass + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent]) await ent.async_update_ha_state(True) @@ -124,6 +127,8 @@ async def test_device_class(hass: HomeAssistant) -> None: ent = entity.Entity() ent.entity_id = "test.overwrite_hidden_true" ent.hass = hass + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent]) ent.async_write_ha_state() state = hass.states.get(ent.entity_id) assert state.attributes.get(ATTR_DEVICE_CLASS) is None @@ -151,6 +156,9 @@ async def test_warn_slow_update( mock_entity.entity_id = "comp_test.test_entity" mock_entity.async_update = async_update + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([mock_entity]) + fast_update_time = 0.0000001 with patch.object(entity, "SLOW_UPDATE_WARNING", fast_update_time): @@ -341,6 +349,9 @@ async def test_async_parallel_updates_with_zero(hass: HomeAssistant) -> None: ent_1 = AsyncEntity("sensor.test_1", 1) ent_2 = AsyncEntity("sensor.test_2", 2) + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent_1, ent_2]) + try: ent_1.async_schedule_update_ha_state(True) ent_2.async_schedule_update_ha_state(True) @@ -382,6 +393,9 @@ async def test_async_parallel_updates_with_zero_on_sync_update( ent_1 = AsyncEntity("sensor.test_1", 1) ent_2 = AsyncEntity("sensor.test_2", 2) + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent_1, ent_2]) + try: ent_1.async_schedule_update_ha_state(True) ent_2.async_schedule_update_ha_state(True) @@ -412,7 +426,6 @@ async def test_async_parallel_updates_with_one(hass: HomeAssistant) -> None: self.entity_id = entity_id self.hass = hass self._count = count - self.parallel_updates = test_semaphore async def async_update(self) -> None: """Test update.""" @@ -423,6 +436,10 @@ async def test_async_parallel_updates_with_one(hass: HomeAssistant) -> None: ent_2 = AsyncEntity("sensor.test_2", 2) ent_3 = AsyncEntity("sensor.test_3", 3) + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + platform.parallel_updates = test_semaphore + await platform.async_add_entities([ent_1, ent_2, ent_3]) + await test_lock.acquire() try: @@ -488,7 +505,6 @@ async def test_async_parallel_updates_with_two(hass: HomeAssistant) -> None: self.entity_id = entity_id self.hass = hass self._count = count - self.parallel_updates = test_semaphore async def async_update(self) -> None: """Test update.""" @@ -500,6 +516,10 @@ async def test_async_parallel_updates_with_two(hass: HomeAssistant) -> None: ent_3 = AsyncEntity("sensor.test_3", 3) ent_4 = AsyncEntity("sensor.test_4", 4) + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + platform.parallel_updates = test_semaphore + await platform.async_add_entities([ent_1, ent_2, ent_3, ent_4]) + await test_lock.acquire() try: @@ -557,7 +577,6 @@ async def test_async_parallel_updates_with_one_using_executor( """Initialize sync test entity.""" self.entity_id = entity_id self.hass = hass - self.parallel_updates = test_semaphore def update(self) -> None: """Test update.""" @@ -565,6 +584,10 @@ async def test_async_parallel_updates_with_one_using_executor( entities = [SyncEntity(f"sensor.test_{i}") for i in range(3)] + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + platform.parallel_updates = test_semaphore + await platform.async_add_entities(entities) + await asyncio.gather( *[ hass.async_create_task( @@ -583,6 +606,8 @@ async def test_async_remove_no_platform(hass: HomeAssistant) -> None: ent = entity.Entity() ent.hass = hass ent.entity_id = "test.test" + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent]) ent.async_write_ha_state() assert len(hass.states.async_entity_ids()) == 1 await ent.async_remove() @@ -659,6 +684,8 @@ async def test_set_context(hass: HomeAssistant) -> None: ent.hass = hass ent.entity_id = "hello.world" ent.async_set_context(context) + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent]) ent.async_write_ha_state() assert hass.states.get("hello.world").context == context @@ -671,6 +698,8 @@ async def test_set_context_expired(hass: HomeAssistant) -> None: ent = entity.Entity() ent.hass = hass ent.entity_id = "hello.world" + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent]) ent.async_set_context(context) ent.async_write_ha_state() @@ -757,6 +786,8 @@ async def test_capability_attrs(hass: HomeAssistant) -> None: ent = entity.Entity() ent.hass = hass ent.entity_id = "hello.world" + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent]) ent.async_write_ha_state() state = hass.states.get("hello.world") @@ -896,6 +927,8 @@ async def test_float_conversion(hass: HomeAssistant) -> None: ent = entity.Entity() ent.hass = hass ent.entity_id = "hello.world" + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent]) ent.async_write_ha_state() state = hass.states.get("hello.world") @@ -910,6 +943,9 @@ async def test_attribution_attribute(hass: HomeAssistant) -> None: mock_entity.entity_id = "hello.world" mock_entity._attr_attribution = "Home Assistant" + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([mock_entity]) + mock_entity.async_schedule_update_ha_state(True) await hass.async_block_till_done() @@ -963,12 +999,15 @@ def test_entity_category_schema_error(value) -> None: schema(value) -async def test_entity_description_fallback() -> None: +async def test_entity_description_fallback(hass: HomeAssistant) -> None: """Test entity description has same defaults as entity.""" ent = entity.Entity() ent_with_description = entity.Entity() ent_with_description.entity_description = entity.EntityDescription(key="test") + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent, ent_with_description]) + for field in dataclasses.fields(entity.EntityDescription._dataclass): if field.name == "key": continue @@ -1665,31 +1704,23 @@ async def test_warn_using_async_update_ha_state( assert error_message not in caplog.text -async def test_warn_no_platform( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test we warn am entity does not have a platform.""" +async def test_raise_if_no_platform(hass: HomeAssistant) -> None: + """Test we raise if entity does not have a platform.""" ent = entity.Entity() ent.hass = hass - ent.platform = MockEntityPlatform(hass) ent.entity_id = "hello.world" - error_message = "does not have a platform" - # Without a platform, it should trigger the warning - ent.platform = None - caplog.clear() - ent.async_write_ha_state() - assert error_message in caplog.text - - # Without a platform, it should not trigger the warning again - caplog.clear() - ent.async_write_ha_state() - assert error_message not in caplog.text - - # No warning if the entity has a platform - caplog.clear() - ent.async_write_ha_state() - assert error_message not in caplog.text + # Without a platform, it should raise + error = re.escape( + "Detected code that Entity hello.world ()" + " does not have a platform,this may be caused by adding it manually instead" + " of with an EntityComponent helper. Please report this issue" + ) + with pytest.raises( + RuntimeError, + match=error, + ): + ent.async_write_ha_state() async def test_invalid_state( @@ -1700,6 +1731,9 @@ async def test_invalid_state( ent.entity_id = "test.test" ent.hass = hass + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent]) + ent._attr_state = "x" * 255 ent.async_write_ha_state() assert hass.states.get("test.test").state == "x" * 255 @@ -1726,13 +1760,6 @@ async def test_suggest_report_issue_built_in( mock_entity = entity.Entity() mock_entity.entity_id = "comp_test.test_entity" - suggestion = mock_entity._suggest_report_issue() - assert suggestion == ( - "create a bug report at https://github.com/home-assistant/core/issues" - "?q=is%3Aopen+is%3Aissue" - ) - - mock_integration(hass, MockModule(domain="test"), built_in=True) platform = MockEntityPlatform(hass, domain="comp_test", platform_name="test") await platform.async_add_entities([mock_entity]) @@ -1756,20 +1783,27 @@ async def test_suggest_report_issue_custom_component( mock_entity = CustomComponentEntity() mock_entity.entity_id = "comp_test.test_entity" - suggestion = mock_entity._suggest_report_issue() - assert suggestion == "report it to the custom integration author" - - mock_integration( - hass, - MockModule( - domain="test", partial_manifest={"issue_tracker": "https://some_url"} - ), - built_in=False, - ) platform = MockEntityPlatform(hass, domain="comp_test", platform_name="test") await platform.async_add_entities([mock_entity]) suggestion = mock_entity._suggest_report_issue() + assert suggestion == "report it to the author of the 'test' custom integration" + + mock_entity2 = CustomComponentEntity() + mock_entity2.entity_id = "comp_test.test_entity2" + + mock_integration( + hass, + MockModule( + domain="test2", partial_manifest={"issue_tracker": "https://some_url"} + ), + built_in=False, + ) + platform = MockEntityPlatform(hass, domain="comp_test2", platform_name="test2") + await platform.async_add_entities([mock_entity2]) + await hass.async_block_till_done() + + suggestion = mock_entity2._suggest_report_issue() assert suggestion == "create a bug report at https://some_url" @@ -2629,18 +2663,27 @@ async def test_async_write_ha_state_thread_safety(hass: HomeAssistant) -> None: ent = entity.Entity() ent.entity_id = "test.any" ent.hass = hass - ent.async_write_ha_state() - assert hass.states.get(ent.entity_id) ent2 = entity.Entity() ent2.entity_id = "test.any2" ent2.hass = hass + + platform = MockEntityPlatform(hass, domain="test") + await platform.async_add_entities([ent, ent2]) + + ent._attr_state = "test" + ent2._attr_state = "test" + + ent.async_write_ha_state() + assert hass.states.get(ent.entity_id).state == "test" + with pytest.raises( RuntimeError, match="Detected code that calls async_write_ha_state from a thread.", ): await hass.async_add_executor_job(ent2.async_write_ha_state) - assert not hass.states.get(ent2.entity_id) + await hass.async_block_till_done() + assert hass.states.get(ent2.entity_id).state != "test" async def test_async_write_ha_state_thread_safety_always(