From c8e2a2b520c3085e7d3f2bc33648106341c14c7a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Apr 2026 18:00:58 +0200 Subject: [PATCH] Extract type casting template functions into a type cast Jinja2 extension (#167280) --- .../components/utility_meter/sensor.py | 9 +- homeassistant/helpers/template/__init__.py | 104 +----------- .../helpers/template/extensions/__init__.py | 2 + .../helpers/template/extensions/type_cast.py | 93 +++++++++++ homeassistant/helpers/template/helpers.py | 41 ++++- .../template/extensions/test_type_cast.py | 153 ++++++++++++++++++ tests/helpers/template/test_init.py | 126 --------------- 7 files changed, 295 insertions(+), 233 deletions(-) create mode 100644 homeassistant/helpers/template/extensions/type_cast.py create mode 100644 tests/helpers/template/extensions/test_type_cast.py diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index f7e6f6e3008..c9c737c4999 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -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") diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 22a476fb941..c11d09e4d7e 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -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 diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index 588d5aaf38b..9c4e32516cf 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -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", ] diff --git a/homeassistant/helpers/template/extensions/type_cast.py b/homeassistant/helpers/template/extensions/type_cast.py new file mode 100644 index 00000000000..600f0ddb4d4 --- /dev/null +++ b/homeassistant/helpers/template/extensions/type_cast.py @@ -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)) diff --git a/homeassistant/helpers/template/helpers.py b/homeassistant/helpers/template/helpers.py index 71c95f77c47..921faa0c1b6 100644 --- a/homeassistant/helpers/template/helpers.py +++ b/homeassistant/helpers/template/helpers.py @@ -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) diff --git a/tests/helpers/template/extensions/test_type_cast.py b/tests/helpers/template/extensions/test_type_cast.py new file mode 100644 index 00000000000..93b9d3edee3 --- /dev/null +++ b/tests/helpers/template/extensions/test_type_cast.py @@ -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 diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 30846eef202..26dc45f9c56 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -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)