Fix recursive limit in find_next_time_expression_time (#58914)
* Fix recursive limit in find_next_time_expression_time * Add test case * Update test_event.py Co-authored-by: Erik Montnemery <erik@montnemery.com>pull/58977/head
parent
53cc9f35b9
commit
6cd256f26b
|
@ -245,6 +245,16 @@ def _dst_offset_diff(dattim: dt.datetime) -> dt.timedelta:
|
||||||
return (dattim + delta).utcoffset() - (dattim - delta).utcoffset() # type: ignore[operator]
|
return (dattim + delta).utcoffset() - (dattim - delta).utcoffset() # type: ignore[operator]
|
||||||
|
|
||||||
|
|
||||||
|
def _lower_bound(arr: list[int], cmp: int) -> int | None:
|
||||||
|
"""Return the first value in arr greater or equal to cmp.
|
||||||
|
|
||||||
|
Return None if no such value exists.
|
||||||
|
"""
|
||||||
|
if (left := bisect.bisect_left(arr, cmp)) == len(arr):
|
||||||
|
return None
|
||||||
|
return arr[left]
|
||||||
|
|
||||||
|
|
||||||
def find_next_time_expression_time(
|
def find_next_time_expression_time(
|
||||||
now: dt.datetime, # pylint: disable=redefined-outer-name
|
now: dt.datetime, # pylint: disable=redefined-outer-name
|
||||||
seconds: list[int],
|
seconds: list[int],
|
||||||
|
@ -263,108 +273,99 @@ def find_next_time_expression_time(
|
||||||
if not seconds or not minutes or not hours:
|
if not seconds or not minutes or not hours:
|
||||||
raise ValueError("Cannot find a next time: Time expression never matches!")
|
raise ValueError("Cannot find a next time: Time expression never matches!")
|
||||||
|
|
||||||
def _lower_bound(arr: list[int], cmp: int) -> int | None:
|
while True:
|
||||||
"""Return the first value in arr greater or equal to cmp.
|
# Reset microseconds and fold; fold (for ambiguous DST times) will be handled later
|
||||||
|
result = now.replace(microsecond=0, fold=0)
|
||||||
|
|
||||||
Return None if no such value exists.
|
# Match next second
|
||||||
"""
|
if (next_second := _lower_bound(seconds, result.second)) is None:
|
||||||
if (left := bisect.bisect_left(arr, cmp)) == len(arr):
|
# No second to match in this minute. Roll-over to next minute.
|
||||||
return None
|
next_second = seconds[0]
|
||||||
return arr[left]
|
result += dt.timedelta(minutes=1)
|
||||||
|
|
||||||
# Reset microseconds and fold; fold (for ambiguous DST times) will be handled later
|
result = result.replace(second=next_second)
|
||||||
result = now.replace(microsecond=0, fold=0)
|
|
||||||
|
|
||||||
# Match next second
|
# Match next minute
|
||||||
if (next_second := _lower_bound(seconds, result.second)) is None:
|
next_minute = _lower_bound(minutes, result.minute)
|
||||||
# No second to match in this minute. Roll-over to next minute.
|
if next_minute != result.minute:
|
||||||
next_second = seconds[0]
|
# We're in the next minute. Seconds needs to be reset.
|
||||||
result += dt.timedelta(minutes=1)
|
result = result.replace(second=seconds[0])
|
||||||
|
|
||||||
result = result.replace(second=next_second)
|
if next_minute is None:
|
||||||
|
# No minute to match in this hour. Roll-over to next hour.
|
||||||
|
next_minute = minutes[0]
|
||||||
|
result += dt.timedelta(hours=1)
|
||||||
|
|
||||||
# Match next minute
|
result = result.replace(minute=next_minute)
|
||||||
next_minute = _lower_bound(minutes, result.minute)
|
|
||||||
if next_minute != result.minute:
|
|
||||||
# We're in the next minute. Seconds needs to be reset.
|
|
||||||
result = result.replace(second=seconds[0])
|
|
||||||
|
|
||||||
if next_minute is None:
|
# Match next hour
|
||||||
# No minute to match in this hour. Roll-over to next hour.
|
next_hour = _lower_bound(hours, result.hour)
|
||||||
next_minute = minutes[0]
|
if next_hour != result.hour:
|
||||||
result += dt.timedelta(hours=1)
|
# We're in the next hour. Seconds+minutes needs to be reset.
|
||||||
|
result = result.replace(second=seconds[0], minute=minutes[0])
|
||||||
|
|
||||||
result = result.replace(minute=next_minute)
|
if next_hour is None:
|
||||||
|
# No minute to match in this day. Roll-over to next day.
|
||||||
|
next_hour = hours[0]
|
||||||
|
result += dt.timedelta(days=1)
|
||||||
|
|
||||||
# Match next hour
|
result = result.replace(hour=next_hour)
|
||||||
next_hour = _lower_bound(hours, result.hour)
|
|
||||||
if next_hour != result.hour:
|
|
||||||
# We're in the next hour. Seconds+minutes needs to be reset.
|
|
||||||
result = result.replace(second=seconds[0], minute=minutes[0])
|
|
||||||
|
|
||||||
if next_hour is None:
|
if result.tzinfo in (None, UTC):
|
||||||
# No minute to match in this day. Roll-over to next day.
|
# Using UTC, no DST checking needed
|
||||||
next_hour = hours[0]
|
return result
|
||||||
result += dt.timedelta(days=1)
|
|
||||||
|
|
||||||
result = result.replace(hour=next_hour)
|
if not _datetime_exists(result):
|
||||||
|
# When entering DST and clocks are turned forward.
|
||||||
|
# There are wall clock times that don't "exist" (an hour is skipped).
|
||||||
|
|
||||||
|
# -> trigger on the next time that 1. matches the pattern and 2. does exist
|
||||||
|
# for example:
|
||||||
|
# on 2021.03.28 02:00:00 in CET timezone clocks are turned forward an hour
|
||||||
|
# with pattern "02:30", don't run on 28 mar (such a wall time does not exist on this day)
|
||||||
|
# instead run at 02:30 the next day
|
||||||
|
|
||||||
|
# We solve this edge case by just iterating one second until the result exists
|
||||||
|
# (max. 3600 operations, which should be fine for an edge case that happens once a year)
|
||||||
|
now += dt.timedelta(seconds=1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
now_is_ambiguous = _datetime_ambiguous(now)
|
||||||
|
result_is_ambiguous = _datetime_ambiguous(result)
|
||||||
|
|
||||||
|
# When leaving DST and clocks are turned backward.
|
||||||
|
# Then there are wall clock times that are ambiguous i.e. exist with DST and without DST
|
||||||
|
# The logic above does not take into account if a given pattern matches _twice_
|
||||||
|
# in a day.
|
||||||
|
# Example: on 2021.10.31 02:00:00 in CET timezone clocks are turned backward an hour
|
||||||
|
|
||||||
|
if now_is_ambiguous and result_is_ambiguous:
|
||||||
|
# `now` and `result` are both ambiguous, so the next match happens
|
||||||
|
# _within_ the current fold.
|
||||||
|
|
||||||
|
# Examples:
|
||||||
|
# 1. 2021.10.31 02:00:00+02:00 with pattern 02:30 -> 2021.10.31 02:30:00+02:00
|
||||||
|
# 2. 2021.10.31 02:00:00+01:00 with pattern 02:30 -> 2021.10.31 02:30:00+01:00
|
||||||
|
return result.replace(fold=now.fold)
|
||||||
|
|
||||||
|
if now_is_ambiguous and now.fold == 0 and not result_is_ambiguous:
|
||||||
|
# `now` is in the first fold, but result is not ambiguous (meaning it no longer matches
|
||||||
|
# within the fold).
|
||||||
|
# -> Check if result matches in the next fold. If so, emit that match
|
||||||
|
|
||||||
|
# Turn back the time by the DST offset, effectively run the algorithm on the first fold
|
||||||
|
# If it matches on the first fold, that means it will also match on the second one.
|
||||||
|
|
||||||
|
# Example: 2021.10.31 02:45:00+02:00 with pattern 02:30 -> 2021.10.31 02:30:00+01:00
|
||||||
|
|
||||||
|
check_result = find_next_time_expression_time(
|
||||||
|
now + _dst_offset_diff(now), seconds, minutes, hours
|
||||||
|
)
|
||||||
|
if _datetime_ambiguous(check_result):
|
||||||
|
return check_result.replace(fold=1)
|
||||||
|
|
||||||
if result.tzinfo in (None, UTC):
|
|
||||||
# Using UTC, no DST checking needed
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if not _datetime_exists(result):
|
|
||||||
# When entering DST and clocks are turned forward.
|
|
||||||
# There are wall clock times that don't "exist" (an hour is skipped).
|
|
||||||
|
|
||||||
# -> trigger on the next time that 1. matches the pattern and 2. does exist
|
|
||||||
# for example:
|
|
||||||
# on 2021.03.28 02:00:00 in CET timezone clocks are turned forward an hour
|
|
||||||
# with pattern "02:30", don't run on 28 mar (such a wall time does not exist on this day)
|
|
||||||
# instead run at 02:30 the next day
|
|
||||||
|
|
||||||
# We solve this edge case by just iterating one second until the result exists
|
|
||||||
# (max. 3600 operations, which should be fine for an edge case that happens once a year)
|
|
||||||
return find_next_time_expression_time(
|
|
||||||
result + dt.timedelta(seconds=1), seconds, minutes, hours
|
|
||||||
)
|
|
||||||
|
|
||||||
now_is_ambiguous = _datetime_ambiguous(now)
|
|
||||||
result_is_ambiguous = _datetime_ambiguous(result)
|
|
||||||
|
|
||||||
# When leaving DST and clocks are turned backward.
|
|
||||||
# Then there are wall clock times that are ambiguous i.e. exist with DST and without DST
|
|
||||||
# The logic above does not take into account if a given pattern matches _twice_
|
|
||||||
# in a day.
|
|
||||||
# Example: on 2021.10.31 02:00:00 in CET timezone clocks are turned backward an hour
|
|
||||||
|
|
||||||
if now_is_ambiguous and result_is_ambiguous:
|
|
||||||
# `now` and `result` are both ambiguous, so the next match happens
|
|
||||||
# _within_ the current fold.
|
|
||||||
|
|
||||||
# Examples:
|
|
||||||
# 1. 2021.10.31 02:00:00+02:00 with pattern 02:30 -> 2021.10.31 02:30:00+02:00
|
|
||||||
# 2. 2021.10.31 02:00:00+01:00 with pattern 02:30 -> 2021.10.31 02:30:00+01:00
|
|
||||||
return result.replace(fold=now.fold)
|
|
||||||
|
|
||||||
if now_is_ambiguous and now.fold == 0 and not result_is_ambiguous:
|
|
||||||
# `now` is in the first fold, but result is not ambiguous (meaning it no longer matches
|
|
||||||
# within the fold).
|
|
||||||
# -> Check if result matches in the next fold. If so, emit that match
|
|
||||||
|
|
||||||
# Turn back the time by the DST offset, effectively run the algorithm on the first fold
|
|
||||||
# If it matches on the first fold, that means it will also match on the second one.
|
|
||||||
|
|
||||||
# Example: 2021.10.31 02:45:00+02:00 with pattern 02:30 -> 2021.10.31 02:30:00+01:00
|
|
||||||
|
|
||||||
check_result = find_next_time_expression_time(
|
|
||||||
now + _dst_offset_diff(now), seconds, minutes, hours
|
|
||||||
)
|
|
||||||
if _datetime_ambiguous(check_result):
|
|
||||||
return check_result.replace(fold=1)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _datetime_exists(dattim: dt.datetime) -> bool:
|
def _datetime_exists(dattim: dt.datetime) -> bool:
|
||||||
"""Check if a datetime exists."""
|
"""Check if a datetime exists."""
|
||||||
|
|
|
@ -3452,6 +3452,72 @@ async def test_periodic_task_entering_dst(hass):
|
||||||
unsub()
|
unsub()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_periodic_task_entering_dst_2(hass):
|
||||||
|
"""Test periodic task behavior when entering dst.
|
||||||
|
|
||||||
|
This tests a task firing every second in the range 0..58 (not *:*:59)
|
||||||
|
"""
|
||||||
|
timezone = dt_util.get_time_zone("Europe/Vienna")
|
||||||
|
dt_util.set_default_time_zone(timezone)
|
||||||
|
specific_runs = []
|
||||||
|
|
||||||
|
# DST starts early morning March 27th 2022
|
||||||
|
yy = 2022
|
||||||
|
mm = 3
|
||||||
|
dd = 27
|
||||||
|
|
||||||
|
# There's no 2022-03-27 02:00:00, the event should not fire until 2022-03-28 03:00:00
|
||||||
|
time_that_will_not_match_right_away = datetime(
|
||||||
|
yy, mm, dd, 1, 59, 59, tzinfo=timezone, fold=0
|
||||||
|
)
|
||||||
|
# Make sure we enter DST during the test
|
||||||
|
assert (
|
||||||
|
time_that_will_not_match_right_away.utcoffset()
|
||||||
|
!= (time_that_will_not_match_right_away + timedelta(hours=2)).utcoffset()
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
|
||||||
|
):
|
||||||
|
unsub = async_track_time_change(
|
||||||
|
hass,
|
||||||
|
callback(lambda x: specific_runs.append(x)),
|
||||||
|
second=list(range(59)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async_fire_time_changed(
|
||||||
|
hass, datetime(yy, mm, dd, 1, 59, 59, 999999, tzinfo=timezone)
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(specific_runs) == 0
|
||||||
|
|
||||||
|
async_fire_time_changed(
|
||||||
|
hass, datetime(yy, mm, dd, 3, 0, 0, 999999, tzinfo=timezone)
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(specific_runs) == 1
|
||||||
|
|
||||||
|
async_fire_time_changed(
|
||||||
|
hass, datetime(yy, mm, dd, 3, 0, 1, 999999, tzinfo=timezone)
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(specific_runs) == 2
|
||||||
|
|
||||||
|
async_fire_time_changed(
|
||||||
|
hass, datetime(yy, mm, dd + 1, 1, 59, 59, 999999, tzinfo=timezone)
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(specific_runs) == 3
|
||||||
|
|
||||||
|
async_fire_time_changed(
|
||||||
|
hass, datetime(yy, mm, dd + 1, 2, 0, 0, 999999, tzinfo=timezone)
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(specific_runs) == 4
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
|
||||||
async def test_periodic_task_leaving_dst(hass):
|
async def test_periodic_task_leaving_dst(hass):
|
||||||
"""Test periodic task behavior when leaving dst."""
|
"""Test periodic task behavior when leaving dst."""
|
||||||
timezone = dt_util.get_time_zone("Europe/Vienna")
|
timezone = dt_util.get_time_zone("Europe/Vienna")
|
||||||
|
|
Loading…
Reference in New Issue