From 1fb18580b2573f7997e786027502bce93d815deb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Oct 2020 11:30:58 +0100 Subject: [PATCH] Store original result on template results (#42391) * Store original result on template results * Fix shell command test --- homeassistant/helpers/config_validation.py | 6 +- homeassistant/helpers/template.py | 80 +++++++++++++++++---- tests/components/shell_command/test_init.py | 2 +- tests/helpers/test_config_validation.py | 17 ++++- tests/helpers/test_template.py | 22 ++++++ 5 files changed, 108 insertions(+), 19 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e41e26e5e0f..190cee5e050 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -486,7 +486,11 @@ def string(value: Any) -> str: """Coerce value to string, except for None.""" if value is None: raise vol.Invalid("string value is None") - if isinstance(value, (list, dict)): + + if isinstance(value, template_helper.ResultWrapper): + value = value.render_result + + elif isinstance(value, (list, dict)): raise vol.Invalid("value should be a string") return str(value) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8920060d8e2..9ffc6593955 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -11,7 +11,7 @@ import math from operator import attrgetter import random import re -from typing import Any, Generator, Iterable, List, Optional, Union +from typing import Any, Dict, Generator, Iterable, List, Optional, Type, Union from urllib.parse import urlencode as urllib_urlencode import weakref @@ -124,6 +124,43 @@ def is_template_string(maybe_template: str) -> bool: return _RE_JINJA_DELIMITERS.search(maybe_template) is not None +class ResultWrapper: + """Result wrapper class to store render result.""" + + render_result: str + + +def gen_result_wrapper(kls): + """Generate a result wrapper.""" + + class Wrapper(kls, ResultWrapper): + """Wrapper of a kls that can store render_result.""" + + def __init__(self, value: kls, render_result: str) -> None: + super().__init__(value) + self.render_result = render_result + + return Wrapper + + +class TupleWrapper(tuple, ResultWrapper): + """Wrap a tuple.""" + + def __new__(cls, value: tuple, render_result: str) -> "TupleWrapper": + """Create a new tuple class.""" + return super().__new__(cls, tuple(value)) + + def __init__(self, value: tuple, render_result: str): + """Initialize a new tuple class.""" + self.render_result = render_result + + +RESULT_WRAPPERS: Dict[Type, Type] = { + kls: gen_result_wrapper(kls) for kls in (list, dict, set) +} +RESULT_WRAPPERS[tuple] = TupleWrapper + + def extract_entities( hass: HomeAssistantType, template: Optional[str], @@ -285,7 +322,7 @@ class Template: if not isinstance(template, str): raise TypeError("Expected template to be a string") - self.template: str = template + self.template: str = template.strip() self._compiled_code = None self._compiled = None self.hass = hass @@ -322,7 +359,9 @@ class Template: def render(self, variables: TemplateVarsType = None, **kwargs: Any) -> Any: """Render given template.""" if self.is_static: - return self.template.strip() + if self.hass.config.legacy_templates: + return self.template + return self._parse_result(self.template) if variables is not None: kwargs.update(variables) @@ -338,7 +377,9 @@ class Template: This method must be run in the event loop. """ if self.is_static: - return self.template.strip() + if self.hass.config.legacy_templates: + return self.template + return self._parse_result(self.template) compiled = self._compiled or self._ensure_compiled() @@ -352,18 +393,27 @@ class Template: render_result = render_result.strip() - if not self.hass.config.legacy_templates: - try: - result = literal_eval(render_result) + if self.hass.config.legacy_templates: + return render_result - # If the literal_eval result is a string, use the original - # render, by not returning right here. The evaluation of strings - # resulting in strings impacts quotes, to avoid unexpected - # output; use the original render instead of the evaluated one. - if not isinstance(result, str): - return result - except (ValueError, SyntaxError, MemoryError): - pass + return self._parse_result(render_result) + + def _parse_result(self, render_result: str) -> Any: + """Parse the result.""" + try: + result = literal_eval(render_result) + + if type(result) in RESULT_WRAPPERS: + result = RESULT_WRAPPERS[type(result)](result, render_result) + + # If the literal_eval result is a string, use the original + # render, by not returning right here. The evaluation of strings + # resulting in strings impacts quotes, to avoid unexpected + # output; use the original render instead of the evaluated one. + if not isinstance(result, str): + return result + except (ValueError, SyntaxError, MemoryError): + pass return render_result diff --git a/tests/components/shell_command/test_init.py b/tests/components/shell_command/test_init.py index f5ad37cc617..440375438c1 100644 --- a/tests/components/shell_command/test_init.py +++ b/tests/components/shell_command/test_init.py @@ -174,7 +174,7 @@ async def test_do_no_run_forever(hass, caplog): assert await async_setup_component( hass, shell_command.DOMAIN, - {shell_command.DOMAIN: {"test_service": "sleep 10000"}}, + {shell_command.DOMAIN: {"test_service": "sleep 10000s"}}, ) await hass.async_block_till_done() diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index d8956c143df..693785f4ea7 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -9,7 +9,7 @@ import pytest import voluptuous as vol import homeassistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, template from tests.async_mock import Mock, patch @@ -365,7 +365,7 @@ def test_slug(): schema(value) -def test_string(): +def test_string(hass): """Test string validation.""" schema = vol.Schema(cv.string) @@ -381,6 +381,19 @@ def test_string(): for value in (True, 1, "hello"): schema(value) + # Test template support + for text, native in ( + ("[1, 2]", [1, 2]), + ("{1, 2}", {1, 2}), + ("(1, 2)", (1, 2)), + ('{"hello": True}', {"hello": True}), + ): + tpl = template.Template(text, hass) + result = tpl.async_render() + assert isinstance(result, template.ResultWrapper) + assert result == native + assert schema(result) == text + def test_string_with_no_html(): """Test string with no html validation.""" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 33f0e3f7f04..86e17f8cb0b 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -2651,3 +2651,25 @@ async def test_legacy_templates(hass): template.Template("{{ states.sensor.temperature.state }}", hass).async_render() == "12" ) + + +async def test_is_static_still_ast_evals(hass): + """Test is_static still convers to native type.""" + tpl = template.Template("[1, 2]", hass) + assert tpl.is_static + assert tpl.async_render() == [1, 2] + + +async def test_result_wrappers(hass): + """Test result wrappers.""" + for text, native in ( + ("[1, 2]", [1, 2]), + ("{1, 2}", {1, 2}), + ("(1, 2)", (1, 2)), + ('{"hello": True}', {"hello": True}), + ): + tpl = template.Template(text, hass) + result = tpl.async_render() + assert isinstance(result, template.ResultWrapper) + assert result == native + assert result.render_result == text