From 1120246194affc07e7564fee03d83b18d2d4140d Mon Sep 17 00:00:00 2001 From: rlippmann <70883373+rlippmann@users.noreply.github.com> Date: Wed, 24 Apr 2024 05:13:07 -0400 Subject: [PATCH] Deprecate relative_time() in favor of time_since() and time_until() (#111177) * add time_since/time_until. add deprecation of relative_time * fix merge conflicts * Apply suggestions from code review * Update homeassistant/helpers/template.py * Update homeassistant/helpers/template.py * Update homeassistant/helpers/template.py --------- Co-authored-by: Erik Montnemery --- .../components/homeassistant/strings.json | 4 + homeassistant/helpers/template.py | 74 ++++ homeassistant/util/dt.py | 82 +++-- tests/helpers/test_template.py | 334 +++++++++++++++++- tests/util/test_dt.py | 67 ++++ 5 files changed, 539 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 09b2f17c947..5cdd47d8be4 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -56,6 +56,10 @@ "config_entry_reauth": { "title": "[%key:common::config_flow::title::reauth%]", "description": "Reauthentication is needed" + }, + "template_function_relative_time_deprecated": { + "title": "The {relative_time} template function is deprecated", + "description": "The {relative_time} template function is deprecated in Home Assistant. Please use the {time_since} or {time_until} template functions instead." } }, "system_health": { diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 24baab96a4e..335d6842548 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -59,6 +59,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import ( + DOMAIN as HA_DOMAIN, Context, HomeAssistant, State, @@ -2480,6 +2481,29 @@ def relative_time(hass: HomeAssistant, value: Any) -> Any: If the input are not a datetime object the input will be returned unmodified. """ + + def warn_relative_time_deprecated() -> None: + ir = issue_registry.async_get(hass) + issue_id = "template_function_relative_time_deprecated" + if ir.async_get_issue(HA_DOMAIN, issue_id): + return + issue_registry.async_create_issue( + hass, + HA_DOMAIN, + issue_id, + breaks_in_ha_version="2024.11.0", + is_fixable=False, + severity=issue_registry.IssueSeverity.WARNING, + translation_key=issue_id, + translation_placeholders={ + "relative_time": "relative_time()", + "time_since": "time_since()", + "time_until": "time_until()", + }, + ) + _LOGGER.warning("Template function 'relative_time' is deprecated") + + warn_relative_time_deprecated() if (render_info := _render_info.get()) is not None: render_info.has_time = True @@ -2492,6 +2516,50 @@ def relative_time(hass: HomeAssistant, value: Any) -> Any: 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.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.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 urlencode(value): """Urlencode dictionary and return as UTF-8 string.""" return urllib_urlencode(value).encode("utf-8") @@ -2890,6 +2958,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "floor_id", "floor_name", "relative_time", + "time_since", + "time_until", "today_at", "label_id", "label_name", @@ -2946,6 +3016,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["now"] = hassfunction(now) self.globals["relative_time"] = hassfunction(relative_time) self.filters["relative_time"] = self.globals["relative_time"] + self.globals["time_since"] = hassfunction(time_since) + self.filters["time_since"] = self.globals["time_since"] + self.globals["time_until"] = hassfunction(time_until) + self.filters["time_until"] = self.globals["time_until"] self.globals["today_at"] = hassfunction(today_at) self.filters["today_at"] = self.globals["today_at"] diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 2f2b415144f..923838a48a5 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -286,36 +286,78 @@ def parse_time(time_str: str) -> dt.time | None: return None -def get_age(date: dt.datetime) -> str: - """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. - Make sure date is not in the future, or else it won't work. - """ +def _get_timestring(timediff: float, precision: int = 1) -> str: + """Return a string representation of a time diff.""" def formatn(number: int, unit: str) -> str: """Add "unit" if it's plural.""" if number == 1: - return f"1 {unit}" - return f"{number:d} {unit}s" + return f"1 {unit} " + return f"{number:d} {unit}s " + + if timediff == 0.0: + return "0 seconds" + + units = ("year", "month", "day", "hour", "minute", "second") + + factors = (365 * 24 * 60 * 60, 30 * 24 * 60 * 60, 24 * 60 * 60, 60 * 60, 60, 1) + + result_string: str = "" + current_precision = 0 + + for i, current_factor in enumerate(factors): + selected_unit = units[i] + if timediff < current_factor: + continue + current_precision = current_precision + 1 + if current_precision == precision: + return ( + result_string + formatn(round(timediff / current_factor), selected_unit) + ).rstrip() + curr_diff = int(timediff // current_factor) + result_string += formatn(curr_diff, selected_unit) + timediff -= (curr_diff) * current_factor + + return result_string.rstrip() + + +def get_age(date: dt.datetime, precision: int = 1) -> str: + """Take a datetime and return its "age" as a string. + + The age can be in second, minute, hour, day, month and year. + + depth number of units will be returned, with the last unit rounded + + The date must be in the past or a ValueException will be raised. + """ delta = (now() - date).total_seconds() + rounded_delta = round(delta) - units = ["second", "minute", "hour", "day", "month"] - factors = [60, 60, 24, 30, 12] - selected_unit = "year" + if rounded_delta < 0: + raise ValueError("Time value is in the future") + return _get_timestring(rounded_delta, precision) - for i, next_factor in enumerate(factors): - if rounded_delta < next_factor: - selected_unit = units[i] - break - delta /= next_factor - rounded_delta = round(delta) - return formatn(rounded_delta, selected_unit) +def get_time_remaining(date: dt.datetime, precision: int = 1) -> str: + """Take a datetime and return its "age" as a string. + + The age can be in second, minute, hour, day, month and year. + + depth number of units will be returned, with the last unit rounded + + The date must be in the future or a ValueException will be raised. + """ + + delta = (date - now()).total_seconds() + + rounded_delta = round(delta) + + if rounded_delta < 0: + raise ValueError("Time value is in the past") + + return _get_timestring(rounded_delta, precision) def parse_time_expression(parameter: Any, min_value: int, max_value: int) -> list[int]: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f55a94d7283..d134570d119 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -31,7 +31,7 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfVolume, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers import ( area_registry as ar, @@ -2240,6 +2240,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" hass.config.set_time_zone("UTC") now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + issue_registry = ir.async_get(hass) relative_time_template = ( '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' ) @@ -2249,7 +2250,9 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: hass, ).async_render() assert result == "1 hour" - + assert issue_registry.async_get_issue( + HA_DOMAIN, "template_function_relative_time_deprecated" + ) result = template.Template( ( "{{" @@ -2308,6 +2311,333 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: assert info.has_time is True +@patch( + "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", + return_value=True, +) +def test_time_since(mock_is_safe, hass: HomeAssistant) -> None: + """Test time_since method.""" + hass.config.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 = template.Template( + time_since_template, + hass, + ).async_render() + assert result == "1 hour" + + result = template.Template( + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 09:00:00 +01:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + hass, + ).async_render() + assert result == "2 hours" + + result = template.Template( + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 03:00:00 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + hass, + ).async_render() + assert result == "1 hour" + + result1 = str( + template.strptime("2000-01-01 11:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + ) + result2 = template.Template( + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 11:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 2" + " )" + "}}" + ), + hass, + ).async_render() + assert result1 == result2 + + result = template.Template( + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 09:05:00 +01:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision=2" + " )" + "}}" + ), + hass, + ).async_render() + assert result == "1 hour 55 minutes" + + result = template.Template( + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 02:05:27 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 3" + " )" + "}}" + ), + hass, + ).async_render() + assert result == "1 hour 54 minutes 33 seconds" + result = template.Template( + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 02:05:27 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z")' + " )" + "}}" + ), + hass, + ).async_render() + assert result == "2 hours" + result = template.Template( + ( + "{{" + " time_since(" + " strptime(" + ' "1999-02-01 02:05:27 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 0" + " )" + "}}" + ), + hass, + ).async_render() + assert result == "11 months 4 days 1 hour 54 minutes 33 seconds" + result = template.Template( + ( + "{{" + " time_since(" + " strptime(" + ' "1999-02-01 02:05:27 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z")' + " )" + "}}" + ), + hass, + ).async_render() + assert result == "11 months" + result1 = str( + template.strptime("2000-01-01 11:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + ) + result2 = template.Template( + ( + "{{" + " time_since(" + " strptime(" + ' "2000-01-01 11:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision=3" + " )" + "}}" + ), + hass, + ).async_render() + assert result1 == result2 + + result = template.Template( + '{{time_since("string")}}', + hass, + ).async_render() + assert result == "string" + + info = template.Template(time_since_template, hass).async_render_to_info() + assert info.has_time is True + + +@patch( + "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", + return_value=True, +) +def test_time_until(mock_is_safe, hass: HomeAssistant) -> None: + """Test time_until method.""" + hass.config.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 = template.Template( + time_until_template, + hass, + ).async_render() + assert result == "1 hour" + + result = template.Template( + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 13:00:00 +01:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + hass, + ).async_render() + assert result == "2 hours" + + result = template.Template( + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 05:00:00 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + hass, + ).async_render() + assert result == "1 hour" + + result1 = str( + template.strptime("2000-01-01 09:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + ) + result2 = template.Template( + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 09:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 2" + " )" + "}}" + ), + hass, + ).async_render() + assert result1 == result2 + + result = template.Template( + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 12:05:00 +01:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision=2" + " )" + "}}" + ), + hass, + ).async_render() + assert result == "1 hour 5 minutes" + + result = template.Template( + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 05:54:33 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 3" + " )" + "}}" + ), + hass, + ).async_render() + assert result == "1 hour 54 minutes 33 seconds" + result = template.Template( + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 05:54:33 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z")' + " )" + "}}" + ), + hass, + ).async_render() + assert result == "2 hours" + result = template.Template( + ( + "{{" + " time_until(" + " strptime(" + ' "2001-02-01 05:54:33 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 0" + " )" + "}}" + ), + hass, + ).async_render() + assert result == "1 year 1 month 2 days 1 hour 54 minutes 33 seconds" + result = template.Template( + ( + "{{" + " time_until(" + " strptime(" + ' "2001-02-01 05:54:33 -06:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision = 4" + " )" + "}}" + ), + hass, + ).async_render() + 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 = template.Template( + ( + "{{" + " time_until(" + " strptime(" + ' "2000-01-01 09:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"),' + " precision=3" + " )" + "}}" + ), + hass, + ).async_render() + assert result1 == result2 + + result = template.Template( + '{{time_until("string")}}', + hass, + ).async_render() + assert result == "string" + + info = template.Template(time_until_template, hass).async_render_to_info() + assert info.has_time is True + + @patch( "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 7ed8154f033..215524c426b 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -178,12 +178,18 @@ def test_get_age() -> None: """Test get_age.""" diff = dt_util.now() - timedelta(seconds=0) assert dt_util.get_age(diff) == "0 seconds" + assert dt_util.get_age(diff, precision=2) == "0 seconds" diff = dt_util.now() - timedelta(seconds=1) assert dt_util.get_age(diff) == "1 second" + assert dt_util.get_age(diff, precision=2) == "1 second" + + diff = dt_util.now() + timedelta(seconds=1) + pytest.raises(ValueError, dt_util.get_age, diff) diff = dt_util.now() - timedelta(seconds=30) assert dt_util.get_age(diff) == "30 seconds" + diff = dt_util.now() + timedelta(seconds=30) diff = dt_util.now() - timedelta(minutes=5) assert dt_util.get_age(diff) == "5 minutes" @@ -196,20 +202,81 @@ def test_get_age() -> None: diff = dt_util.now() - timedelta(minutes=320) assert dt_util.get_age(diff) == "5 hours" + assert dt_util.get_age(diff, precision=2) == "5 hours 20 minutes" + assert dt_util.get_age(diff, precision=3) == "5 hours 20 minutes" diff = dt_util.now() - timedelta(minutes=1.6 * 60 * 24) assert dt_util.get_age(diff) == "2 days" + assert dt_util.get_age(diff, precision=2) == "1 day 14 hours" + assert dt_util.get_age(diff, precision=3) == "1 day 14 hours 24 minutes" + diff = dt_util.now() + timedelta(minutes=1.6 * 60 * 24) + pytest.raises(ValueError, dt_util.get_age, diff) diff = dt_util.now() - timedelta(minutes=2 * 60 * 24) assert dt_util.get_age(diff) == "2 days" diff = dt_util.now() - timedelta(minutes=32 * 60 * 24) assert dt_util.get_age(diff) == "1 month" + assert dt_util.get_age(diff, precision=10) == "1 month 2 days" + + diff = dt_util.now() - timedelta(minutes=32 * 60 * 24 + 1) + assert dt_util.get_age(diff, precision=3) == "1 month 2 days 1 minute" diff = dt_util.now() - timedelta(minutes=365 * 60 * 24) assert dt_util.get_age(diff) == "1 year" +def test_time_remaining() -> None: + """Test get_age.""" + diff = dt_util.now() + timedelta(seconds=0) + assert dt_util.get_time_remaining(diff) == "0 seconds" + assert dt_util.get_time_remaining(diff) == "0 seconds" + assert dt_util.get_time_remaining(diff, precision=2) == "0 seconds" + + diff = dt_util.now() + timedelta(seconds=1) + assert dt_util.get_time_remaining(diff) == "1 second" + + diff = dt_util.now() - timedelta(seconds=1) + pytest.raises(ValueError, dt_util.get_time_remaining, diff) + + diff = dt_util.now() + timedelta(seconds=30) + assert dt_util.get_time_remaining(diff) == "30 seconds" + + diff = dt_util.now() + timedelta(minutes=5) + assert dt_util.get_time_remaining(diff) == "5 minutes" + + diff = dt_util.now() + timedelta(minutes=1) + assert dt_util.get_time_remaining(diff) == "1 minute" + + diff = dt_util.now() + timedelta(minutes=300) + assert dt_util.get_time_remaining(diff) == "5 hours" + + diff = dt_util.now() + timedelta(minutes=320) + assert dt_util.get_time_remaining(diff) == "5 hours" + assert dt_util.get_time_remaining(diff, precision=2) == "5 hours 20 minutes" + assert dt_util.get_time_remaining(diff, precision=3) == "5 hours 20 minutes" + + diff = dt_util.now() + timedelta(minutes=1.6 * 60 * 24) + assert dt_util.get_time_remaining(diff) == "2 days" + assert dt_util.get_time_remaining(diff, precision=2) == "1 day 14 hours" + assert dt_util.get_time_remaining(diff, precision=3) == "1 day 14 hours 24 minutes" + diff = dt_util.now() - timedelta(minutes=1.6 * 60 * 24) + pytest.raises(ValueError, dt_util.get_time_remaining, diff) + + diff = dt_util.now() + timedelta(minutes=2 * 60 * 24) + assert dt_util.get_time_remaining(diff) == "2 days" + + diff = dt_util.now() + timedelta(minutes=32 * 60 * 24) + assert dt_util.get_time_remaining(diff) == "1 month" + assert dt_util.get_time_remaining(diff, precision=10) == "1 month 2 days" + + diff = dt_util.now() + timedelta(minutes=32 * 60 * 24 + 1) + assert dt_util.get_time_remaining(diff, precision=3) == "1 month 2 days 1 minute" + + diff = dt_util.now() + timedelta(minutes=365 * 60 * 24) + assert dt_util.get_time_remaining(diff) == "1 year" + + def test_parse_time_expression() -> None: """Test parse_time_expression.""" assert list(range(60)) == dt_util.parse_time_expression("*", 0, 59)