Skip to content

Commit

Permalink
Add native Python types support to templates (#41227)
Browse files Browse the repository at this point in the history
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
  • Loading branch information
frenck and balloob committed Oct 6, 2020
1 parent cbb4324 commit ee91436
Show file tree
Hide file tree
Showing 29 changed files with 349 additions and 282 deletions.
2 changes: 1 addition & 1 deletion homeassistant/components/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ async def post(self, request):
try:
data = await request.json()
tpl = template.Template(data["template"], request.app["hass"])
return tpl.async_render(data.get("variables"))
return str(tpl.async_render(data.get("variables")))
except (ValueError, TemplateError) as ex:
return self.json_message(
f"Error rendering template: {ex}", HTTP_BAD_REQUEST
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/history_stats/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ def update_period(self):
except (TemplateError, TypeError) as ex:
HistoryStatsHelper.handle_template_exception(ex, "start")
return
start = dt_util.parse_datetime(start_rendered)
start = dt_util.parse_datetime(str(start_rendered))
if start is None:
try:
start = dt_util.as_local(
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/template/cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ def _update_state(self, result):
self._position = None
return

state = result.lower()
state = str(result).lower()
if state in _VALID_STATES:
if state in ("true", STATE_OPEN):
self._position = 100
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/template/fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ async def async_added_to_hass(self):
@callback
def _update_speed(self, speed):
# Validate speed
speed = str(speed)
if speed in self._speed_list:
self._speed = speed
elif speed in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
Expand Down
19 changes: 12 additions & 7 deletions homeassistant/components/template/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ def _update_state(self, result):
self._available = True
return

state = result.lower()
state = str(result).lower()
if state in _VALID_STATES:
self._state = state in ("true", STATE_ON)
else:
Expand Down Expand Up @@ -451,12 +451,17 @@ def _update_temperature(self, render):
@callback
def _update_color(self, render):
"""Update the hs_color from the template."""
if render in ("None", ""):
self._color = None
return
h_str, s_str = map(
float, render.replace("(", "").replace(")", "").split(",", 1)
)
h_str = s_str = None
if isinstance(render, str):
if render in ("None", ""):
self._color = None
return
h_str, s_str = map(
float, render.replace("(", "").replace(")", "").split(",", 1)
)
elif isinstance(render, (list, tuple)) and len(render) == 2:
h_str, s_str = render

if (
h_str is not None
and s_str is not None
Expand Down
11 changes: 10 additions & 1 deletion homeassistant/components/template/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,16 @@ def _update_state(self, result):
if isinstance(result, TemplateError):
self._state = None
return
self._state = result.lower() in ("true", STATE_ON, STATE_LOCKED)

if isinstance(result, bool):
self._state = result
return

if isinstance(result, str):
self._state = result.lower() in ("true", STATE_ON, STATE_LOCKED)
return

self._state = False

async def async_added_to_hass(self):
"""Register callbacks."""
Expand Down
11 changes: 10 additions & 1 deletion homeassistant/components/template/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,16 @@ def _update_state(self, result):
if isinstance(result, TemplateError):
self._state = None
return
self._state = result.lower() in ("true", STATE_ON)

if isinstance(result, bool):
self._state = result
return

if isinstance(result, str):
self._state = result.lower() in ("true", STATE_ON)
return

self._state = False

async def async_added_to_hass(self):
"""Register callbacks."""
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
CONF_ID,
CONF_INTERNAL_URL,
CONF_LATITUDE,
CONF_LEGACY_TEMPLATES,
CONF_LONGITUDE,
CONF_MEDIA_DIRS,
CONF_NAME,
Expand Down Expand Up @@ -224,6 +225,7 @@ def _no_duplicate_auth_mfa_module(
),
# pylint: disable=no-value-for-parameter
vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()),
vol.Optional(CONF_LEGACY_TEMPLATES): cv.boolean,
}
)

Expand Down Expand Up @@ -500,6 +502,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non
(CONF_INTERNAL_URL, "internal_url"),
(CONF_EXTERNAL_URL, "external_url"),
(CONF_MEDIA_DIRS, "media_dirs"),
(CONF_LEGACY_TEMPLATES, "legacy_templates"),
):
if key in config:
setattr(hac, attr, config[key])
Expand Down
1 change: 1 addition & 0 deletions homeassistant/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
CONF_INTERNAL_URL = "internal_url"
CONF_IP_ADDRESS = "ip_address"
CONF_LATITUDE = "latitude"
CONF_LEGACY_TEMPLATES = "legacy_templates"
CONF_LIGHTS = "lights"
CONF_LONGITUDE = "longitude"
CONF_MAC = "mac"
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1427,6 +1427,9 @@ def __init__(self, hass: HomeAssistant) -> None:
# If Home Assistant is running in safe mode
self.safe_mode: bool = False

