diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 822a13cbf2c1..11b51a84021f 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -6,6 +6,7 @@ import collections.abc from collections.abc import Callable import contextlib from datetime import timedelta +from enum import ReprEnum from functools import lru_cache, partial import logging import pathlib @@ -34,6 +35,7 @@ from homeassistant.helpers.typing import TemplateVarsType from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads +from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.thread import ThreadWithException from .context import ( @@ -299,6 +301,38 @@ def _cached_parse_result(render_result: str) -> Any: return render_result +_FINALIZE_DICT_TYPES = (dict, ReadOnlyDict) +_FINALIZE_CONTAINER_TYPES = (list, set, tuple) + + +def _finalize_output(value: Any, _nested: bool = False) -> Any: + """Resolve ReprEnum members nested in containers so output round-trips. + + Jinja stringifies containers via repr() of their items, and enum members + repr as e.g. , which literal_eval cannot parse back into + a dict/list. Replace such members inside containers with their underlying + value before the container is stringified. Only ReprEnum members (StrEnum, + IntEnum, IntFlag) are handled: they use the mixed-in type's str(), so their + value is a literal-safe scalar that already matches their bare str() output + and nested/top-level rendering stay consistent. Plain Enum/Flag members are + left untouched. ReadOnlyDict is handled explicitly since state attributes + use it; other dict subclasses, namedtuples, and result wrappers are left + untouched to avoid rebuilding types that don't take an iterable constructor + or carry their own str(). + """ + if _nested and isinstance(value, ReprEnum): + return value.value + value_type = type(value) + if value_type in _FINALIZE_DICT_TYPES: + return { + _finalize_output(key, True): _finalize_output(item, True) + for key, item in value.items() + } + if value_type in _FINALIZE_CONTAINER_TYPES: + return value_type(_finalize_output(item, True) for item in value) + return value + + class Template: """Class to hold a template and manage caching and rendering.""" @@ -597,7 +631,8 @@ class Template: render_result = render_with_context( self.template, compiled, **variables ).strip() - except jinja2.TemplateError as ex: + # A cyclic value makes the finalize hook recurse until RecursionError. + except (jinja2.TemplateError, RecursionError) as ex: if error_value is _SENTINEL: _LOGGER.error( "Error parsing value: %s (value: %s, template: %s)", @@ -795,7 +830,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): log_fn: Callable[[int, str], None] | None = None, ) -> None: """Initialise template environment.""" - super().__init__(undefined=make_logging_undefined(strict, log_fn)) + super().__init__( + undefined=make_logging_undefined(strict, log_fn), + finalize=_finalize_output, + ) self.hass = hass self.limited = limited self.template_cache: weakref.WeakValueDictionary[ diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 6d1c1a31363f..44a234979919 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -1,6 +1,7 @@ """Test Home Assistant template helper methods.""" from datetime import datetime +from enum import Enum, IntEnum, IntFlag, StrEnum import gc from unittest.mock import patch @@ -223,6 +224,19 @@ def test_render_with_possible_json_value_with_invalid_json(hass: HomeAssistant) assert tpl.async_render_with_possible_json_value("{ I AM NOT JSON }") == "" +def test_render_with_possible_json_value_cyclic(hass: HomeAssistant) -> None: + """A cyclic value returns the fallback instead of escaping a RecursionError.""" + cyclic: list[object] = [] + cyclic.append(cyclic) + tpl = template.Template("{{ x }}", hass) + assert ( + tpl.async_render_with_possible_json_value( + "value", error_value="fallback", variables={"x": cyclic} + ) + == "fallback" + ) + + def test_render_with_possible_json_value_with_template_error_value( hass: HomeAssistant, ) -> None: @@ -935,6 +949,166 @@ async def test_parse_result(hass: HomeAssistant) -> None: assert render(hass, tpl) == result +class _Color(StrEnum): + RED = "red" + + +class _Level(IntEnum): + LOW = 1 + + +class _LegacyStr(str, Enum): # noqa: UP042 # legacy str-mixin under test + FOO = "foo" + + +class _Plain(Enum): + A = "aval" + + +class _Flags(IntFlag): + X = 1 + Y = 2 + + +_ENUM_VARS = { + "color": _Color.RED, + "level": _Level.LOW, + "legacy": _LegacyStr.FOO, + "plain": _Plain.A, + "flag_single": _Flags.X, + # IntFlag union, like supported_features (e.g. WeatherEntityFeature); value 3. + "flag": _Flags.X | _Flags.Y, + # Jinja has no set-literal syntax, so a set can only reach output as a var. + "color_set": {_Color.RED}, + "level_set": {_Level.LOW}, +} + + +@pytest.mark.parametrize( + ("template_string", "expected"), + [ + pytest.param("{{ {'k': color} }}", {"k": "red"}, id="strenum-dict-value"), + pytest.param("{{ {color: 1} }}", {"red": 1}, id="strenum-dict-key"), + pytest.param("{{ [color] }}", ["red"], id="strenum-list"), + pytest.param("{{ (color,) }}", ("red",), id="strenum-tuple"), + pytest.param("{{ color_set }}", {"red"}, id="strenum-set"), + pytest.param("{{ {'k': level} }}", {"k": 1}, id="intenum-dict-value"), + pytest.param("{{ {level: 'x'} }}", {1: "x"}, id="intenum-dict-key"), + pytest.param("{{ [level] }}", [1], id="intenum-list"), + pytest.param("{{ level_set }}", {1}, id="intenum-set"), + pytest.param("{{ {'k': flag_single} }}", {"k": 1}, id="intflag-single"), + pytest.param("{{ {'k': flag} }}", {"k": 3}, id="intflag-union-dict-value"), + pytest.param("{{ [flag] }}", [3], id="intflag-union-list"), + pytest.param( + "{{ {color: {level: [color, level]}} }}", + {"red": {1: ["red", 1]}}, + id="deeply-nested-mixed-keys-and-values", + ), + pytest.param( + "{{ [{'a': (color, [level])}] }}", + [{"a": ("red", [1])}], + id="nested-mixed-containers", + ), + ], +) +async def test_parse_result_resolves_enums_in_containers( + hass: HomeAssistant, template_string: str, expected: object +) -> None: + """ReprEnum members in containers resolve to their value and round-trip.""" + assert render(hass, template_string, _ENUM_VARS) == expected + + +@pytest.mark.parametrize( + "variable", + ["legacy", "plain"], +) +async def test_parse_result_unsupported_enums_fall_back_to_string( + hass: HomeAssistant, variable: str +) -> None: + """Non-ReprEnum members (plain Enum, legacy mixins) are not resolved.""" + result = render(hass, "{{ {'k': " + variable + "} }}", _ENUM_VARS) + assert isinstance(result, str) + + +@pytest.mark.parametrize( + ("template_string", "expected"), + [ + pytest.param("{{ color }}", "red", id="bare-strenum"), + pytest.param("{{ level }}", 1, id="bare-intenum"), + ], +) +async def test_parse_result_bare_enum_unchanged( + hass: HomeAssistant, template_string: str, expected: object +) -> None: + """A bare Enum member renders via its default str() and is not coerced.""" + assert render(hass, template_string, _ENUM_VARS) == expected + + +async def test_parse_result_resolves_enums_in_state_attributes( + hass: HomeAssistant, +) -> None: + """State attributes (a ReadOnlyDict) with enum keys/values resolve when rendered. + + Mirrors the reported case: a StrEnum key plus an IntFlag supported_features + value (like WeatherEntityFeature) would otherwise make the attributes render + as an unparsable string. + """ + hass.states.async_set( + "light.test", + "on", + {_Color.RED: "rainbow", "supported_features": _Flags.X | _Flags.Y}, + ) + assert render(hass, "{{ states.light.test.attributes }}") == { + "red": "rainbow", + "supported_features": 3, + } + + +@pytest.mark.parametrize( + ("template_string", "expected_value"), + [ + pytest.param( + "{{ dict(foo=\"{'a': 1}\") }}", "{'a': 1}", id="python-dict-literal" + ), + pytest.param("{{ dict(foo='{\"a\": 1}') }}", '{"a": 1}', id="json-object"), + pytest.param("{{ dict(foo='[1, 2, 3]') }}", "[1, 2, 3]", id="json-array"), + pytest.param("{{ dict(foo='(1, 2)') }}", "(1, 2)", id="tuple-literal"), + pytest.param("{{ dict(foo='{1, 2}') }}", "{1, 2}", id="set-literal"), + pytest.param("{{ dict(foo='123') }}", "123", id="int-like"), + pytest.param("{{ dict(foo='1.5') }}", "1.5", id="float-like"), + pytest.param("{{ dict(foo='True') }}", "True", id="bool-like"), + pytest.param("{{ dict(foo='null') }}", "null", id="json-null"), + ], +) +async def test_parse_result_dict_keeps_stringified_value( + hass: HomeAssistant, template_string: str, expected_value: str +) -> None: + """dict() keeps a value that looks like a literal as a string, not re-parsed. + + Common workaround (JSON payload templates, MQTT topics) to stop the template + parser from resolving a string value. The result must be a dict whose value + is the untouched string, even when that string is itself a valid literal. + """ + result = render(hass, template_string) + assert isinstance(result, dict) + assert result == {"foo": expected_value} + assert isinstance(result["foo"], str) + + +async def test_parse_result_cyclic_container_raises(hass: HomeAssistant) -> None: + """A cyclic passed-in container raises TemplateError instead of crashing. + + Cyclic values cannot be constructed within a template (the sandbox blocks + mutation), so this only covers a cycle passed in as a variable. Resolution + recurses through the back-reference until the recursion limit, which is + surfaced as a TemplateError. + """ + cyclic: list[object] = [] + cyclic.append(cyclic) + with pytest.raises(TemplateError): + render(hass, "{{ x }}", {"x": cyclic}) + + @pytest.mark.parametrize( "template_string", [