1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-20 07:20:14 +01:00

Extract type casting template functions into a type cast Jinja2 extension (#167280)

This commit is contained in:
Franck Nijhof
2026-04-03 18:00:58 +02:00
committed by GitHub
parent 27365a4457
commit c8e2a2b520
7 changed files with 295 additions and 233 deletions
@@ -7,6 +7,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
from decimal import Decimal, DecimalException, InvalidOperation
import logging
import math
from typing import Any, Self
from cronsim import CronSim
@@ -52,7 +53,6 @@ from homeassistant.helpers.event import (
async_track_state_change_event,
)
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.template import is_number
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, slugify
from homeassistant.util.enum import try_parse_enum
@@ -113,8 +113,11 @@ COLLECTING = "collecting"
def validate_is_number(value):
"""Validate value is a number."""
if is_number(value):
return value
try:
if math.isfinite(float(value)):
return value
except ValueError, TypeError:
pass
raise vol.Invalid("Value is not a number")
+4 -100
View File
@@ -32,7 +32,6 @@ from jinja2.utils import Namespace
from lru import LRU
import orjson
from propcache.api import under_cached_property
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -76,7 +75,7 @@ from .context import (
template_context_manager,
template_cv,
)
from .helpers import raise_no_default
from .helpers import raise_no_default, result_as_boolean as result_as_boolean
from .render_info import RenderInfo, render_info_cv
if TYPE_CHECKING:
@@ -1127,42 +1126,6 @@ def _resolve_state(
return None
@overload
def forgiving_boolean(value: Any) -> bool | object: ...
@overload
def forgiving_boolean[_T](value: Any, default: _T) -> bool | _T: ...
def forgiving_boolean[_T](
value: Any, default: _T | object = _SENTINEL
) -> bool | _T | object:
"""Try to convert value to a boolean."""
try:
# Import here, not at top-level to avoid circular import
from homeassistant.helpers import config_validation as cv # noqa: PLC0415
return cv.boolean(value)
except vol.Invalid:
if default is _SENTINEL:
raise_no_default("bool", value)
return default
def result_as_boolean(template_result: Any | None) -> bool:
"""Convert the template result to a boolean.
True/not 0/'1'/'true'/'yes'/'on'/'enable' are considered truthy
False/0/None/'0'/'false'/'no'/'off'/'disable' are considered falsy
All other values are falsy
"""
if template_result is None:
return False
return forgiving_boolean(template_result, default=False)
def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]:
"""Expand out any groups and zones into entity states."""
# circular import.
@@ -1601,58 +1564,6 @@ def fail_when_undefined(value):
return value
def forgiving_float(value, default=_SENTINEL):
"""Try to convert value to a float."""
try:
return float(value)
except ValueError, TypeError:
if default is _SENTINEL:
raise_no_default("float", value)
return default
def forgiving_float_filter(value, default=_SENTINEL):
"""Try to convert value to a float."""
try:
return float(value)
except ValueError, TypeError:
if default is _SENTINEL:
raise_no_default("float", value)
return default
def forgiving_int(value, default=_SENTINEL, base=10):
"""Try to convert value to an int, and raise if it fails."""
result = jinja2.filters.do_int(value, default=default, base=base)
if result is _SENTINEL:
raise_no_default("int", value)
return result
def forgiving_int_filter(value, default=_SENTINEL, base=10):
"""Try to convert value to an int, and raise if it fails."""
result = jinja2.filters.do_int(value, default=default, base=base)
if result is _SENTINEL:
raise_no_default("int", value)
return result
def is_number(value):
"""Try to convert value to a float."""
try:
fvalue = float(value)
except ValueError, TypeError:
return False
if not math.isfinite(fvalue):
return False
return True
def _is_string_like(value: Any) -> bool:
"""Return whether a value is a string or string like object."""
return isinstance(value, (str, bytes, bytearray))
def struct_pack(value: Any | None, format_string: str) -> bytes | None:
"""Pack an object into a bytes object."""
try:
@@ -1944,15 +1855,14 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
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.add_extension(
"homeassistant.helpers.template.extensions.TypeCastExtension"
)
self.globals["apply"] = apply
self.globals["as_function"] = as_function
self.globals["bool"] = forgiving_boolean
self.globals["combine"] = combine
self.globals["float"] = forgiving_float
self.globals["iif"] = iif
self.globals["int"] = forgiving_int
self.globals["is_number"] = is_number
self.globals["merge_response"] = merge_response
self.globals["pack"] = struct_pack
self.globals["typeof"] = typeof
@@ -1963,16 +1873,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.filters["add"] = add
self.filters["apply"] = apply
self.filters["as_function"] = as_function
self.filters["bool"] = forgiving_boolean
self.filters["combine"] = combine
self.filters["contains"] = contains
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["is_defined"] = fail_when_undefined
self.filters["is_number"] = is_number
self.filters["multiply"] = multiply
self.filters["ord"] = ord
self.filters["pack"] = struct_pack
@@ -1985,8 +1891,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.tests["apply"] = apply
self.tests["contains"] = contains
self.tests["is_number"] = is_number
self.tests["string_like"] = _is_string_like
if hass is None:
return
@@ -12,6 +12,7 @@ from .labels import LabelExtension
from .math import MathExtension
from .regex import RegexExtension
from .string import StringExtension
from .type_cast import TypeCastExtension
__all__ = [
"AreaExtension",
@@ -26,4 +27,5 @@ __all__ = [
"MathExtension",
"RegexExtension",
"StringExtension",
"TypeCastExtension",
]
@@ -0,0 +1,93 @@
"""Type casting functions for Home Assistant templates."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any
import jinja2.filters
from homeassistant.helpers.template.helpers import forgiving_boolean, raise_no_default
from .base import BaseTemplateExtension, TemplateFunction
if TYPE_CHECKING:
from homeassistant.helpers.template import TemplateEnvironment
_SENTINEL = object()
class TypeCastExtension(BaseTemplateExtension):
"""Jinja2 extension for type casting functions."""
def __init__(self, environment: TemplateEnvironment) -> None:
"""Initialize the type cast extension."""
super().__init__(
environment,
functions=[
TemplateFunction(
"bool",
forgiving_boolean,
as_global=True,
as_filter=True,
),
TemplateFunction(
"float",
self.forgiving_float,
as_global=True,
as_filter=True,
),
TemplateFunction(
"int",
self.forgiving_int,
as_global=True,
as_filter=True,
),
TemplateFunction(
"is_number",
self.is_number,
as_global=True,
as_filter=True,
as_test=True,
),
TemplateFunction(
"string_like",
self.is_string_like,
as_test=True,
),
],
)
@staticmethod
def forgiving_float(value: Any, default: Any = _SENTINEL) -> Any:
"""Try to convert value to a float."""
try:
return float(value)
except ValueError, TypeError:
if default is _SENTINEL:
raise_no_default("float", value)
return default
@staticmethod
def forgiving_int(value: Any, default: Any = _SENTINEL, base: int = 10) -> Any:
"""Try to convert value to an int, and raise if it fails."""
result = jinja2.filters.do_int(value, default=default, base=base)
if result is _SENTINEL:
raise_no_default("int", value)
return result
@staticmethod
def is_number(value: Any) -> bool:
"""Try to convert value to a float."""
try:
fvalue = float(value)
except ValueError, TypeError:
return False
if not math.isfinite(fvalue):
return False
return True
@staticmethod
def is_string_like(value: Any) -> bool:
"""Return whether a value is a string or string like object."""
return isinstance(value, (str, bytes, bytearray))
+37 -4
View File
@@ -2,12 +2,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, NoReturn
from typing import TYPE_CHECKING, Any, NoReturn, overload
import voluptuous as vol
from homeassistant.helpers import (
area_registry as ar,
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
@@ -17,6 +18,8 @@ from .context import template_cv
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
_SENTINEL = object()
def raise_no_default(function: str, value: Any) -> NoReturn:
"""Raise ValueError when no default is specified for template functions."""
@@ -47,9 +50,6 @@ def resolve_area_id(hass: HomeAssistant, lookup_value: Any) -> str | None:
if areas_list:
return areas_list[0].id
# Import here, not at top-level to avoid circular import
from homeassistant.helpers import config_validation as cv # noqa: PLC0415
# Check if it's an entity ID
try:
cv.entity_id(lookup_value)
@@ -69,3 +69,36 @@ def resolve_area_id(hass: HomeAssistant, lookup_value: Any) -> str | None:
return device.area_id
return None
@overload
def forgiving_boolean(value: Any) -> bool | object: ...
@overload
def forgiving_boolean[_T](value: Any, default: _T) -> bool | _T: ...
def forgiving_boolean[_T](
value: Any, default: _T | object = _SENTINEL
) -> bool | _T | object:
"""Try to convert value to a boolean."""
try:
return cv.boolean(value)
except vol.Invalid:
if default is _SENTINEL:
raise_no_default("bool", value)
return default
def result_as_boolean(template_result: Any | None) -> bool:
"""Convert the template result to a boolean.
True/not 0/'1'/'true'/'yes'/'on'/'enable' are considered truthy
False/0/None/'0'/'false'/'no'/'off'/'disable' are considered falsy
All other values are falsy
"""
if template_result is None:
return False
return forgiving_boolean(template_result, default=False)
@@ -0,0 +1,153 @@
"""Test type casting functions for Home Assistant templates."""
from __future__ import annotations
import math
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from tests.helpers.template.helpers import render
def test_float_function(hass: HomeAssistant) -> None:
"""Test float function."""
hass.states.async_set("sensor.temperature", "12")
assert render(hass, "{{ float(states.sensor.temperature.state) }}") == 12.0
assert render(hass, "{{ float(states.sensor.temperature.state) > 11 }}") is True
# Test handling of invalid input
with pytest.raises(TemplateError):
render(hass, "{{ float('forgiving') }}")
# Test handling of default return value
assert render(hass, "{{ float('bad', 1) }}") == 1
assert render(hass, "{{ float('bad', default=1) }}") == 1
def test_float_filter(hass: HomeAssistant) -> None:
"""Test float filter."""
hass.states.async_set("sensor.temperature", "12")
assert render(hass, "{{ states.sensor.temperature.state | float }}") == 12.0
assert render(hass, "{{ states.sensor.temperature.state | float > 11 }}") is True
# Test handling of invalid input
with pytest.raises(TemplateError):
render(hass, "{{ 'bad' | float }}")
# Test handling of default return value
assert render(hass, "{{ 'bad' | float(1) }}") == 1
assert render(hass, "{{ 'bad' | float(default=1) }}") == 1
def test_int_filter(hass: HomeAssistant) -> None:
"""Test int filter."""
hass.states.async_set("sensor.temperature", "12.2")
assert render(hass, "{{ states.sensor.temperature.state | int }}") == 12
assert render(hass, "{{ states.sensor.temperature.state | int > 11 }}") is True
hass.states.async_set("sensor.temperature", "0x10")
assert render(hass, "{{ states.sensor.temperature.state | int(base=16) }}") == 16
# Test handling of invalid input
with pytest.raises(TemplateError):
render(hass, "{{ 'bad' | int }}")
# Test handling of default return value
assert render(hass, "{{ 'bad' | int(1) }}") == 1
assert render(hass, "{{ 'bad' | int(default=1) }}") == 1
def test_int_function(hass: HomeAssistant) -> None:
"""Test int filter."""
hass.states.async_set("sensor.temperature", "12.2")
assert render(hass, "{{ int(states.sensor.temperature.state) }}") == 12
assert render(hass, "{{ int(states.sensor.temperature.state) > 11 }}") is True
hass.states.async_set("sensor.temperature", "0x10")
assert render(hass, "{{ int(states.sensor.temperature.state, base=16) }}") == 16
# Test handling of invalid input
with pytest.raises(TemplateError):
render(hass, "{{ int('bad') }}")
# Test handling of default return value
assert render(hass, "{{ int('bad', 1) }}") == 1
assert render(hass, "{{ int('bad', default=1) }}") == 1
def test_bool_function(hass: HomeAssistant) -> None:
"""Test bool function."""
assert render(hass, "{{ bool(true) }}") is True
assert render(hass, "{{ bool(false) }}") is False
assert render(hass, "{{ bool('on') }}") is True
assert render(hass, "{{ bool('off') }}") is False
with pytest.raises(TemplateError):
render(hass, "{{ bool('unknown') }}")
with pytest.raises(TemplateError):
render(hass, "{{ bool(none) }}")
assert render(hass, "{{ bool('unavailable', none) }}") is None
assert render(hass, "{{ bool('unavailable', default=none) }}") is None
def test_bool_filter(hass: HomeAssistant) -> None:
"""Test bool filter."""
assert render(hass, "{{ true | bool }}") is True
assert render(hass, "{{ false | bool }}") is False
assert render(hass, "{{ 'on' | bool }}") is True
assert render(hass, "{{ 'off' | bool }}") is False
with pytest.raises(TemplateError):
render(hass, "{{ 'unknown' | bool }}")
with pytest.raises(TemplateError):
render(hass, "{{ none | bool }}")
assert render(hass, "{{ 'unavailable' | bool(none) }}") is None
assert render(hass, "{{ 'unavailable' | bool(default=none) }}") is None
@pytest.mark.parametrize(
("value", "expected"),
[
(0, True),
(0.0, True),
("0", True),
("0.0", True),
(True, True),
(False, True),
("True", False),
("False", False),
(None, False),
("None", False),
("horse", False),
(math.pi, True),
(math.nan, False),
(math.inf, False),
("nan", False),
("inf", False),
],
)
def test_isnumber(hass: HomeAssistant, value: object, expected: bool) -> None:
"""Test is_number."""
assert render(hass, "{{ is_number(value) }}", {"value": value}) == expected
assert render(hass, "{{ value | is_number }}", {"value": value}) == expected
assert render(hass, "{{ value is is_number }}", {"value": value}) == expected
@pytest.mark.parametrize(
("value", "expected"),
[
("hello", True),
(b"hello", True),
(bytearray(b"hello"), True),
(42, False),
([1, 2], False),
(None, False),
],
)
def test_string_like(hass: HomeAssistant, value: object, expected: bool) -> None:
"""Test string_like."""
assert render(hass, "{{ value is string_like }}", {"value": value}) == expected
-126
View File
@@ -6,7 +6,6 @@ from collections.abc import Iterable
from datetime import datetime, timedelta
import json
import logging
import math
import random
from unittest.mock import patch
@@ -299,131 +298,6 @@ def test_loop_controls(hass: HomeAssistant) -> None:
assert render(hass, tpl) == "02"
def test_float_function(hass: HomeAssistant) -> None:
"""Test float function."""
hass.states.async_set("sensor.temperature", "12")
assert render(hass, "{{ float(states.sensor.temperature.state) }}") == 12.0
assert render(hass, "{{ float(states.sensor.temperature.state) > 11 }}") is True
# Test handling of invalid input
with pytest.raises(TemplateError):
render(hass, "{{ float('forgiving') }}")
# Test handling of default return value
assert render(hass, "{{ float('bad', 1) }}") == 1
assert render(hass, "{{ float('bad', default=1) }}") == 1
def test_float_filter(hass: HomeAssistant) -> None:
"""Test float filter."""
hass.states.async_set("sensor.temperature", "12")
assert render(hass, "{{ states.sensor.temperature.state | float }}") == 12.0
assert render(hass, "{{ states.sensor.temperature.state | float > 11 }}") is True
# Test handling of invalid input
with pytest.raises(TemplateError):
render(hass, "{{ 'bad' | float }}")
# Test handling of default return value
assert render(hass, "{{ 'bad' | float(1) }}") == 1
assert render(hass, "{{ 'bad' | float(default=1) }}") == 1
def test_int_filter(hass: HomeAssistant) -> None:
"""Test int filter."""
hass.states.async_set("sensor.temperature", "12.2")
assert render(hass, "{{ states.sensor.temperature.state | int }}") == 12
assert render(hass, "{{ states.sensor.temperature.state | int > 11 }}") is True
hass.states.async_set("sensor.temperature", "0x10")
assert render(hass, "{{ states.sensor.temperature.state | int(base=16) }}") == 16
# Test handling of invalid input
with pytest.raises(TemplateError):
render(hass, "{{ 'bad' | int }}")
# Test handling of default return value
assert render(hass, "{{ 'bad' | int(1) }}") == 1
assert render(hass, "{{ 'bad' | int(default=1) }}") == 1
def test_int_function(hass: HomeAssistant) -> None:
"""Test int filter."""
hass.states.async_set("sensor.temperature", "12.2")
assert render(hass, "{{ int(states.sensor.temperature.state) }}") == 12
assert render(hass, "{{ int(states.sensor.temperature.state) > 11 }}") is True
hass.states.async_set("sensor.temperature", "0x10")
assert render(hass, "{{ int(states.sensor.temperature.state, base=16) }}") == 16
# Test handling of invalid input
with pytest.raises(TemplateError):
render(hass, "{{ int('bad') }}")
# Test handling of default return value
assert render(hass, "{{ int('bad', 1) }}") == 1
assert render(hass, "{{ int('bad', default=1) }}") == 1
def test_bool_function(hass: HomeAssistant) -> None:
"""Test bool function."""
assert render(hass, "{{ bool(true) }}") is True
assert render(hass, "{{ bool(false) }}") is False
assert render(hass, "{{ bool('on') }}") is True
assert render(hass, "{{ bool('off') }}") is False
with pytest.raises(TemplateError):
render(hass, "{{ bool('unknown') }}")
with pytest.raises(TemplateError):
render(hass, "{{ bool(none) }}")
assert render(hass, "{{ bool('unavailable', none) }}") is None
assert render(hass, "{{ bool('unavailable', default=none) }}") is None
def test_bool_filter(hass: HomeAssistant) -> None:
"""Test bool filter."""
assert render(hass, "{{ true | bool }}") is True
assert render(hass, "{{ false | bool }}") is False
assert render(hass, "{{ 'on' | bool }}") is True
assert render(hass, "{{ 'off' | bool }}") is False
with pytest.raises(TemplateError):
render(hass, "{{ 'unknown' | bool }}")
with pytest.raises(TemplateError):
render(hass, "{{ none | bool }}")
assert render(hass, "{{ 'unavailable' | bool(none) }}") is None
assert render(hass, "{{ 'unavailable' | bool(default=none) }}") is None
@pytest.mark.parametrize(
("value", "expected"),
[
(0, True),
(0.0, True),
("0", True),
("0.0", True),
(True, True),
(False, True),
("True", False),
("False", False),
(None, False),
("None", False),
("horse", False),
(math.pi, True),
(math.nan, False),
(math.inf, False),
("nan", False),
("inf", False),
],
)
def test_isnumber(hass: HomeAssistant, value, expected) -> None:
"""Test is_number."""
assert render(hass, "{{ is_number(value) }}", {"value": value}) == expected
assert render(hass, "{{ value | is_number }}", {"value": value}) == expected
assert render(hass, "{{ value is is_number }}", {"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)