Skip to content

Commit

Permalink
Improved timezones handling in next_run (#604)
Browse files Browse the repository at this point in the history
  • Loading branch information
SijmenHuizenga committed Nov 17, 2023
1 parent 073dbc6 commit a388620
Show file tree
Hide file tree
Showing 4 changed files with 272 additions and 38 deletions.
34 changes: 23 additions & 11 deletions docs/timezones.rst
Expand Up @@ -29,18 +29,30 @@ This causes the ``next_run`` and ``last_run`` to always be in Pythons local time

Daylight Saving Time
~~~~~~~~~~~~~~~~~~~~
When scheduling jobs with relative time (that is when not using ``.at()``), daylight saving time (DST) is **not** taken into account.
A job that is set to run every 4 hours might execute after 3 realtime hours when DST goes into effect.
This is because schedule is timezone-unaware for relative times.

However, when using ``.at()``, DST **is** handed correctly: the job will always run at (or close after) the set timestamp.
A job scheduled during a moment that is skipped, the job will execute after the clock is moved.
For example, a job is scheduled ``.at("02:30")``, clock moves from ``02:00`` to ``03:00``, the job will run at ``03:00``.

Example
~~~~~~~
Scheduling jobs that do not specify a timezone do **not** take clock-changes into account.
Timezone unaware jobs will use naive local times to calculate the next run.
For example, a job that is set to run every 4 hours might execute after 3 realtime hours when DST goes into effect.

But when passing a timezone to ``.at()``, DST **is** taken into account.
The job will run at the specified time, even when the clock changes.

Example clock moves forward:
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A job is scheduled ``.at("02:30", "Europe/Berlin")``.
When the clock moves from ``02:00`` to ``03:00``, the job will run once at ``03:30``.
The day after it will return to normal and run at ``02:30``.

Example clock moves backwards:
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A job is scheduled ``.at("02:30", "Europe/Berlin")``.
When the clock moves from ``02:00`` to ``03:00``, the job will run once at ``02:30``.
It will run only at the first time the clock hits ``02:30``, but not the second time.
The day after, it will return to normal and run at ``02:30``.

Example scheduling across timezones
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Let's say we are in ``Europe/Berlin`` and local datetime is ``2022 march 20, 10:00:00``.
At the moment daylight saving time is not in effect in Berlin (UTC+1).
At this moment daylight saving time is not in effect in Berlin (UTC+1).

We schedule a job to run every day at 10:30:00 in America/New_York.
At this time, daylight saving time is in effect in New York (UTC-4).
Expand Down
61 changes: 47 additions & 14 deletions schedule/__init__.py
Expand Up @@ -470,7 +470,6 @@ def tag(self, *tags: Hashable):
return self

def at(self, time_str: str, tz: Optional[str] = None):

"""
Specify a particular time that the job should be run at.
Expand Down Expand Up @@ -716,8 +715,14 @@ def _schedule_next_run(self) -> None:
else:
interval = self.interval

# Do all computation in the context of the requested timezone
if self.at_time_zone is not None:
now = datetime.datetime.now(self.at_time_zone)
else:
now = datetime.datetime.now()

self.period = datetime.timedelta(**{self.unit: interval})
self.next_run = datetime.datetime.now() + self.period
self.next_run = now + self.period
if self.start_day is not None:
if self.unit != "weeks":
raise ScheduleValueError("`unit` should be 'weeks'")
Expand All @@ -739,6 +744,12 @@ def _schedule_next_run(self) -> None:
if days_ahead <= 0: # Target day already happened this week
days_ahead += 7
self.next_run += datetime.timedelta(days_ahead) - self.period

# before we apply the .at() time, we need to normalize the timestamp
# to ensure we change the time elements in the new timezone
if self.at_time_zone is not None:
self.next_run = self.at_time_zone.normalize(self.next_run)

if self.at_time is not None:
if self.unit not in ("days", "hours", "minutes") and self.start_day is None:
raise ScheduleValueError("Invalid unit without specifying start day")
Expand All @@ -747,22 +758,14 @@ def _schedule_next_run(self) -> None:
kwargs["hour"] = self.at_time.hour
if self.unit in ["days", "hours"] or self.start_day is not None:
kwargs["minute"] = self.at_time.minute
self.next_run = self.next_run.replace(**kwargs) # type: ignore

if self.at_time_zone is not None:
# Convert next_run from the expected timezone into the local time
# self.next_run is a naive datetime so after conversion remove tzinfo
self.next_run = (
self.at_time_zone.localize(self.next_run)
.astimezone()
.replace(tzinfo=None)
)
self.next_run = self.next_run.replace(**kwargs) # type: ignore

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

# Calculations happen in the configured timezone, but to execute the schedule we
# need to know the next_run time in the system time. So we convert back to naive local
if self.at_time_zone is not None:
self.next_run = self._normalize_preserve_timestamp(self.next_run)
self.next_run = self.next_run.astimezone().replace(tzinfo=None)

# Usually when normalization of a timestamp causes the timestamp to change,
# it preserves the moment in time and changes the local timestamp.
# This method applies pytz normalization but preserves the local timestamp, in fact changing the moment in time.
def _normalize_preserve_timestamp(
self, input: datetime.datetime
) -> datetime.datetime:
if self.at_time_zone is None or input is None:
return input
normalized = self.at_time_zone.normalize(input)
return normalized.replace(
day=input.day,
hour=input.hour,
minute=input.minute,
second=input.second,
microsecond=input.microsecond,
)

def _to_at_timezone(
self, input: Optional[datetime.datetime]
) -> Optional[datetime.datetime]:
if self.at_time_zone is None or input is None:
return input
return input.astimezone(self.at_time_zone)

def _is_overdue(self, when: datetime.datetime):
return self.cancel_after is not None and when > self.cancel_after

Expand Down
205 changes: 197 additions & 8 deletions test_schedule.py
Expand Up @@ -58,15 +58,18 @@ def today(cls):
return cls(self.year, self.month, self.day)

@classmethod
def now(cls):
return cls(
def now(cls, tz=None):
mock_date = cls(
self.year,
self.month,
self.day,
self.hour,
self.minute,
self.second,
)
if tz:
return mock_date.astimezone(tz)
return mock_date

self.original_datetime = datetime.datetime
datetime.datetime = MockDate
Expand Down Expand Up @@ -500,26 +503,75 @@ def test_next_run_time_day_end(self):
assert job.next_run.hour == 23

def test_next_run_time_hour_end(self):
try:
import pytz
except ModuleNotFoundError:
self.skipTest("pytz unavailable")

self.tst_next_run_time_hour_end(None, 0)

def test_next_run_time_hour_end_london(self):
try:
import pytz
except ModuleNotFoundError:
self.skipTest("pytz unavailable")

self.tst_next_run_time_hour_end("Europe/London", 0)

def test_next_run_time_hour_end_katmandu(self):
try:
import pytz
except ModuleNotFoundError:
self.skipTest("pytz unavailable")

# 12:00 in Berlin is 15:45 in Kathmandu
# this test schedules runs at :10 minutes, so job runs at
# 16:10 in Kathmandu, which is 13:25 in Berlin
# in local time we don't run at :10, but at :25, offset of 15 minutes
self.tst_next_run_time_hour_end("Asia/Kathmandu", 15)

def tst_next_run_time_hour_end(self, tz, offsetMinutes):
mock_job = make_mock_job()

# So a job scheduled to run at :10 in Kathmandu, runs always 25 minutes
with mock_datetime(2010, 10, 10, 12, 0, 0):
job = every().hour.at(":10").do(mock_job)
job = every().hour.at(":10", tz).do(mock_job)
assert job.next_run.hour == 12
assert job.next_run.minute == 10
assert job.next_run.minute == 10 + offsetMinutes

with mock_datetime(2010, 10, 10, 13, 0, 0):
job.run()
assert job.next_run.hour == 13
assert job.next_run.minute == 10
assert job.next_run.minute == 10 + offsetMinutes

with mock_datetime(2010, 10, 10, 13, 15, 0):
with mock_datetime(2010, 10, 10, 13, 30, 0):
job.run()
assert job.next_run.hour == 14
assert job.next_run.minute == 10
assert job.next_run.minute == 10 + offsetMinutes

def test_next_run_time_minute_end(self):
self.tst_next_run_time_minute_end(None)

def test_next_run_time_minute_end_london(self):
try:
import pytz
except ModuleNotFoundError:
self.skipTest("pytz unavailable")

self.tst_next_run_time_minute_end("Europe/London")

def test_next_run_time_minute_end_katmhandu(self):
try:
import pytz
except ModuleNotFoundError:
self.skipTest("pytz unavailable")

self.tst_next_run_time_minute_end("Asia/Kathmandu")

def tst_next_run_time_minute_end(self, tz):
mock_job = make_mock_job()
with mock_datetime(2010, 10, 10, 10, 10, 0):
job = every().minute.at(":15").do(mock_job)
job = every().minute.at(":15", tz).do(mock_job)
assert job.next_run.minute == 10
assert job.next_run.second == 15

Expand Down Expand Up @@ -585,6 +637,40 @@ def test_at_timezone(self):
assert next.hour == 15
assert next.minute == 30

# Test the DST-case that is described in the documentation
with mock_datetime(2023, 3, 26, 1, 30):
# Current Berlin time: 01:30 (NOT during daylight saving)
# Expected to run: 02:30 - this time doesn't exist
# because clock moves from 02:00 to 03:00
# Next run: 03:30
job = every().day.at("02:30", "Europe/Berlin").do(mock_job)
assert job.next_run.day == 26
assert job.next_run.hour == 3
assert job.next_run.minute == 30
with mock_datetime(2023, 3, 27, 1, 30):
# the next day the job shall again run at 02:30
job.run()
assert job.next_run.day == 27
assert job.next_run.hour == 2
assert job.next_run.minute == 30

# Test the DST-case that is described in the documentation
with mock_datetime(2023, 10, 29, 1, 30):
# Current Berlin time: 01:30 (during daylight saving)
# Expected to run: 02:30 - this time exists twice
# because clock moves from 03:00 to 02:00
# Next run should be at the first occurrence of 02:30
job = every().day.at("02:30", "Europe/Berlin").do(mock_job)
assert job.next_run.day == 29
assert job.next_run.hour == 2
assert job.next_run.minute == 30
with mock_datetime(2023, 10, 29, 2, 35):
# After the job runs, the next run should be scheduled on the next day at 02:30
job.run()
assert job.next_run.day == 30
assert job.next_run.hour == 2
assert job.next_run.minute == 30

with mock_datetime(2022, 3, 20, 10, 0):
# Current Berlin time: 10:00 (local) (NOT during daylight saving)
# Current Krasnoyarsk time: 16:00
Expand Down Expand Up @@ -628,6 +714,109 @@ def test_at_timezone(self):
assert next.hour == 13
assert next.minute == 45

with mock_datetime(2023, 10, 19, 15, 0, 0, TZ_UTC):
# Testing issue #603
# Current UTC: oktober-19 15:00
# Current Amsterdam: oktober-19 17:00 (daylight saving active)
# Expected run Amsterdam: oktober-20 00:00:20 (daylight saving active)
# Next run UTC time: oktober-19 22:00:20
schedule.clear()
next = every().day.at("00:00:20", "Europe/Amsterdam").do(mock_job).next_run
assert next.day == 19
assert next.hour == 22
assert next.minute == 00
assert next.second == 20

with mock_datetime(2023, 10, 22, 23, 0, 0, TZ_UTC):
# Current UTC: sunday 22-okt 23:00
# Current Amsterdam: monday 23-okt 01:00 (daylight saving active)
# Expected run Amsterdam: sunday 29 oktober 23:00 (daylight saving NOT active)
# Next run UTC time: oktober-29 22:00
schedule.clear()
next = every().sunday.at("23:00", "Europe/Amsterdam").do(mock_job).next_run
assert next.day == 29
assert next.hour == 22
assert next.minute == 00

with mock_datetime(2023, 12, 31, 23, 0, 0):
# Current Berlin time: dec-31 23:00 (local)
# Current Sydney time: jan-1 09:00 (next day)
# Expected to run Sydney time: jan-1 12:00
# Next run Berlin time: jan-1 02:00
next = every().day.at("12:00", "Australia/Sydney").do(mock_job).next_run
assert next.day == 1
assert next.hour == 2
assert next.minute == 0

with mock_datetime(2023, 3, 26, 1, 30):
# Daylight Saving Time starts in Berlin
# Current Berlin time: march-26 01:30 (30 mintues before moving to 03:00 due to DST)
# Current London time: march-26 00:30 (30 mintues before moving to 02:00 due to DST)
# Expected to run London time: march-26 02:00 (which is equal to 01:00 due to DST)
# Next run Berlin time: march-26 03:00
next = every().day.at("01:00", "Europe/London").do(mock_job).next_run
assert next.day == 26
assert next.hour == 3
assert next.minute == 0

with mock_datetime(2023, 10, 29, 2, 30):
# Daylight Saving Time ends in Berlin
# Current Berlin time: oct-29 02:30 (after moving back to 02:00 due to DST end)
# Current Istanbul time: oct-29 04:30
# Expected to run Istanbul time: oct-29 06:00
# Next run Berlin time: oct-29 04:00
next = every().day.at("06:00", "Europe/Istanbul").do(mock_job).next_run
assert next.hour == 4
assert next.minute == 0

with mock_datetime(2023, 12, 31, 23, 50):
# End of the year in Berlin
# Current Berlin time: dec-31 23:50
# Current Tokyo time: jan-1 07:50 (next day)
# Expected to run Tokyo time: jan-1 09:00
# Next run Berlin time: jan-1 01:00
next = every().day.at("09:00", "Asia/Tokyo").do(mock_job).next_run
assert next.day == 1
assert next.hour == 1
assert next.minute == 0

with mock_datetime(2023, 2, 28, 23, 50):
# End of the month (non-leap year) in Berlin
# Current Berlin time: feb-28 23:50
# Current Sydney time: mar-1 09:50 (next day)
# Expected to run Sydney time: mar-1 10:00
# Next run Berlin time: mar-1 00:00
next = every().day.at("10:00", "Australia/Sydney").do(mock_job).next_run
assert next.day == 1
assert next.hour == 0
assert next.minute == 0

with mock_datetime(2024, 2, 28, 23, 50):
# End of the month (leap year) in Berlin
# Current Berlin time: feb-28 23:50
# Current Dubai time: feb-29 02:50
# Expected to run Dubai time: feb-29 04:00
# Next run Berlin time: feb-29 01:00
next = every().day.at("04:00", "Asia/Dubai").do(mock_job).next_run
assert next.month == 2
assert next.day == 29
assert next.hour == 1
assert next.minute == 0

with mock_datetime(2023, 9, 18, 10, 00, 0, TZ_AUCKLAND):
schedule.clear()
# Testing issue #605
# Current time: Monday 18 September 10:00 NZST
# Current time UTC: Sunday 17 September 22:00
# We expect the job to run at 23:00 on Sunday 17 September NZST
# That is an expected idle time of 1 hour
# Expected next run in NZST: 2023-09-18 11:00:00
next = schedule.every().day.at("23:00", "UTC").do(mock_job).next_run
assert round(schedule.idle_seconds() / 3600) == 1
assert next.day == 18
assert next.hour == 11
assert next.minute == 0

with self.assertRaises(pytz.exceptions.UnknownTimeZoneError):
every().day.at("10:30", "FakeZone").do(mock_job)

Expand Down

0 comments on commit a388620

Please sign in to comment.