From d97998e2e196b7abbbf197e294e8e5d5ccfcae66 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 23 Nov 2025 11:47:49 +0100 Subject: [PATCH] Extract date/time template functions into an datetime Jinja2 extension (#157042) --- homeassistant/helpers/template/__init__.py | 235 +----- .../helpers/template/extensions/__init__.py | 2 + .../helpers/template/extensions/datetime.py | 324 +++++++ .../template/extensions/test_datetime.py | 790 ++++++++++++++++++ tests/helpers/template/test_init.py | 775 ----------------- 5 files changed, 1121 insertions(+), 1005 deletions(-) create mode 100644 homeassistant/helpers/template/extensions/datetime.py create mode 100644 tests/helpers/template/extensions/test_datetime.py diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index a0fca2ed5cc..0e157bf8f9c 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -7,7 +7,7 @@ import asyncio import collections.abc from collections.abc import Callable, Generator, Iterable from copy import deepcopy -from datetime import date, datetime, time, timedelta +from datetime import datetime, timedelta from functools import cache, lru_cache, partial, wraps import json import logging @@ -63,7 +63,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.singleton import singleton from homeassistant.helpers.translation import async_translate_state from homeassistant.helpers.typing import TemplateVarsType -from homeassistant.util import convert, dt as dt_util, location as location_util +from homeassistant.util import convert, location as location_util from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -1417,22 +1417,6 @@ def has_value(hass: HomeAssistant, entity_id: str) -> bool: ) -def now(hass: HomeAssistant) -> datetime: - """Record fetching now.""" - if (render_info := render_info_cv.get()) is not None: - render_info.has_time = True - - return dt_util.now() - - -def utcnow(hass: HomeAssistant) -> datetime: - """Record fetching utcnow.""" - if (render_info := render_info_cv.get()) is not None: - render_info.has_time = True - - return dt_util.utcnow() - - def forgiving_round(value, precision=0, method="common", default=_SENTINEL): """Filter to round a value.""" try: @@ -1510,85 +1494,6 @@ def version(value): return AwesomeVersion(value) -def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True, default=_SENTINEL): - """Filter to convert given timestamp to format.""" - try: - result = dt_util.utc_from_timestamp(value) - - if local: - result = dt_util.as_local(result) - - return result.strftime(date_format) - except (ValueError, TypeError): - # If timestamp can't be converted - if default is _SENTINEL: - raise_no_default("timestamp_custom", value) - return default - - -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)).isoformat() - except (ValueError, TypeError): - # If timestamp can't be converted - if default is _SENTINEL: - raise_no_default("timestamp_local", value) - return default - - -def timestamp_utc(value, default=_SENTINEL): - """Filter to convert given timestamp to UTC date/time.""" - try: - return dt_util.utc_from_timestamp(value).isoformat() - except (ValueError, TypeError): - # If timestamp can't be converted - if default is _SENTINEL: - raise_no_default("timestamp_utc", value) - return default - - -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): - if default is _SENTINEL: - raise_no_default("as_timestamp", value) - return default - - -def as_datetime(value: Any, default: Any = _SENTINEL) -> Any: - """Filter and to convert a time string or UNIX timestamp to datetime object.""" - # Return datetime.datetime object without changes - if type(value) is datetime: - return value - # Add midnight to datetime.date object - if type(value) is date: - return datetime.combine(value, time(0, 0, 0)) - try: - # Check for a valid UNIX timestamp string, int or float - timestamp = float(value) - return dt_util.utc_from_timestamp(timestamp) - except (ValueError, TypeError): - # Try to parse datetime string to datetime object - try: - return dt_util.parse_datetime(value, raise_on_error=True) - except (ValueError, TypeError): - if default is _SENTINEL: - # Return None on string input - # to ensure backwards compatibility with HA Core 2024.1 and before. - if isinstance(value, str): - return None - raise_no_default("as_datetime", value) - return default - - -def as_timedelta(value: str) -> timedelta | None: - """Parse a ISO8601 duration like 'PT10M' to a timedelta.""" - return dt_util.parse_duration(value) - - def merge_response(value: ServiceResponse) -> list[Any]: """Merge action responses into single list. @@ -1646,16 +1551,6 @@ def merge_response(value: ServiceResponse) -> list[Any]: return response_items -def strptime(string, fmt, default=_SENTINEL): - """Parse a time string to datetime.""" - try: - return datetime.strptime(string, fmt) - except (ValueError, AttributeError, TypeError): - if default is _SENTINEL: - raise_no_default("strptime", string) - return default - - def fail_when_undefined(value): """Filter to force a failure when the value is undefined.""" if isinstance(value, jinja2.Undefined): @@ -1710,11 +1605,6 @@ def is_number(value): return True -def _is_datetime(value: Any) -> bool: - """Return whether a value is a datetime.""" - return isinstance(value, datetime) - - def _is_string_like(value: Any) -> bool: """Return whether a value is a string or string like object.""" return isinstance(value, (str, bytes, bytearray)) @@ -1819,94 +1709,6 @@ def random_every_time(context, values): return random.choice(values) -def today_at(hass: HomeAssistant, time_str: str = "") -> datetime: - """Record fetching now where the time has been replaced with value.""" - if (render_info := render_info_cv.get()) is not None: - render_info.has_time = True - - today = dt_util.start_of_local_day() - if not time_str: - return today - - if (time_today := dt_util.parse_time(time_str)) is None: - raise ValueError( - f"could not convert {type(time_str).__name__} to datetime: '{time_str}'" - ) - - return datetime.combine(today, time_today, today.tzinfo) - - -def relative_time(hass: HomeAssistant, value: Any) -> Any: - """Take a datetime and return its "age" as a string. - - The age can be in second, minute, hour, day, month or year. Only the - biggest unit is considered, e.g. if it's 2 days and 3 hours, "2 days" will - be returned. - If the input datetime is in the future, - the input datetime will be returned. - - If the input are not a datetime object the input will be returned unmodified. - - Note: This template function is deprecated in favor of `time_until`, but is still - supported so as not to break old templates. - """ - - if (render_info := render_info_cv.get()) is not None: - render_info.has_time = True - - if not isinstance(value, datetime): - return value - if not value.tzinfo: - value = dt_util.as_local(value) - if dt_util.now() < value: - return value - return dt_util.get_age(value) - - -def time_since(hass: HomeAssistant, value: Any | datetime, precision: int = 1) -> Any: - """Take a datetime and return its "age" as a string. - - The age can be in seconds, minutes, hours, days, months and year. - - precision is the number of units to return, with the last unit rounded. - - If the value not a datetime object the input will be returned unmodified. - """ - if (render_info := render_info_cv.get()) is not None: - render_info.has_time = True - - if not isinstance(value, datetime): - return value - if not value.tzinfo: - value = dt_util.as_local(value) - if dt_util.now() < value: - return value - - return dt_util.get_age(value, precision) - - -def time_until(hass: HomeAssistant, value: Any | datetime, precision: int = 1) -> Any: - """Take a datetime and return the amount of time until that time as a string. - - The time until can be in seconds, minutes, hours, days, months and years. - - precision is the number of units to return, with the last unit rounded. - - If the value not a datetime object the input will be returned unmodified. - """ - if (render_info := render_info_cv.get()) is not None: - render_info.has_time = True - - if not isinstance(value, datetime): - return value - if not value.tzinfo: - value = dt_util.as_local(value) - if dt_util.now() > value: - return value - - return dt_util.get_time_remaining(value, precision) - - def iif( value: Any, if_true: Any = True, if_false: Any = False, if_none: Any = _SENTINEL ) -> Any: @@ -2089,6 +1891,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "homeassistant.helpers.template.extensions.CollectionExtension" ) self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension") + self.add_extension( + "homeassistant.helpers.template.extensions.DateTimeExtension" + ) self.add_extension("homeassistant.helpers.template.extensions.DeviceExtension") self.add_extension("homeassistant.helpers.template.extensions.FloorExtension") self.add_extension("homeassistant.helpers.template.extensions.LabelExtension") @@ -2097,11 +1902,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.add_extension("homeassistant.helpers.template.extensions.StringExtension") self.globals["apply"] = apply - self.globals["as_datetime"] = as_datetime self.globals["as_function"] = as_function - self.globals["as_local"] = dt_util.as_local - self.globals["as_timedelta"] = as_timedelta - self.globals["as_timestamp"] = forgiving_as_timestamp self.globals["bool"] = forgiving_boolean self.globals["combine"] = combine self.globals["float"] = forgiving_float @@ -2110,8 +1911,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["is_number"] = is_number self.globals["merge_response"] = merge_response self.globals["pack"] = struct_pack - self.globals["strptime"] = strptime - self.globals["timedelta"] = timedelta self.globals["typeof"] = typeof self.globals["unpack"] = struct_unpack self.globals["version"] = version @@ -2119,11 +1918,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["add"] = add self.filters["apply"] = apply - self.filters["as_datetime"] = as_datetime self.filters["as_function"] = as_function - self.filters["as_local"] = dt_util.as_local - self.filters["as_timedelta"] = as_timedelta - self.filters["as_timestamp"] = forgiving_as_timestamp self.filters["bool"] = forgiving_boolean self.filters["combine"] = combine self.filters["contains"] = contains @@ -2139,9 +1934,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["pack"] = struct_pack self.filters["random"] = random_every_time self.filters["round"] = forgiving_round - self.filters["timestamp_custom"] = timestamp_custom - self.filters["timestamp_local"] = timestamp_local - self.filters["timestamp_utc"] = timestamp_utc self.filters["to_json"] = to_json self.filters["typeof"] = typeof self.filters["unpack"] = struct_unpack @@ -2149,7 +1941,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.tests["apply"] = apply self.tests["contains"] = contains - self.tests["datetime"] = _is_datetime self.tests["is_number"] = is_number self.tests["string_like"] = _is_string_like @@ -2218,15 +2009,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "is_hidden_entity", "is_state_attr", "is_state", - "now", - "relative_time", "state_attr", "state_translated", "states", - "time_since", - "time_until", - "today_at", - "utcnow", ] hass_filters = [ "area_id", @@ -2253,20 +2038,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["distance"] = hassfunction(distance) self.globals["expand"] = hassfunction(expand) self.globals["has_value"] = hassfunction(has_value) - self.globals["now"] = hassfunction(now) - self.globals["relative_time"] = hassfunction(relative_time) - self.globals["time_since"] = hassfunction(time_since) - self.globals["time_until"] = hassfunction(time_until) - self.globals["today_at"] = hassfunction(today_at) - self.globals["utcnow"] = hassfunction(utcnow) self.filters["closest"] = hassfunction(closest_filter) self.filters["expand"] = self.globals["expand"] self.filters["has_value"] = self.globals["has_value"] - self.filters["relative_time"] = self.globals["relative_time"] - self.filters["time_since"] = self.globals["time_since"] - self.filters["time_until"] = self.globals["time_until"] - self.filters["today_at"] = self.globals["today_at"] self.tests["has_value"] = hassfunction(has_value, pass_eval_context) diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index 9fdd8232c2a..8254efc6b14 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -4,6 +4,7 @@ from .areas import AreaExtension from .base64 import Base64Extension from .collection import CollectionExtension from .crypto import CryptoExtension +from .datetime import DateTimeExtension from .devices import DeviceExtension from .floors import FloorExtension from .labels import LabelExtension @@ -16,6 +17,7 @@ __all__ = [ "Base64Extension", "CollectionExtension", "CryptoExtension", + "DateTimeExtension", "DeviceExtension", "FloorExtension", "LabelExtension", diff --git a/homeassistant/helpers/template/extensions/datetime.py b/homeassistant/helpers/template/extensions/datetime.py new file mode 100644 index 00000000000..e5c1ed9985f --- /dev/null +++ b/homeassistant/helpers/template/extensions/datetime.py @@ -0,0 +1,324 @@ +"""DateTime functions for Home Assistant templates.""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from typing import TYPE_CHECKING, Any + +from homeassistant.helpers.template.helpers import raise_no_default +from homeassistant.helpers.template.render_info import render_info_cv +from homeassistant.util import dt as dt_util + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + +_SENTINEL = object() +DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" + + +class DateTimeExtension(BaseTemplateExtension): + """Extension for datetime-related template functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the datetime extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "as_datetime", + self.as_datetime, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "as_local", + self.as_local, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "as_timedelta", + self.as_timedelta, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "as_timestamp", + self.as_timestamp, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "strptime", + self.strptime, + as_global=True, + ), + TemplateFunction( + "timedelta", + timedelta, + as_global=True, + ), + TemplateFunction( + "timestamp_custom", + self.timestamp_custom, + as_filter=True, + ), + TemplateFunction( + "timestamp_local", + self.timestamp_local, + as_filter=True, + ), + TemplateFunction( + "timestamp_utc", + self.timestamp_utc, + as_filter=True, + ), + TemplateFunction( + "datetime", + self.is_datetime, + as_test=True, + ), + # Functions that require hass + TemplateFunction( + "now", + self.now, + as_global=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "utcnow", + self.utcnow, + as_global=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "relative_time", + self.relative_time, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "time_since", + self.time_since, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "time_until", + self.time_until, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "today_at", + self.today_at, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + ], + ) + + def timestamp_custom( + self, + value: Any, + date_format: str = DATE_STR_FORMAT, + local: bool = True, + default: Any = _SENTINEL, + ) -> Any: + """Filter to convert given timestamp to format.""" + try: + result = dt_util.utc_from_timestamp(value) + + if local: + result = dt_util.as_local(result) + + return result.strftime(date_format) + except (ValueError, TypeError): + # If timestamp can't be converted + if default is _SENTINEL: + raise_no_default("timestamp_custom", value) + return default + + def timestamp_local(self, value: Any, default: Any = _SENTINEL) -> Any: + """Filter to convert given timestamp to local date/time.""" + try: + return dt_util.as_local(dt_util.utc_from_timestamp(value)).isoformat() + except (ValueError, TypeError): + # If timestamp can't be converted + if default is _SENTINEL: + raise_no_default("timestamp_local", value) + return default + + def timestamp_utc(self, value: Any, default: Any = _SENTINEL) -> Any: + """Filter to convert given timestamp to UTC date/time.""" + try: + return dt_util.utc_from_timestamp(value).isoformat() + except (ValueError, TypeError): + # If timestamp can't be converted + if default is _SENTINEL: + raise_no_default("timestamp_utc", value) + return default + + def as_timestamp(self, value: Any, default: Any = _SENTINEL) -> Any: + """Filter and function which tries to convert value to timestamp.""" + try: + return dt_util.as_timestamp(value) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("as_timestamp", value) + return default + + def as_datetime(self, value: Any, default: Any = _SENTINEL) -> Any: + """Filter and to convert a time string or UNIX timestamp to datetime object.""" + # Return datetime.datetime object without changes + if type(value) is datetime: + return value + # Add midnight to datetime.date object + if type(value) is date: + return datetime.combine(value, time(0, 0, 0)) + try: + # Check for a valid UNIX timestamp string, int or float + timestamp = float(value) + return dt_util.utc_from_timestamp(timestamp) + except (ValueError, TypeError): + # Try to parse datetime string to datetime object + try: + return dt_util.parse_datetime(value, raise_on_error=True) + except (ValueError, TypeError): + if default is _SENTINEL: + # Return None on string input + # to ensure backwards compatibility with HA Core 2024.1 and before. + if isinstance(value, str): + return None + raise_no_default("as_datetime", value) + return default + + def as_timedelta(self, value: str) -> timedelta | None: + """Parse a ISO8601 duration like 'PT10M' to a timedelta.""" + return dt_util.parse_duration(value) + + def strptime(self, string: str, fmt: str, default: Any = _SENTINEL) -> Any: + """Parse a time string to datetime.""" + try: + return datetime.strptime(string, fmt) + except (ValueError, AttributeError, TypeError): + if default is _SENTINEL: + raise_no_default("strptime", string) + return default + + def as_local(self, value: datetime) -> datetime: + """Filter and function to convert time to local.""" + return dt_util.as_local(value) + + def is_datetime(self, value: Any) -> bool: + """Return whether a value is a datetime.""" + return isinstance(value, datetime) + + def now(self) -> datetime: + """Record fetching now.""" + if (render_info := render_info_cv.get()) is not None: + render_info.has_time = True + + return dt_util.now() + + def utcnow(self) -> datetime: + """Record fetching utcnow.""" + if (render_info := render_info_cv.get()) is not None: + render_info.has_time = True + + return dt_util.utcnow() + + def today_at(self, time_str: str = "") -> datetime: + """Record fetching now where the time has been replaced with value.""" + if (render_info := render_info_cv.get()) is not None: + render_info.has_time = True + + today = dt_util.start_of_local_day() + if not time_str: + return today + + if (time_today := dt_util.parse_time(time_str)) is None: + raise ValueError( + f"could not convert {type(time_str).__name__} to datetime: '{time_str}'" + ) + + return datetime.combine(today, time_today, today.tzinfo) + + def relative_time(self, value: Any) -> Any: + """Take a datetime and return its "age" as a string. + + The age can be in second, minute, hour, day, month or year. Only the + biggest unit is considered, e.g. if it's 2 days and 3 hours, "2 days" will + be returned. + If the input datetime is in the future, + the input datetime will be returned. + + If the input are not a datetime object the input will be returned unmodified. + + Note: This template function is deprecated in favor of `time_until`, but is still + supported so as not to break old templates. + """ + if (render_info := render_info_cv.get()) is not None: + render_info.has_time = True + + if not isinstance(value, datetime): + return value + if not value.tzinfo: + value = dt_util.as_local(value) + if dt_util.now() < value: + return value + return dt_util.get_age(value) + + def time_since(self, value: Any | datetime, precision: int = 1) -> Any: + """Take a datetime and return its "age" as a string. + + The age can be in seconds, minutes, hours, days, months and year. + + precision is the number of units to return, with the last unit rounded. + + If the value not a datetime object the input will be returned unmodified. + """ + if (render_info := render_info_cv.get()) is not None: + render_info.has_time = True + + if not isinstance(value, datetime): + return value + if not value.tzinfo: + value = dt_util.as_local(value) + if dt_util.now() < value: + return value + + return dt_util.get_age(value, precision) + + def time_until(self, value: Any | datetime, precision: int = 1) -> Any: + """Take a datetime and return the amount of time until that time as a string. + + The time until can be in seconds, minutes, hours, days, months and years. + + precision is the number of units to return, with the last unit rounded. + + If the value not a datetime object the input will be returned unmodified. + """ + if (render_info := render_info_cv.get()) is not None: + render_info.has_time = True + + if not isinstance(value, datetime): + return value + if not value.tzinfo: + value = dt_util.as_local(value) + if dt_util.now() > value: + return value + + return dt_util.get_time_remaining(value, precision) diff --git a/tests/helpers/template/extensions/test_datetime.py b/tests/helpers/template/extensions/test_datetime.py new file mode 100644 index 00000000000..65d12d1c9fb --- /dev/null +++ b/tests/helpers/template/extensions/test_datetime.py @@ -0,0 +1,790 @@ +"""Test datetime template functions.""" + +from __future__ import annotations + +from datetime import datetime +from types import MappingProxyType +from typing import Any +from unittest.mock import patch + +from freezegun import freeze_time +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.util import dt as dt_util +from homeassistant.util.read_only_dict import ReadOnlyDict + +from tests.helpers.template.helpers import render, render_to_info + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), True), + ], +) +def test_is_datetime(hass: HomeAssistant, value, expected) -> None: + """Test is datetime.""" + assert render(hass, "{{ value is datetime }}", {"value": value}) == expected + + +def test_strptime(hass: HomeAssistant) -> None: + """Test the parse timestamp method.""" + tests = [ + ("2016-10-19 15:22:05.588122 UTC", "%Y-%m-%d %H:%M:%S.%f %Z", None), + ("2016-10-19 15:22:05.588122+0100", "%Y-%m-%d %H:%M:%S.%f%z", None), + ("2016-10-19 15:22:05.588122", "%Y-%m-%d %H:%M:%S.%f", None), + ("2016-10-19", "%Y-%m-%d", None), + ("2016", "%Y", None), + ("15:22:05", "%H:%M:%S", None), + ] + + for inp, fmt, expected in tests: + if expected is None: + expected = str(datetime.strptime(inp, fmt)) + + temp = f"{{{{ strptime('{inp}', '{fmt}') }}}}" + + assert render(hass, temp) == expected + + # Test handling of invalid input + invalid_tests = [ + ("1469119144", "%Y"), + ("invalid", "%Y"), + ] + + for inp, fmt in invalid_tests: + temp = f"{{{{ strptime('{inp}', '{fmt}') }}}}" + + with pytest.raises(TemplateError): + render(hass, temp) + + # Test handling of default return value + assert render(hass, "{{ strptime('invalid', '%Y', 1) }}") == 1 + assert render(hass, "{{ strptime('invalid', '%Y', default=1) }}") == 1 + + +async def test_timestamp_custom(hass: HomeAssistant) -> None: + """Test the timestamps to custom filter.""" + await hass.config.async_set_time_zone("UTC") + now = dt_util.utcnow() + tests = [ + (1469119144, None, True, "2016-07-21 16:39:04"), + (1469119144, "%Y", True, 2016), + (1469119144, "invalid", True, "invalid"), + (dt_util.as_timestamp(now), None, False, now.strftime("%Y-%m-%d %H:%M:%S")), + ] + + for inp, fmt, local, out in tests: + if fmt: + fil = f"timestamp_custom('{fmt}')" + elif fmt and local: + fil = f"timestamp_custom('{fmt}', {local})" + else: + fil = "timestamp_custom" + + assert render(hass, f"{{{{ {inp} | {fil} }}}}") == out + + # Test handling of invalid input + invalid_tests = [ + (None, None, None), + ] + + for inp, fmt, local in invalid_tests: + if fmt: + fil = f"timestamp_custom('{fmt}')" + elif fmt and local: + fil = f"timestamp_custom('{fmt}', {local})" + else: + fil = "timestamp_custom" + + with pytest.raises(TemplateError): + render(hass, f"{{{{ {inp} | {fil} }}}}") + + # 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 + + +async def test_timestamp_local(hass: HomeAssistant) -> None: + """Test the timestamps to local filter.""" + await hass.config.async_set_time_zone("UTC") + tests = [ + (1469119144, "2016-07-21T16:39:04+00:00"), + ] + + for inp, out in tests: + assert render(hass, f"{{{{ {inp} | timestamp_local }}}}") == out + + # Test handling of invalid input + invalid_tests = [ + None, + ] + + for inp in invalid_tests: + with pytest.raises(TemplateError): + render(hass, f"{{{{ {inp} | timestamp_local }}}}") + + # 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", + [ + "2021-06-03 13:00:00.000000+00:00", + "1986-07-09T12:00:00Z", + "2016-10-19 15:22:05.588122+0100", + "2016-10-19", + "2021-01-01 00:00:01", + "invalid", + ], +) +def test_as_datetime(hass: HomeAssistant, input) -> None: + """Test converting a timestamp string to a date object.""" + expected = dt_util.parse_datetime(input) + if expected is not None: + expected = str(expected) + assert render(hass, f"{{{{ as_datetime('{input}') }}}}") == expected + assert render(hass, f"{{{{ '{input}' | as_datetime }}}}") == expected + + +@pytest.mark.parametrize( + ("input", "output"), + [ + (1469119144, "2016-07-21 16:39:04+00:00"), + (1469119144.0, "2016-07-21 16:39:04+00:00"), + (-1, "1969-12-31 23:59:59+00:00"), + ], +) +def test_as_datetime_from_timestamp( + hass: HomeAssistant, + input: float, + output: str, +) -> None: + """Test converting a UNIX timestamp to a date object.""" + assert render(hass, f"{{{{ as_datetime({input}) }}}}") == output + assert render(hass, f"{{{{ {input} | as_datetime }}}}") == output + assert render(hass, f"{{{{ as_datetime('{input}') }}}}") == output + assert render(hass, f"{{{{ '{input}' | as_datetime }}}}") == output + + +@pytest.mark.parametrize( + ("input", "output"), + [ + ( + "{% set dt = as_datetime('2024-01-01 16:00:00-08:00') %}", + "2024-01-01 16:00:00-08:00", + ), + ( + "{% set dt = as_datetime('2024-01-29').date() %}", + "2024-01-29 00:00:00", + ), + ], +) +def test_as_datetime_from_datetime( + hass: HomeAssistant, input: str, output: str +) -> None: + """Test using datetime.datetime or datetime.date objects as input.""" + + assert render(hass, f"{input}{{{{ dt | as_datetime }}}}") == output + + assert render(hass, f"{input}{{{{ as_datetime(dt) }}}}") == output + + +@pytest.mark.parametrize( + ("input", "default", "output"), + [ + (1469119144, 123, "2016-07-21 16:39:04+00:00"), + ('"invalid"', ["default output"], ["default output"]), + (["a", "list"], 0, 0), + ({"a": "dict"}, None, None), + ], +) +def test_as_datetime_default( + hass: HomeAssistant, input: Any, default: Any, output: str +) -> None: + """Test invalid input and return default value.""" + + assert render(hass, f"{{{{ as_datetime({input}, default={default}) }}}}") == output + assert render(hass, f"{{{{ {input} | as_datetime({default}) }}}}") == output + + +def test_as_local(hass: HomeAssistant) -> None: + """Test converting time to local.""" + + hass.states.async_set("test.object", "available") + last_updated = hass.states.get("test.object").last_updated + assert render(hass, "{{ as_local(states.test.object.last_updated) }}") == str( + dt_util.as_local(last_updated) + ) + assert render(hass, "{{ states.test.object.last_updated | as_local }}") == str( + dt_util.as_local(last_updated) + ) + + +def test_timestamp_utc(hass: HomeAssistant) -> None: + """Test the timestamps to local filter.""" + now = dt_util.utcnow() + tests = [ + (1469119144, "2016-07-21T16:39:04+00:00"), + (dt_util.as_timestamp(now), now.isoformat()), + ] + + for inp, out in tests: + assert render(hass, f"{{{{ {inp} | timestamp_utc }}}}") == out + + # Test handling of invalid input + invalid_tests = [ + None, + ] + + for inp in invalid_tests: + with pytest.raises(TemplateError): + render(hass, f"{{{{ {inp} | timestamp_utc }}}}") + + # 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: HomeAssistant) -> None: + """Test the as_timestamp function.""" + with pytest.raises(TemplateError): + render(hass, '{{ as_timestamp("invalid") }}') + + hass.states.async_set("test.object", None) + with pytest.raises(TemplateError): + render(hass, "{{ as_timestamp(states.test.object) }}") + + tpl = ( + '{{ as_timestamp(strptime("2024-02-03T09:10:24+0000", ' + '"%Y-%m-%dT%H:%M:%S%z")) }}' + ) + assert render(hass, tpl) == 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 + + +def test_as_timedelta(hass: HomeAssistant) -> None: + """Test the as_timedelta function/filter.""" + + result = render(hass, "{{ as_timedelta('PT10M') }}") + assert result == "0:10:00" + + result = render(hass, "{{ 'PT10M' | as_timedelta }}") + assert result == "0:10:00" + + result = render(hass, "{{ 'T10M' | as_timedelta }}") + assert result is None + + +@patch( + "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", + return_value=True, +) +def test_now(mock_is_safe, hass: HomeAssistant) -> None: + """Test now method.""" + now = dt_util.now() + with freeze_time(now): + info = render_to_info(hass, "{{ now().isoformat() }}") + assert now.isoformat() == info.result() + + assert info.has_time is True + + +@patch( + "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", + return_value=True, +) +def test_utcnow(mock_is_safe, hass: HomeAssistant) -> None: + """Test now method.""" + utcnow = dt_util.utcnow() + with freeze_time(utcnow): + info = render_to_info(hass, "{{ utcnow().isoformat() }}") + assert utcnow.isoformat() == info.result() + + assert info.has_time is True + + +@pytest.mark.parametrize( + ("now", "expected", "expected_midnight", "timezone_str"), + [ + # Host clock in UTC + ( + "2021-11-24 03:00:00+00:00", + "2021-11-23T10:00:00-08:00", + "2021-11-23T00:00:00-08:00", + "America/Los_Angeles", + ), + # Host clock in local time + ( + "2021-11-23 19:00:00-08:00", + "2021-11-23T10:00:00-08:00", + "2021-11-23T00:00:00-08:00", + "America/Los_Angeles", + ), + ], +) +@patch( + "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", + return_value=True, +) +async def test_today_at( + mock_is_safe, hass: HomeAssistant, now, expected, expected_midnight, timezone_str +) -> None: + """Test today_at method.""" + freezer = freeze_time(now) + freezer.start() + + await hass.config.async_set_time_zone(timezone_str) + + result = render(hass, "{{ today_at('10:00').isoformat() }}") + assert result == expected + + result = render(hass, "{{ today_at('10:00:00').isoformat() }}") + assert result == expected + + result = render(hass, "{{ ('10:00:00' | today_at).isoformat() }}") + assert result == expected + + result = render(hass, "{{ today_at().isoformat() }}") + assert result == expected_midnight + + with pytest.raises(TemplateError): + render(hass, "{{ today_at('bad') }}") + + info = render_to_info(hass, "{{ today_at('10:00').isoformat() }}") + assert info.has_time is True + + freezer.stop() + + +@patch( + "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", + return_value=True, +) +async def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: + """Test relative_time method.""" + await hass.config.async_set_time_zone("UTC") + now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + relative_time_template = ( + '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' + ) + with freeze_time(now): + result = render(hass, relative_time_template) + assert result == "1 hour" + result = render( + hass, + ( + "{{" + " relative_time(" + " strptime(" + ' "2000-01-01 09:00:00 +01:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + ) + assert result == "2 hours" + + result = render( + hass, + ( + "{{" + " relative_time(" + " strptime(" + ' "2000-01-01 03:00:00 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + ) + assert result == "1 hour" + + result1 = str( + datetime.strptime("2000-01-01 11:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + ) + result2 = render( + hass, + ( + "{{" + " relative_time(" + " strptime(" + ' "2000-01-01 11:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + ) + assert result1 == result2 + + result = render(hass, '{{relative_time("string")}}') + assert result == "string" + + # Test behavior when current time is same as the input time + result = render( + hass, + ( + "{{" + " relative_time(" + " strptime(" + ' "2000-01-01 10:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + ) + assert result == "0 seconds" + + # Test behavior when the input time is in the future + result = render( + hass, + ( + "{{" + " relative_time(" + " strptime(" + ' "2000-01-01 11:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + ) + assert result == "2000-01-01 11:00:00+00:00" + + info = render_to_info(hass, relative_time_template) + assert info.has_time is True + + +@patch( + "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", + return_value=True, +) +async def test_time_since(mock_is_safe, hass: HomeAssistant) -> None: + """Test time_since method.""" + await hass.config.async_set_time_zone("UTC") + now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + time_since_template = ( + '{{time_since(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' + ) + with freeze_time(now): + result = render(hass, time_since_template) + assert result == "1 hour" + + result = render( + hass, + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 09:00:00 +01:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + ) + assert result == "2 hours" + + result = render( + hass, + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 03:00:00 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + ) + assert result == "1 hour" + + result1 = str( + datetime.strptime("2000-01-01 11:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + ) + result2 = render( + hass, + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 11:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 2" + " )" + "}}" + ), + ) + assert result1 == result2 + + result = render( + hass, + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 09:05:00 +01:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision=2" + " )" + "}}" + ), + ) + assert result == "1 hour 55 minutes" + + result = render( + hass, + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 02:05:27 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 3" + " )" + "}}" + ), + ) + assert result == "1 hour 54 minutes 33 seconds" + result = render( + hass, + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 02:05:27 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z")' + " )" + "}}" + ), + ) + assert result == "2 hours" + result = render( + hass, + ( + "{{" + " time_since(" + " strptime(" + ' "1999-02-01 02:05:27 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 0" + " )" + "}}" + ), + ) + assert result == "11 months 4 days 1 hour 54 minutes 33 seconds" + result = render( + hass, + ( + "{{" + " time_since(" + " strptime(" + ' "1999-02-01 02:05:27 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z")' + " )" + "}}" + ), + ) + assert result == "11 months" + result1 = str( + datetime.strptime("2000-01-01 11:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + ) + result2 = render( + hass, + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 11:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision=3" + " )" + "}}" + ), + ) + assert result1 == result2 + + result = render(hass, '{{time_since("string")}}') + assert result == "string" + + info = render_to_info(hass, time_since_template) + assert info.has_time is True + + +@patch( + "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", + return_value=True, +) +async def test_time_until(mock_is_safe, hass: HomeAssistant) -> None: + """Test time_until method.""" + await hass.config.async_set_time_zone("UTC") + now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + time_until_template = ( + '{{time_until(strptime("2000-01-01 11:00:00", "%Y-%m-%d %H:%M:%S"))}}' + ) + with freeze_time(now): + result = render(hass, time_until_template) + assert result == "1 hour" + + result = render( + hass, + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 13:00:00 +01:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + ) + assert result == "2 hours" + + result = render( + hass, + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 05:00:00 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + ) + assert result == "1 hour" + + result1 = str( + datetime.strptime("2000-01-01 09:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + ) + result2 = render( + hass, + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 09:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 2" + " )" + "}}" + ), + ) + assert result1 == result2 + + result = render( + hass, + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 12:05:00 +01:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision=2" + " )" + "}}" + ), + ) + assert result == "1 hour 5 minutes" + + result = render( + hass, + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 05:54:33 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 3" + " )" + "}}" + ), + ) + assert result == "1 hour 54 minutes 33 seconds" + result = render( + hass, + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 05:54:33 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z")' + " )" + "}}" + ), + ) + assert result == "2 hours" + result = render( + hass, + ( + "{{" + " time_until(" + " strptime(" + ' "2001-02-01 05:54:33 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 0" + " )" + "}}" + ), + ) + assert result == "1 year 1 month 2 days 1 hour 54 minutes 33 seconds" + result = render( + hass, + ( + "{{" + " time_until(" + " strptime(" + ' "2001-02-01 05:54:33 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 4" + " )" + "}}" + ), + ) + assert result == "1 year 1 month 2 days 2 hours" + result1 = str( + datetime.strptime("2000-01-01 09:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + ) + result2 = render( + hass, + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 09:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision=3" + " )" + "}}" + ), + ) + assert result1 == result2 + + result = render(hass, '{{time_until("string")}}') + assert result == "string" + + info = render_to_info(hass, time_until_template) + assert info.has_time is True diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 48317703d3f..ce2c08c90aa 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -8,8 +8,6 @@ import json import logging import math import random -from types import MappingProxyType -from typing import Any from unittest.mock import patch from freezegun import freeze_time @@ -50,7 +48,6 @@ from homeassistant.helpers.template.render_info import ( ) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.unit_system import UnitSystem from .helpers import assert_result_info, render, render_to_info @@ -436,25 +433,6 @@ def test_converting_datetime_to_iterable(hass: HomeAssistant) -> None: render(hass, "{{ set(value) }}", {"value": dt_}) -@pytest.mark.parametrize( - ("value", "expected"), - [ - ([1, 2], False), - ({1, 2}, False), - ({"a": 1, "b": 2}, False), - (ReadOnlyDict({"a": 1, "b": 2}), False), - (MappingProxyType({"a": 1, "b": 2}), False), - ("abc", False), - (b"abc", False), - ((1, 2), False), - (datetime(2024, 1, 1, 0, 0, 0), True), - ], -) -def test_is_datetime(hass: HomeAssistant, value, expected) -> None: - """Test is datetime.""" - assert render(hass, "{{ value is datetime }}", {"value": value}) == expected - - def test_rounding_value(hass: HomeAssistant) -> None: """Test rounding value.""" hass.states.async_set("sensor.temperature", 12.78) @@ -594,202 +572,6 @@ def test_as_function_no_arguments(hass: HomeAssistant) -> None: assert render(hass, tpl) == "Hello" -def test_strptime(hass: HomeAssistant) -> None: - """Test the parse timestamp method.""" - tests = [ - ("2016-10-19 15:22:05.588122 UTC", "%Y-%m-%d %H:%M:%S.%f %Z", None), - ("2016-10-19 15:22:05.588122+0100", "%Y-%m-%d %H:%M:%S.%f%z", None), - ("2016-10-19 15:22:05.588122", "%Y-%m-%d %H:%M:%S.%f", None), - ("2016-10-19", "%Y-%m-%d", None), - ("2016", "%Y", None), - ("15:22:05", "%H:%M:%S", None), - ] - - for inp, fmt, expected in tests: - if expected is None: - expected = str(datetime.strptime(inp, fmt)) - - temp = f"{{{{ strptime('{inp}', '{fmt}') }}}}" - - assert render(hass, temp) == expected - - # Test handling of invalid input - invalid_tests = [ - ("1469119144", "%Y"), - ("invalid", "%Y"), - ] - - for inp, fmt in invalid_tests: - temp = f"{{{{ strptime('{inp}', '{fmt}') }}}}" - - with pytest.raises(TemplateError): - render(hass, temp) - - # Test handling of default return value - assert render(hass, "{{ strptime('invalid', '%Y', 1) }}") == 1 - assert render(hass, "{{ strptime('invalid', '%Y', default=1) }}") == 1 - - -async def test_timestamp_custom(hass: HomeAssistant) -> None: - """Test the timestamps to custom filter.""" - await hass.config.async_set_time_zone("UTC") - now = dt_util.utcnow() - tests = [ - (1469119144, None, True, "2016-07-21 16:39:04"), - (1469119144, "%Y", True, 2016), - (1469119144, "invalid", True, "invalid"), - (dt_util.as_timestamp(now), None, False, now.strftime("%Y-%m-%d %H:%M:%S")), - ] - - for inp, fmt, local, out in tests: - if fmt: - fil = f"timestamp_custom('{fmt}')" - elif fmt and local: - fil = f"timestamp_custom('{fmt}', {local})" - else: - fil = "timestamp_custom" - - assert render(hass, f"{{{{ {inp} | {fil} }}}}") == out - - # Test handling of invalid input - invalid_tests = [ - (None, None, None), - ] - - for inp, fmt, local in invalid_tests: - if fmt: - fil = f"timestamp_custom('{fmt}')" - elif fmt and local: - fil = f"timestamp_custom('{fmt}', {local})" - else: - fil = "timestamp_custom" - - with pytest.raises(TemplateError): - render(hass, f"{{{{ {inp} | {fil} }}}}") - - # 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 - - -async def test_timestamp_local(hass: HomeAssistant) -> None: - """Test the timestamps to local filter.""" - await hass.config.async_set_time_zone("UTC") - tests = [ - (1469119144, "2016-07-21T16:39:04+00:00"), - ] - - for inp, out in tests: - assert render(hass, f"{{{{ {inp} | timestamp_local }}}}") == out - - # Test handling of invalid input - invalid_tests = [ - None, - ] - - for inp in invalid_tests: - with pytest.raises(TemplateError): - render(hass, f"{{{{ {inp} | timestamp_local }}}}") - - # 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", - [ - "2021-06-03 13:00:00.000000+00:00", - "1986-07-09T12:00:00Z", - "2016-10-19 15:22:05.588122+0100", - "2016-10-19", - "2021-01-01 00:00:01", - "invalid", - ], -) -def test_as_datetime(hass: HomeAssistant, input) -> None: - """Test converting a timestamp string to a date object.""" - expected = dt_util.parse_datetime(input) - if expected is not None: - expected = str(expected) - assert render(hass, f"{{{{ as_datetime('{input}') }}}}") == expected - assert render(hass, f"{{{{ '{input}' | as_datetime }}}}") == expected - - -@pytest.mark.parametrize( - ("input", "output"), - [ - (1469119144, "2016-07-21 16:39:04+00:00"), - (1469119144.0, "2016-07-21 16:39:04+00:00"), - (-1, "1969-12-31 23:59:59+00:00"), - ], -) -def test_as_datetime_from_timestamp( - hass: HomeAssistant, - input: float, - output: str, -) -> None: - """Test converting a UNIX timestamp to a date object.""" - assert render(hass, f"{{{{ as_datetime({input}) }}}}") == output - assert render(hass, f"{{{{ {input} | as_datetime }}}}") == output - assert render(hass, f"{{{{ as_datetime('{input}') }}}}") == output - assert render(hass, f"{{{{ '{input}' | as_datetime }}}}") == output - - -@pytest.mark.parametrize( - ("input", "output"), - [ - ( - "{% set dt = as_datetime('2024-01-01 16:00:00-08:00') %}", - "2024-01-01 16:00:00-08:00", - ), - ( - "{% set dt = as_datetime('2024-01-29').date() %}", - "2024-01-29 00:00:00", - ), - ], -) -def test_as_datetime_from_datetime( - hass: HomeAssistant, input: str, output: str -) -> None: - """Test using datetime.datetime or datetime.date objects as input.""" - - assert render(hass, f"{input}{{{{ dt | as_datetime }}}}") == output - - assert render(hass, f"{input}{{{{ as_datetime(dt) }}}}") == output - - -@pytest.mark.parametrize( - ("input", "default", "output"), - [ - (1469119144, 123, "2016-07-21 16:39:04+00:00"), - ('"invalid"', ["default output"], ["default output"]), - (["a", "list"], 0, 0), - ({"a": "dict"}, None, None), - ], -) -def test_as_datetime_default( - hass: HomeAssistant, input: Any, default: Any, output: str -) -> None: - """Test invalid input and return default value.""" - - assert render(hass, f"{{{{ as_datetime({input}, default={default}) }}}}") == output - assert render(hass, f"{{{{ {input} | as_datetime({default}) }}}}") == output - - -def test_as_local(hass: HomeAssistant) -> None: - """Test converting time to local.""" - - hass.states.async_set("test.object", "available") - last_updated = hass.states.get("test.object").last_updated - assert render(hass, "{{ as_local(states.test.object.last_updated) }}") == str( - dt_util.as_local(last_updated) - ) - assert render(hass, "{{ states.test.object.last_updated | as_local }}") == str( - dt_util.as_local(last_updated) - ) - - def test_to_json(hass: HomeAssistant) -> None: """Test the object to JSON string filter.""" @@ -892,53 +674,6 @@ def test_from_hex(hass: HomeAssistant) -> None: assert render(hass, "{{ '0F010003' | from_hex }}") == b"\x0f\x01\x00\x03" -def test_timestamp_utc(hass: HomeAssistant) -> None: - """Test the timestamps to local filter.""" - now = dt_util.utcnow() - tests = [ - (1469119144, "2016-07-21T16:39:04+00:00"), - (dt_util.as_timestamp(now), now.isoformat()), - ] - - for inp, out in tests: - assert render(hass, f"{{{{ {inp} | timestamp_utc }}}}") == out - - # Test handling of invalid input - invalid_tests = [ - None, - ] - - for inp in invalid_tests: - with pytest.raises(TemplateError): - render(hass, f"{{{{ {inp} | timestamp_utc }}}}") - - # 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: HomeAssistant) -> None: - """Test the as_timestamp function.""" - with pytest.raises(TemplateError): - render(hass, '{{ as_timestamp("invalid") }}') - - hass.states.async_set("test.object", None) - with pytest.raises(TemplateError): - render(hass, "{{ as_timestamp(states.test.object) }}") - - tpl = ( - '{{ as_timestamp(strptime("2024-02-03T09:10:24+0000", ' - '"%Y-%m-%dT%H:%M:%S%z")) }}' - ) - assert render(hass, tpl) == 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: HomeAssistant) -> None: """Ensure the random filter runs every time, not just once.""" @@ -1334,503 +1069,6 @@ def test_has_value(hass: HomeAssistant) -> None: assert result == "yes" -@patch( - "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", - return_value=True, -) -def test_now(mock_is_safe, hass: HomeAssistant) -> None: - """Test now method.""" - now = dt_util.now() - with freeze_time(now): - info = render_to_info(hass, "{{ now().isoformat() }}") - assert now.isoformat() == info.result() - - assert info.has_time is True - - -@patch( - "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", - return_value=True, -) -def test_utcnow(mock_is_safe, hass: HomeAssistant) -> None: - """Test now method.""" - utcnow = dt_util.utcnow() - with freeze_time(utcnow): - info = render_to_info(hass, "{{ utcnow().isoformat() }}") - assert utcnow.isoformat() == info.result() - - assert info.has_time is True - - -@pytest.mark.parametrize( - ("now", "expected", "expected_midnight", "timezone_str"), - [ - # Host clock in UTC - ( - "2021-11-24 03:00:00+00:00", - "2021-11-23T10:00:00-08:00", - "2021-11-23T00:00:00-08:00", - "America/Los_Angeles", - ), - # Host clock in local time - ( - "2021-11-23 19:00:00-08:00", - "2021-11-23T10:00:00-08:00", - "2021-11-23T00:00:00-08:00", - "America/Los_Angeles", - ), - ], -) -@patch( - "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", - return_value=True, -) -async def test_today_at( - mock_is_safe, hass: HomeAssistant, now, expected, expected_midnight, timezone_str -) -> None: - """Test today_at method.""" - freezer = freeze_time(now) - freezer.start() - - await hass.config.async_set_time_zone(timezone_str) - - result = render(hass, "{{ today_at('10:00').isoformat() }}") - assert result == expected - - result = render(hass, "{{ today_at('10:00:00').isoformat() }}") - assert result == expected - - result = render(hass, "{{ ('10:00:00' | today_at).isoformat() }}") - assert result == expected - - result = render(hass, "{{ today_at().isoformat() }}") - assert result == expected_midnight - - with pytest.raises(TemplateError): - render(hass, "{{ today_at('bad') }}") - - info = render_to_info(hass, "{{ today_at('10:00').isoformat() }}") - assert info.has_time is True - - freezer.stop() - - -@patch( - "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", - return_value=True, -) -async def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: - """Test relative_time method.""" - await hass.config.async_set_time_zone("UTC") - now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - relative_time_template = ( - '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' - ) - with freeze_time(now): - result = render(hass, relative_time_template) - assert result == "1 hour" - result = render( - hass, - ( - "{{" - " relative_time(" - " strptime(" - ' "2000-01-01 09:00:00 +01:00",' - ' "%Y-%m-%d %H:%M:%S %z"' - " )" - " )" - "}}" - ), - ) - assert result == "2 hours" - - result = render( - hass, - ( - "{{" - " relative_time(" - " strptime(" - ' "2000-01-01 03:00:00 -06:00",' - ' "%Y-%m-%d %H:%M:%S %z"' - " )" - " )" - "}}" - ), - ) - assert result == "1 hour" - - result1 = str( - template.strptime("2000-01-01 11:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - ) - result2 = render( - hass, - ( - "{{" - " relative_time(" - " strptime(" - ' "2000-01-01 11:00:00 +00:00",' - ' "%Y-%m-%d %H:%M:%S %z"' - " )" - " )" - "}}" - ), - ) - assert result1 == result2 - - result = render(hass, '{{relative_time("string")}}') - assert result == "string" - - # Test behavior when current time is same as the input time - result = render( - hass, - ( - "{{" - " relative_time(" - " strptime(" - ' "2000-01-01 10:00:00 +00:00",' - ' "%Y-%m-%d %H:%M:%S %z"' - " )" - " )" - "}}" - ), - ) - assert result == "0 seconds" - - # Test behavior when the input time is in the future - result = render( - hass, - ( - "{{" - " relative_time(" - " strptime(" - ' "2000-01-01 11:00:00 +00:00",' - ' "%Y-%m-%d %H:%M:%S %z"' - " )" - " )" - "}}" - ), - ) - assert result == "2000-01-01 11:00:00+00:00" - - info = render_to_info(hass, relative_time_template) - assert info.has_time is True - - -@patch( - "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", - return_value=True, -) -async def test_time_since(mock_is_safe, hass: HomeAssistant) -> None: - """Test time_since method.""" - await hass.config.async_set_time_zone("UTC") - now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - time_since_template = ( - '{{time_since(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' - ) - with freeze_time(now): - result = render(hass, time_since_template) - assert result == "1 hour" - - result = render( - hass, - ( - "{{" - " time_since(" - " strptime(" - ' "2000-01-01 09:00:00 +01:00",' - ' "%Y-%m-%d %H:%M:%S %z"' - " )" - " )" - "}}" - ), - ) - assert result == "2 hours" - - result = render( - hass, - ( - "{{" - " time_since(" - " strptime(" - ' "2000-01-01 03:00:00 -06:00",' - ' "%Y-%m-%d %H:%M:%S %z"' - " )" - " )" - "}}" - ), - ) - assert result == "1 hour" - - result1 = str( - template.strptime("2000-01-01 11:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - ) - result2 = render( - hass, - ( - "{{" - " time_since(" - " strptime(" - ' "2000-01-01 11:00:00 +00:00",' - ' "%Y-%m-%d %H:%M:%S %z"),' - " precision = 2" - " )" - "}}" - ), - ) - assert result1 == result2 - - result = render( - hass, - ( - "{{" - " time_since(" - " strptime(" - ' "2000-01-01 09:05:00 +01:00",' - ' "%Y-%m-%d %H:%M:%S %z"),' - " precision=2" - " )" - "}}" - ), - ) - assert result == "1 hour 55 minutes" - - result = render( - hass, - ( - "{{" - " time_since(" - " strptime(" - ' "2000-01-01 02:05:27 -06:00",' - ' "%Y-%m-%d %H:%M:%S %z"),' - " precision = 3" - " )" - "}}" - ), - ) - assert result == "1 hour 54 minutes 33 seconds" - result = render( - hass, - ( - "{{" - " time_since(" - " strptime(" - ' "2000-01-01 02:05:27 -06:00",' - ' "%Y-%m-%d %H:%M:%S %z")' - " )" - "}}" - ), - ) - assert result == "2 hours" - result = render( - hass, - ( - "{{" - " time_since(" - " strptime(" - ' "1999-02-01 02:05:27 -06:00",' - ' "%Y-%m-%d %H:%M:%S %z"),' - " precision = 0" - " )" - "}}" - ), - ) - assert result == "11 months 4 days 1 hour 54 minutes 33 seconds" - result = render( - hass, - ( - "{{" - " time_since(" - " strptime(" - ' "1999-02-01 02:05:27 -06:00",' - ' "%Y-%m-%d %H:%M:%S %z")' - " )" - "}}" - ), - ) - assert result == "11 months" - result1 = str( - template.strptime("2000-01-01 11:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - ) - result2 = render( - hass, - ( - "{{" - " time_since(" - " strptime(" - ' "2000-01-01 11:00:00 +00:00",' - ' "%Y-%m-%d %H:%M:%S %z"),' - " precision=3" - " )" - "}}" - ), - ) - assert result1 == result2 - - result = render(hass, '{{time_since("string")}}') - assert result == "string" - - info = render_to_info(hass, time_since_template) - assert info.has_time is True - - -@patch( - "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", - return_value=True, -) -async def test_time_until(mock_is_safe, hass: HomeAssistant) -> None: - """Test time_until method.""" - await hass.config.async_set_time_zone("UTC") - now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - time_until_template = ( - '{{time_until(strptime("2000-01-01 11:00:00", "%Y-%m-%d %H:%M:%S"))}}' - ) - with freeze_time(now): - result = render(hass, time_until_template) - assert result == "1 hour" - - result = render( - hass, - ( - "{{" - " time_until(" - " strptime(" - ' "2000-01-01 13:00:00 +01:00",' - ' "%Y-%m-%d %H:%M:%S %z"' - " )" - " )" - "}}" - ), - ) - assert result == "2 hours" - - result = render( - hass, - ( - "{{" - " time_until(" - " strptime(" - ' "2000-01-01 05:00:00 -06:00",' - ' "%Y-%m-%d %H:%M:%S %z"' - " )" - " )" - "}}" - ), - ) - assert result == "1 hour" - - result1 = str( - template.strptime("2000-01-01 09:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - ) - result2 = render( - hass, - ( - "{{" - " time_until(" - " strptime(" - ' "2000-01-01 09:00:00 +00:00",' - ' "%Y-%m-%d %H:%M:%S %z"),' - " precision = 2" - " )" - "}}" - ), - ) - assert result1 == result2 - - result = render( - hass, - ( - "{{" - " time_until(" - " strptime(" - ' "2000-01-01 12:05:00 +01:00",' - ' "%Y-%m-%d %H:%M:%S %z"),' - " precision=2" - " )" - "}}" - ), - ) - assert result == "1 hour 5 minutes" - - result = render( - hass, - ( - "{{" - " time_until(" - " strptime(" - ' "2000-01-01 05:54:33 -06:00",' - ' "%Y-%m-%d %H:%M:%S %z"),' - " precision = 3" - " )" - "}}" - ), - ) - assert result == "1 hour 54 minutes 33 seconds" - result = render( - hass, - ( - "{{" - " time_until(" - " strptime(" - ' "2000-01-01 05:54:33 -06:00",' - ' "%Y-%m-%d %H:%M:%S %z")' - " )" - "}}" - ), - ) - assert result == "2 hours" - result = render( - hass, - ( - "{{" - " time_until(" - " strptime(" - ' "2001-02-01 05:54:33 -06:00",' - ' "%Y-%m-%d %H:%M:%S %z"),' - " precision = 0" - " )" - "}}" - ), - ) - assert result == "1 year 1 month 2 days 1 hour 54 minutes 33 seconds" - result = render( - hass, - ( - "{{" - " time_until(" - " strptime(" - ' "2001-02-01 05:54:33 -06:00",' - ' "%Y-%m-%d %H:%M:%S %z"),' - " precision = 4" - " )" - "}}" - ), - ) - assert result == "1 year 1 month 2 days 2 hours" - result1 = str( - template.strptime("2000-01-01 09:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - ) - result2 = render( - hass, - ( - "{{" - " time_until(" - " strptime(" - ' "2000-01-01 09:00:00 +00:00",' - ' "%Y-%m-%d %H:%M:%S %z"),' - " precision=3" - " )" - "}}" - ), - ) - assert result1 == result2 - - result = render(hass, '{{time_until("string")}}') - assert result == "string" - - info = render_to_info(hass, time_until_template) - assert info.has_time is True - - @patch( "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, @@ -3294,19 +2532,6 @@ def test_render_complex_handling_non_template_values(hass: HomeAssistant) -> Non ) == {True: 1, False: 2} -def test_as_timedelta(hass: HomeAssistant) -> None: - """Test the as_timedelta function/filter.""" - - result = render(hass, "{{ as_timedelta('PT10M') }}") - assert result == "0:10:00" - - result = render(hass, "{{ 'PT10M' | as_timedelta }}") - assert result == "0:10:00" - - result = render(hass, "{{ 'T10M' | as_timedelta }}") - assert result is None - - def test_iif(hass: HomeAssistant) -> None: """Test the immediate if function/filter."""