diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index 9614762c..d88f88d0 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -62,6 +62,8 @@ APScheduler, see the :doc:`migration section `. acquire the same schedules at once - Changed ``SQLAlchemyDataStore`` to automatically create the explicitly specified schema if it's missing (PR by @zhu0629) +- Fixed an issue with ``CronTrigger`` infinitely looping to get next date when DST ends + (`#980 `_; PR by @hlobit) **4.0.0a5** diff --git a/src/apscheduler/triggers/cron/__init__.py b/src/apscheduler/triggers/cron/__init__.py index b0f17235..e34bd6b7 100644 --- a/src/apscheduler/triggers/cron/__init__.py +++ b/src/apscheduler/triggers/cron/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Sequence -from datetime import datetime, timedelta, tzinfo +from datetime import datetime, tzinfo from typing import Any, ClassVar import attrs @@ -207,16 +207,17 @@ def _set_field_value( else: values[field.name] = new_value - return datetime(**values, tzinfo=self.timezone) + return datetime(**values, tzinfo=self.timezone, fold=dateval.fold) def next(self) -> datetime | None: if self._last_fire_time: - start_time = self._last_fire_time + timedelta(microseconds=1) + next_time = datetime.fromtimestamp( + self._last_fire_time.timestamp() + 1, self.timezone + ) else: - start_time = self.start_time + next_time = self.start_time fieldnum = 0 - next_time = datetime_ceil(start_time).astimezone(self.timezone) while 0 <= fieldnum < len(self._fields): field = self._fields[fieldnum] curr_value = field.get_value(next_time) @@ -276,11 +277,3 @@ def __repr__(self) -> str: fields.append(f"timezone={timezone_repr(self.timezone)!r}") return f'CronTrigger({", ".join(fields)})' - - -def datetime_ceil(dateval: datetime) -> datetime: - """Round the given datetime object upwards.""" - if dateval.microsecond > 0: - return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond) - - return dateval diff --git a/tests/triggers/test_cron.py b/tests/triggers/test_cron.py index 58b46291..da1798e9 100644 --- a/tests/triggers/test_cron.py +++ b/tests/triggers/test_cron.py @@ -400,6 +400,44 @@ def test_dst_change( ) +@pytest.mark.parametrize( + "cron_expression, start_time, correct_next_dates", + [ + ( + "0 * * * *", + datetime(2024, 10, 27, 2, 0, 0, 0), + [ + (datetime(2024, 10, 27, 2, 0, 0, 0), 0), + (datetime(2024, 10, 27, 2, 0, 0, 0), 1), + (datetime(2024, 10, 27, 3, 0, 0, 0), 0), + ], + ), + ( + "1 * * * *", + datetime(2024, 10, 27, 2, 1, 0, 0), + [ + (datetime(2024, 10, 27, 2, 1, 0, 0), 0), + (datetime(2024, 10, 27, 2, 1, 0, 0), 1), + (datetime(2024, 10, 27, 3, 1, 0, 0), 0), + ], + ), + ], + ids=["dst_change_0", "dst_change_1"], +) +def test_dst_change2( + cron_expression, + start_time, + correct_next_dates, + timezone, +): + trigger = CronTrigger.from_crontab(cron_expression, timezone=timezone) + trigger.start_time = start_time.replace(tzinfo=timezone) + for correct_next_date, fold in correct_next_dates: + next_date = trigger.next() + assert next_date == correct_next_date.replace(tzinfo=timezone, fold=fold) + assert str(next_date) == str(correct_next_date) + + def test_zero_value(timezone): start_time = datetime(2020, 1, 1, tzinfo=timezone) trigger = CronTrigger(