Files
core/tests/helpers/template/test_init.py
T

1166 lines
39 KiB
Python

"""Test Home Assistant template helper methods."""
from datetime import datetime
from enum import Enum, IntEnum, IntFlag, StrEnum
import gc
from unittest.mock import patch
from freezegun import freeze_time
import pytest
import voluptuous as vol
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import entity_registry as er, template
from homeassistant.helpers.template.render_info import (
ALL_STATES_RATE_LIMIT,
DOMAIN_STATES_RATE_LIMIT,
)
from homeassistant.util import dt as dt_util
from .helpers import assert_result_info, render, render_to_info
async def test_template_render_missing_hass(hass: HomeAssistant) -> None:
"""Test template render when hass is not set."""
hass.states.async_set("sensor.test", "23")
template_obj = template.Template("{{ states('sensor.test') }}", None)
template.render_info_cv.set(template.RenderInfo(template_obj))
with pytest.raises(RuntimeError, match="hass not set while rendering"):
template_obj.async_render_to_info()
async def test_template_render_info_collision(hass: HomeAssistant) -> None:
"""Test template render info collision.
This usually means the template is being rendered
in the wrong thread.
"""
hass.states.async_set("sensor.test", "23")
template_obj = template.Template("{{ states('sensor.test') }}", None)
template_obj.hass = hass
template.render_info_cv.set(template.RenderInfo(template_obj))
with pytest.raises(RuntimeError, match="RenderInfo already set while rendering"):
template_obj.async_render_to_info()
def test_template_equality(hass: HomeAssistant) -> None:
"""Test template comparison and hashing."""
template_one = template.Template("{{ template_one }}", hass)
template_one_1 = template.Template("{{ template_one }}", hass)
template_two = template.Template("{{ template_two }}", hass)
assert template_one == template_one_1
assert template_one != template_two
assert hash(template_one) == hash(template_one_1)
assert hash(template_one) != hash(template_two)
assert str(template_one_1) == "Template<template=({{ template_one }}) renders=0>"
with pytest.raises(TypeError):
template.Template(["{{ template_one }}"], hass)
def test_invalid_template(hass: HomeAssistant) -> None:
"""Invalid template raises error."""
tmpl = template.Template("{{", hass)
with pytest.raises(TemplateError):
tmpl.ensure_valid()
with pytest.raises(TemplateError):
tmpl.async_render()
info = tmpl.async_render_to_info()
with pytest.raises(TemplateError):
assert info.result() == "impossible"
tmpl = template.Template("{{states(keyword)}}", hass)
tmpl.ensure_valid()
with pytest.raises(TemplateError):
tmpl.async_render()
def test_raise_exception_on_error(hass: HomeAssistant) -> None:
"""Test raising an exception on error."""
with pytest.raises(TemplateError):
template.Template("{{ invalid_syntax", hass).ensure_valid()
async def test_import(hass: HomeAssistant) -> None:
"""Test that imports work from the config/custom_templates folder."""
await template.async_load_custom_templates(hass)
assert "test.jinja" in template._get_hass_loader(hass).sources
assert "inner/inner_test.jinja" in template._get_hass_loader(hass).sources
assert (
render(
hass,
"""
{% import 'test.jinja' as t %}
{{ t.test_macro() }} {{ t.test_variable }}
""",
)
== "macro variable"
)
assert (
render(
hass,
"""
{% import 'inner/inner_test.jinja' as t %}
{{ t.test_macro() }} {{ t.test_variable }}
""",
)
== "inner macro inner variable"
)
with pytest.raises(TemplateError):
render(
hass,
"""
{% import 'notfound.jinja' as t %}
{{ t.test_macro() }} {{ t.test_variable }}
""",
)
async def test_import_change(hass: HomeAssistant) -> None:
"""Test that a change in HassLoader results in updated imports."""
await template.async_load_custom_templates(hass)
to_test = template.Template(
"""
{% import 'test.jinja' as t %}
{{ t.test_macro() }} {{ t.test_variable }}
""",
hass,
)
assert to_test.async_render() == "macro variable"
template._get_hass_loader(hass).sources = {
"test.jinja": """
{% macro test_macro() -%}
macro2
{%- endmacro %}
{% set test_variable = "variable2" %}
"""
}
assert to_test.async_render() == "macro2 variable2"
def test_loop_controls(hass: HomeAssistant) -> None:
"""Test that loop controls are enabled."""
tpl = """
{%- for v in range(10) %}
{%- if v == 1 -%}
{%- continue -%}
{%- elif v == 3 -%}
{%- break -%}
{%- endif -%}
{{ v }}
{%- endfor -%}
"""
assert render(hass, tpl) == "02"
def test_converting_datetime_to_iterable(hass: HomeAssistant) -> None:
"""Test converting a datetime to an iterable raises an error."""
dt_ = datetime(2020, 1, 1, 0, 0, 0)
with pytest.raises(TemplateError):
render(hass, "{{ tuple(value) }}", {"value": dt_})
with pytest.raises(TemplateError):
render(hass, "{{ set(value) }}", {"value": dt_})
def test_passing_vars_as_keywords(hass: HomeAssistant) -> None:
"""Test passing variables as keywords."""
assert render(hass, "{{ hello }}", hello=127) == 127
def test_passing_vars_as_vars(hass: HomeAssistant) -> None:
"""Test passing variables as variables."""
assert render(hass, "{{ hello }}", {"hello": 127}) == 127
def test_passing_vars_as_list(hass: HomeAssistant) -> None:
"""Test passing variables as list."""
assert template.render_complex(
template.Template("{{ hello }}", hass), {"hello": ["foo", "bar"]}
) == ["foo", "bar"]
def test_passing_vars_as_list_element(hass: HomeAssistant) -> None:
"""Test passing variables as list."""
tpl = template.Template("{{ hello[1] }}", hass)
assert template.render_complex(tpl, {"hello": ["foo", "bar"]}) == "bar"
def test_passing_vars_as_dict_element(hass: HomeAssistant) -> None:
"""Test passing variables as list."""
tpl = template.Template("{{ hello.foo }}", hass)
assert template.render_complex(tpl, {"hello": {"foo": "bar"}}) == "bar"
def test_passing_vars_as_dict(hass: HomeAssistant) -> None:
"""Test passing variables as list."""
tpl = template.Template("{{ hello }}", hass)
assert template.render_complex(tpl, {"hello": {"foo": "bar"}}) == {"foo": "bar"}
def test_render_with_possible_json_value_with_valid_json(hass: HomeAssistant) -> None:
"""Render with possible JSON value with valid JSON."""
tpl = template.Template("{{ value_json.hello }}", hass)
assert tpl.async_render_with_possible_json_value('{"hello": "world"}') == "world"
def test_render_with_possible_json_value_with_invalid_json(hass: HomeAssistant) -> None:
"""Render with possible JSON value with invalid JSON."""
tpl = template.Template("{{ value_json }}", hass)
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:
"""Render with possible JSON value with template error value."""
tpl = template.Template("{{ non_existing.variable }}", hass)
assert tpl.async_render_with_possible_json_value("hello", "-") == "-"
def test_render_with_possible_json_value_with_missing_json_value(
hass: HomeAssistant,
) -> None:
"""Render with possible JSON value with unknown JSON object."""
tpl = template.Template("{{ value_json.goodbye }}", hass)
assert tpl.async_render_with_possible_json_value('{"hello": "world"}') == ""
def test_render_with_possible_json_value_non_string_value(hass: HomeAssistant) -> None:
"""Render with possible JSON value with non-string value."""
tpl = template.Template(
"""{{ strptime(value~'+0000', '%Y-%m-%d %H:%M:%S%z') }}""",
hass,
)
value = datetime(2019, 1, 18, 12, 13, 14)
expected = str(value.replace(tzinfo=dt_util.UTC))
assert tpl.async_render_with_possible_json_value(value) == expected
def test_render_with_possible_json_value_and_parse_result(hass: HomeAssistant) -> None:
"""Render with possible JSON value with valid JSON."""
tpl = template.Template("{{ value_json.hello }}", hass)
result = tpl.async_render_with_possible_json_value(
"""{"hello": {"world": "value1"}}""", parse_result=True
)
assert isinstance(result, dict)
def test_render_with_possible_json_value_and_dont_parse_result(
hass: HomeAssistant,
) -> None:
"""Render with possible JSON value with valid JSON."""
tpl = template.Template("{{ value_json.hello }}", hass)
result = tpl.async_render_with_possible_json_value(
"""{"hello": {"world": "value1"}}""", parse_result=False
)
assert isinstance(result, str)
@patch(
"homeassistant.helpers.template.TemplateEnvironment.is_safe_callable",
return_value=True,
)
def test_timedelta(mock_is_safe, hass: HomeAssistant) -> None:
"""Test relative_time method."""
now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z")
with freeze_time(now):
result = render(hass, "{{timedelta(seconds=120)}}")
assert result == "0:02:00"
result = render(hass, "{{timedelta(seconds=86400)}}")
assert result == "1 day, 0:00:00"
result = render(hass, "{{timedelta(days=1, hours=4)}}")
assert result == "1 day, 4:00:00"
result = render(hass, "{{relative_time(now() - timedelta(seconds=3600))}}")
assert result == "1 hour"
result = render(hass, "{{relative_time(now() - timedelta(seconds=86400))}}")
assert result == "1 day"
result = render(hass, "{{relative_time(now() - timedelta(seconds=86401))}}")
assert result == "1 day"
result = render(hass, "{{relative_time(now() - timedelta(weeks=2, days=1))}}")
assert result == "15 days"
def test_async_render_to_info_with_branching(hass: HomeAssistant) -> None:
"""Test async_render_to_info function by domain."""
hass.states.async_set("light.a", "off")
hass.states.async_set("light.b", "on")
hass.states.async_set("light.c", "off")
info = render_to_info(
hass,
"""
{% if states.light.a == "on" %}
{{ states.light.b.state }}
{% else %}
{{ states.light.c.state }}
{% endif %}
""",
)
assert_result_info(info, "off", {"light.a", "light.c"})
assert info.rate_limit is None
info = render_to_info(
hass,
"""
{% if states.light.a.state == "off" %}
{% set domain = "light" %}
{{ states[domain].b.state }}
{% endif %}
""",
)
assert_result_info(info, "on", {"light.a", "light.b"})
assert info.rate_limit is None
def test_async_render_to_info_with_complex_branching(hass: HomeAssistant) -> None:
"""Test async_render_to_info function by domain."""
hass.states.async_set("light.a", "off")
hass.states.async_set("light.b", "on")
hass.states.async_set("light.c", "off")
hass.states.async_set("vacuum.a", "off")
hass.states.async_set("device_tracker.a", "off")
hass.states.async_set("device_tracker.b", "off")
hass.states.async_set("lock.a", "off")
hass.states.async_set("sensor.a", "off")
hass.states.async_set("binary_sensor.a", "off")
info = render_to_info(
hass,
"""
{% set domain = "vacuum" %}
{% if states.light.a == "on" %}
{{ states.light.b.state }}
{% elif states.light.a == "on" %}
{{ states.device_tracker }}
{% elif states.light.a == "on" %}
{{ states[domain] | list }}
{% elif states('light.b') == "on" %}
{{ states[otherdomain] | sort(attribute='entity_id')
| map(attribute='entity_id') | list }}
{% elif states.light.a == "on" %}
{{ states["nonexist"] | list }}
{% else %}
else
{% endif %}
""",
{"otherdomain": "sensor"},
)
assert_result_info(info, ["sensor.a"], {"light.a", "light.b"}, {"sensor"})
assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT
async def test_async_render_to_info_with_wildcard_matching_entity_id(
hass: HomeAssistant,
) -> None:
"""Test tracking template with a wildcard."""
template_complex_str = r"""
{% for state in states.cover %}
{% if 'office_' in state.entity_id %}
{{ state.entity_id }}={{ state.state }}
{% endif %}
{% endfor %}
"""
hass.states.async_set("cover.office_drapes", "closed")
hass.states.async_set("cover.office_window", "closed")
hass.states.async_set("cover.office_skylight", "open")
info = render_to_info(hass, template_complex_str)
assert info.domains == {"cover"}
assert info.entities == set()
assert info.all_states is False
assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT
async def test_async_render_to_info_with_wildcard_matching_state(
hass: HomeAssistant,
) -> None:
"""Test tracking template with a wildcard."""
template_complex_str = """
{% for state in states %}
{% if state.state.startswith('ope') %}
{{ state.entity_id }}={{ state.state }}
{% endif %}
{% endfor %}
"""
hass.states.async_set("cover.office_drapes", "closed")
hass.states.async_set("cover.office_window", "closed")
hass.states.async_set("cover.office_skylight", "open")
hass.states.async_set("cover.x_skylight", "open")
hass.states.async_set("binary_sensor.door", "on")
await hass.async_block_till_done()
info = render_to_info(hass, template_complex_str)
assert not info.domains
assert info.entities == set()
assert info.all_states is True
assert info.rate_limit == ALL_STATES_RATE_LIMIT
hass.states.async_set("binary_sensor.door", "off")
info = render_to_info(hass, template_complex_str)
assert not info.domains
assert info.entities == set()
assert info.all_states is True
assert info.rate_limit == ALL_STATES_RATE_LIMIT
template_cover_str = """
{% for state in states.cover %}
{% if state.state.startswith('ope') %}
{{ state.entity_id }}={{ state.state }}
{% endif %}
{% endfor %}
"""
hass.states.async_set("cover.x_skylight", "closed")
info = render_to_info(hass, template_cover_str)
assert info.domains == {"cover"}
assert info.entities == set()
assert info.all_states is False
assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT
def test_nested_async_render_to_info_case(hass: HomeAssistant) -> None:
"""Test a deeply nested state with async_render_to_info."""
hass.states.async_set("input_select.picker", "vacuum.a")
hass.states.async_set("vacuum.a", "off")
info = render_to_info(
hass, "{{ states[states['input_select.picker'].state].state }}", {}
)
assert_result_info(info, "off", {"input_select.picker", "vacuum.a"})
assert info.rate_limit is None
def test_result_as_boolean(hass: HomeAssistant) -> None:
"""Test converting a template result to a boolean."""
assert template.result_as_boolean(True) is True
assert template.result_as_boolean(" 1 ") is True
assert template.result_as_boolean(" true ") is True
assert template.result_as_boolean(" TrUE ") is True
assert template.result_as_boolean(" YeS ") is True
assert template.result_as_boolean(" On ") is True
assert template.result_as_boolean(" Enable ") is True
assert template.result_as_boolean(1) is True
assert template.result_as_boolean(-1) is True
assert template.result_as_boolean(500) is True
assert template.result_as_boolean(0.5) is True
assert template.result_as_boolean(0.389) is True
assert template.result_as_boolean(35) is True
assert template.result_as_boolean(False) is False
assert template.result_as_boolean(" 0 ") is False
assert template.result_as_boolean(" false ") is False
assert template.result_as_boolean(" FaLsE ") is False
assert template.result_as_boolean(" no ") is False
assert template.result_as_boolean(" off ") is False
assert template.result_as_boolean(" disable ") is False
assert template.result_as_boolean(0) is False
assert template.result_as_boolean(0.0) is False
assert template.result_as_boolean("0.00") is False
assert template.result_as_boolean(None) is False
async def test_async_render_to_info_in_conditional(hass: HomeAssistant) -> None:
"""Test extract entities function with none entities stuff."""
info = render_to_info(hass, '{{ states("sensor.xyz") == "dog" }}')
assert_result_info(info, False, ["sensor.xyz"], [])
hass.states.async_set("sensor.xyz", "dog")
hass.states.async_set("sensor.cow", "True")
await hass.async_block_till_done()
template_str = """
{% if states("sensor.xyz") == "dog" %}
{{ states("sensor.cow") }}
{% else %}
{{ states("sensor.pig") }}
{% endif %}
"""
info = render_to_info(hass, template_str)
assert_result_info(info, True, ["sensor.xyz", "sensor.cow"], [])
hass.states.async_set("sensor.xyz", "sheep")
hass.states.async_set("sensor.pig", "oink")
await hass.async_block_till_done()
info = render_to_info(hass, template_str)
assert_result_info(info, "oink", ["sensor.xyz", "sensor.pig"], [])
def test_jinja_namespace(hass: HomeAssistant) -> None:
"""Test Jinja's namespace command can be used."""
test_template = template.Template(
(
"{% set ns = namespace(a_key='') %}"
"{% set ns.a_key = states.sensor.dummy.state %}"
"{{ ns.a_key }}"
),
hass,
)
hass.states.async_set("sensor.dummy", "a value")
assert test_template.async_render() == "a value"
hass.states.async_set("sensor.dummy", "another value")
assert test_template.async_render() == "another value"
@pytest.mark.parametrize(
("rounded", "with_unit", "output1_1", "output1_2", "output2_1", "output2_2"),
[
(False, False, 23, 23.015, 23, 23.015),
(False, True, "23 beers", "23.015 beers", "23 beers", "23.015 beers"),
(True, False, 23.0, 23.02, 23, 23.015),
(True, True, "23.00 beers", "23.02 beers", "23 beers", "23.015 beers"),
],
)
def test_state_with_unit_and_rounding_options(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
rounded: str,
with_unit: str,
output1_1,
output1_2,
output2_1,
output2_2,
) -> None:
"""Test formatting the state rounded and with unit."""
entry = entity_registry.async_get_or_create(
"sensor", "test", "very_unique", suggested_object_id="test"
)
entity_registry.async_update_entity_options(
entry.entity_id,
"sensor",
{
"suggested_display_precision": 2,
},
)
assert entry.entity_id == "sensor.test"
hass.states.async_set("sensor.test", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"})
hass.states.async_set("sensor.test2", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"})
tpl = template.Template(
f"{{{{ states('sensor.test', rounded={rounded}, with_unit={with_unit}) }}}}",
hass,
)
tpl2 = template.Template(
f"{{{{ states('sensor.test2', rounded={rounded}, with_unit={with_unit}) }}}}",
hass,
)
assert tpl.async_render() == output1_1
assert tpl2.async_render() == output2_1
hass.states.async_set("sensor.test", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"})
hass.states.async_set("sensor.test2", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"})
assert tpl.async_render() == output1_2
assert tpl2.async_render() == output2_2
def test_render_complex_handling_non_template_values(hass: HomeAssistant) -> None:
"""Test that we can render non-template fields."""
assert template.render_complex(
{True: 1, False: template.Template("{{ hello }}", hass)}, {"hello": 2}
) == {True: 1, False: 2}
async def test_compiled_code_cache(hass: HomeAssistant) -> None:
"""Test the compiled code cache outlives the Template object."""
template_string = (
"{% set dict = {'foo': 'x&y', 'bar': 42} %} {{ dict | urlencode }}"
)
tpl = template.Template(
(template_string),
hass,
)
tpl.ensure_valid()
env = tpl._env
assert env.template_cache.get(template_string)
del tpl
gc.collect()
assert env.template_cache.get(template_string)
with patch.object(
template.TemplateEnvironment, "compile", side_effect=AssertionError
):
tpl2 = template.Template(
(template_string),
hass,
)
tpl2.ensure_valid()
assert tpl2.async_render() == "foo=x%26y&bar=42"
async def test_compiled_code_cache_is_bounded(hass: HomeAssistant) -> None:
"""Test the compiled code cache does not grow without bound."""
tpl = template.Template("{{ 'env getter' }}", hass)
tpl.ensure_valid()
env = tpl._env
del tpl
for i in range(template.COMPILED_CODE_PIN_SIZE + 10):
template.Template(f"{{{{ {i} }}}}", hass).ensure_valid()
gc.collect()
assert len(env.compiled_code_pin) == template.COMPILED_CODE_PIN_SIZE
assert len(env.template_cache) == template.COMPILED_CODE_PIN_SIZE
async def test_compiled_code_cache_alive_templates(hass: HomeAssistant) -> None:
"""Test live Template objects keep their cache entries beyond the pin size."""
template_string = "{{ 'kept alive' }}"
tpl = template.Template(template_string, hass)
tpl.ensure_valid()
env = tpl._env
# Overflow the strong pin with other templates
for i in range(template.COMPILED_CODE_PIN_SIZE + 10):
template.Template(f"{{{{ {i} }}}}", hass).ensure_valid()
gc.collect()
assert template_string not in env.compiled_code_pin
# The live Template still keeps the weak cache entry alive,
# so an identical template does not recompile.
with patch.object(
template.TemplateEnvironment, "compile", side_effect=AssertionError
):
tpl2 = template.Template(template_string, hass)
tpl2.ensure_valid()
assert tpl2.async_render() == "kept alive"
# A cache hit for a template that is alive anyway does not
# take up a pin slot.
assert template_string not in env.compiled_code_pin
def test_is_template_string() -> None:
"""Test is template string."""
assert template.is_template_string("{{ x }}") is True
assert template.is_template_string("{% if x == 2 %}1{% else %}0{%end if %}") is True
assert template.is_template_string("{# a comment #} Hey") is True
assert template.is_template_string("1") is False
assert template.is_template_string("Some Text") is False
async def test_protected_blocked(hass: HomeAssistant) -> None:
"""Test accessing __getattr__ produces a template error."""
with pytest.raises(TemplateError):
render(hass, '{{ states.__getattr__("any") }}')
with pytest.raises(TemplateError):
render(hass, '{{ states.sensor.__getattr__("any") }}')
with pytest.raises(TemplateError):
render(hass, '{{ states.sensor.any.__getattr__("any") }}')
async def test_demo_template(hass: HomeAssistant) -> None:
"""Test the demo template works as expected."""
hass.states.async_set(
"sun.sun",
"above",
{"elevation": 50, "next_rising": "2022-05-12T03:00:08.503651+00:00"},
)
for i in range(2):
hass.states.async_set(f"sensor.sensor{i}", "on")
demo_template_str = """
{## Imitate available variables: ##}
{% set my_test_json = {
"temperature": 25,
"unit": "°C"
} %}
The temperature is {{ my_test_json.temperature }} {{ my_test_json.unit }}.
{% if is_state("sun.sun", "above_horizon") -%}
The sun rose {{ relative_time(states.sun.sun.last_changed) }} ago.
{%- else -%}
The sun will rise at {{
as_timestamp(state_attr("sun.sun", "next_rising"))
| timestamp_local }}.
{%- endif %}
For loop example getting 3 entity values:
{% for states in states | slice(3) -%}
{% set state = states | first %}
{%- if loop.first %}The {% elif loop.last %} and the {% else %}, the {% endif -%}
{{ state.name | lower }} is {{state.state_with_unit}}
{%- endfor %}.
"""
result = render(hass, demo_template_str)
assert "The temperature is 25" in result
assert "is on" in result
assert "sensor0" in result
assert "sensor1" in result
assert "sun" in result
async def test_lifecycle(hass: HomeAssistant) -> None:
"""Test that we limit template render info for lifecycle events."""
hass.states.async_set("sun.sun", "above", {"elevation": 50, "next_rising": "later"})
for i in range(2):
hass.states.async_set(f"sensor.sensor{i}", "on")
hass.states.async_set("sensor.removed", "off")
await hass.async_block_till_done()
hass.states.async_set("sun.sun", "below", {"elevation": 60, "next_rising": "later"})
for i in range(2):
hass.states.async_set(f"sensor.sensor{i}", "off")
hass.states.async_set("sensor.new", "off")
hass.states.async_remove("sensor.removed")
await hass.async_block_till_done()
info = render_to_info(hass, "{{ states | count }}")
assert info.all_states is False
assert info.all_states_lifecycle is True
assert info.rate_limit is None
assert info.has_time is False
assert info.entities == set()
assert info.domains == set()
assert info.domains_lifecycle == set()
assert info.filter("sun.sun") is False
assert info.filter("sensor.sensor1") is False
assert info.filter_lifecycle("sensor.new") is True
assert info.filter_lifecycle("sensor.removed") is True
async def test_template_timeout(hass: HomeAssistant) -> None:
"""Test to see if a template will timeout."""
for i in range(2):
hass.states.async_set(f"sensor.sensor{i}", "on")
tmp = template.Template("{{ states | count }}", hass)
assert await tmp.async_render_will_timeout(3) is False
tmp3 = template.Template("static", hass)
assert await tmp3.async_render_will_timeout(3) is False
tmp4 = template.Template("{{ var1 }}", hass)
assert await tmp4.async_render_will_timeout(3, {"var1": "ok"}) is False
slow_template_str = """
{% for var in range(1000) -%}
{% for var in range(1000) -%}
{{ var }}
{%- endfor %}
{%- endfor %}
"""
tmp5 = template.Template(slow_template_str, hass)
assert await tmp5.async_render_will_timeout(0.000001) is True
async def test_template_timeout_raise(hass: HomeAssistant) -> None:
"""Test we can raise from."""
tmp2 = template.Template("{{ error_invalid + 1 }}", hass)
with pytest.raises(TemplateError):
assert await tmp2.async_render_will_timeout(3) is False
async def test_lights(hass: HomeAssistant) -> None:
"""Test we can sort lights."""
tmpl = """
{% set lights_on = states.light
|selectattr('state','eq','on')
|sort(attribute='entity_id')
|map(attribute='name')|list %}
{% if lights_on|length == 0 %}
No lights on. Sleep well..
{% elif lights_on|length == 1 %}
The {{lights_on[0]}} light is on.
{% elif lights_on|length == 2 %}
The {{lights_on[0]}} and {{lights_on[1]}} lights are on.
{% else %}
The {{lights_on[:-1]|join(', ')}}, and {{lights_on[-1]}} lights are on.
{% endif %}
"""
states = []
for i in range(10):
states.append(f"light.sensor{i}")
hass.states.async_set(f"light.sensor{i}", "on")
info = render_to_info(hass, tmpl)
assert info.entities == set()
assert info.domains == {"light"}
assert "lights are on" in info.result()
for i in range(10):
assert f"sensor{i}" in info.result()
async def test_template_errors(hass: HomeAssistant) -> None:
"""Test template rendering wraps exceptions with TemplateError."""
with pytest.raises(TemplateError):
render(hass, "{{ now() | rando }}")
with pytest.raises(TemplateError):
render(hass, "{{ utcnow() | rando }}")
with pytest.raises(TemplateError):
render(hass, "{{ now() | random }}")
with pytest.raises(TemplateError):
render(hass, "{{ utcnow() | random }}")
async def test_no_result_parsing(hass: HomeAssistant) -> None:
"""Test if templates results are not parsed."""
hass.states.async_set("sensor.temperature", "12")
assert (
render(hass, "{{ states.sensor.temperature.state }}", parse_result=False)
== "12"
)
assert render(hass, "{{ false }}", parse_result=False) == "False"
assert render(hass, "{{ [1, 2, 3] }}", parse_result=False) == "[1, 2, 3]"
async def test_is_static_still_ast_evals(hass: HomeAssistant) -> None:
"""Test is_static still converts to native type."""
tpl = template.Template("[1, 2]", hass)
assert tpl.is_static
assert tpl.async_render() == [1, 2]
async def test_result_wrappers(hass: HomeAssistant) -> None:
"""Test result wrappers."""
for text, native, orig_type, schema in (
("[1, 2]", [1, 2], list, vol.Schema([int])),
("{1, 2}", {1, 2}, set, vol.Schema({int})),
("(1, 2)", (1, 2), tuple, vol.ExactSequence([int, int])),
('{"hello": True}', {"hello": True}, dict, vol.Schema({"hello": bool})),
):
result = render(hass, text)
assert isinstance(result, orig_type)
assert isinstance(result, template.ResultWrapper)
assert result == native
assert result.render_result == text
schema(result) # should not raise
# Result with render text stringifies to original text
assert str(result) == text
# Result without render text stringifies same as original type
assert str(template.RESULT_WRAPPERS[orig_type](native)) == str(
orig_type(native)
)
async def test_parse_result(hass: HomeAssistant) -> None:
"""Test parse result."""
for tpl, result in (
('{{ "{{}}" }}', "{{}}"),
("not-something", "not-something"),
("2a", "2a"),
("123E5", "123E5"),
("1j", "1j"),
("1e+100", "1e+100"),
("0xface", "0xface"),
("123", 123),
("10", 10),
("123.0", 123.0),
(".5", 0.5),
("0.5", 0.5),
("-1", -1),
("-1.0", -1.0),
("+1", 1),
("5.", 5.0),
("-0", 0),
("-0.0", -0.0),
("+", "+"),
("-", "-"),
(".", "."),
# Exceeds the int digit limit for both int() and literal_eval
("9" * 5000, "9" * 5000),
("123_123_123", "123_123_123"),
# ("+48100200300", "+48100200300"), # phone number
("010", "010"),
("0011101.00100001010001", "0011101.00100001010001"),
# Plain string states are not Python literals and must be returned
# unchanged (literal_eval raises and the original render is used).
("on", "on"),
("off", "off"),
("unavailable", "unavailable"),
("home", "home"),
("standby", "standby"),
("True story", "True story"),
# Keyword and collection literals must still be parsed.
("True", True),
("False", False),
("None", None),
# "set()" is the only call expression literal_eval accepts (empty set).
("set()", set()),
):
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",
[
"{{ no_such_variable }}",
"{{ no_such_variable and True }}",
"{{ no_such_variable | join(', ') }}",
],
)
async def test_undefined_symbol_warnings(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
template_string: str,
) -> None:
"""Test a warning is logged on undefined variables."""
assert render(hass, template_string) == ""
assert (
f"Template variable warning: 'no_such_variable' is"
f" undefined when rendering '{template_string}'" in caplog.text
)
async def test_render_to_info_with_exception(hass: HomeAssistant) -> None:
"""Test info is still available if the template has an exception."""
hass.states.async_set("test_domain.object", "dog")
info = render_to_info(hass, '{{ states("test_domain.object") | float }}')
with pytest.raises(TemplateError, match="no default was specified"):
info.result()
assert info.all_states is False
assert info.entities == {"test_domain.object"}
async def test_template_thread_safety_checks(hass: HomeAssistant) -> None:
"""Test template thread safety checks."""
hass.states.async_set("sensor.test", "23")
template_str = "{{ states('sensor.test') }}"
template_obj = template.Template(template_str, None)
template_obj.hass = hass
hass.config.debug = True
with pytest.raises(
RuntimeError,
match="Detected code that calls async_render_to_info from a thread.",
):
await hass.async_add_executor_job(template_obj.async_render_to_info)
assert template_obj.async_render_to_info().result() == 23
def test_template_output_exceeds_maximum_size(hass: HomeAssistant) -> None:
"""Test template output exceeds maximum size."""
with pytest.raises(TemplateError):
render(hass, "{{ 'a' * 1024 * 257 }}")