Warn if template functions fail and no default is specified (#56453)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
pull/56785/head
Erik Montnemery 2021-09-29 20:16:02 +02:00 committed by GitHub
parent ae00c221e0
commit ef13e473cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 280 additions and 64 deletions

View File

@ -6,7 +6,7 @@ import asyncio
import base64
import collections.abc
from collections.abc import Callable, Generator, Iterable
from contextlib import suppress
from contextlib import contextmanager, suppress
from contextvars import ContextVar
from datetime import datetime, timedelta
from functools import partial, wraps
@ -88,7 +88,9 @@ _COLLECTABLE_STATE_ATTRIBUTES = {
ALL_STATES_RATE_LIMIT = timedelta(minutes=1)
DOMAIN_STATES_RATE_LIMIT = timedelta(seconds=1)
template_cv: ContextVar[str | None] = ContextVar("template_cv", default=None)
template_cv: ContextVar[tuple[str, str] | None] = ContextVar(
"template_cv", default=None
)
@bind_hass
@ -336,13 +338,14 @@ class Template:
def ensure_valid(self) -> None:
"""Return if template is valid."""
if self.is_static or self._compiled_code is not None:
return
with set_template(self.template, "compiling"):
if self.is_static or self._compiled_code is not None:
return
try:
self._compiled_code = self._env.compile(self.template) # type: ignore[no-untyped-call]
except jinja2.TemplateError as err:
raise TemplateError(err) from err
try:
self._compiled_code = self._env.compile(self.template) # type: ignore[no-untyped-call]
except jinja2.TemplateError as err:
raise TemplateError(err) from err
def render(
self,
@ -1201,8 +1204,26 @@ def utcnow(hass: HomeAssistant) -> datetime:
return dt_util.utcnow()
def forgiving_round(value, precision=0, method="common"):
"""Round accepted strings."""
def warn_no_default(function, value, default):
"""Log warning if no default is specified."""
template, action = template_cv.get() or ("", "rendering or compiling")
_LOGGER.warning(
(
"Template warning: '%s' got invalid input '%s' when %s template '%s' "
"but no default was specified. Currently '%s' will return '%s', however this template will fail "
"to render in Home Assistant core 2021.12"
),
function,
value,
action,
template,
function,
default,
)
def forgiving_round(value, precision=0, method="common", default=_SENTINEL):
"""Filter to round a value."""
try:
# support rounding methods like jinja
multiplier = float(10 ** precision)
@ -1218,94 +1239,137 @@ def forgiving_round(value, precision=0, method="common"):
return int(value) if precision == 0 else value
except (ValueError, TypeError):
# If value can't be converted to float
return value
if default is _SENTINEL:
warn_no_default("round", value, value)
return value
return default
def multiply(value, amount):
def multiply(value, amount, default=_SENTINEL):
"""Filter to convert value to float and multiply it."""
try:
return float(value) * amount
except (ValueError, TypeError):
# If value can't be converted to float
return value
if default is _SENTINEL:
warn_no_default("multiply", value, value)
return value
return default
def logarithm(value, base=math.e):
"""Filter to get logarithm of the value with a specific base."""
def logarithm(value, base=math.e, default=_SENTINEL):
"""Filter and function to get logarithm of the value with a specific base."""
try:
return math.log(float(value), float(base))
except (ValueError, TypeError):
return value
if default is _SENTINEL:
warn_no_default("log", value, value)
return value
return default
def sine(value):
"""Filter to get sine of the value."""
def sine(value, default=_SENTINEL):
"""Filter and function to get sine of the value."""
try:
return math.sin(float(value))
except (ValueError, TypeError):
return value
if default is _SENTINEL:
warn_no_default("sin", value, value)
return value
return default
def cosine(value):
"""Filter to get cosine of the value."""
def cosine(value, default=_SENTINEL):
"""Filter and function to get cosine of the value."""
try:
return math.cos(float(value))
except (ValueError, TypeError):
return value
if default is _SENTINEL:
warn_no_default("cos", value, value)
return value
return default
def tangent(value):
"""Filter to get tangent of the value."""
def tangent(value, default=_SENTINEL):
"""Filter and function to get tangent of the value."""
try:
return math.tan(float(value))
except (ValueError, TypeError):
return value
if default is _SENTINEL:
warn_no_default("tan", value, value)
return value
return default
def arc_sine(value):
"""Filter to get arc sine of the value."""
def arc_sine(value, default=_SENTINEL):
"""Filter and function to get arc sine of the value."""
try:
return math.asin(float(value))
except (ValueError, TypeError):
return value
if default is _SENTINEL:
warn_no_default("asin", value, value)
return value
return default
def arc_cosine(value):
"""Filter to get arc cosine of the value."""
def arc_cosine(value, default=_SENTINEL):
"""Filter and function to get arc cosine of the value."""
try:
return math.acos(float(value))
except (ValueError, TypeError):
return value
if default is _SENTINEL:
warn_no_default("acos", value, value)
return value
return default
def arc_tangent(value):
"""Filter to get arc tangent of the value."""
def arc_tangent(value, default=_SENTINEL):
"""Filter and function to get arc tangent of the value."""
try:
return math.atan(float(value))
except (ValueError, TypeError):
return value
if default is _SENTINEL:
warn_no_default("atan", value, value)
return value
return default
def arc_tangent2(*args):
"""Filter to calculate four quadrant arc tangent of y / x."""
def arc_tangent2(*args, default=_SENTINEL):
"""Filter and function to calculate four quadrant arc tangent of y / x.
The parameters to atan2 may be passed either in an iterable or as separate arguments
The default value may be passed either as a positional or in a keyword argument
"""
try:
if len(args) == 1 and isinstance(args[0], (list, tuple)):
if 1 <= len(args) <= 2 and isinstance(args[0], (list, tuple)):
if len(args) == 2 and default is _SENTINEL:
# Default value passed as a positional argument
default = args[1]
args = args[0]
elif len(args) == 3 and default is _SENTINEL:
# Default value passed as a positional argument
default = args[2]
return math.atan2(float(args[0]), float(args[1]))
except (ValueError, TypeError):
return args
if default is _SENTINEL:
warn_no_default("atan2", args, args)
return args
return default
def square_root(value):
"""Filter to get square root of the value."""
def square_root(value, default=_SENTINEL):
"""Filter and function to get square root of the value."""
try:
return math.sqrt(float(value))
except (ValueError, TypeError):
return value
if default is _SENTINEL:
warn_no_default("sqrt", value, value)
return value
return default
def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True):
def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True, default=_SENTINEL):
"""Filter to convert given timestamp to format."""
try:
date = dt_util.utc_from_timestamp(value)
@ -1316,10 +1380,13 @@ def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True):
return date.strftime(date_format)
except (ValueError, TypeError):
# If timestamp can't be converted
return value
if default is _SENTINEL:
warn_no_default("timestamp_custom", value, value)
return value
return default
def timestamp_local(value):
def timestamp_local(value, default=_SENTINEL):
"""Filter to convert given timestamp to local date/time."""
try:
return dt_util.as_local(dt_util.utc_from_timestamp(value)).strftime(
@ -1327,32 +1394,44 @@ def timestamp_local(value):
)
except (ValueError, TypeError):
# If timestamp can't be converted
return value
if default is _SENTINEL:
warn_no_default("timestamp_local", value, value)
return value
return default
def timestamp_utc(value):
def timestamp_utc(value, default=_SENTINEL):
"""Filter to convert given timestamp to UTC date/time."""
try:
return dt_util.utc_from_timestamp(value).strftime(DATE_STR_FORMAT)
except (ValueError, TypeError):
# If timestamp can't be converted
return value
if default is _SENTINEL:
warn_no_default("timestamp_utc", value, value)
return value
return default
def forgiving_as_timestamp(value):
"""Try to convert value to timestamp."""
def forgiving_as_timestamp(value, default=_SENTINEL):
"""Filter and function which tries to convert value to timestamp."""
try:
return dt_util.as_timestamp(value)
except (ValueError, TypeError):
return None
if default is _SENTINEL:
warn_no_default("as_timestamp", value, None)
return None
return default
def strptime(string, fmt):
def strptime(string, fmt, default=_SENTINEL):
"""Parse a time string to datetime."""
try:
return datetime.strptime(string, fmt)
except (ValueError, AttributeError, TypeError):
return string
if default is _SENTINEL:
warn_no_default("strptime", string, string)
return string
return default
def fail_when_undefined(value):
@ -1362,12 +1441,26 @@ def fail_when_undefined(value):
return value
def forgiving_float(value):
def forgiving_float(value, default=_SENTINEL):
"""Try to convert value to a float."""
try:
return float(value)
except (ValueError, TypeError):
return value
if default is _SENTINEL:
warn_no_default("float", value, value)
return 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:
warn_no_default("float", value, 0)
return 0
return default
def is_number(value):
@ -1493,22 +1586,33 @@ def urlencode(value):
return urllib_urlencode(value).encode("utf-8")
@contextmanager
def set_template(template_str: str, action: str) -> Generator:
"""Store template being parsed or rendered in a Contextvar to aid error handling."""
template_cv.set((template_str, action))
try:
yield
finally:
template_cv.set(None)
def _render_with_context(
template_str: str, template: jinja2.Template, **kwargs: Any
) -> str:
"""Store template being rendered in a ContextVar to aid error handling."""
template_cv.set(template_str)
return template.render(**kwargs)
with set_template(template_str, "rendering"):
return template.render(**kwargs)
class LoggingUndefined(jinja2.Undefined):
"""Log on undefined variables."""
def _log_message(self):
template = template_cv.get() or ""
template, action = template_cv.get() or ("", "rendering or compiling")
_LOGGER.warning(
"Template variable warning: %s when rendering '%s'",
"Template variable warning: %s when %s '%s'",
self._undefined_message,
action,
template,
)
@ -1516,10 +1620,11 @@ class LoggingUndefined(jinja2.Undefined):
try:
return super()._fail_with_undefined_error(*args, **kwargs)
except self._undefined_exception as ex:
template = template_cv.get() or ""
template, action = template_cv.get() or ("", "rendering or compiling")
_LOGGER.error(
"Template variable error: %s when rendering '%s'",
"Template variable error: %s when %s '%s'",
self._undefined_message,
action,
template,
)
raise ex
@ -1587,6 +1692,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.filters["bitwise_or"] = bitwise_or
self.filters["ord"] = ord
self.filters["is_number"] = is_number
self.filters["float"] = forgiving_float_filter
self.globals["log"] = logarithm
self.globals["sin"] = sine
self.globals["cos"] = cosine

