mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Resolve StrEnum, IntEnum, and IntFlag members in template container output (#175529)
This commit is contained in:
@@ -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[
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user