From 90e5d691848dc2eb327023504bebc8fc86fd44b9 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Mon, 23 May 2022 19:32:22 +0200 Subject: [PATCH] Add template as_timedelta (#71801) --- homeassistant/helpers/template.py | 7 +++ homeassistant/util/dt.py | 72 +++++++++++++++++++++++++++++++ tests/helpers/test_template.py | 12 ++++++ tests/util/test_dt.py | 28 ++++++++++++ 4 files changed, 119 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index d1ed9d06db9..1a8febf5ac2 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1535,6 +1535,11 @@ def as_datetime(value): return dt_util.parse_datetime(value) +def as_timedelta(value: str) -> timedelta | None: + """Parse a ISO8601 duration like 'PT10M' to a timedelta.""" + return dt_util.parse_duration(value) + + def strptime(string, fmt, default=_SENTINEL): """Parse a time string to datetime.""" try: @@ -1902,6 +1907,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["atan2"] = arc_tangent2 self.filters["sqrt"] = square_root self.filters["as_datetime"] = as_datetime + self.filters["as_timedelta"] = as_timedelta self.filters["as_timestamp"] = forgiving_as_timestamp self.filters["today_at"] = today_at self.filters["as_local"] = dt_util.as_local @@ -1947,6 +1953,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["float"] = forgiving_float self.globals["as_datetime"] = as_datetime self.globals["as_local"] = dt_util.as_local + self.globals["as_timedelta"] = as_timedelta self.globals["as_timestamp"] = forgiving_as_timestamp self.globals["today_at"] = today_at self.globals["relative_time"] = relative_time diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index c7073c0306f..80b322c1a14 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -28,6 +28,49 @@ DATETIME_RE = re.compile( r"(?PZ|[+-]\d{2}(?::?\d{2})?)?$" ) +# Copyright (c) Django Software Foundation and individual contributors. +# All rights reserved. +# https://github.com/django/django/blob/master/LICENSE +STANDARD_DURATION_RE = re.compile( + r"^" + r"(?:(?P-?\d+) (days?, )?)?" + r"(?P-?)" + r"((?:(?P\d+):)(?=\d+:\d+))?" + r"(?:(?P\d+):)?" + r"(?P\d+)" + r"(?:[\.,](?P\d{1,6})\d{0,6})?" + r"$" +) + +# Copyright (c) Django Software Foundation and individual contributors. +# All rights reserved. +# https://github.com/django/django/blob/master/LICENSE +ISO8601_DURATION_RE = re.compile( + r"^(?P[-+]?)" + r"P" + r"(?:(?P\d+([\.,]\d+)?)D)?" + r"(?:T" + r"(?:(?P\d+([\.,]\d+)?)H)?" + r"(?:(?P\d+([\.,]\d+)?)M)?" + r"(?:(?P\d+([\.,]\d+)?)S)?" + r")?" + r"$" +) + +# Copyright (c) Django Software Foundation and individual contributors. +# All rights reserved. +# https://github.com/django/django/blob/master/LICENSE +POSTGRES_INTERVAL_RE = re.compile( + r"^" + r"(?:(?P-?\d+) (days? ?))?" + r"(?:(?P[-+])?" + r"(?P\d+):" + r"(?P\d\d):" + r"(?P\d\d)" + r"(?:\.(?P\d{1,6}))?" + r")?$" +) + def set_default_time_zone(time_zone: dt.tzinfo) -> None: """Set a default time zone to be used when none is specified. @@ -171,6 +214,35 @@ def parse_date(dt_str: str) -> dt.date | None: return None +# Copyright (c) Django Software Foundation and individual contributors. +# All rights reserved. +# https://github.com/django/django/blob/master/LICENSE +def parse_duration(value: str) -> dt.timedelta | None: + """Parse a duration string and return a datetime.timedelta. + + Also supports ISO 8601 representation and PostgreSQL's day-time interval + format. + """ + match = ( + STANDARD_DURATION_RE.match(value) + or ISO8601_DURATION_RE.match(value) + or POSTGRES_INTERVAL_RE.match(value) + ) + if match: + kws = match.groupdict() + sign = -1 if kws.pop("sign", "+") == "-" else 1 + if kws.get("microseconds"): + kws["microseconds"] = kws["microseconds"].ljust(6, "0") + time_delta_args: dict[str, float] = { + k: float(v.replace(",", ".")) for k, v in kws.items() if v is not None + } + days = dt.timedelta(float(time_delta_args.pop("days", 0.0) or 0.0)) + if match.re == ISO8601_DURATION_RE: + days *= sign + return days + sign * dt.timedelta(**time_delta_args) + return None + + def parse_time(time_str: str) -> dt.time | None: """Parse a time string (00:20:00) into Time object. diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b2448bc1b5c..ddda17c20ac 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -3388,6 +3388,18 @@ def test_urlencode(hass): assert tpl.async_render() == "the%20quick%20brown%20fox%20%3D%20true" +def test_as_timedelta(hass: HomeAssistant) -> None: + """Test the as_timedelta function/filter.""" + tpl = template.Template("{{ as_timedelta('PT10M') }}", hass) + assert tpl.async_render() == "0:10:00" + + tpl = template.Template("{{ 'PT10M' | as_timedelta }}", hass) + assert tpl.async_render() == "0:10:00" + + tpl = template.Template("{{ 'T10M' | as_timedelta }}", hass) + assert tpl.async_render() is None + + def test_iif(hass: HomeAssistant) -> None: """Test the immediate if function/filter.""" tpl = template.Template("{{ (1 == 1) | iif }}", hass) diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index c7992e05068..79cd4e5e0df 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -1,4 +1,6 @@ """Test Home Assistant date util methods.""" +from __future__ import annotations + from datetime import datetime, timedelta import pytest @@ -142,6 +144,32 @@ def test_parse_datetime_returns_none_for_incorrect_format(): assert dt_util.parse_datetime("not a datetime string") is None +@pytest.mark.parametrize( + "duration_string,expected_result", + [ + ("PT10M", timedelta(minutes=10)), + ("PT0S", timedelta(0)), + ("P10DT11H11M01S", timedelta(days=10, hours=11, minutes=11, seconds=1)), + ( + "4 1:20:30.111111", + timedelta(days=4, hours=1, minutes=20, seconds=30, microseconds=111111), + ), + ("4 1:2:30", timedelta(days=4, hours=1, minutes=2, seconds=30)), + ("3 days 04:05:06", timedelta(days=3, hours=4, minutes=5, seconds=6)), + ("P1YT10M", None), + ("P1MT10M", None), + ("1MT10M", None), + ("P1MT100M", None), + ("P1234", None), + ], +) +def test_parse_duration( + duration_string: str, expected_result: timedelta | None +) -> None: + """Test that parse_duration returns the expected result.""" + assert dt_util.parse_duration(duration_string) == expected_result + + def test_get_age(): """Test get_age.""" diff = dt_util.now() - timedelta(seconds=0)