Refactor template engine: Extract collection & data structure functions into CollectionExtension (#152446)

This commit is contained in:
Franck Nijhof
2025-09-17 00:48:50 +02:00
committed by GitHub
parent c34af4be86
commit 4a4c124181
5 changed files with 588 additions and 598 deletions

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from ast import literal_eval
import asyncio
import collections.abc
from collections.abc import Callable, Generator, Iterable, MutableSequence
from collections.abc import Callable, Generator, Iterable
from contextlib import AbstractContextManager
from contextvars import ContextVar
from copy import deepcopy
@@ -2245,31 +2245,6 @@ def is_number(value):
return True
def _is_list(value: Any) -> bool:
"""Return whether a value is a list."""
return isinstance(value, list)
def _is_set(value: Any) -> bool:
"""Return whether a value is a set."""
return isinstance(value, set)
def _is_tuple(value: Any) -> bool:
"""Return whether a value is a tuple."""
return isinstance(value, tuple)
def _to_set(value: Any) -> set[Any]:
"""Convert value to set."""
return set(value)
def _to_tuple(value):
"""Convert value to tuple."""
return tuple(value)
def _is_datetime(value: Any) -> bool:
"""Return whether a value is a datetime."""
return isinstance(value, datetime)
@@ -2487,98 +2462,11 @@ def iif(
return if_false
def shuffle(*args: Any, seed: Any = None) -> MutableSequence[Any]:
"""Shuffle a list, either with a seed or without."""
if not args:
raise TypeError("shuffle expected at least 1 argument, got 0")
# If first argument is iterable and more than 1 argument provided
# but not a named seed, then use 2nd argument as seed.
if isinstance(args[0], Iterable):
items = list(args[0])
if len(args) > 1 and seed is None:
seed = args[1]
elif len(args) == 1:
raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
else:
items = list(args)
if seed:
r = random.Random(seed)
r.shuffle(items)
else:
random.shuffle(items)
return items
def typeof(value: Any) -> Any:
"""Return the type of value passed to debug types."""
return value.__class__.__name__
def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]:
"""Flattens list of lists."""
if not isinstance(value, Iterable) or isinstance(value, str):
raise TypeError(f"flatten expected a list, got {type(value).__name__}")
flattened: list[Any] = []
for item in value:
if isinstance(item, Iterable) and not isinstance(item, str):
if levels is None:
flattened.extend(flatten(item))
elif levels >= 1:
flattened.extend(flatten(item, levels=(levels - 1)))
else:
flattened.append(item)
else:
flattened.append(item)
return flattened
def intersect(value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
"""Return the common elements between two lists."""
if not isinstance(value, Iterable) or isinstance(value, str):
raise TypeError(f"intersect expected a list, got {type(value).__name__}")
if not isinstance(other, Iterable) or isinstance(other, str):
raise TypeError(f"intersect expected a list, got {type(other).__name__}")
return list(set(value) & set(other))
def difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
"""Return elements in first list that are not in second list."""
if not isinstance(value, Iterable) or isinstance(value, str):
raise TypeError(f"difference expected a list, got {type(value).__name__}")
if not isinstance(other, Iterable) or isinstance(other, str):
raise TypeError(f"difference expected a list, got {type(other).__name__}")
return list(set(value) - set(other))
def union(value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
"""Return all unique elements from both lists combined."""
if not isinstance(value, Iterable) or isinstance(value, str):
raise TypeError(f"union expected a list, got {type(value).__name__}")
if not isinstance(other, Iterable) or isinstance(other, str):
raise TypeError(f"union expected a list, got {type(other).__name__}")
return list(set(value) | set(other))
def symmetric_difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
"""Return elements that are in either list but not in both."""
if not isinstance(value, Iterable) or isinstance(value, str):
raise TypeError(
f"symmetric_difference expected a list, got {type(value).__name__}"
)
if not isinstance(other, Iterable) or isinstance(other, str):
raise TypeError(
f"symmetric_difference expected a list, got {type(other).__name__}"
)
return list(set(value) ^ set(other))
def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]:
"""Combine multiple dictionaries into one."""
if not args:
@@ -2760,11 +2648,15 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.add_extension("jinja2.ext.loopcontrols")
self.add_extension("jinja2.ext.do")
self.add_extension("homeassistant.helpers.template.extensions.Base64Extension")
self.add_extension(
"homeassistant.helpers.template.extensions.CollectionExtension"
)
self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension")
self.add_extension("homeassistant.helpers.template.extensions.MathExtension")
self.add_extension("homeassistant.helpers.template.extensions.RegexExtension")
self.add_extension("homeassistant.helpers.template.extensions.StringExtension")
self.globals["apply"] = apply
self.globals["as_datetime"] = as_datetime
self.globals["as_function"] = as_function
self.globals["as_local"] = dt_util.as_local
@@ -2772,23 +2664,15 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["as_timestamp"] = forgiving_as_timestamp
self.globals["bool"] = forgiving_boolean
self.globals["combine"] = combine
self.globals["difference"] = difference
self.globals["flatten"] = flatten
self.globals["float"] = forgiving_float
self.globals["iif"] = iif
self.globals["int"] = forgiving_int
self.globals["intersect"] = intersect
self.globals["is_number"] = is_number
self.globals["merge_response"] = merge_response
self.globals["pack"] = struct_pack
self.globals["set"] = _to_set
self.globals["shuffle"] = shuffle
self.globals["strptime"] = strptime
self.globals["symmetric_difference"] = symmetric_difference
self.globals["timedelta"] = timedelta
self.globals["tuple"] = _to_tuple
self.globals["typeof"] = typeof
self.globals["union"] = union
self.globals["unpack"] = struct_unpack
self.globals["version"] = version
self.globals["zip"] = zip
@@ -2803,14 +2687,11 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.filters["bool"] = forgiving_boolean
self.filters["combine"] = combine
self.filters["contains"] = contains
self.filters["difference"] = difference
self.filters["flatten"] = flatten
self.filters["float"] = forgiving_float_filter
self.filters["from_json"] = from_json
self.filters["from_hex"] = from_hex
self.filters["iif"] = iif
self.filters["int"] = forgiving_int_filter
self.filters["intersect"] = intersect
self.filters["is_defined"] = fail_when_undefined
self.filters["is_number"] = is_number
self.filters["multiply"] = multiply
@@ -2818,14 +2699,11 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.filters["pack"] = struct_pack
self.filters["random"] = random_every_time
self.filters["round"] = forgiving_round
self.filters["shuffle"] = shuffle
self.filters["symmetric_difference"] = symmetric_difference
self.filters["timestamp_custom"] = timestamp_custom
self.filters["timestamp_local"] = timestamp_local
self.filters["timestamp_utc"] = timestamp_utc
self.filters["to_json"] = to_json
self.filters["typeof"] = typeof
self.filters["union"] = union
self.filters["unpack"] = struct_unpack
self.filters["version"] = version
@@ -2833,10 +2711,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.tests["contains"] = contains
self.tests["datetime"] = _is_datetime
self.tests["is_number"] = is_number
self.tests["list"] = _is_list
self.tests["set"] = _is_set
self.tests["string_like"] = _is_string_like
self.tests["tuple"] = _is_tuple
if hass is None:
return

View File

@@ -1,6 +1,7 @@
"""Home Assistant template extensions."""
from .base64 import Base64Extension
from .collection import CollectionExtension
from .crypto import CryptoExtension
from .math import MathExtension
from .regex import RegexExtension
@@ -8,6 +9,7 @@ from .string import StringExtension
__all__ = [
"Base64Extension",
"CollectionExtension",
"CryptoExtension",
"MathExtension",
"RegexExtension",

View File

@@ -0,0 +1,191 @@
"""Collection and data structure functions for Home Assistant templates."""
from __future__ import annotations
from collections.abc import Iterable, MutableSequence
import random
from typing import TYPE_CHECKING, Any
from .base import BaseTemplateExtension, TemplateFunction
if TYPE_CHECKING:
from homeassistant.helpers.template import TemplateEnvironment
class CollectionExtension(BaseTemplateExtension):
"""Extension for collection and data structure operations."""
def __init__(self, environment: TemplateEnvironment) -> None:
"""Initialize the collection extension."""
super().__init__(
environment,
functions=[
TemplateFunction(
"flatten",
self.flatten,
as_global=True,
as_filter=True,
),
TemplateFunction(
"shuffle",
self.shuffle,
as_global=True,
as_filter=True,
),
# Set operations
TemplateFunction(
"intersect",
self.intersect,
as_global=True,
as_filter=True,
),
TemplateFunction(
"difference",
self.difference,
as_global=True,
as_filter=True,
),
TemplateFunction(
"union",
self.union,
as_global=True,
as_filter=True,
),
TemplateFunction(
"symmetric_difference",
self.symmetric_difference,
as_global=True,
as_filter=True,
),
# Type conversion functions
TemplateFunction(
"set",
self.to_set,
as_global=True,
),
TemplateFunction(
"tuple",
self.to_tuple,
as_global=True,
),
# Type checking functions (tests)
TemplateFunction(
"list",
self.is_list,
as_test=True,
),
TemplateFunction(
"set",
self.is_set,
as_test=True,
),
TemplateFunction(
"tuple",
self.is_tuple,
as_test=True,
),
],
)
def flatten(self, value: Iterable[Any], levels: int | None = None) -> list[Any]:
"""Flatten list of lists."""
if not isinstance(value, Iterable) or isinstance(value, str):
raise TypeError(f"flatten expected a list, got {type(value).__name__}")
flattened: list[Any] = []
for item in value:
if isinstance(item, Iterable) and not isinstance(item, str):
if levels is None:
flattened.extend(self.flatten(item))
elif levels >= 1:
flattened.extend(self.flatten(item, levels=(levels - 1)))
else:
flattened.append(item)
else:
flattened.append(item)
return flattened
def shuffle(self, *args: Any, seed: Any = None) -> MutableSequence[Any]:
"""Shuffle a list, either with a seed or without."""
if not args:
raise TypeError("shuffle expected at least 1 argument, got 0")
# If first argument is iterable and more than 1 argument provided
# but not a named seed, then use 2nd argument as seed.
if isinstance(args[0], Iterable) and not isinstance(args[0], str):
items = list(args[0])
if len(args) > 1 and seed is None:
seed = args[1]
elif len(args) == 1:
raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
else:
items = list(args)
if seed:
r = random.Random(seed)
r.shuffle(items)
else:
random.shuffle(items)
return items
def intersect(self, value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
"""Return the common elements between two lists."""
if not isinstance(value, Iterable) or isinstance(value, str):
raise TypeError(f"intersect expected a list, got {type(value).__name__}")
if not isinstance(other, Iterable) or isinstance(other, str):
raise TypeError(f"intersect expected a list, got {type(other).__name__}")
return list(set(value) & set(other))
def difference(self, value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
"""Return elements in first list that are not in second list."""
if not isinstance(value, Iterable) or isinstance(value, str):
raise TypeError(f"difference expected a list, got {type(value).__name__}")
if not isinstance(other, Iterable) or isinstance(other, str):
raise TypeError(f"difference expected a list, got {type(other).__name__}")
return list(set(value) - set(other))
def union(self, value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
"""Return all unique elements from both lists combined."""
if not isinstance(value, Iterable) or isinstance(value, str):
raise TypeError(f"union expected a list, got {type(value).__name__}")
if not isinstance(other, Iterable) or isinstance(other, str):
raise TypeError(f"union expected a list, got {type(other).__name__}")
return list(set(value) | set(other))
def symmetric_difference(
self, value: Iterable[Any], other: Iterable[Any]
) -> list[Any]:
"""Return elements that are in either list but not in both."""
if not isinstance(value, Iterable) or isinstance(value, str):
raise TypeError(
f"symmetric_difference expected a list, got {type(value).__name__}"
)
if not isinstance(other, Iterable) or isinstance(other, str):
raise TypeError(
f"symmetric_difference expected a list, got {type(other).__name__}"
)
return list(set(value) ^ set(other))
def to_set(self, value: Any) -> set[Any]:
"""Convert value to set."""
return set(value)
def to_tuple(self, value: Any) -> tuple[Any, ...]:
"""Convert value to tuple."""
return tuple(value)
def is_list(self, value: Any) -> bool:
"""Return whether a value is a list."""
return isinstance(value, list)
def is_set(self, value: Any) -> bool:
"""Return whether a value is a set."""
return isinstance(value, set)
def is_tuple(self, value: Any) -> bool:
"""Return whether a value is a tuple."""
return isinstance(value, tuple)

View File

@@ -0,0 +1,357 @@
"""Test collection extension."""
from __future__ import annotations
from typing import Any
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import template
@pytest.mark.parametrize(
("value", "expected"),
[
([1, 2, 3], True),
({"a": 1}, False),
({1, 2, 3}, False),
((1, 2, 3), False),
("abc", False),
("", False),
(5, False),
(None, False),
({"foo": "bar", "baz": "qux"}, False),
],
)
def test_is_list(hass: HomeAssistant, value: Any, expected: bool) -> None:
"""Test list test."""
assert (
template.Template("{{ value is list }}", hass).async_render({"value": value})
== expected
)
@pytest.mark.parametrize(
("value", "expected"),
[
([1, 2, 3], False),
({"a": 1}, False),
({1, 2, 3}, True),
((1, 2, 3), False),
("abc", False),
("", False),
(5, False),
(None, False),
({"foo": "bar", "baz": "qux"}, False),
],
)
def test_is_set(hass: HomeAssistant, value: Any, expected: bool) -> None:
"""Test set test."""
assert (
template.Template("{{ value is set }}", hass).async_render({"value": value})
== expected
)
@pytest.mark.parametrize(
("value", "expected"),
[
([1, 2, 3], False),
({"a": 1}, False),
({1, 2, 3}, False),
((1, 2, 3), True),
("abc", False),
("", False),
(5, False),
(None, False),
({"foo": "bar", "baz": "qux"}, False),
],
)
def test_is_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None:
"""Test tuple test."""
assert (
template.Template("{{ value is tuple }}", hass).async_render({"value": value})
== expected
)
@pytest.mark.parametrize(
("value", "expected"),
[
([1, 2, 3], {"expected0": {1, 2, 3}}),
({"a": 1}, {"expected1": {"a"}}),
({1, 2, 3}, {"expected2": {1, 2, 3}}),
((1, 2, 3), {"expected3": {1, 2, 3}}),
("abc", {"expected4": {"a", "b", "c"}}),
("", {"expected5": set()}),
(range(3), {"expected6": {0, 1, 2}}),
({"foo": "bar", "baz": "qux"}, {"expected7": {"foo", "baz"}}),
],
)
def test_set(hass: HomeAssistant, value: Any, expected: bool) -> None:
"""Test set conversion."""
assert (
template.Template("{{ set(value) }}", hass).async_render({"value": value})
== list(expected.values())[0]
)
@pytest.mark.parametrize(
("value", "expected"),
[
([1, 2, 3], {"expected0": (1, 2, 3)}),
({"a": 1}, {"expected1": ("a",)}),
({1, 2, 3}, {"expected2": (1, 2, 3)}), # Note: set order is not guaranteed
((1, 2, 3), {"expected3": (1, 2, 3)}),
("abc", {"expected4": ("a", "b", "c")}),
("", {"expected5": ()}),
(range(3), {"expected6": (0, 1, 2)}),
({"foo": "bar", "baz": "qux"}, {"expected7": ("foo", "baz")}),
],
)
def test_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None:
"""Test tuple conversion."""
result = template.Template("{{ tuple(value) }}", hass).async_render(
{"value": value}
)
expected_value = list(expected.values())[0]
if isinstance(value, set): # Sets don't have predictable order
assert set(result) == set(expected_value)
else:
assert result == expected_value
@pytest.mark.parametrize(
("cola", "colb", "expected"),
[
([1, 2], [3, 4], [(1, 3), (2, 4)]),
([1, 2], [3, 4, 5], [(1, 3), (2, 4)]),
([1, 2, 3, 4], [3, 4], [(1, 3), (2, 4)]),
],
)
def test_zip(hass: HomeAssistant, cola, colb, expected) -> None:
"""Test zip."""
assert (
template.Template("{{ zip(cola, colb) | list }}", hass).async_render(
{"cola": cola, "colb": colb}
)
== expected
)
assert (
template.Template(
"[{% for a, b in zip(cola, colb) %}({{a}}, {{b}}), {% endfor %}]", hass
).async_render({"cola": cola, "colb": colb})
== expected
)
@pytest.mark.parametrize(
("col", "expected"),
[
([(1, 3), (2, 4)], [(1, 2), (3, 4)]),
(["ax", "by", "cz"], [("a", "b", "c"), ("x", "y", "z")]),
],
)
def test_unzip(hass: HomeAssistant, col, expected) -> None:
"""Test unzipping using zip."""
assert (
template.Template("{{ zip(*col) | list }}", hass).async_render({"col": col})
== expected
)
assert (
template.Template(
"{% set a, b = zip(*col) %}[{{a}}, {{b}}]", hass
).async_render({"col": col})
== expected
)
def test_shuffle(hass: HomeAssistant) -> None:
"""Test shuffle."""
# Test basic shuffle
result = template.Template("{{ shuffle([1, 2, 3, 4, 5]) }}", hass).async_render()
assert len(result) == 5
assert set(result) == {1, 2, 3, 4, 5}
# Test shuffle with seed
result1 = template.Template(
"{{ shuffle([1, 2, 3, 4, 5], seed=42) }}", hass
).async_render()
result2 = template.Template(
"{{ shuffle([1, 2, 3, 4, 5], seed=42) }}", hass
).async_render()
assert result1 == result2 # Same seed should give same result
# Test shuffle with different seed
result3 = template.Template(
"{{ shuffle([1, 2, 3, 4, 5], seed=123) }}", hass
).async_render()
# Different seeds should usually give different results
# (but we can't guarantee it for small lists)
assert len(result3) == 5
assert set(result3) == {1, 2, 3, 4, 5}
def test_flatten(hass: HomeAssistant) -> None:
"""Test flatten."""
# Test basic flattening
assert template.Template(
"{{ flatten([[1, 2], [3, 4]]) }}", hass
).async_render() == [1, 2, 3, 4]
# Test nested flattening
assert template.Template(
"{{ flatten([[[1, 2], [3, 4]], [[5, 6], [7, 8]]]) }}", hass
).async_render() == [1, 2, 3, 4, 5, 6, 7, 8]
# Test flattening with levels
assert template.Template(
"{{ flatten([[[1, 2], [3, 4]], [[5, 6], [7, 8]]], levels=1) }}", hass
).async_render() == [[1, 2], [3, 4], [5, 6], [7, 8]]
# Test mixed types
assert template.Template(
"{{ flatten([[1, 'a'], [2, 'b']]) }}", hass
).async_render() == [1, "a", 2, "b"]
# Test empty list
assert template.Template("{{ flatten([]) }}", hass).async_render() == []
# Test single level
assert template.Template("{{ flatten([1, 2, 3]) }}", hass).async_render() == [
1,
2,
3,
]
def test_intersect(hass: HomeAssistant) -> None:
"""Test intersect."""
# Test basic intersection
result = template.Template(
"{{ [1, 2, 3, 4] | intersect([3, 4, 5, 6]) | sort }}", hass
).async_render()
assert result == [3, 4]
# Test no intersection
result = template.Template("{{ [1, 2] | intersect([3, 4]) }}", hass).async_render()
assert result == []
# Test string intersection
result = template.Template(
"{{ ['a', 'b', 'c'] | intersect(['b', 'c', 'd']) | sort }}", hass
).async_render()
assert result == ["b", "c"]
# Test empty list intersection
result = template.Template("{{ [] | intersect([1, 2, 3]) }}", hass).async_render()
assert result == []
def test_difference(hass: HomeAssistant) -> None:
"""Test difference."""
# Test basic difference
result = template.Template(
"{{ [1, 2, 3, 4] | difference([3, 4, 5, 6]) | sort }}", hass
).async_render()
assert result == [1, 2]
# Test no difference
result = template.Template(
"{{ [1, 2] | difference([1, 2, 3, 4]) }}", hass
).async_render()
assert result == []
# Test string difference
result = template.Template(
"{{ ['a', 'b', 'c'] | difference(['b', 'c', 'd']) | sort }}", hass
).async_render()
assert result == ["a"]
# Test empty list difference
result = template.Template("{{ [] | difference([1, 2, 3]) }}", hass).async_render()
assert result == []
def test_union(hass: HomeAssistant) -> None:
"""Test union."""
# Test basic union
result = template.Template(
"{{ [1, 2, 3] | union([3, 4, 5]) | sort }}", hass
).async_render()
assert result == [1, 2, 3, 4, 5]
# Test string union
result = template.Template(
"{{ ['a', 'b'] | union(['b', 'c']) | sort }}", hass
).async_render()
assert result == ["a", "b", "c"]
# Test empty list union
result = template.Template(
"{{ [] | union([1, 2, 3]) | sort }}", hass
).async_render()
assert result == [1, 2, 3]
# Test duplicate elements
result = template.Template(
"{{ [1, 1, 2, 2] | union([2, 2, 3, 3]) | sort }}", hass
).async_render()
assert result == [1, 2, 3]
def test_symmetric_difference(hass: HomeAssistant) -> None:
"""Test symmetric_difference."""
# Test basic symmetric difference
result = template.Template(
"{{ [1, 2, 3, 4] | symmetric_difference([3, 4, 5, 6]) | sort }}", hass
).async_render()
assert result == [1, 2, 5, 6]
# Test no symmetric difference (identical sets)
result = template.Template(
"{{ [1, 2, 3] | symmetric_difference([1, 2, 3]) }}", hass
).async_render()
assert result == []
# Test string symmetric difference
result = template.Template(
"{{ ['a', 'b', 'c'] | symmetric_difference(['b', 'c', 'd']) | sort }}", hass
).async_render()
assert result == ["a", "d"]
# Test empty list symmetric difference
result = template.Template(
"{{ [] | symmetric_difference([1, 2, 3]) | sort }}", hass
).async_render()
assert result == [1, 2, 3]
def test_collection_functions_as_tests(hass: HomeAssistant) -> None:
"""Test that type checking functions work as tests."""
# Test various type checking functions
assert template.Template("{{ [1,2,3] is list }}", hass).async_render()
assert template.Template("{{ set([1,2,3]) is set }}", hass).async_render()
assert template.Template("{{ (1,2,3) is tuple }}", hass).async_render()
def test_collection_error_handling(hass: HomeAssistant) -> None:
"""Test error handling in collection functions."""
# Test flatten with non-iterable
with pytest.raises(TemplateError, match="flatten expected a list"):
template.Template("{{ flatten(123) }}", hass).async_render()
# Test intersect with non-iterable
with pytest.raises(TemplateError, match="intersect expected a list"):
template.Template("{{ [1, 2] | intersect(123) }}", hass).async_render()
# Test difference with non-iterable
with pytest.raises(TemplateError, match="difference expected a list"):
template.Template("{{ [1, 2] | difference(123) }}", hass).async_render()
# Test shuffle with no arguments
with pytest.raises(TemplateError, match="shuffle expected at least 1 argument"):
template.Template("{{ shuffle() }}", hass).async_render()

View File

@@ -15,7 +15,6 @@ from unittest.mock import patch
from freezegun import freeze_time
import orjson
import pytest
from pytest_unordered import unordered
from syrupy.assertion import SnapshotAssertion
import voluptuous as vol
@@ -514,114 +513,6 @@ def test_isnumber(hass: HomeAssistant, value, expected) -> None:
)
@pytest.mark.parametrize(
("value", "expected"),
[
([1, 2], True),
({1, 2}, False),
({"a": 1, "b": 2}, False),
(ReadOnlyDict({"a": 1, "b": 2}), False),
(MappingProxyType({"a": 1, "b": 2}), False),
("abc", False),
(b"abc", False),
((1, 2), False),
(datetime(2024, 1, 1, 0, 0, 0), False),
],
)
def test_is_list(hass: HomeAssistant, value: Any, expected: bool) -> None:
"""Test is list."""
assert (
template.Template("{{ value is list }}", hass).async_render({"value": value})
== expected
)
@pytest.mark.parametrize(
("value", "expected"),
[
([1, 2], False),
({1, 2}, True),
({"a": 1, "b": 2}, False),
(ReadOnlyDict({"a": 1, "b": 2}), False),
(MappingProxyType({"a": 1, "b": 2}), False),
("abc", False),
(b"abc", False),
((1, 2), False),
(datetime(2024, 1, 1, 0, 0, 0), False),
],
)
def test_is_set(hass: HomeAssistant, value: Any, expected: bool) -> None:
"""Test is set."""
assert (
template.Template("{{ value is set }}", hass).async_render({"value": value})
== expected
)
@pytest.mark.parametrize(
("value", "expected"),
[
([1, 2], False),
({1, 2}, False),
({"a": 1, "b": 2}, False),
(ReadOnlyDict({"a": 1, "b": 2}), False),
(MappingProxyType({"a": 1, "b": 2}), False),
("abc", False),
(b"abc", False),
((1, 2), True),
(datetime(2024, 1, 1, 0, 0, 0), False),
],
)
def test_is_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None:
"""Test is tuple."""
assert (
template.Template("{{ value is tuple }}", hass).async_render({"value": value})
== expected
)
@pytest.mark.parametrize(
("value", "expected"),
[
([1, 2], {1, 2}),
({1, 2}, {1, 2}),
({"a": 1, "b": 2}, {"a", "b"}),
(ReadOnlyDict({"a": 1, "b": 2}), {"a", "b"}),
(MappingProxyType({"a": 1, "b": 2}), {"a", "b"}),
("abc", {"a", "b", "c"}),
(b"abc", {97, 98, 99}),
((1, 2), {1, 2}),
],
)
def test_set(hass: HomeAssistant, value: Any, expected: bool) -> None:
"""Test convert to set function."""
assert (
template.Template("{{ set(value) }}", hass).async_render({"value": value})
== expected
)
@pytest.mark.parametrize(
("value", "expected"),
[
([1, 2], (1, 2)),
({1, 2}, (1, 2)),
({"a": 1, "b": 2}, ("a", "b")),
(ReadOnlyDict({"a": 1, "b": 2}), ("a", "b")),
(MappingProxyType({"a": 1, "b": 2}), ("a", "b")),
("abc", ("a", "b", "c")),
(b"abc", (97, 98, 99)),
((1, 2), (1, 2)),
],
)
def test_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None:
"""Test convert to tuple function."""
assert (
template.Template("{{ tuple(value) }}", hass).async_render({"value": value})
== expected
)
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)
@@ -655,30 +546,6 @@ def test_is_datetime(hass: HomeAssistant, value, expected) -> None:
)
@pytest.mark.parametrize(
("value", "expected"),
[
([1, 2], False),
({1, 2}, False),
({"a": 1, "b": 2}, False),
(ReadOnlyDict({"a": 1, "b": 2}), False),
(MappingProxyType({"a": 1, "b": 2}), False),
("abc", True),
(b"abc", True),
((1, 2), False),
(datetime(2024, 1, 1, 0, 0, 0), False),
],
)
def test_is_string_like(hass: HomeAssistant, value, expected) -> None:
"""Test is string_like."""
assert (
template.Template("{{ value is string_like }}", hass).async_render(
{"value": value}
)
== expected
)
def test_rounding_value(hass: HomeAssistant) -> None:
"""Test rounding value."""
hass.states.async_set("sensor.temperature", 12.78)
@@ -795,37 +662,46 @@ def test_apply(hass: HomeAssistant) -> None:
def test_apply_macro_with_arguments(hass: HomeAssistant) -> None:
"""Test apply macro with positional, named, and mixed arguments."""
# Test macro with positional arguments
assert template.Template(
"""
{%- macro greet(name, greeting) -%}
{{ greeting }}, {{ name }}!
{%- endmacro %}
{{ ["Alice", "Bob"] | map('apply', greet, "Hello") | list }}
assert (
template.Template(
"""
{%- macro add_numbers(a, b, c) -%}
{{ a + b + c }}
{%- endmacro -%}
{{ apply(5, add_numbers, 10, 15) }}
""",
hass,
).async_render() == ["Hello, Alice!", "Hello, Bob!"]
hass,
).async_render()
== 30
)
# Test macro with named arguments
assert template.Template(
"""
{%- macro greet(name, greeting="Hi") -%}
assert (
template.Template(
"""
{%- macro greet(name, greeting="Hello") -%}
{{ greeting }}, {{ name }}!
{%- endmacro %}
{{ ["Alice", "Bob"] | map('apply', greet, greeting="Hello") | list }}
{%- endmacro -%}
{{ apply("World", greet, greeting="Hi") }}
""",
hass,
).async_render() == ["Hello, Alice!", "Hello, Bob!"]
hass,
).async_render()
== "Hi, World!"
)
# Test macro with mixed positional and named arguments
assert template.Template(
"""
{%- macro greet(name, separator, greeting="Hi") -%}
{{ greeting }}{{separator}} {{ name }}!
{%- endmacro %}
{{ ["Alice", "Bob"] | map('apply', greet, "," , greeting="Hey") | list }}
# Test macro with mixed arguments
assert (
template.Template(
"""
{%- macro format_message(prefix, name, suffix="!") -%}
{{ prefix }} {{ name }}{{ suffix }}
{%- endmacro -%}
{{ apply("Welcome", format_message, "John", suffix="...") }}
""",
hass,
).async_render() == ["Hey, Alice!", "Hey, Bob!"]
hass,
).async_render()
== "Welcome John..."
)
def test_as_function(hass: HomeAssistant) -> None:
@@ -5695,51 +5571,6 @@ async def test_template_thread_safety_checks(hass: HomeAssistant) -> None:
assert template_obj.async_render_to_info().result() == 23
@pytest.mark.parametrize(
("cola", "colb", "expected"),
[
([1, 2], [3, 4], [(1, 3), (2, 4)]),
([1, 2], [3, 4, 5], [(1, 3), (2, 4)]),
([1, 2, 3, 4], [3, 4], [(1, 3), (2, 4)]),
],
)
def test_zip(hass: HomeAssistant, cola, colb, expected) -> None:
"""Test zip."""
assert (
template.Template("{{ zip(cola, colb) | list }}", hass).async_render(
{"cola": cola, "colb": colb}
)
== expected
)
assert (
template.Template(
"[{% for a, b in zip(cola, colb) %}({{a}}, {{b}}), {% endfor %}]", hass
).async_render({"cola": cola, "colb": colb})
== expected
)
@pytest.mark.parametrize(
("col", "expected"),
[
([(1, 3), (2, 4)], [(1, 2), (3, 4)]),
(["ax", "by", "cz"], [("a", "b", "c"), ("x", "y", "z")]),
],
)
def test_unzip(hass: HomeAssistant, col, expected) -> None:
"""Test unzipping using zip."""
assert (
template.Template("{{ zip(*col) | list }}", hass).async_render({"col": col})
== expected
)
assert (
template.Template(
"{% set a, b = zip(*col) %}[{{a}}, {{b}}]", hass
).async_render({"col": col})
== expected
)
def test_template_output_exceeds_maximum_size(hass: HomeAssistant) -> None:
"""Test template output exceeds maximum size."""
tpl = template.Template("{{ 'a' * 1024 * 257 }}", hass)
@@ -6040,57 +5871,6 @@ async def test_merge_response_not_mutate_original_object(
assert tpl.async_render()
def test_shuffle(hass: HomeAssistant) -> None:
"""Test the shuffle function and filter."""
assert list(
template.Template("{{ [1, 2, 3] | shuffle }}", hass).async_render()
) == unordered([1, 2, 3])
assert list(
template.Template("{{ shuffle([1, 2, 3]) }}", hass).async_render()
) == unordered([1, 2, 3])
assert list(
template.Template("{{ shuffle(1, 2, 3) }}", hass).async_render()
) == unordered([1, 2, 3])
assert list(template.Template("{{ shuffle([]) }}", hass).async_render()) == []
assert list(template.Template("{{ [] | shuffle }}", hass).async_render()) == []
# Testing using seed
assert list(
template.Template("{{ shuffle([1, 2, 3], 'seed') }}", hass).async_render()
) == [2, 3, 1]
assert list(
template.Template(
"{{ shuffle([1, 2, 3], seed='seed') }}",
hass,
).async_render()
) == [2, 3, 1]
assert list(
template.Template(
"{{ [1, 2, 3] | shuffle('seed') }}",
hass,
).async_render()
) == [2, 3, 1]
assert list(
template.Template(
"{{ [1, 2, 3] | shuffle(seed='seed') }}",
hass,
).async_render()
) == [2, 3, 1]
with pytest.raises(TemplateError):
template.Template("{{ 1 | shuffle }}", hass).async_render()
with pytest.raises(TemplateError):
template.Template("{{ shuffle() }}", hass).async_render()
def test_typeof(hass: HomeAssistant) -> None:
"""Test the typeof debug filter/function."""
assert template.Template("{{ True | typeof }}", hass).async_render() == "bool"
@@ -6118,221 +5898,6 @@ def test_typeof(hass: HomeAssistant) -> None:
)
def test_flatten(hass: HomeAssistant) -> None:
"""Test the flatten function and filter."""
assert template.Template(
"{{ flatten([1, [2, [3]], 4, [5 , 6]]) }}", hass
).async_render() == [1, 2, 3, 4, 5, 6]
assert template.Template(
"{{ [1, [2, [3]], 4, [5 , 6]] | flatten }}", hass
).async_render() == [1, 2, 3, 4, 5, 6]
assert template.Template(
"{{ flatten([1, [2, [3]], 4, [5 , 6]], 1) }}", hass
).async_render() == [1, 2, [3], 4, 5, 6]
assert template.Template(
"{{ flatten([1, [2, [3]], 4, [5 , 6]], levels=1) }}", hass
).async_render() == [1, 2, [3], 4, 5, 6]
assert template.Template(
"{{ [1, [2, [3]], 4, [5 , 6]] | flatten(1) }}", hass
).async_render() == [1, 2, [3], 4, 5, 6]
assert template.Template(
"{{ [1, [2, [3]], 4, [5 , 6]] | flatten(levels=1) }}", hass
).async_render() == [1, 2, [3], 4, 5, 6]
assert template.Template("{{ flatten([]) }}", hass).async_render() == []
assert template.Template("{{ [] | flatten }}", hass).async_render() == []
with pytest.raises(TemplateError):
template.Template("{{ 'string' | flatten }}", hass).async_render()
with pytest.raises(TemplateError):
template.Template("{{ flatten() }}", hass).async_render()
def test_intersect(hass: HomeAssistant) -> None:
"""Test the intersect function and filter."""
assert list(
template.Template(
"{{ intersect([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass
).async_render()
) == unordered([1, 2, 3, 4, 5])
assert list(
template.Template(
"{{ [1, 2, 5, 3, 4, 10] | intersect([1, 2, 3, 4, 5, 11, 99]) }}", hass
).async_render()
) == unordered([1, 2, 3, 4, 5])
assert list(
template.Template(
"{{ intersect(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass
).async_render()
) == unordered(["b", "c"])
assert list(
template.Template(
"{{ ['a', 'b', 'c'] | intersect(['b', 'c', 'd']) }}", hass
).async_render()
) == unordered(["b", "c"])
assert (
template.Template("{{ intersect([], [1, 2, 3]) }}", hass).async_render() == []
)
assert (
template.Template("{{ [] | intersect([1, 2, 3]) }}", hass).async_render() == []
)
with pytest.raises(TemplateError, match="intersect expected a list, got str"):
template.Template("{{ 'string' | intersect([1, 2, 3]) }}", hass).async_render()
with pytest.raises(TemplateError, match="intersect expected a list, got str"):
template.Template("{{ [1, 2, 3] | intersect('string') }}", hass).async_render()
def test_difference(hass: HomeAssistant) -> None:
"""Test the difference function and filter."""
assert list(
template.Template(
"{{ difference([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass
).async_render()
) == [10]
assert list(
template.Template(
"{{ [1, 2, 5, 3, 4, 10] | difference([1, 2, 3, 4, 5, 11, 99]) }}", hass
).async_render()
) == [10]
assert list(
template.Template(
"{{ difference(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass
).async_render()
) == ["a"]
assert list(
template.Template(
"{{ ['a', 'b', 'c'] | difference(['b', 'c', 'd']) }}", hass
).async_render()
) == ["a"]
assert (
template.Template("{{ difference([], [1, 2, 3]) }}", hass).async_render() == []
)
assert (
template.Template("{{ [] | difference([1, 2, 3]) }}", hass).async_render() == []
)
with pytest.raises(TemplateError, match="difference expected a list, got str"):
template.Template("{{ 'string' | difference([1, 2, 3]) }}", hass).async_render()
with pytest.raises(TemplateError, match="difference expected a list, got str"):
template.Template("{{ [1, 2, 3] | difference('string') }}", hass).async_render()
def test_union(hass: HomeAssistant) -> None:
"""Test the union function and filter."""
assert list(
template.Template(
"{{ union([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass
).async_render()
) == unordered([1, 2, 3, 4, 5, 10, 11, 99])
assert list(
template.Template(
"{{ [1, 2, 5, 3, 4, 10] | union([1, 2, 3, 4, 5, 11, 99]) }}", hass
).async_render()
) == unordered([1, 2, 3, 4, 5, 10, 11, 99])
assert list(
template.Template(
"{{ union(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass
).async_render()
) == unordered(["a", "b", "c", "d"])
assert list(
template.Template(
"{{ ['a', 'b', 'c'] | union(['b', 'c', 'd']) }}", hass
).async_render()
) == unordered(["a", "b", "c", "d"])
assert list(
template.Template("{{ union([], [1, 2, 3]) }}", hass).async_render()
) == unordered([1, 2, 3])
assert list(
template.Template("{{ [] | union([1, 2, 3]) }}", hass).async_render()
) == unordered([1, 2, 3])
with pytest.raises(TemplateError, match="union expected a list, got str"):
template.Template("{{ 'string' | union([1, 2, 3]) }}", hass).async_render()
with pytest.raises(TemplateError, match="union expected a list, got str"):
template.Template("{{ [1, 2, 3] | union('string') }}", hass).async_render()
def test_symmetric_difference(hass: HomeAssistant) -> None:
"""Test the symmetric_difference function and filter."""
assert list(
template.Template(
"{{ symmetric_difference([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}",
hass,
).async_render()
) == unordered([10, 11, 99])
assert list(
template.Template(
"{{ [1, 2, 5, 3, 4, 10] | symmetric_difference([1, 2, 3, 4, 5, 11, 99]) }}",
hass,
).async_render()
) == unordered([10, 11, 99])
assert list(
template.Template(
"{{ symmetric_difference(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass
).async_render()
) == unordered(["a", "d"])
assert list(
template.Template(
"{{ ['a', 'b', 'c'] | symmetric_difference(['b', 'c', 'd']) }}", hass
).async_render()
) == unordered(["a", "d"])
assert list(
template.Template(
"{{ symmetric_difference([], [1, 2, 3]) }}", hass
).async_render()
) == unordered([1, 2, 3])
assert list(
template.Template(
"{{ [] | symmetric_difference([1, 2, 3]) }}", hass
).async_render()
) == unordered([1, 2, 3])
with pytest.raises(
TemplateError, match="symmetric_difference expected a list, got str"
):
template.Template(
"{{ 'string' | symmetric_difference([1, 2, 3]) }}", hass
).async_render()
with pytest.raises(
TemplateError, match="symmetric_difference expected a list, got str"
):
template.Template(
"{{ [1, 2, 3] | symmetric_difference('string') }}", hass
).async_render()
def test_combine(hass: HomeAssistant) -> None:
"""Test combine filter and function."""
assert template.Template(