diff --git a/homeassistant/core.py b/homeassistant/core.py index b8f509abef3..b568ee72689 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1110,6 +1110,13 @@ class State: self.domain, self.object_id = split_entity_id(self.entity_id) self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None + def __hash__(self) -> int: + """Make the state hashable. + + State objects are effectively immutable. + """ + return hash((id(self), self.last_updated)) + @property def name(self) -> str: """Name of this state.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ac5b4c8119f..eca76a8c7bc 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -9,7 +9,7 @@ from collections.abc import Callable, Generator, Iterable from contextlib import contextmanager, suppress from contextvars import ContextVar from datetime import datetime, timedelta -from functools import partial, wraps +from functools import cache, lru_cache, partial, wraps import json import logging import math @@ -98,6 +98,9 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( "template_cv", default=None ) +CACHED_TEMPLATE_STATES = 512 +EVAL_CACHE_SIZE = 512 + @bind_hass def attach(hass: HomeAssistant, obj: Any) -> None: @@ -222,6 +225,9 @@ def _false(arg: str) -> bool: return False +_cached_literal_eval = lru_cache(maxsize=EVAL_CACHE_SIZE)(literal_eval) + + class RenderInfo: """Holds information about a template render.""" @@ -318,6 +324,7 @@ class Template: "_exc_info", "_limited", "_strict", + "_hash_cache", ) def __init__(self, template, hass=None): @@ -333,6 +340,7 @@ class Template: self._exc_info = None self._limited = None self._strict = None + self._hash_cache: int = hash(self.template) @property def _env(self) -> TemplateEnvironment: @@ -421,7 +429,7 @@ class Template: def _parse_result(self, render_result: str) -> Any: """Parse the result.""" try: - result = literal_eval(render_result) + result = _cached_literal_eval(render_result) if type(result) in RESULT_WRAPPERS: result = RESULT_WRAPPERS[type(result)]( @@ -618,16 +626,30 @@ class Template: def __hash__(self) -> int: """Hash code for template.""" - return hash(self.template) + return self._hash_cache def __repr__(self) -> str: """Representation of Template.""" return 'Template("' + self.template + '")' +@cache +def _domain_states(hass: HomeAssistant, name: str) -> DomainStates: + return DomainStates(hass, name) + + +def _readonly(*args: Any, **kwargs: Any) -> Any: + """Raise an exception when a states object is modified.""" + raise RuntimeError(f"Cannot modify template States object: {args} {kwargs}") + + class AllStates: """Class to expose all HA states as attributes.""" + __setitem__ = _readonly + __delitem__ = _readonly + __slots__ = ("_hass",) + def __init__(self, hass: HomeAssistant) -> None: """Initialize all states.""" self._hass = hass @@ -643,7 +665,7 @@ class AllStates: if not valid_entity_id(f"{name}.entity"): raise TemplateError(f"Invalid domain name '{name}'") - return DomainStates(self._hass, name) + return _domain_states(self._hass, name) # Jinja will try __getitem__ first and it avoids the need # to call is_safe_attribute @@ -682,6 +704,11 @@ class AllStates: class DomainStates: """Class to expose a specific HA domain as attributes.""" + __slots__ = ("_hass", "_domain") + + __setitem__ = _readonly + __delitem__ = _readonly + def __init__(self, hass: HomeAssistant, domain: str) -> None: """Initialize the domain states.""" self._hass = hass @@ -727,6 +754,9 @@ class TemplateStateBase(State): _state: State + __setitem__ = _readonly + __delitem__ = _readonly + # Inheritance is done so functions that check against State keep working # pylint: disable=super-init-not-called def __init__(self, hass: HomeAssistant, collect: bool, entity_id: str) -> None: @@ -865,10 +895,15 @@ def _collect_state(hass: HomeAssistant, entity_id: str) -> None: entity_collect.entities.add(entity_id) +@lru_cache(maxsize=CACHED_TEMPLATE_STATES) +def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: + return TemplateState(hass, state, collect=False) + + def _state_generator(hass: HomeAssistant, domain: str | None) -> Generator: """State generator for a domain or all states.""" for state in sorted(hass.states.async_all(domain), key=attrgetter("entity_id")): - yield TemplateState(hass, state, collect=False) + yield _template_state_no_collect(hass, state) def _get_state_if_valid(hass: HomeAssistant, entity_id: str) -> TemplateState | None: @@ -882,6 +917,11 @@ def _get_state(hass: HomeAssistant, entity_id: str) -> TemplateState | None: return _get_template_state_from_state(hass, entity_id, hass.states.get(entity_id)) +@lru_cache(maxsize=CACHED_TEMPLATE_STATES) +def _template_state(hass: HomeAssistant, state: State) -> TemplateState: + return TemplateState(hass, state) + + def _get_template_state_from_state( hass: HomeAssistant, entity_id: str, state: State | None ) -> TemplateState | None: @@ -890,7 +930,7 @@ def _get_template_state_from_state( # access to the state properties in the state wrapper. _collect_state(hass, entity_id) return None - return TemplateState(hass, state) + return _template_state(hass, state) def _resolve_state( diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index a1fd3e73f59..69a3af22759 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -18,6 +18,7 @@ from homeassistant.const import ( MASS_GRAMS, PRESSURE_PA, SPEED_KILOMETERS_PER_HOUR, + STATE_ON, TEMP_CELSIUS, VOLUME_LITERS, ) @@ -3831,3 +3832,12 @@ async def test_undefined_variable(hass, caplog): "Template variable warning: 'no_such_variable' is undefined when rendering '{{ no_such_variable }}'" in caplog.text ) + + +async def test_template_states_blocks_setitem(hass): + """Test we cannot setitem on TemplateStates.""" + hass.states.async_set("light.new", STATE_ON) + state = hass.states.get("light.new") + template_state = template.TemplateState(hass, state, True) + with pytest.raises(RuntimeError): + template_state["any"] = "any"