diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 33a33703668..301f106edcc 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -290,6 +290,7 @@ def handle_ping(hass, connection, msg): vol.Optional("entity_ids"): cv.entity_ids, vol.Optional("variables"): dict, vol.Optional("timeout"): vol.Coerce(float), + vol.Optional("strict", default=False): bool, } ) @decorators.async_response @@ -303,7 +304,9 @@ async def handle_render_template(hass, connection, msg): if timeout: try: - timed_out = await template_obj.async_render_will_timeout(timeout) + timed_out = await template_obj.async_render_will_timeout( + timeout, strict=msg["strict"] + ) except TemplateError as ex: connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex)) return @@ -337,6 +340,7 @@ async def handle_render_template(hass, connection, msg): [TrackTemplate(template_obj, variables)], _template_listener, raise_on_template_error=True, + strict=msg["strict"], ) except TemplateError as ex: connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex)) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 2a3ee75ce75..d52ebdb551f 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -790,12 +790,14 @@ class _TrackTemplateResultInfo: self._track_state_changes: _TrackStateChangeFiltered | None = None self._time_listeners: dict[Template, Callable] = {} - def async_setup(self, raise_on_template_error: bool) -> None: + def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> None: """Activation of template tracking.""" for track_template_ in self._track_templates: template = track_template_.template variables = track_template_.variables - self._info[template] = info = template.async_render_to_info(variables) + self._info[template] = info = template.async_render_to_info( + variables, strict=strict + ) if info.exception: if raise_on_template_error: @@ -1022,6 +1024,7 @@ def async_track_template_result( track_templates: Iterable[TrackTemplate], action: TrackTemplateResultListener, raise_on_template_error: bool = False, + strict: bool = False, ) -> _TrackTemplateResultInfo: """Add a listener that fires when the result of a template changes. @@ -1050,6 +1053,8 @@ def async_track_template_result( processing the template during setup, the system will raise the exception instead of setting up tracking. + strict + When set to True, raise on undefined variables. Returns ------- @@ -1057,7 +1062,7 @@ def async_track_template_result( """ tracker = _TrackTemplateResultInfo(hass, track_templates, action) - tracker.async_setup(raise_on_template_error) + tracker.async_setup(raise_on_template_error, strict=strict) return tracker diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 9580da82d65..ea338e22b84 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -15,6 +15,7 @@ import math from operator import attrgetter import random import re +import sys from typing import Any, Generator, Iterable, cast from urllib.parse import urlencode as urllib_urlencode import weakref @@ -57,6 +58,7 @@ DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" _RENDER_INFO = "template.render_info" _ENVIRONMENT = "template.environment" _ENVIRONMENT_LIMITED = "template.environment_limited" +_ENVIRONMENT_STRICT = "template.environment_strict" _RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{|\{#") # Match "simple" ints and floats. -1.0, 1, +5, 5.0 @@ -292,7 +294,9 @@ class Template: "is_static", "_compiled_code", "_compiled", + "_exc_info", "_limited", + "_strict", ) def __init__(self, template, hass=None): @@ -305,16 +309,23 @@ class Template: self._compiled: jinja2.Template | None = None self.hass = hass self.is_static = not is_template_string(template) + self._exc_info = None self._limited = None + self._strict = None @property def _env(self) -> TemplateEnvironment: if self.hass is None: return _NO_HASS_ENV - wanted_env = _ENVIRONMENT_LIMITED if self._limited else _ENVIRONMENT + if self._limited: + wanted_env = _ENVIRONMENT_LIMITED + elif self._strict: + wanted_env = _ENVIRONMENT_STRICT + else: + wanted_env = _ENVIRONMENT ret: TemplateEnvironment | None = self.hass.data.get(wanted_env) if ret is None: - ret = self.hass.data[wanted_env] = TemplateEnvironment(self.hass, self._limited) # type: ignore[no-untyped-call] + ret = self.hass.data[wanted_env] = TemplateEnvironment(self.hass, self._limited, self._strict) # type: ignore[no-untyped-call] return ret def ensure_valid(self) -> None: @@ -354,6 +365,7 @@ class Template: variables: TemplateVarsType = None, parse_result: bool = True, limited: bool = False, + strict: bool = False, **kwargs: Any, ) -> Any: """Render given template. @@ -367,7 +379,7 @@ class Template: return self.template return self._parse_result(self.template) - compiled = self._compiled or self._ensure_compiled(limited) + compiled = self._compiled or self._ensure_compiled(limited, strict) if variables is not None: kwargs.update(variables) @@ -418,7 +430,11 @@ class Template: return render_result async def async_render_will_timeout( - self, timeout: float, variables: TemplateVarsType = None, **kwargs: Any + self, + timeout: float, + variables: TemplateVarsType = None, + strict: bool = False, + **kwargs: Any, ) -> bool: """Check to see if rendering a template will timeout during render. @@ -436,11 +452,12 @@ class Template: if self.is_static: return False - compiled = self._compiled or self._ensure_compiled() + compiled = self._compiled or self._ensure_compiled(strict=strict) if variables is not None: kwargs.update(variables) + self._exc_info = None finish_event = asyncio.Event() def _render_template() -> None: @@ -448,6 +465,8 @@ class Template: _render_with_context(self.template, compiled, **kwargs) except TimeoutError: pass + except Exception: # pylint: disable=broad-except + self._exc_info = sys.exc_info() finally: run_callback_threadsafe(self.hass.loop, finish_event.set) @@ -455,6 +474,8 @@ class Template: template_render_thread = ThreadWithException(target=_render_template) template_render_thread.start() await asyncio.wait_for(finish_event.wait(), timeout=timeout) + if self._exc_info: + raise TemplateError(self._exc_info[1].with_traceback(self._exc_info[2])) except asyncio.TimeoutError: template_render_thread.raise_exc(TimeoutError) return True @@ -465,7 +486,7 @@ class Template: @callback def async_render_to_info( - self, variables: TemplateVarsType = None, **kwargs: Any + self, variables: TemplateVarsType = None, strict: bool = False, **kwargs: Any ) -> RenderInfo: """Render the template and collect an entity filter.""" assert self.hass and _RENDER_INFO not in self.hass.data @@ -480,7 +501,7 @@ class Template: self.hass.data[_RENDER_INFO] = render_info try: - render_info._result = self.async_render(variables, **kwargs) + render_info._result = self.async_render(variables, strict=strict, **kwargs) except TemplateError as ex: render_info.exception = ex finally: @@ -540,7 +561,9 @@ class Template: ) return value if error_value is _SENTINEL else error_value - def _ensure_compiled(self, limited: bool = False) -> jinja2.Template: + def _ensure_compiled( + self, limited: bool = False, strict: bool = False + ) -> jinja2.Template: """Bind a template to a specific hass instance.""" self.ensure_valid() @@ -548,8 +571,13 @@ class Template: assert ( self._limited is None or self._limited == limited ), "can't change between limited and non limited template" + assert ( + self._strict is None or self._strict == strict + ), "can't change between strict and non strict template" + assert not (strict and limited), "can't combine strict and limited template" self._limited = limited + self._strict = strict env = self._env self._compiled = cast( @@ -1369,9 +1397,13 @@ class LoggingUndefined(jinja2.Undefined): class TemplateEnvironment(ImmutableSandboxedEnvironment): """The Home Assistant template environment.""" - def __init__(self, hass, limited=False): + def __init__(self, hass, limited=False, strict=False): """Initialise template environment.""" - super().__init__(undefined=LoggingUndefined) + if not strict: + undefined = LoggingUndefined + else: + undefined = jinja2.StrictUndefined + super().__init__(undefined=undefined) self.hass = hass self.template_cache = weakref.WeakValueDictionary() self.filters["round"] = forgiving_round diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 3b01e6ecd8a..67abb7b2b53 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -697,10 +697,19 @@ async def test_render_template_manual_entity_ids_no_longer_needed( } -async def test_render_template_with_error(hass, websocket_client, caplog): +@pytest.mark.parametrize( + "template", + [ + "{{ my_unknown_func() + 1 }}", + "{{ my_unknown_var }}", + "{{ my_unknown_var + 1 }}", + "{{ now() | unknown_filter }}", + ], +) +async def test_render_template_with_error(hass, websocket_client, caplog, template): """Test a template with an error.""" await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": "{{ my_unknown_var() + 1 }}"} + {"id": 5, "type": "render_template", "template": template, "strict": True} ) msg = await websocket_client.receive_json() @@ -709,17 +718,30 @@ async def test_render_template_with_error(hass, websocket_client, caplog): assert not msg["success"] assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + assert "Template variable error" not in caplog.text assert "TemplateError" not in caplog.text -async def test_render_template_with_timeout_and_error(hass, websocket_client, caplog): +@pytest.mark.parametrize( + "template", + [ + "{{ my_unknown_func() + 1 }}", + "{{ my_unknown_var }}", + "{{ my_unknown_var + 1 }}", + "{{ now() | unknown_filter }}", + ], +) +async def test_render_template_with_timeout_and_error( + hass, websocket_client, caplog, template +): """Test a template with an error with a timeout.""" await websocket_client.send_json( { "id": 5, "type": "render_template", - "template": "{{ now() | rando }}", + "template": template, "timeout": 5, + "strict": True, } ) @@ -729,6 +751,7 @@ async def test_render_template_with_timeout_and_error(hass, websocket_client, ca assert not msg["success"] assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + assert "Template variable error" not in caplog.text assert "TemplateError" not in caplog.text diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index a8924f513c6..06b313218ca 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -2267,9 +2267,6 @@ async def test_template_timeout(hass): tmp = template.Template("{{ states | count }}", hass) assert await tmp.async_render_will_timeout(3) is False - tmp2 = template.Template("{{ error_invalid + 1 }}", hass) - assert await tmp2.async_render_will_timeout(3) is False - tmp3 = template.Template("static", hass) assert await tmp3.async_render_will_timeout(3) is False @@ -2287,6 +2284,13 @@ async def test_template_timeout(hass): assert await tmp5.async_render_will_timeout(0.000001) is True +async def test_template_timeout_raise(hass): + """Test we can raise from.""" + tmp2 = template.Template("{{ error_invalid + 1 }}", hass) + with pytest.raises(TemplateError): + assert await tmp2.async_render_will_timeout(3) is False + + async def test_lights(hass): """Test we can sort lights."""