# Use legacy template behavior
self.legacy_templates: bool = False

def distance(self, lat: float, lon: float) -> Optional[float]:
"""Calculate distance from Home Assistant.
Expand Down
5 changes: 4 additions & 1 deletion homeassistant/helpers/condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,10 @@ def async_template(
_LOGGER.error("Error during template condition: %s", ex)
return False

return value.lower() == "true"
if isinstance(value, bool):
return value

return str(value).lower() == "true"


def async_template_from_config(
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/helpers/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ class TrackTemplateResult:
"""

template: Template
last_result: Union[str, None, TemplateError]
result: Union[str, TemplateError]
last_result: Any
result: Any


def threaded_listener_factory(async_factory: Callable[..., Any]) -> CALLBACK_TYPE:
Expand Down
24 changes: 21 additions & 3 deletions homeassistant/helpers/template.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Template helper methods for rendering strings with Home Assistant data."""
from ast import literal_eval
import asyncio
import base64
import collections.abc
Expand Down Expand Up @@ -302,7 +303,7 @@ def extract_entities(

return extract_entities(self.hass, self.template, variables)

def render(self, variables: TemplateVarsType = None, **kwargs: Any) -> str:
def render(self, variables: TemplateVarsType = None, **kwargs: Any) -> Any:
"""Render given template."""
if self.is_static:
return self.template.strip()
Expand All @@ -315,7 +316,7 @@ def render(self, variables: TemplateVarsType = None, **kwargs: Any) -> str:
).result()

@callback
def async_render(self, variables: TemplateVarsType = None, **kwargs: Any) -> str:
def async_render(self, variables: TemplateVarsType = None, **kwargs: Any) -> Any:
"""Render given template.
This method must be run in the event loop.
Expand All @@ -329,10 +330,27 @@ def async_render(self, variables: TemplateVarsType = None, **kwargs: Any) -> str
kwargs.update(variables)

try:
return compiled.render(kwargs).strip()
render_result = compiled.render(kwargs)
except jinja2.TemplateError as err:
raise TemplateError(err) from err

render_result = render_result.strip()

if not self.hass.config.legacy_templates:
try:
result = literal_eval(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

async def async_render_will_timeout(
self, timeout: float, variables: TemplateVarsType = None, **kwargs: Any
) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion tests/components/demo/test_notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def test_sending_templated_message(self):
self.hass.block_till_done()
last_event = self.events[-1]
assert last_event.data[notify.ATTR_TITLE] == "temperature"
assert last_event.data[notify.ATTR_MESSAGE] == "10"
assert last_event.data[notify.ATTR_MESSAGE] == 10

def test_method_forwards_correct_data(self):
"""Test that all data from the service gets forwarded to service."""
Expand Down
8 changes: 4 additions & 4 deletions tests/components/mqtt/test_alarm_control_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"name": "test",
"state_topic": "alarm/state",
"command_topic": "alarm/command",
"code": "1234",
"code": "0123",
"code_arm_required": True,
}
}
Expand Down Expand Up @@ -396,7 +396,7 @@ async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock):
When command_template set to output json
"""
config = copy.deepcopy(DEFAULT_CONFIG_CODE)
config[alarm_control_panel.DOMAIN]["code"] = "1234"
config[alarm_control_panel.DOMAIN]["code"] = "0123"
config[alarm_control_panel.DOMAIN]["command_template"] = (
'{"action":"{{ action }}",' '"code":"{{ code }}"}'
)
Expand All @@ -407,9 +407,9 @@ async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock):
)
await hass.async_block_till_done()

await common.async_alarm_disarm(hass, 1234)
await common.async_alarm_disarm(hass, "0123")
mqtt_mock.async_publish.assert_called_once_with(
"alarm/command", '{"action":"DISARM","code":"1234"}', 0, False
"alarm/command", {"action": "DISARM", "code": "0123"}, 0, False
)


Expand Down
4 changes: 1 addition & 3 deletions tests/components/mqtt/test_cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,9 +702,7 @@ async def test_set_position_templated(hass, mqtt_mock):
blocking=True,
)

mqtt_mock.async_publish.assert_called_once_with(
"set-position-topic", "38", 0, False
)
mqtt_mock.async_publish.assert_called_once_with("set-position-topic", 38, 0, False)


async def test_set_position_untemplated(hass, mqtt_mock):
Expand Down
2 changes: 1 addition & 1 deletion tests/components/mqtt/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ async def test_service_call_with_template_payload_renders_template(hass, mqtt_mo
mqtt.async_publish_template(hass, "test/topic", "{{ 1+1 }}")
await hass.async_block_till_done()
assert mqtt_mock.async_publish.called
assert mqtt_mock.async_publish.call_args[0][1] == "2"
assert mqtt_mock.async_publish.call_args[0][1] == 2


async def test_service_call_with_payload_doesnt_render_template(hass, mqtt_mock):
Expand Down
2 changes: 1 addition & 1 deletion tests/components/mqtt/test_light.py
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,7 @@ async def test_sending_mqtt_color_temp_command_with_template(hass, mqtt_mock):
mqtt_mock.async_publish.assert_has_calls(
[
call("test_light_color_temp/set", "on", 0, False),
call("test_light_color_temp/color_temp/set", "10", 0, False),
call("test_light_color_temp/color_temp/set", 10, 0, False),
],
any_order=True,
)
Expand Down
2 changes: 1 addition & 1 deletion tests/components/script/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,4 +705,4 @@ async def test_script_variables(hass, caplog):
await hass.services.async_call("script", "script3", {"break": 0}, blocking=True)

assert len(mock_calls) == 4
assert mock_calls[3].data["value"] == "1"
assert mock_calls[3].data["value"] == 1
2 changes: 1 addition & 1 deletion tests/components/snips/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ async def test_intent_special_slots(hass, mqtt_mock):
assert len(calls) == 1
assert calls[0].domain == "light"
assert calls[0].service == "turn_on"
assert calls[0].data["confidenceScore"] == "0.85"
assert calls[0].data["confidenceScore"] == 0.85
assert calls[0].data["site_id"] == "default"


Expand Down
2 changes: 1 addition & 1 deletion tests/components/template/test_fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,7 @@ def _verify(
"""Verify fan's state, speed and osc."""
state = hass.states.get(_TEST_FAN)
attributes = state.attributes
assert state.state == expected_state
assert state.state == str(expected_state)
assert attributes.get(ATTR_SPEED) == expected_speed
assert attributes.get(ATTR_OSCILLATING) == expected_oscillating
assert attributes.get(ATTR_DIRECTION) == expected_direction
Expand Down
22 changes: 11 additions & 11 deletions tests/components/template/test_light.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ def test_white_value_action_no_template(self):
)
self.hass.block_till_done()
assert len(self.calls) == 1
assert self.calls[0].data["white_value"] == "124"
assert self.calls[0].data["white_value"] == 124

