Skip to content

Commit a388620

Browse files
Improved timezones handling in next_run (dbader#604)
1 parent 073dbc6 commit a388620

File tree

4 files changed

+272
-38
lines changed

4 files changed

+272
-38
lines changed

docs/timezones.rst

+23-11
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,30 @@ This causes the ``next_run`` and ``last_run`` to always be in Pythons local time
2929

3030
Daylight Saving Time
3131
~~~~~~~~~~~~~~~~~~~~
32-
When scheduling jobs with relative time (that is when not using ``.at()``), daylight saving time (DST) is **not** taken into account.
33-
A job that is set to run every 4 hours might execute after 3 realtime hours when DST goes into effect.
34-
This is because schedule is timezone-unaware for relative times.
35-
36-
However, when using ``.at()``, DST **is** handed correctly: the job will always run at (or close after) the set timestamp.
37-
A job scheduled during a moment that is skipped, the job will execute after the clock is moved.
38-
For example, a job is scheduled ``.at("02:30")``, clock moves from ``02:00`` to ``03:00``, the job will run at ``03:00``.
39-
40-
Example
41-
~~~~~~~
32+
Scheduling jobs that do not specify a timezone do **not** take clock-changes into account.
33+
Timezone unaware jobs will use naive local times to calculate the next run.
34+
For example, a job that is set to run every 4 hours might execute after 3 realtime hours when DST goes into effect.
35+
36+
But when passing a timezone to ``.at()``, DST **is** taken into account.
37+
The job will run at the specified time, even when the clock changes.
38+
39+
Example clock moves forward:
40+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
41+
A job is scheduled ``.at("02:30", "Europe/Berlin")``.
42+
When the clock moves from ``02:00`` to ``03:00``, the job will run once at ``03:30``.
43+
The day after it will return to normal and run at ``02:30``.
44+
45+
Example clock moves backwards:
46+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
47+
A job is scheduled ``.at("02:30", "Europe/Berlin")``.
48+
When the clock moves from ``02:00`` to ``03:00``, the job will run once at ``02:30``.
49+
It will run only at the first time the clock hits ``02:30``, but not the second time.
50+
The day after, it will return to normal and run at ``02:30``.
51+
52+
Example scheduling across timezones
53+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4254
Let's say we are in ``Europe/Berlin`` and local datetime is ``2022 march 20, 10:00:00``.
43-
At the moment daylight saving time is not in effect in Berlin (UTC+1).
55+
At this moment daylight saving time is not in effect in Berlin (UTC+1).
4456

4557
We schedule a job to run every day at 10:30:00 in America/New_York.
4658
At this time, daylight saving time is in effect in New York (UTC-4).

schedule/__init__.py

+47-14
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,6 @@ def tag(self, *tags: Hashable):
470470
return self
471471

472472
def at(self, time_str: str, tz: Optional[str] = None):
473-
474473
"""
475474
Specify a particular time that the job should be run at.
476475
@@ -716,8 +715,14 @@ def _schedule_next_run(self) -> None:
716715
else:
717716
interval = self.interval
718717

718+
# Do all computation in the context of the requested timezone
719+
if self.at_time_zone is not None:
720+
now = datetime.datetime.now(self.at_time_zone)
721+
else:
722+
now = datetime.datetime.now()
723+
719724
self.period = datetime.timedelta(**{self.unit: interval})
720-
self.next_run = datetime.datetime.now() + self.period
725+
self.next_run = now + self.period
721726
if self.start_day is not None:
722727
if self.unit != "weeks":
723728
raise ScheduleValueError("`unit` should be 'weeks'")
@@ -739,6 +744,12 @@ def _schedule_next_run(self) -> None:
739744
if days_ahead <= 0: # Target day already happened this week
740745
days_ahead += 7
741746
self.next_run += datetime.timedelta(days_ahead) - self.period
747+
748+
# before we apply the .at() time, we need to normalize the timestamp
749+
# to ensure we change the time elements in the new timezone
750+
if self.at_time_zone is not None:
751+
self.next_run = self.at_time_zone.normalize(self.next_run)
752+
742753
if self.at_time is not None:
743754
if self.unit not in ("days", "hours", "minutes") and self.start_day is None:
744755
raise ScheduleValueError("Invalid unit without specifying start day")
@@ -747,22 +758,14 @@ def _schedule_next_run(self) -> None:
747758
kwargs["hour"] = self.at_time.hour
748759
if self.unit in ["days", "hours"] or self.start_day is not None:
749760
kwargs["minute"] = self.at_time.minute
750-
self.next_run = self.next_run.replace(**kwargs) # type: ignore
751761

752-
if self.at_time_zone is not None:
753-
# Convert next_run from the expected timezone into the local time
754-
# self.next_run is a naive datetime so after conversion remove tzinfo
755-
self.next_run = (
756-
self.at_time_zone.localize(self.next_run)
757-
.astimezone()
758-
.replace(tzinfo=None)
759-
)
762+
self.next_run = self.next_run.replace(**kwargs) # type: ignore
760763

761764
# Make sure we run at the specified time *today* (or *this hour*)
762765
# as well. This accounts for when a job takes so long it finished
763766
# in the next period.
764-
if not self.last_run or (self.next_run - self.last_run) > self.period:
765-
now = datetime.datetime.now()
767+
last_run_tz = self._to_at_timezone(self.last_run)
768+
if not last_run_tz or (self.next_run - last_run_tz) > self.period:
766769
if (
767770
self.unit == "days"
768771
and self.next_run.time() > now.time()
@@ -781,9 +784,39 @@ def _schedule_next_run(self) -> None:
781784
self.next_run = self.next_run - datetime.timedelta(minutes=1)
782785
if self.start_day is not None and self.at_time is not None:
783786
# Let's see if we will still make that time we specified today
784-
if (self.next_run - datetime.datetime.now()).days >= 7:
787+
if (self.next_run - now).days >= 7:
785788
self.next_run -= self.period
786789

790+
# Calculations happen in the configured timezone, but to execute the schedule we
791+
# need to know the next_run time in the system time. So we convert back to naive local
792+
if self.at_time_zone is not None:
793+
self.next_run = self._normalize_preserve_timestamp(self.next_run)
794+
self.next_run = self.next_run.astimezone().replace(tzinfo=None)
795+
796+
# Usually when normalization of a timestamp causes the timestamp to change,
797+
# it preserves the moment in time and changes the local timestamp.
798+
# This method applies pytz normalization but preserves the local timestamp, in fact changing the moment in time.
799+
def _normalize_preserve_timestamp(
800+
self, input: datetime.datetime
801+
) -> datetime.datetime:
802+
if self.at_time_zone is None or input is None:
803+
return input
804+
normalized = self.at_time_zone.normalize(input)
805+
return normalized.replace(
806+
day=input.day,
807+
hour=input.hour,
808+
minute=input.minute,
809+
second=input.second,
810+
microsecond=input.microsecond,
811+
)
812+
813+
def _to_at_timezone(
814+
self, input: Optional[datetime.datetime]
815+
) -> Optional[datetime.datetime]:
816+
if self.at_time_zone is None or input is None:
817+
return input
818+
return input.astimezone(self.at_time_zone)
819+
787820
def _is_overdue(self, when: datetime.datetime):
788821
return self.cancel_after is not None and when > self.cancel_after
789822

test_schedule.py

+197-8
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,18 @@ def today(cls):
5858
return cls(self.year, self.month, self.day)
5959

6060
@classmethod
61-
def now(cls):
62-
return cls(
61+
def now(cls, tz=None):
62+
mock_date = cls(
6363
self.year,
6464
self.month,
6565
self.day,
6666
self.hour,
6767
self.minute,
6868
self.second,
6969
)
70+
if tz:
71+
return mock_date.astimezone(tz)
72+
return mock_date
7073

7174
self.original_datetime = datetime.datetime
7275
datetime.datetime = MockDate
@@ -500,26 +503,75 @@ def test_next_run_time_day_end(self):
500503
assert job.next_run.hour == 23
501504

502505
def test_next_run_time_hour_end(self):
506+
try:
507+
import pytz
508+
except ModuleNotFoundError:
509+
self.skipTest("pytz unavailable")
510+
511+
self.tst_next_run_time_hour_end(None, 0)
512+
513+
def test_next_run_time_hour_end_london(self):
514+
try:
515+
import pytz
516+
except ModuleNotFoundError:
517+
self.skipTest("pytz unavailable")
518+
519+
self.tst_next_run_time_hour_end("Europe/London", 0)
520+
521+
def test_next_run_time_hour_end_katmandu(self):
522+
try:
523+
import pytz
524+
except ModuleNotFoundError:
525+
self.skipTest("pytz unavailable")
526+
527+
# 12:00 in Berlin is 15:45 in Kathmandu
528+
# this test schedules runs at :10 minutes, so job runs at
529+
# 16:10 in Kathmandu, which is 13:25 in Berlin
530+
# in local time we don't run at :10, but at :25, offset of 15 minutes
531+
self.tst_next_run_time_hour_end("Asia/Kathmandu", 15)
532+
533+
def tst_next_run_time_hour_end(self, tz, offsetMinutes):
503534
mock_job = make_mock_job()
535+
536+
# So a job scheduled to run at :10 in Kathmandu, runs always 25 minutes
504537
with mock_datetime(2010, 10, 10, 12, 0, 0):
505-
job = every().hour.at(":10").do(mock_job)
538+
job = every().hour.at(":10", tz).do(mock_job)
506539
assert job.next_run.hour == 12
507-
assert job.next_run.minute == 10
540+
assert job.next_run.minute == 10 + offsetMinutes
508541

509542
with mock_datetime(2010, 10, 10, 13, 0, 0):
510543
job.run()
511544
assert job.next_run.hour == 13
512-
assert job.next_run.minute == 10
545+
assert job.next_run.minute == 10 + offsetMinutes
513546

514-
with mock_datetime(2010, 10, 10, 13, 15, 0):
547+
with mock_datetime(2010, 10, 10, 13, 30, 0):
515548
job.run()
516549
assert job.next_run.hour == 14
517-
assert job.next_run.minute == 10
550+
assert job.next_run.minute == 10 + offsetMinutes
518551

519552
def test_next_run_time_minute_end(self):
553+
self.tst_next_run_time_minute_end(None)
554+
555+
def test_next_run_time_minute_end_london(self):
556+
try:
557+
import pytz
558+
except ModuleNotFoundError:
559+
self.skipTest("pytz unavailable")
560+
561+
self.tst_next_run_time_minute_end("Europe/London")
562+
563+
def test_next_run_time_minute_end_katmhandu(self):
564+
try:
565+
import pytz
566+
except ModuleNotFoundError:
567+
self.skipTest("pytz unavailable")
568+
569+
self.tst_next_run_time_minute_end("Asia/Kathmandu")
570+
571+
def tst_next_run_time_minute_end(self, tz):
520572
mock_job = make_mock_job()
521573
with mock_datetime(2010, 10, 10, 10, 10, 0):
522-
job = every().minute.at(":15").do(mock_job)
574+
job = every().minute.at(":15", tz).do(mock_job)
523575
assert job.next_run.minute == 10
524576
assert job.next_run.second == 15
525577

@@ -585,6 +637,40 @@ def test_at_timezone(self):
585637
assert next.hour == 15
586638
assert next.minute == 30
587639

640+
# Test the DST-case that is described in the documentation
641+
with mock_datetime(2023, 3, 26, 1, 30):
642+
# Current Berlin time: 01:30 (NOT during daylight saving)
643+
# Expected to run: 02:30 - this time doesn't exist
644+
# because clock moves from 02:00 to 03:00
645+
# Next run: 03:30
646+
job = every().day.at("02:30", "Europe/Berlin").do(mock_job)
647+
assert job.next_run.day == 26
648+
assert job.next_run.hour == 3
649+
assert job.next_run.minute == 30
650+
with mock_datetime(2023, 3, 27, 1, 30):
651+
# the next day the job shall again run at 02:30
652+
job.run()
653+
assert job.next_run.day == 27
654+
assert job.next_run.hour == 2
655+
assert job.next_run.minute == 30
656+
657+
# Test the DST-case that is described in the documentation
658+
with mock_datetime(2023, 10, 29, 1, 30):
659+
# Current Berlin time: 01:30 (during daylight saving)
660+
# Expected to run: 02:30 - this time exists twice
661+
# because clock moves from 03:00 to 02:00
662+
# Next run should be at the first occurrence of 02:30
663+
job = every().day.at("02:30", "Europe/Berlin").do(mock_job)
664+
assert job.next_run.day == 29
665+
assert job.next_run.hour == 2
666+
assert job.next_run.minute == 30
667+
with mock_datetime(2023, 10, 29, 2, 35):
668+
# After the job runs, the next run should be scheduled on the next day at 02:30
669+
job.run()
670+
assert job.next_run.day == 30
671+
assert job.next_run.hour == 2
672+
assert job.next_run.minute == 30
673+
588674
with mock_datetime(2022, 3, 20, 10, 0):
589675
# Current Berlin time: 10:00 (local) (NOT during daylight saving)
590676
# Current Krasnoyarsk time: 16:00
@@ -628,6 +714,109 @@ def test_at_timezone(self):
628714
assert next.hour == 13
629715
assert next.minute == 45
630716

717+
with mock_datetime(2023, 10, 19, 15, 0, 0, TZ_UTC):
718+
# Testing issue #603
719+
# Current UTC: oktober-19 15:00
720+
# Current Amsterdam: oktober-19 17:00 (daylight saving active)
721+
# Expected run Amsterdam: oktober-20 00:00:20 (daylight saving active)
722+
# Next run UTC time: oktober-19 22:00:20
723+
schedule.clear()
724+
next = every().day.at("00:00:20", "Europe/Amsterdam").do(mock_job).next_run
725+
assert next.day == 19
726+
assert next.hour == 22
727+
assert next.minute == 00
728+
assert next.second == 20
729+
730+
with mock_datetime(2023, 10, 22, 23, 0, 0, TZ_UTC):
731+
# Current UTC: sunday 22-okt 23:00
732+
# Current Amsterdam: monday 23-okt 01:00 (daylight saving active)
733+
# Expected run Amsterdam: sunday 29 oktober 23:00 (daylight saving NOT active)
734+
# Next run UTC time: oktober-29 22:00
735+
schedule.clear()
736+
next = every().sunday.at("23:00", "Europe/Amsterdam").do(mock_job).next_run
737+
assert next.day == 29
738+
assert next.hour == 22
739+
assert next.minute == 00
740+
741+
with mock_datetime(2023, 12, 31, 23, 0, 0):
742+
# Current Berlin time: dec-31 23:00 (local)
743+
# Current Sydney time: jan-1 09:00 (next day)
744+
# Expected to run Sydney time: jan-1 12:00
745+
# Next run Berlin time: jan-1 02:00
746+
next = every().day.at("12:00", "Australia/Sydney").do(mock_job).next_run
747+
assert next.day == 1
748+
assert next.hour == 2
749+
assert next.minute == 0
750+
751+
with mock_datetime(2023, 3, 26, 1, 30):
752+
# Daylight Saving Time starts in Berlin
753+
# Current Berlin time: march-26 01:30 (30 mintues before moving to 03:00 due to DST)
754+
# Current London time: march-26 00:30 (30 mintues before moving to 02:00 due to DST)
755+
# Expected to run London time: march-26 02:00 (which is equal to 01:00 due to DST)
756+
# Next run Berlin time: march-26 03:00
757+
next = every().day.at("01:00", "Europe/London").do(mock_job).next_run
758+
assert next.day == 26
759+
assert next.hour == 3
760+
assert next.minute == 0
761+
762+
with mock_datetime(2023, 10, 29, 2, 30):
763+
# Daylight Saving Time ends in Berlin
764+
# Current Berlin time: oct-29 02:30 (after moving back to 02:00 due to DST end)
765+
# Current Istanbul time: oct-29 04:30
766+
# Expected to run Istanbul time: oct-29 06:00
767+
# Next run Berlin time: oct-29 04:00
768+
next = every().day.at("06:00", "Europe/Istanbul").do(mock_job).next_run
769+
assert next.hour == 4
770+
assert next.minute == 0
771+
772+
with mock_datetime(2023, 12, 31, 23, 50):
773+
# End of the year in Berlin
774+
# Current Berlin time: dec-31 23:50
775+
# Current Tokyo time: jan-1 07:50 (next day)
776+
# Expected to run Tokyo time: jan-1 09:00
777+
# Next run Berlin time: jan-1 01:00
778+
next = every().day.at("09:00", "Asia/Tokyo").do(mock_job).next_run
779+
assert next.day == 1
780+
assert next.hour == 1
781+
assert next.minute == 0
782+
783+
with mock_datetime(2023, 2, 28, 23, 50):
784+
# End of the month (non-leap year) in Berlin
785+
# Current Berlin time: feb-28 23:50
786+
# Current Sydney time: mar-1 09:50 (next day)
787+
# Expected to run Sydney time: mar-1 10:00
788+
# Next run Berlin time: mar-1 00:00
789+
next = every().day.at("10:00", "Australia/Sydney").do(mock_job).next_run
790+
assert next.day == 1
791+
assert next.hour == 0
792+
assert next.minute == 0
793+
794+
with mock_datetime(2024, 2, 28, 23, 50):
795+
# End of the month (leap year) in Berlin
796+
# Current Berlin time: feb-28 23:50
797+
# Current Dubai time: feb-29 02:50
798+
# Expected to run Dubai time: feb-29 04:00
799+
# Next run Berlin time: feb-29 01:00
800+
next = every().day.at("04:00", "Asia/Dubai").do(mock_job).next_run
801+
assert next.month == 2
802+
assert next.day == 29
803+
assert next.hour == 1
804+
assert next.minute == 0
805+
806+
with mock_datetime(2023, 9, 18, 10, 00, 0, TZ_AUCKLAND):
807+
schedule.clear()
808+
# Testing issue #605
809+
# Current time: Monday 18 September 10:00 NZST
810+
# Current time UTC: Sunday 17 September 22:00
811+
# We expect the job to run at 23:00 on Sunday 17 September NZST
812+
# That is an expected idle time of 1 hour
813+
# Expected next run in NZST: 2023-09-18 11:00:00
814+
next = schedule.every().day.at("23:00", "UTC").do(mock_job).next_run
815+
assert round(schedule.idle_seconds() / 3600) == 1
816+
assert next.day == 18
817+
assert next.hour == 11
818+
assert next.minute == 0
819+
631820
with self.assertRaises(pytz.exceptions.UnknownTimeZoneError):
632821
every().day.at("10:30", "FakeZone").do(mock_job)
633822

0 commit comments

Comments
 (0)