From 5b9d01139d016ef0b19f63bd44b40b7b0b801d99 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Wed, 1 May 2019 10:54:25 +0800 Subject: [PATCH] render_with_collect method for template (#23283) * Make entity_filter be a modifiable builder * Add render_with_collect method * Use sync render_with_collect and non-class based test case * Refactor: Template renders to RenderInfo * Freeze with exception too * Finish merging test changes * Removed unused sync interface * Final bits of the diff --- homeassistant/helpers/config_validation.py | 3 +- homeassistant/helpers/template.py | 203 ++++++++++-- tests/helpers/test_template.py | 358 +++++++++++++++++---- 3 files changed, 474 insertions(+), 90 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 1f139704e5f..9282770de1a 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -23,7 +23,6 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, WEEKDAYS, __version__) from homeassistant.core import valid_entity_id, split_entity_id from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template as template_helper from homeassistant.helpers.logging import KeywordStyleAdapter from homeassistant.util import slugify as util_slugify @@ -445,6 +444,8 @@ unit_system = vol.All(vol.Lower, vol.Any(CONF_UNIT_SYSTEM_METRIC, def template(value): """Validate a jinja2 template.""" + from homeassistant.helpers import template as template_helper + if value is None: raise vol.Invalid('template value is None') if isinstance(value, (list, dict, template_helper.Template)): diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 24275c87061..203e460aaa5 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1,21 +1,21 @@ """Template helper methods for rendering strings with Home Assistant data.""" -from datetime import datetime +import base64 import json import logging import math import random -import base64 import re +from datetime import datetime import jinja2 from jinja2 import contextfilter from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace -from homeassistant.const import ( - ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_UNIT_OF_MEASUREMENT, MATCH_ALL, - STATE_UNKNOWN) -from homeassistant.core import State, valid_entity_id +from homeassistant.const import (ATTR_LATITUDE, ATTR_LONGITUDE, MATCH_ALL, + ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN) +from homeassistant.core import ( + State, callback, valid_entity_id, split_entity_id) from homeassistant.exceptions import TemplateError from homeassistant.helpers import location as loc_helper from homeassistant.helpers.typing import TemplateVarsType @@ -29,6 +29,8 @@ _LOGGER = logging.getLogger(__name__) _SENTINEL = object() DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" +_RENDER_INFO = 'template.render_info' + _RE_NONE_ENTITIES = re.compile(r"distance\(|closest\(", re.I | re.M) _RE_GET_ENTITIES = re.compile( r"(?:(?:states\.|(?:is_state|is_state_attr|state_attr|states)" @@ -89,6 +91,54 @@ def extract_entities(template, variables=None): return MATCH_ALL +def _true(arg) -> bool: + return True + + +class RenderInfo: + """Holds information about a template render.""" + + def __init__(self, template): + """Initialise.""" + self.template = template + # Will be set sensibly once frozen. + self.filter_lifecycle = _true + self._result = None + self._exception = None + self._all_states = False + self._domains = [] + self._entities = [] + + def filter(self, entity_id: str) -> bool: + """Template should re-render if the state changes.""" + return entity_id in self._entities + + def _filter_lifecycle(self, entity_id: str) -> bool: + """Template should re-render if the state changes.""" + return ( + split_entity_id(entity_id)[0] in self._domains + or entity_id in self._entities) + + @property + def result(self) -> str: + """Results of the template computation.""" + if self._exception is not None: + raise self._exception # pylint: disable=raising-bad-type + return self._result + + def _freeze(self) -> None: + self._entities = frozenset(self._entities) + if self._all_states: + # Leave lifecycle_filter as True + del self._domains + elif not self._domains: + del self._domains + self.filter_lifecycle = self.filter + else: + self._domains = frozenset(self._domains) + self.filter_lifecycle = self._filter_lifecycle + + class Template: """Class to hold a template and manage caching and rendering.""" @@ -124,6 +174,7 @@ class Template: return run_callback_threadsafe( self.hass.loop, self.async_render, kwargs).result() + @callback def async_render(self, variables: TemplateVarsType = None, **kwargs) -> str: """Render given template. @@ -141,6 +192,23 @@ class Template: except jinja2.TemplateError as err: raise TemplateError(err) + @callback + def async_render_to_info( + self, variables: TemplateVarsType = None, + **kwargs) -> RenderInfo: + """Render the template and collect an entity filter.""" + assert self.hass and _RENDER_INFO not in self.hass.data + render_info = self.hass.data[_RENDER_INFO] = RenderInfo(self) + # pylint: disable=protected-access + try: + render_info._result = self.async_render(variables, **kwargs) + except TemplateError as ex: + render_info._exception = ex + finally: + del self.hass.data[_RENDER_INFO] + render_info._freeze() + return render_info + def render_with_possible_json_value(self, value, error_value=_SENTINEL): """Render template with value exposed. @@ -150,6 +218,7 @@ class Template: self.hass.loop, self.async_render_with_possible_json_value, value, error_value).result() + @callback def async_render_with_possible_json_value(self, value, error_value=_SENTINEL, variables=None): @@ -190,7 +259,7 @@ class Template: global_vars = ENV.make_globals({ 'closest': template_methods.closest, 'distance': template_methods.distance, - 'is_state': self.hass.states.is_state, + 'is_state': template_methods.is_state, 'is_state_attr': template_methods.is_state_attr, 'state_attr': template_methods.state_attr, 'states': AllStates(self.hass), @@ -207,6 +276,14 @@ class Template: self.template == other.template and self.hass == other.hass) + def __hash__(self): + """Hash code for template.""" + return hash(self.template) + + def __repr__(self): + """Representation of Template.""" + return 'Template(\"' + self.template + '\")' + class AllStates: """Class to expose all HA states as attributes.""" @@ -217,24 +294,42 @@ class AllStates: def __getattr__(self, name): """Return the domain state.""" + if '.' in name: + if not valid_entity_id(name): + raise TemplateError("Invalid entity ID '{}'".format(name)) + return _get_state(self._hass, name) + if not valid_entity_id(name + '.entity'): + raise TemplateError("Invalid domain name '{}'".format(name)) return DomainStates(self._hass, name) + def _collect_all(self): + render_info = self._hass.data.get(_RENDER_INFO) + if render_info is not None: + # pylint: disable=protected-access + render_info._all_states = True + def __iter__(self): """Return all states.""" + self._collect_all() return iter( - _wrap_state(state) for state in + _wrap_state(self._hass, state) for state in sorted(self._hass.states.async_all(), key=lambda state: state.entity_id)) def __len__(self): """Return number of states.""" + self._collect_all() return len(self._hass.states.async_entity_ids()) def __call__(self, entity_id): """Return the states.""" - state = self._hass.states.get(entity_id) + state = _get_state(self._hass, entity_id) return STATE_UNKNOWN if state is None else state.state + def __repr__(self): + """Representation of All States.""" + return '