Skip to content

Commit 0fe2c7c

Browse files
authored
Fixed a daylight savings time issue in CronTrigger (#981)
* Removed the `timedelta` operations - which are not timezone aware * Made sure the "fold" attribute remains when incrementing
1 parent 45a1dfa commit 0fe2c7c

File tree

3 files changed

+47
-13
lines changed

3 files changed

+47
-13
lines changed

docs/versionhistory.rst

+2
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ APScheduler, see the :doc:`migration section <migration>`.
6262
acquire the same schedules at once
6363
- Changed ``SQLAlchemyDataStore`` to automatically create the explicitly specified
6464
schema if it's missing (PR by @zhu0629)
65+
- Fixed an issue with ``CronTrigger`` infinitely looping to get next date when DST ends
66+
(`#980 <https://github.com/agronholm/apscheduler/issues/980>`_; PR by @hlobit)
6567

6668
**4.0.0a5**
6769

src/apscheduler/triggers/cron/__init__.py

+6-13
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from collections.abc import Sequence
4-
from datetime import datetime, timedelta, tzinfo
4+
from datetime import datetime, tzinfo
55
from typing import Any, ClassVar
66

77
import attrs
@@ -207,16 +207,17 @@ def _set_field_value(
207207
else:
208208
values[field.name] = new_value
209209

210-
return datetime(**values, tzinfo=self.timezone)
210+
return datetime(**values, tzinfo=self.timezone, fold=dateval.fold)
211211

212212
def next(self) -> datetime | None:
213213
if self._last_fire_time:
214-
start_time = self._last_fire_time + timedelta(microseconds=1)
214+
next_time = datetime.fromtimestamp(
215+
self._last_fire_time.timestamp() + 1, self.timezone
216+
)
215217
else:
216-
start_time = self.start_time
218+
next_time = self.start_time
217219

218220
fieldnum = 0
219-
next_time = datetime_ceil(start_time).astimezone(self.timezone)
220221
while 0 <= fieldnum < len(self._fields):
221222
field = self._fields[fieldnum]
222223
curr_value = field.get_value(next_time)
@@ -276,11 +277,3 @@ def __repr__(self) -> str:
276277

277278
fields.append(f"timezone={timezone_repr(self.timezone)!r}")
278279
return f"CronTrigger({', '.join(fields)})"
279-
280-
281-
def datetime_ceil(dateval: datetime) -> datetime:
282-
"""Round the given datetime object upwards."""
283-
if dateval.microsecond > 0:
284-
return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond)
285-
286-
return dateval

tests/triggers/test_cron.py

+39
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,45 @@ def test_dst_change(
400400
)
401401

402402

403+
@pytest.mark.parametrize(
404+
"minute, start_time, correct_next_dates",
405+
[
406+
(
407+
0,
408+
datetime(2024, 10, 27, 2, 0, 0, 0),
409+
[
410+
(datetime(2024, 10, 27, 2, 0, 0, 0), 0),
411+
(datetime(2024, 10, 27, 2, 0, 0, 0), 1),
412+
(datetime(2024, 10, 27, 3, 0, 0, 0), 0),
413+
],
414+
),
415+
(
416+
1,
417+
datetime(2024, 10, 27, 2, 1, 0, 0),
418+
[
419+
(datetime(2024, 10, 27, 2, 1, 0, 0), 0),
420+
(datetime(2024, 10, 27, 2, 1, 0, 0), 1),
421+
(datetime(2024, 10, 27, 3, 1, 0, 0), 0),
422+
],
423+
),
424+
],
425+
ids=["dst_change_0", "dst_change_1"],
426+
)
427+
def test_dst_change2(
428+
minute,
429+
start_time,
430+
correct_next_dates,
431+
timezone,
432+
):
433+
trigger = CronTrigger(minute=minute, timezone=timezone)
434+
trigger.start_time = start_time.replace(tzinfo=timezone)
435+
for correct_next_date, fold in correct_next_dates:
436+
correct_next_date = correct_next_date.replace(tzinfo=timezone, fold=fold)
437+
next_date = trigger.next()
438+
assert next_date == correct_next_date
439+
assert str(next_date) == str(correct_next_date)
440+
441+
403442
def test_zero_value(timezone):
404443
start_time = datetime(2020, 1, 1, tzinfo=timezone)
405444
trigger = CronTrigger(

0 commit comments

Comments
 (0)