From 1cd9be7538ebd5c979f5c62523c3476af6866b15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Nov 2021 18:11:17 -0500 Subject: [PATCH] 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 --- homeassistant/util/dt.py | 179 ++++++++++++++++++------------------ tests/helpers/test_event.py | 66 +++++++++++++ 2 files changed, 156 insertions(+), 89 deletions(-) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 592b47ab6b1..39f8a63e53f 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -245,6 +245,16 @@ def _dst_offset_diff(dattim: dt.datetime) -> dt.timedelta: 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( now: dt.datetime, # pylint: disable=redefined-outer-name seconds: list[int], @@ -263,108 +273,99 @@ def find_next_time_expression_time( if not seconds or not minutes or not hours: raise ValueError("Cannot find a next time: Time expression never matches!") - def _lower_bound(arr: list[int], cmp: int) -> int | None: - """Return the first value in arr greater or equal to cmp. + while True: + # 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. - """ - if (left := bisect.bisect_left(arr, cmp)) == len(arr): - return None - return arr[left] + # Match next second + if (next_second := _lower_bound(seconds, result.second)) is None: + # No second to match in this minute. Roll-over to next minute. + next_second = seconds[0] + result += dt.timedelta(minutes=1) - # Reset microseconds and fold; fold (for ambiguous DST times) will be handled later - result = now.replace(microsecond=0, fold=0) + result = result.replace(second=next_second) - # Match next second - if (next_second := _lower_bound(seconds, result.second)) is None: - # No second to match in this minute. Roll-over to next minute. - next_second = seconds[0] - result += dt.timedelta(minutes=1) + # Match 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]) - 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 - 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]) + result = result.replace(minute=next_minute) - 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 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]) - 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 - 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]) + result = result.replace(hour=next_hour) - 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) + if result.tzinfo in (None, UTC): + # Using UTC, no DST checking needed + return result - 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 - 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: """Check if a datetime exists.""" diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index f0b7a2c5d2d..9d48b0c0ada 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3452,6 +3452,72 @@ async def test_periodic_task_entering_dst(hass): 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): """Test periodic task behavior when leaving dst.""" timezone = dt_util.get_time_zone("Europe/Vienna")