state = self.hass.states.get("light.test_template_light")
assert state is not None
Expand Down Expand Up @@ -649,7 +649,7 @@ def test_level_action_no_template(self):
common.turn_on(self.hass, "light.test_template_light", **{ATTR_BRIGHTNESS: 124})
self.hass.block_till_done()
assert len(self.calls) == 1
assert self.calls[0].data["brightness"] == "124"
assert self.calls[0].data["brightness"] == 124

state = self.hass.states.get("light.test_template_light")
_LOGGER.info(str(state.attributes))
Expand Down Expand Up @@ -802,7 +802,7 @@ def test_temperature_action_no_template(self):
common.turn_on(self.hass, "light.test_template_light", **{ATTR_COLOR_TEMP: 345})
self.hass.block_till_done()
assert len(self.calls) == 1
assert self.calls[0].data["color_temp"] == "345"
assert self.calls[0].data["color_temp"] == 345

state = self.hass.states.get("light.test_template_light")
_LOGGER.info(str(state.attributes))
Expand Down Expand Up @@ -1008,18 +1008,18 @@ def test_color_action_no_template(self):
)
self.hass.block_till_done()
assert len(self.calls) == 2
assert self.calls[0].data["h"] == "40"
assert self.calls[0].data["s"] == "50"
assert self.calls[1].data["h"] == "40"
assert self.calls[1].data["s"] == "50"
assert self.calls[0].data["h"] == 40
assert self.calls[0].data["s"] == 50
assert self.calls[1].data["h"] == 40
assert self.calls[1].data["s"] == 50

state = self.hass.states.get("light.test_template_light")
_LOGGER.info(str(state.attributes))
assert state is not None
assert self.calls[0].data["h"] == "40"
assert self.calls[0].data["s"] == "50"
assert self.calls[1].data["h"] == "40"
assert self.calls[1].data["s"] == "50"
assert self.calls[0].data["h"] == 40
assert self.calls[0].data["s"] == 50
assert self.calls[1].data["h"] == 40
assert self.calls[1].data["s"] == 50

@pytest.mark.parametrize(
"expected_hs,template",
Expand Down
2 changes: 1 addition & 1 deletion tests/components/template/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,7 @@ async def test_self_referencing_entity_picture_loop(hass, caplog):

state = hass.states.get("sensor.test")
assert int(state.state) == 1
assert state.attributes[ATTR_ENTITY_PICTURE] == "2"
assert state.attributes[ATTR_ENTITY_PICTURE] == 2

await hass.async_block_till_done()
assert int(state.state) == 1
Expand Down

0 comments on commit ee91436

Please sign in to comment.