Resolve StrEnum, IntEnum, and IntFlag members in template container output (#175529)

This commit is contained in:
Markus Tuominen
2026-09-04 18:51:54 +02:00
committed by GitHub
parent 0828053500
commit 295f19789f
2 changed files with 214 additions and 2 deletions
+40 -2
View File
@@ -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. <MyEnum.FOO: 'foo'>, 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[
+174
View File
@@ -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",
[