Avoid mutating registry when rendering assist_exposed_entities

async_should_expose persists a derived should_expose value into the
entity registry (or the legacy store) whenever no explicit setting
exists, firing registry-updated events and scheduling a save. Calling
it for every entity while rendering a template meant the first render
could materialize defaults for potentially every entity, so template
rendering was not side-effect free.

Add a read-only async_get_should_expose that returns the same result
(including the expose-new-entities default) without persisting it, and
use it from the template extension. The default computation is factored
into a shared helper so the caching and read-only paths stay in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Michael Hansen
2026-07-15 15:46:18 -05:00
parent 807b5f2f3f
commit 33d38b1846
3 changed files with 65 additions and 10 deletions
@@ -253,10 +253,9 @@ class ExposedEntities:
should_expose = registry_entry.options[assistant]["should_expose"]
return should_expose
if self.async_get_expose_new_entities(assistant):
should_expose = self._is_default_exposed(entity_id, registry_entry)
else:
should_expose = False
should_expose = self._async_compute_default_should_expose(
assistant, entity_id, registry_entry
)
assistant_options: ReadOnlyDict[str, Any] | dict[str, Any]
assistant_options = registry_entry.options.get(assistant, {})
@@ -267,6 +266,27 @@ class ExposedEntities:
return should_expose
@callback
def async_get_should_expose(self, assistant: str, entity_id: str) -> bool:
"""Return True if an entity should be exposed, without persisting defaults."""
should_expose: bool
entity_registry = er.async_get(self._hass)
if registry_entry := entity_registry.async_get(entity_id):
options: Mapping[str, Any] = registry_entry.options.get(assistant, {})
if "should_expose" in options:
should_expose = options["should_expose"]
return should_expose
return self._async_compute_default_should_expose(
assistant, entity_id, registry_entry
)
if (
exposed_entity := self.entities.get(entity_id)
) and "should_expose" in exposed_entity.assistants.get(assistant, {}):
should_expose = exposed_entity.assistants[assistant]["should_expose"]
return should_expose
return self._async_compute_default_should_expose(assistant, entity_id, None)
def _async_should_expose_legacy_entity(
self, assistant: str, entity_id: str
) -> bool:
@@ -280,10 +300,9 @@ class ExposedEntities:
should_expose = exposed_entity.assistants[assistant]["should_expose"]
return should_expose
if self.async_get_expose_new_entities(assistant):
should_expose = self._is_default_exposed(entity_id, None)
else:
should_expose = False
should_expose = self._async_compute_default_should_expose(
assistant, entity_id, None
)
if exposed_entity:
new_exposed_entity = self._update_exposed_entity(
@@ -298,6 +317,15 @@ class ExposedEntities:
return should_expose
@callback
def _async_compute_default_should_expose(
self, assistant: str, entity_id: str, registry_entry: er.RegistryEntry | None
) -> bool:
"""Compute default exposure for an entity without persisting it."""
if self.async_get_expose_new_entities(assistant):
return self._is_default_exposed(entity_id, registry_entry)
return False
def _is_default_exposed(
self, entity_id: str, registry_entry: er.RegistryEntry | None
) -> bool:
@@ -518,6 +546,15 @@ def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) ->
return exposed_entities.async_should_expose(assistant, entity_id)
@callback
def async_get_should_expose(
hass: HomeAssistant, assistant: str, entity_id: str
) -> bool:
"""Return True if an entity should be exposed, without persisting defaults."""
exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
return exposed_entities.async_get_should_expose(assistant, entity_id)
@callback
def async_set_assistant_option(
hass: HomeAssistant, assistant: str, entity_id: str, option: str, value: Any
@@ -35,7 +35,7 @@ class ExposedEntitiesExtension(BaseTemplateExtension):
# extension is imported very early during bootstrap.
from homeassistant.components.homeassistant.exposed_entities import ( # noqa: PLC0415
DATA_EXPOSED_ENTITIES,
async_should_expose,
async_get_should_expose,
)
exposed_entities = self.hass.data[DATA_EXPOSED_ENTITIES]
@@ -47,5 +47,5 @@ class ExposedEntitiesExtension(BaseTemplateExtension):
for entity_id in dict.fromkeys(
chain(exposed_entities.entities, entity_registry.entities)
)
if async_should_expose(self.hass, "conversation", entity_id)
if async_get_should_expose(self.hass, "conversation", entity_id)
]
@@ -58,6 +58,24 @@ async def test_assist_exposed_entities_only_conversation(
assert info.rate_limit is None
async def test_assist_exposed_entities_default_exposure_is_read_only(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test entities exposed only by default are returned without mutating state."""
assert await async_setup_component(hass, "homeassistant", {})
# conversation exposes new entities by default and "light" is a default
# exposed domain, so this entity is exposed without any explicit setting.
entry = entity_registry.async_get_or_create("light", "test", "entity1")
info = render_to_info(hass, "{{ assist_exposed_entities() }}")
assert_result_info(info, [entry.entity_id])
assert info.rate_limit is None
# Rendering must not persist the derived should_expose value.
assert "conversation" not in entity_registry.async_get(entry.entity_id).options
async def test_assist_exposed_entities_not_in_registry(hass: HomeAssistant) -> None:
"""Test assist_exposed_entities includes entities not in the registry."""
assert await async_setup_component(hass, "homeassistant", {})