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 <erik@montnemery.com>
This commit is contained in:
rlippmann 2024-04-24 05:13:07 -04:00 committed by GitHub
parent e0b58c3f45
commit 1120246194
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 539 additions and 22 deletions

View File

@ -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": {

View File

@ -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"]

View File

@ -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]:

View File

@ -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,

View File

@ -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)