Skip to content

Commit

Permalink
Add more timezone edge-cases, fix even more bugs
Browse files Browse the repository at this point in the history
  • Loading branch information
SijmenHuizenga committed Oct 23, 2023
1 parent cd7a95d commit ed2c493
Show file tree
Hide file tree
Showing 2 changed files with 84 additions and 12 deletions.
24 changes: 14 additions & 10 deletions schedule/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,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 @@ -756,15 +762,6 @@ def _schedule_next_run(self) -> None:

self.next_run = self.next_run.replace(**kwargs) # type: ignore

if self.at_time_zone is not None:
# Sometimes when changing time we move into a different timezone (e.g. DST)
# To correct the timezone-element, we can 'normalize' the time.
self.next_run = self.at_time_zone.normalize(self.next_run)
# But normalization keeps the hour/minute/second elements at the same moment in time,
# For example 23:00 might become 22:00. But the .at() promises a specific hour/minute/second
# so we re-apply those elements here.
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.
Expand Down Expand Up @@ -793,9 +790,16 @@ def _schedule_next_run(self) -> None:
# 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.at_time_zone.normalize(self.next_run)
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 preseves 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:
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 _is_overdue(self, when: datetime.datetime):
return self.cancel_after is not None and when > self.cancel_after

Expand Down
72 changes: 70 additions & 2 deletions test_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -645,8 +645,8 @@ def test_at_timezone(self):
assert next.second == 20

with mock_datetime(2023, 10, 22, 23, 0, 0, TZ_UTC):
# Current UTC: sunday 23:00
# Current Amsterdam: monday 01:00 (daylight saving active)
# 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()
Expand All @@ -655,6 +655,74 @@ def test_at_timezone(self):
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 self.assertRaises(pytz.exceptions.UnknownTimeZoneError):
every().day.at("10:30", "FakeZone").do(mock_job)

Expand Down

0 comments on commit ed2c493

Please sign in to comment.