View File

@ -38,6 +38,12 @@ def _set_up_units(hass):
)
def render(hass, template_str, variables=None):
"""Create render info from template."""
tmp = template.Template(template_str, hass)
return tmp.async_render(variables)
def render_to_info(hass, template_str, variables=None):
"""Create render info from template."""
tmp = template.Template(template_str, hass)
@ -196,8 +202,8 @@ def test_iterating_domain_states(hass):
)
def test_float(hass):
"""Test float."""
def test_float_function(hass):
"""Test float function."""
hass.states.async_set("sensor.temperature", "12")
assert (
@ -219,6 +225,20 @@ def test_float(hass):
== "forgiving"
)
assert render(hass, "{{ float('bad', 1) }}") == 1
assert render(hass, "{{ float('bad', default=1) }}") == 1
def test_float_filter(hass):
"""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
assert render(hass, "{{ 'bad' | float }}") == 0
assert render(hass, "{{ 'bad' | float(1) }}") == 1
assert render(hass, "{{ 'bad' | float(default=1) }}") == 1
@pytest.mark.parametrize(
"value, expected",
@ -295,8 +315,8 @@ def test_rounding_value(hass):
)
def test_rounding_value_get_original_value_on_error(hass):
"""Test rounding value get original value on error."""
def test_rounding_value_on_error(hass):
"""Test rounding value handling of error."""
assert template.Template("{{ None | round }}", hass).async_render() is None
assert (
@ -304,6 +324,9 @@ def test_rounding_value_get_original_value_on_error(hass):
== "no_number"
)
# Test handling of default return value
assert render(hass, "{{ 'no_number' | round(default=1) }}") == 1
def test_multiply(hass):
"""Test multiply."""
@ -317,6 +340,10 @@ def test_multiply(hass):
== out
)
# Test handling of default return value
assert render(hass, "{{ 'no_number' | multiply(10, 1) }}") == 1
assert render(hass, "{{ 'no_number' | multiply(10, default=1) }}") == 1
def test_logarithm(hass):
"""Test logarithm."""
@ -343,6 +370,12 @@ def test_logarithm(hass):
== expected
)
# Test handling of default return value
assert render(hass, "{{ 'no_number' | log(10, 1) }}") == 1
assert render(hass, "{{ 'no_number' | log(10, default=1) }}") == 1
assert render(hass, "{{ log('no_number', 10, 1) }}") == 1
assert render(hass, "{{ log('no_number', 10, default=1) }}") == 1
def test_sine(hass):
"""Test sine."""
@ -360,6 +393,13 @@ def test_sine(hass):
template.Template("{{ %s | sin | round(3) }}" % value, hass).async_render()
== expected
)
assert render(hass, f"{{{{ sin({value}) | round(3) }}}}") == expected
# Test handling of default return value
assert render(hass, "{{ 'no_number' | sin(1) }}") == 1
assert render(hass, "{{ 'no_number' | sin(default=1) }}") == 1
assert render(hass, "{{ sin('no_number', 1) }}") == 1
assert render(hass, "{{ sin('no_number', default=1) }}") == 1
def test_cos(hass):
@ -378,6 +418,13 @@ def test_cos(hass):
template.Template("{{ %s | cos | round(3) }}" % value, hass).async_render()
== expected
)
assert render(hass, f"{{{{ cos({value}) | round(3) }}}}") == expected
# Test handling of default return value
assert render(hass, "{{ 'no_number' | sin(1) }}") == 1
assert render(hass, "{{ 'no_number' | sin(default=1) }}") == 1
assert render(hass, "{{ sin('no_number', 1) }}") == 1
assert render(hass, "{{ sin('no_number', default=1) }}") == 1
def test_tan(hass):
@ -396,6 +443,13 @@ def test_tan(hass):
template.Template("{{ %s | tan | round(3) }}" % value, hass).async_render()
== expected
)
assert render(hass, f"{{{{ tan({value}) | round(3) }}}}") == expected
# Test handling of default return value
assert render(hass, "{{ 'no_number' | tan(1) }}") == 1
assert render(hass, "{{ 'no_number' | tan(default=1) }}") == 1
assert render(hass, "{{ tan('no_number', 1) }}") == 1
assert render(hass, "{{ tan('no_number', default=1) }}") == 1
def test_sqrt(hass):
@ -414,6 +468,13 @@ def test_sqrt(hass):
template.Template("{{ %s | sqrt | round(3) }}" % value, hass).async_render()
== expected
)
assert render(hass, f"{{{{ sqrt({value}) | round(3) }}}}") == expected
# Test handling of default return value
assert render(hass, "{{ 'no_number' | sqrt(1) }}") == 1
assert render(hass, "{{ 'no_number' | sqrt(default=1) }}") == 1
assert render(hass, "{{ sqrt('no_number', 1) }}") == 1
assert render(hass, "{{ sqrt('no_number', default=1) }}") == 1
def test_arc_sine(hass):
@ -434,6 +495,13 @@ def test_arc_sine(hass):
template.Template("{{ %s | asin | round(3) }}" % value, hass).async_render()
== expected
)
assert render(hass, f"{{{{ asin({value}) | round(3) }}}}") == expected
# Test handling of default return value
assert render(hass, "{{ 'no_number' | asin(1) }}") == 1
assert render(hass, "{{ 'no_number' | asin(default=1) }}") == 1
assert render(hass, "{{ asin('no_number', 1) }}") == 1
assert render(hass, "{{ asin('no_number', default=1) }}") == 1
def test_arc_cos(hass):
@ -454,6 +522,13 @@ def test_arc_cos(hass):
template.Template("{{ %s | acos | round(3) }}" % value, hass).async_render()
== expected
)
assert render(hass, f"{{{{ acos({value}) | round(3) }}}}") == expected
# Test handling of default return value
assert render(hass, "{{ 'no_number' | acos(1) }}") == 1
assert render(hass, "{{ 'no_number' | acos(default=1) }}") == 1
assert render(hass, "{{ acos('no_number', 1) }}") == 1
assert render(hass, "{{ acos('no_number', default=1) }}") == 1
def test_arc_tan(hass):
@ -476,6 +551,13 @@ def test_arc_tan(hass):
template.Template("{{ %s | atan | round(3) }}" % value, hass).async_render()
== expected
)
assert render(hass, f"{{{{ atan({value}) | round(3) }}}}") == expected
# Test handling of default return value
assert render(hass, "{{ 'no_number' | atan(1) }}") == 1
assert render(hass, "{{ 'no_number' | atan(default=1) }}") == 1
assert render(hass, "{{ atan('no_number', 1) }}") == 1
assert render(hass, "{{ atan('no_number', default=1) }}") == 1
def test_arc_tan2(hass):
@ -510,6 +592,12 @@ def test_arc_tan2(hass):
== expected
)
# Test handling of default return value
assert render(hass, "{{ ('duck', 'goose') | atan2(1) }}") == 1
assert render(hass, "{{ ('duck', 'goose') | atan2(default=1) }}") == 1
assert render(hass, "{{ atan2('duck', 'goose', 1) }}") == 1
assert render(hass, "{{ atan2('duck', 'goose', default=1) }}") == 1
def test_strptime(hass):
"""Test the parse timestamp method."""
@ -532,6 +620,10 @@ def test_strptime(hass):
assert template.Template(temp, hass).async_render() == expected
# Test handling of default return value
assert render(hass, "{{ strptime('invalid', '%Y', 1) }}") == 1
assert render(hass, "{{ strptime('invalid', '%Y', default=1) }}") == 1
def test_timestamp_custom(hass):
"""Test the timestamps to custom filter."""
@ -554,6 +646,10 @@ def test_timestamp_custom(hass):
assert template.Template(f"{{{{ {inp} | {fil} }}}}", hass).async_render() == out
# Test handling of default return value
assert render(hass, "{{ None | timestamp_custom('invalid', True, 1) }}") == 1
assert render(hass, "{{ None | timestamp_custom(default=1) }}") == 1
def test_timestamp_local(hass):
"""Test the timestamps to local filter."""
@ -565,6 +661,10 @@ def test_timestamp_local(hass):
== out
)
# Test handling of default return value
assert render(hass, "{{ None | timestamp_local(1) }}") == 1
assert render(hass, "{{ None | timestamp_local(default=1) }}") == 1
@pytest.mark.parametrize(
"input",
@ -702,6 +802,10 @@ def test_timestamp_utc(hass):
== out
)
# Test handling of default return value
assert render(hass, "{{ None | timestamp_utc(1) }}") == 1
assert render(hass, "{{ None | timestamp_utc(default=1) }}") == 1
def test_as_timestamp(hass):
"""Test the as_timestamp function."""
@ -720,6 +824,12 @@ def test_as_timestamp(hass):
)
assert template.Template(tpl, hass).async_render() == 1706951424.0
# Test handling of default return value
assert render(hass, "{{ 'invalid' | as_timestamp(1) }}") == 1
assert render(hass, "{{ 'invalid' | as_timestamp(default=1) }}") == 1
assert render(hass, "{{ as_timestamp('invalid', 1) }}") == 1
assert render(hass, "{{ as_timestamp('invalid', default=1) }}") == 1
@patch.object(random, "choice")
def test_random_every_time(test_choice, hass):