diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 53e2eb122f6..36f5dc9b22c 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -19,7 +19,7 @@ import re import statistics from struct import error as StructError, pack, unpack_from import sys -from typing import Any, cast +from typing import Any, NoReturn, TypeVar, cast, overload from urllib.parse import urlencode as urllib_urlencode import weakref @@ -92,6 +92,8 @@ _COLLECTABLE_STATE_ATTRIBUTES = { "name", } +_T = TypeVar("_T") + ALL_STATES_RATE_LIMIT = timedelta(minutes=1) DOMAIN_STATES_RATE_LIMIT = timedelta(seconds=1) @@ -946,6 +948,31 @@ def _resolve_state( return None +@overload +def forgiving_boolean(value: Any) -> bool | object: + ... + + +@overload +def forgiving_boolean(value: Any, default: _T) -> bool | _T: + ... + + +def forgiving_boolean( + 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 . import config_validation as cv # pylint: disable=import-outside-toplevel + + 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. @@ -956,13 +983,7 @@ def result_as_boolean(template_result: Any | None) -> bool: if template_result is None: return False - try: - # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel - - return cv.boolean(template_result) - except vol.Invalid: - return False + return forgiving_boolean(template_result, default=False) def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: @@ -1368,7 +1389,7 @@ def utcnow(hass: HomeAssistant) -> datetime: return dt_util.utcnow() -def raise_no_default(function, value): +def raise_no_default(function: str, value: Any) -> NoReturn: """Log warning if no default is specified.""" template, action = template_cv.get() or ("", "rendering or compiling") raise ValueError( @@ -1981,6 +2002,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["relative_time"] = relative_time self.filters["slugify"] = slugify self.filters["iif"] = iif + self.filters["bool"] = forgiving_boolean self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -2012,6 +2034,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["unpack"] = struct_unpack self.globals["slugify"] = slugify self.globals["iif"] = iif + self.globals["bool"] = forgiving_boolean self.tests["is_number"] = is_number self.tests["match"] = regex_match self.tests["search"] = regex_search diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 59b653bc23e..fb57eff7685 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -296,6 +296,34 @@ def test_int_function(hass): assert render(hass, "{{ int('bad', default=1) }}") == 1 +def test_bool_function(hass): + """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): + """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", [