diff --git a/docs/timezones.rst b/docs/timezones.rst index 98c080aa..c72cdc8a 100644 --- a/docs/timezones.rst +++ b/docs/timezones.rst @@ -38,10 +38,15 @@ The job will run at the specified time, even when the clock changes. Example clock moves forward: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +When a job is scheduled in the gap that occurs when the clock moves forward, the job is scheduled after the gap. + 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``. +A job is scheduled ``.at("01:00", "Europe/London")``. +When the clock moves from ``01:00`` to ``02:00``, the job will run once at ``02:00``. + Example clock moves backwards: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A job is scheduled ``.at("02:30", "Europe/Berlin")``. diff --git a/schedule/__init__.py b/schedule/__init__.py index 3f7267da..8e12eeb7 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -37,6 +37,7 @@ [2] https://github.com/Rykian/clockwork [3] https://adam.herokuapp.com/past/2010/6/30/replace_cron_with_clockwork/ """ + from collections.abc import Hashable import datetime import functools @@ -243,10 +244,8 @@ def __init__(self, interval: int, scheduler: Optional[Scheduler] = None): # datetime of the next run self.next_run: Optional[datetime.datetime] = None - # timedelta between runs, only valid for - self.period: Optional[datetime.timedelta] = None - - # Specific day of the week to start on + # Weekday to run the job at. Only relevant when unit is 'weeks'. + # For example, when asking 'every week on tuesday' the start_day is 'tuesday'. self.start_day: Optional[str] = None # optional time of final run @@ -707,7 +706,6 @@ def _schedule_next_run(self) -> None: "Invalid unit (valid units are `seconds`, `minutes`, `hours`, " "`days`, and `weeks`)" ) - if self.latest is not None: if not (self.latest >= self.interval): raise ScheduleError("`latest` is greater than `interval`") @@ -716,106 +714,107 @@ def _schedule_next_run(self) -> None: 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() + now = datetime.datetime.now(self.at_time_zone) + + next_run = now - self.period = datetime.timedelta(**{self.unit: interval}) - self.next_run = now + self.period if self.start_day is not None: if self.unit != "weeks": raise ScheduleValueError("`unit` should be 'weeks'") - weekdays = ( - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", - ) - if self.start_day not in weekdays: - raise ScheduleValueError( - "Invalid start day (valid start days are {})".format(weekdays) - ) - weekday = weekdays.index(self.start_day) - days_ahead = weekday - self.next_run.weekday() - 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) + next_run = _move_to_next_weekday(next_run, self.start_day) 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") - kwargs = {"second": self.at_time.second, "microsecond": 0} - if self.unit == "days" or self.start_day is not 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 - - # 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. - 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() - and self.interval == 1 - ): - self.next_run = self.next_run - datetime.timedelta(days=1) - elif self.unit == "hours" and ( - self.at_time.minute > now.minute - or ( - self.at_time.minute == now.minute - and self.at_time.second > now.second - ) - ): - self.next_run = self.next_run - datetime.timedelta(hours=1) - elif self.unit == "minutes" and self.at_time.second > now.second: - 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 - 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, + next_run = self._move_to_at_time(next_run) + + period = datetime.timedelta(**{self.unit: interval}) + if interval != 1: + next_run += period + + while next_run <= now: + next_run += period + + next_run = self._correct_utc_offset( + next_run, fixate_time=(self.at_time is not None) ) - 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) + # To keep the api consistent with older versions, we have to set the 'next_run' to a naive timestamp in the local timezone. + # Because we want to stay backwards compatible with older versions. + if self.at_time_zone is not None: + # Convert back to the local timezone + next_run = next_run.astimezone() + + next_run = next_run.replace(tzinfo=None) + + self.next_run = next_run + + def _move_to_at_time(self, moment: datetime.datetime) -> datetime.datetime: + """ + Takes a datetime and moves the time-component to the job's at_time. + """ + if self.at_time is None: + return moment + + kwargs = {"second": self.at_time.second, "microsecond": 0} + + if self.unit == "days" or self.start_day is not 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 + + moment = moment.replace(**kwargs) # type: ignore + + # When we set the time elements, we might end up in a different UTC-offset than the current offset. + # This happens when we cross into or out of daylight saving time. + moment = self._correct_utc_offset(moment, fixate_time=True) + + return moment + + def _correct_utc_offset( + self, moment: datetime.datetime, fixate_time: bool + ) -> datetime.datetime: + """ + Given a datetime, corrects any mistakes in the utc offset. + This is similar to pytz' normalize, but adds the ability to attempt + keeping the time-component at the same hour/minute/second. + """ + if self.at_time_zone is None: + return moment + # Normalize corrects the utc-offset to match the timezone + # For example: When a date&time&offset does not exist within a timezone, + # the normalization will change the utc-offset to where it is valid. + # It does this while keeping the moment in time the same, by moving the + # time component opposite of the utc-change. + offset_before_normalize = moment.utcoffset() + moment = self.at_time_zone.normalize(moment) + offset_after_normalize = moment.utcoffset() + + if offset_before_normalize == offset_after_normalize: + # There was no change in the utc-offset, datetime didn't change. + return moment + + # The utc-offset and time-component has changed + + if not fixate_time: + # No need to fixate the time. + return moment + + offset_diff = offset_after_normalize - offset_before_normalize + + # Adjust the time to reset the date-time to have the same HH:mm components + moment -= offset_diff + + # Check if moving the timestamp back by the utc-offset-difference made it end up + # in a moment that does not exist within the current timezone/utc-offset + re_normalized_offset = self.at_time_zone.normalize(moment).utcoffset() + if re_normalized_offset != offset_after_normalize: + # We ended up in a DST Gap. The requested 'at' time does not exist + # within the current timezone/utc-offset. As a best effort, we will + # schedule the job 1 offset later than possible. + # For example, if 02:23 does not exist (because DST moves from 02:00 + # to 03:00), this will schedule the job at 03:23. + moment += offset_diff + return moment def _is_overdue(self, when: datetime.datetime): return self.cancel_after is not None and when > self.cancel_after @@ -912,3 +911,35 @@ def _schedule_decorator(decorated_function): return decorated_function return _schedule_decorator + + +def _move_to_next_weekday(moment: datetime.datetime, weekday: str): + """ + Move the given timestamp to the nearest given weekday. May be this week + or next week. If the timestamp is already at the given weekday, it is not + moved. + """ + weekday_index = _weekday_index(weekday) + + days_ahead = weekday_index - moment.weekday() + if days_ahead < 0: + # Target day already happened this week, move to next week + days_ahead += 7 + return moment + datetime.timedelta(days=days_ahead) + + +def _weekday_index(day: str) -> int: + weekdays = ( + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ) + if day not in weekdays: + raise ScheduleValueError( + "Invalid start day (valid start days are {})".format(weekdays) + ) + return weekdays.index(day) diff --git a/test_schedule.py b/test_schedule.py index 339f2d75..f497826d 100644 --- a/test_schedule.py +++ b/test_schedule.py @@ -1,8 +1,8 @@ """Unit tests for schedule.py""" + import datetime import functools -from unittest import mock -import unittest +from unittest import mock, TestCase import os import time @@ -20,8 +20,9 @@ ) # POSIX TZ string format -TZ_BERLIN = "CET-1CEST-2,M3.5.0/02:00:00,M10.5.0/03:00:00" -TZ_AUCKLAND = "NZST-12NZDT-13,M10.1.0/02:00:00,M3.3.0/03:00:00" +TZ_BERLIN = "CET-1CEST,M3.5.0,M10.5.0/3" +TZ_AUCKLAND = "NZST-12NZDT,M9.5.0,M4.1.0/3" +TZ_CHATHAM = "<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45" TZ_UTC = "UTC0" # Set timezone to Europe/Berlin (CEST) to ensure global reproducibility @@ -40,7 +41,7 @@ class mock_datetime: Monkey-patch datetime for predictable results """ - def __init__(self, year, month, day, hour, minute, second=0, zone=None): + def __init__(self, year, month, day, hour, minute, second=0, zone=None, fold=0): self.year = year self.month = month self.day = day @@ -48,6 +49,7 @@ def __init__(self, year, month, day, hour, minute, second=0, zone=None): self.minute = minute self.second = second self.zone = zone + self.fold = fold self.original_datetime = None self.original_zone = None @@ -66,6 +68,7 @@ def now(cls, tz=None): self.hour, self.minute, self.second, + fold=self.fold, ) if tz: return mock_date.astimezone(tz) @@ -90,10 +93,18 @@ def __exit__(self, *args, **kwargs): time.tzset() -class SchedulerTests(unittest.TestCase): +class SchedulerTests(TestCase): def setUp(self): schedule.clear() + def make_tz_mock_job(self, name=None): + try: + import pytz + except ModuleNotFoundError: + self.skipTest("pytz unavailable") + return + return make_mock_job(name) + def test_time_units(self): assert every().seconds.unit == "seconds" assert every().minutes.unit == "minutes" @@ -202,12 +213,6 @@ def test_time_units(self): self.assertRaises(ScheduleValueError, job_instance.at, "0:61:0") self.assertRaises(ScheduleValueError, job_instance.at, "0:0:61") - # test (very specific) seconds with unspecified start_day - job_instance.unit = "seconds" - job_instance.at_time = datetime.datetime.now() - job_instance.start_day = None - self.assertRaises(ScheduleValueError, job_instance._schedule_next_run) - # test self.latest >= self.interval job_instance.latest = 1 self.assertRaises(ScheduleError, job_instance._schedule_next_run) @@ -590,14 +595,8 @@ def tst_next_run_time_minute_end(self, tz): assert job.next_run.minute == 13 assert job.next_run.second == 15 - def test_at_timezone(self): - mock_job = make_mock_job() - try: - import pytz - except ModuleNotFoundError: - self.skipTest("pytz unavailable") - return - + def test_tz(self): + mock_job = self.make_tz_mock_job() with mock_datetime(2022, 2, 1, 23, 15): # Current Berlin time: feb-1 23:15 (local) # Current India time: feb-2 03:45 @@ -608,6 +607,8 @@ def test_at_timezone(self): assert next.hour == 2 assert next.minute == 0 + def test_tz_daily_midnight(self): + mock_job = self.make_tz_mock_job() with mock_datetime(2023, 4, 14, 4, 50): # Current Berlin time: april-14 04:50 (local) (during daylight saving) # Current US/Central time: april-13 21:50 @@ -618,6 +619,8 @@ def test_at_timezone(self): assert next.hour == 7 assert next.minute == 0 + def test_tz_daily_half_hour_offset(self): + mock_job = self.make_tz_mock_job() with mock_datetime(2022, 4, 8, 10, 0): # Current Berlin time: 10:00 (local) (during daylight saving) # Current NY time: 04:00 @@ -627,6 +630,10 @@ def test_at_timezone(self): assert next.hour == 16 assert next.minute == 30 + def test_tz_daily_dst(self): + mock_job = self.make_tz_mock_job() + import pytz + with mock_datetime(2022, 3, 20, 10, 0): # Current Berlin time: 10:00 (local) (NOT during daylight saving) # Current NY time: 04:00 (during daylight saving) @@ -637,6 +644,8 @@ def test_at_timezone(self): assert next.hour == 15 assert next.minute == 30 + def test_tz_daily_dst_skip_hour(self): + mock_job = self.make_tz_mock_job() # 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) @@ -654,6 +663,8 @@ def test_at_timezone(self): assert job.next_run.hour == 2 assert job.next_run.minute == 30 + def test_tz_daily_dst_overlap_hour(self): + mock_job = self.make_tz_mock_job() # 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) @@ -671,6 +682,8 @@ def test_at_timezone(self): assert job.next_run.hour == 2 assert job.next_run.minute == 30 + def test_tz_daily_exact_future_scheduling(self): + mock_job = self.make_tz_mock_job() with mock_datetime(2022, 3, 20, 10, 0): # Current Berlin time: 10:00 (local) (NOT during daylight saving) # Current Krasnoyarsk time: 16:00 @@ -684,6 +697,8 @@ def test_at_timezone(self): ) assert schedule.idle_seconds() == expected_delta.total_seconds() + def test_tz_daily_utc(self): + mock_job = self.make_tz_mock_job() with mock_datetime(2023, 9, 18, 10, 59, 0, TZ_AUCKLAND): # Testing issue #598 # Current Auckland time: 10:59 (local) (NOT during daylight saving) @@ -703,6 +718,8 @@ def test_at_timezone(self): assert next.hour == 12 assert next.minute == 0 + def test_tz_daily_issue_592(self): + mock_job = self.make_tz_mock_job() with mock_datetime(2023, 7, 15, 13, 0, 0, TZ_UTC): # Testing issue #592 # Current UTC time: 13:00 @@ -714,6 +731,8 @@ def test_at_timezone(self): assert next.hour == 13 assert next.minute == 45 + def test_tz_daily_exact_seconds_precision(self): + mock_job = self.make_tz_mock_job() with mock_datetime(2023, 10, 19, 15, 0, 0, TZ_UTC): # Testing issue #603 # Current UTC: oktober-19 15:00 @@ -727,6 +746,8 @@ def test_at_timezone(self): assert next.minute == 00 assert next.second == 20 + def test_tz_weekly_sunday_conversion(self): + mock_job = self.make_tz_mock_job() 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) @@ -738,6 +759,8 @@ def test_at_timezone(self): assert next.hour == 22 assert next.minute == 00 + def test_tz_daily_new_year_offset(self): + mock_job = self.make_tz_mock_job() 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) @@ -748,27 +771,8 @@ def test_at_timezone(self): 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 - + def test_tz_daily_end_year_cross_continent(self): + mock_job = self.make_tz_mock_job() with mock_datetime(2023, 12, 31, 23, 50): # End of the year in Berlin # Current Berlin time: dec-31 23:50 @@ -780,6 +784,8 @@ def test_at_timezone(self): assert next.hour == 1 assert next.minute == 0 + def test_tz_daily_end_month_offset(self): + mock_job = self.make_tz_mock_job() with mock_datetime(2023, 2, 28, 23, 50): # End of the month (non-leap year) in Berlin # Current Berlin time: feb-28 23:50 @@ -791,6 +797,8 @@ def test_at_timezone(self): assert next.hour == 0 assert next.minute == 0 + def test_tz_daily_leap_year(self): + mock_job = self.make_tz_mock_job() with mock_datetime(2024, 2, 28, 23, 50): # End of the month (leap year) in Berlin # Current Berlin time: feb-28 23:50 @@ -803,6 +811,8 @@ def test_at_timezone(self): assert next.hour == 1 assert next.minute == 0 + def test_tz_daily_issue_605(self): + mock_job = self.make_tz_mock_job() with mock_datetime(2023, 9, 18, 10, 00, 0, TZ_AUCKLAND): schedule.clear() # Testing issue #605 @@ -817,12 +827,418 @@ def test_at_timezone(self): assert next.hour == 11 assert next.minute == 0 + def test_tz_daily_dst_starting_point(self): + mock_job = self.make_tz_mock_job() + with mock_datetime(2023, 3, 26, 1, 30): + # Daylight Saving Time starts in Berlin + # In Berlin, 26 March 2023, 02:00:00 clocks were turned forward 1 hour + # In London, 26 March 2023, 01:00:00 clocks were turned forward 1 hour + # Current Berlin time: 26 March 01:30 (UTC+1) + # Current London time: 26 March 00:30 (UTC+0) + # Expected London time: 26 March 02:00 (UTC+1) + # Expected Berlin time: 26 March 03:00 (UTC+2) + 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 + + def test_tz_daily_dst_ending_point(self): + mock_job = self.make_tz_mock_job() + with mock_datetime(2023, 10, 29, 2, 30, fold=1): + # 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 + + def test_tz_daily_issue_608_pre_dst(self): + mock_job = self.make_tz_mock_job() + with mock_datetime(2023, 9, 18, 10, 00, 0, TZ_AUCKLAND): + # See ticket #608 + # Testing timezone conversion the week before daylight saving comes into effect + # Current time: Monday 18 September 10:00 NZST + # Current time UTC: Sunday 17 September 22:00 + # Expected next run in NZST: 2023-09-18 11:00:00 + schedule.clear() + next = schedule.every().day.at("23:00", "UTC").do(mock_job).next_run + assert next.day == 18 + assert next.hour == 11 + assert next.minute == 0 + + def test_tz_daily_issue_608_post_dst(self): + mock_job = self.make_tz_mock_job() + with mock_datetime(2024, 4, 8, 10, 00, 0, TZ_AUCKLAND): + # See ticket #608 + # Testing timezone conversion the week after daylight saving ends + # Current time: Monday 8 April 10:00 NZST + # Current time UTC: Sunday 7 April 22:00 + # Expected next run in NZDT: 2023-04-08 11:00:00 + schedule.clear() + next = schedule.every().day.at("23:00", "UTC").do(mock_job).next_run + assert next.day == 8 + assert next.hour == 11 + assert next.minute == 0 + + def test_tz_daily_issue_608_mid_dst(self): + mock_job = self.make_tz_mock_job() + with mock_datetime(2023, 9, 25, 10, 00, 0, TZ_AUCKLAND): + # See ticket #608 + # Testing timezone conversion during the week after daylight saving comes into effect + # Current time: Monday 25 September 10:00 NZDT + # Current time UTC: Sunday 24 September 21:00 + # Expected next run in UTC: 2023-09-24 23:00 + # Expected next run in NZDT: 2023-09-25 12:00 + schedule.clear() + next = schedule.every().day.at("23:00", "UTC").do(mock_job).next_run + assert next.month == 9 + assert next.day == 25 + assert next.hour == 12 + assert next.minute == 0 + + def test_tz_daily_issue_608_before_dst_end(self): + mock_job = self.make_tz_mock_job() + with mock_datetime(2024, 4, 1, 10, 00, 0, TZ_AUCKLAND): + # See ticket #608 + # Testing timezone conversion during the week before daylight saving ends + # Current time: Monday 1 April 10:00 NZDT + # Current time UTC: Friday 31 March 21:00 + # Expected next run in UTC: 2023-03-31 23:00 + # Expected next run in NZDT: 2024-04-01 12:00 + schedule.clear() + next = schedule.every().day.at("23:00", "UTC").do(mock_job).next_run + assert next.month == 4 + assert next.day == 1 + assert next.hour == 12 + assert next.minute == 0 + + def test_tz_hourly_intermediate_conversion(self): + mock_job = self.make_tz_mock_job() + with mock_datetime(2024, 5, 4, 14, 37, 22, TZ_CHATHAM): + # Crurent time: 14:37:22 New Zealand, Chatham Islands (UTC +12:45) + # Current time: 3 may, 23:22:22 Canada, Newfoundland (UTC -2:30) + # Exected next run in Newfoundland: 4 may, 09:14:45 + # Expected next run in Chatham: 5 may, 00:29:45 + schedule.clear() + next = ( + schedule.every(10) + .hours.at("14:45", "Canada/Newfoundland") + .do(mock_job) + .next_run + ) + assert next.day == 5 + assert next.hour == 0 + assert next.minute == 29 + assert next.second == 45 + + def test_tz_minutes_year_round(self): + mock_job = self.make_tz_mock_job() + # Test a full year of scheduling across timezones, where one timezone + # is in the northern hemisphere and the other in the southern hemisphere + # These two timezones are also a bit exotic (not the usual UTC+1, UTC-1) + # Local timezone: Newfoundland, Canada: UTC-2:30 / DST UTC-3:30 + # Remote timezone: Chatham Islands, New Zealand: UTC+12:45 / DST UTC+13:45 + schedule.clear() + job = schedule.every(20).minutes.at(":13", "Canada/Newfoundland").do(mock_job) + with mock_datetime(2024, 9, 29, 2, 20, 0, TZ_CHATHAM): + # First run, nothing special, no utc-offset change + # Current time: 29 sept, 02:20:00 Chatham + # Current time: 28 sept, 11:05:00 Newfoundland + # Expected time: 28 sept, 11:20:13 Newfoundland + # Expected time: 29 sept, 02:40:13 Chatham + job.run() + assert job.next_run.day == 29 + assert job.next_run.hour == 2 + assert job.next_run.minute == 40 + assert job.next_run.second == 13 + with mock_datetime(2024, 9, 29, 2, 40, 14, TZ_CHATHAM): + # next-schedule happens 1 second behind schedule + job.run() + # On 29 Sep, 02:45 2024, in Chatham, the clock is moved +1 hour + # Thus, the next run happens AFTER the local timezone exits DST + # Current time: 29 sept, 02:40:14 Chatham (UTC +12:45) + # Current time: 28 sept, 11:25:14 Newfoundland (UTC -2:30) + # Expected time: 28 sept, 11:45:13 Newfoundland (UTC -2:30) + # Expected time: 29 sept, 04:00:13 Chatham (UTC +13:45) + assert job.next_run.day == 29 + assert job.next_run.hour == 4 + assert job.next_run.minute == 00 + assert job.next_run.second == 13 + with mock_datetime(2024, 11, 3, 2, 23, 55, TZ_CHATHAM, fold=0): + # Time is right before Newfoundland exits DST + # Local time will move 1 hour back at 03:00 + + job.run() + # There are no timezone switches yet, nothing special going on: + # Current time: 3 Nov, 02:23:55 Chatham + # Expected time: 3 Nov, 02:43:13 Chatham + assert job.next_run.day == 3 + assert job.next_run.hour == 2 + assert job.next_run.minute == 43 # Within the fold, first occurrence + assert job.next_run.second == 13 + with mock_datetime(2024, 11, 3, 2, 23, 55, TZ_CHATHAM, fold=1): + # Time is during the fold. Local time has moved back 1 hour, this is + # the second occurrence of the 02:23 time. + + job.run() + # Current time: 3 Nov, 02:23:55 Chatham + # Expected time: 3 Nov, 02:43:13 Chatham + assert job.next_run.day == 3 + assert job.next_run.hour == 2 + assert job.next_run.minute == 43 + assert job.next_run.second == 13 + with mock_datetime(2025, 3, 9, 19, 00, 00, TZ_CHATHAM): + # Time is right before Newfoundland enters DST + # At 02:00, the remote clock will move forward 1 hour + + job.run() + # Current time: 9 March, 19:00:00 Chatham (UTC +13:45) + # Current time: 9 March, 01:45:00 Newfoundland (UTC -3:30) + # Expected time: 9 March, 03:05:13 Newfoundland (UTC -2:30) + # Expected time 9 March, 19:20:13 Chatham (UTC +13:45) + + assert job.next_run.day == 9 + assert job.next_run.hour == 19 + assert job.next_run.minute == 20 + assert job.next_run.second == 13 + with mock_datetime(2025, 4, 7, 17, 55, 00, TZ_CHATHAM): + # Time is within the few hours before Catham exits DST + # At 03:45, the local clock moves back 1 hour + + job.run() + # Current time: 7 April, 17:55:00 Chatham + # Current time: 7 April, 02:40:00 Newfoundland + # Expected time: 7 April, 03:00:13 Newfoundland + # Expected time 7 April, 18:15:13 Chatham + assert job.next_run.day == 7 + assert job.next_run.hour == 18 + assert job.next_run.minute == 15 + assert job.next_run.second == 13 + with mock_datetime(2025, 4, 7, 18, 55, 00, TZ_CHATHAM): + # Schedule the next run exactly when the clock moved backwards + # Curren time is before the clock-move, next run is after the clock change + + job.run() + # Current time: 7 April, 18:55:00 Chatham + # Current time: 7 April, 03:40:00 Newfoundland + # Expected time: 7 April, 03:00:13 Newfoundland (clock moved back) + # Expected time 7 April, 19:15:13 Chatham + assert job.next_run.day == 7 + assert job.next_run.hour == 19 + assert job.next_run.minute == 15 + assert job.next_run.second == 13 + with mock_datetime(2025, 4, 7, 19, 15, 13, TZ_CHATHAM): + # Schedule during the fold in the remote timezone + + job.run() + # Current time: 7 April, 19:15:13 Chatham + # Current time: 7 April, 03:00:13 Newfoundland (fold) + # Expected time: 7 April, 03:20:13 Newfoundland (fold) + # Expected time: 7 April, 19:35:13 Chatham + assert job.next_run.day == 7 + assert job.next_run.hour == 19 + assert job.next_run.minute == 35 + assert job.next_run.second == 13 + + def test_tz_weekly_large_interval_forward(self): + mock_job = self.make_tz_mock_job() + # Testing scheduling large intervals that skip over clock move forward + with mock_datetime(2024, 3, 28, 11, 0, 0, TZ_BERLIN): + # At March 31st 2024, 02:00:00 clocks were turned forward 1 hour + schedule.clear() + next = ( + schedule.every(7) + .days.at("11:00", "Europe/Berlin") + .do(mock_job) + .next_run + ) + assert next.month == 4 + assert next.day == 4 + assert next.hour == 11 + assert next.minute == 0 + assert next.second == 0 + + def test_tz_weekly_large_interval_backward(self): + mock_job = self.make_tz_mock_job() + import pytz + + # Testing scheduling large intervals that skip over clock move back + with mock_datetime(2024, 10, 25, 11, 0, 0, TZ_BERLIN): + # At March 31st 2024, 02:00:00 clocks were turned forward 1 hour + schedule.clear() + next = ( + schedule.every(7) + .days.at("11:00", "Europe/Berlin") + .do(mock_job) + .next_run + ) + assert next.month == 11 + assert next.day == 1 + assert next.hour == 11 + assert next.minute == 0 + assert next.second == 0 + + def test_tz_daily_skip_dst_change(self): + mock_job = self.make_tz_mock_job() + with mock_datetime(2024, 11, 3, 10, 0): + # At 3 November 2024, 02:00:00 clocks are turned backward 1 hour + # The job skips the whole DST change becaus it runs at 14:00 + # Current time Berlin: 3 Nov, 10:00 + # Current time Anchorage: 3 Nov, 00:00 (UTC-08:00) + # Expected time Anchorage: 3 Nov, 14:00 (UTC-09:00) + # Expected time Berlin: 4 Nov, 00:00 + schedule.clear() + next = ( + schedule.every() + .day.at("14:00", "America/Anchorage") + .do(mock_job) + .next_run + ) + assert next.day == 4 + assert next.hour == 0 + assert next.minute == 00 + + def test_tz_daily_different_simultaneous_dst_change(self): + mock_job = self.make_tz_mock_job() + + # TZ_BERLIN_EXTRA is the same as Berlin, but during summer time + # moves the clock 2 hours forward instead of 1 + # This is a fictional timezone + TZ_BERLIN_EXTRA = "CET-01CEST-03,M3.5.0,M10.5.0/3" + with mock_datetime(2024, 3, 31, 0, 0, 0, TZ_BERLIN_EXTRA): + # In Berlin at March 31 2024, 02:00:00 clocks were turned forward 1 hour + # In Berlin Extra, the clocks move forward 2 hour at the same time + # Current time Berlin Extra: 31 Mar, 00:00 (UTC+01:00) + # Current time Berlin: 31 Mar, 00:00 (UTC+01:00) + # Expected time Berlin: 31 Mar, 10:00 (UTC+02:00) + # Expected time Berlin Extra: 31 Mar, 11:00 (UTC+03:00) + schedule.clear() + next = ( + schedule.every().day.at("10:00", "Europe/Berlin").do(mock_job).next_run + ) + assert next.day == 31 + assert next.hour == 11 + assert next.minute == 00 + + def test_tz_daily_opposite_dst_change(self): + mock_job = self.make_tz_mock_job() + + # TZ_BERLIN_INVERTED changes in the opposite direction of Berlin + # This is a fictional timezone + TZ_BERLIN_INVERTED = "CET-1CEST,M10.5.0/3,M3.5.0" + with mock_datetime(2024, 3, 31, 0, 0, 0, TZ_BERLIN_INVERTED): + # In Berlin at March 31 2024, 02:00:00 clocks were turned forward 1 hour + # In Berlin Inverted, the clocks move back 1 hour at the same time + # Current time Berlin Inverted: 31 Mar, 00:00 (UTC+02:00) + # Current time Berlin: 31 Mar, 00:00 (UTC+01:00) + # Expected time Berlin: 31 Mar, 10:00 (UTC+02:00) +9 hour + # Expected time Berlin Inverted: 31 Mar, 09:00 (UTC+01:00) + schedule.clear() + next = ( + schedule.every().day.at("10:00", "Europe/Berlin").do(mock_job).next_run + ) + assert next.day == 31 + assert next.hour == 9 + assert next.minute == 00 + + def test_tz_invalid_timezone_exceptions(self): + mock_job = self.make_tz_mock_job() + import pytz + with self.assertRaises(pytz.exceptions.UnknownTimeZoneError): every().day.at("10:30", "FakeZone").do(mock_job) with self.assertRaises(ScheduleValueError): every().day.at("10:30", 43).do(mock_job) + def test_align_utc_offset_no_timezone(self): + job = schedule.every().day.at("10:00").do(make_mock_job()) + now = datetime.datetime(2024, 5, 11, 10, 30, 55, 0) + aligned_time = job._correct_utc_offset(now, fixate_time=True) + self.assertEqual(now, aligned_time) + + def setup_utc_offset_test(self): + try: + import pytz + except ModuleNotFoundError: + self.skipTest("pytz unavailable") + job = ( + schedule.every() + .day.at("10:00", "Europe/Berlin") + .do(make_mock_job("tz-test")) + ) + tz = pytz.timezone("Europe/Berlin") + return (job, tz) + + def test_align_utc_offset_no_change(self): + (job, tz) = self.setup_utc_offset_test() + now = tz.localize(datetime.datetime(2023, 3, 26, 1, 30)) + aligned_time = job._correct_utc_offset(now, fixate_time=False) + self.assertEqual(now, aligned_time) + + def test_align_utc_offset_with_dst_gap(self): + (job, tz) = self.setup_utc_offset_test() + # Non-existent time in Berlin timezone + gap_time = tz.localize(datetime.datetime(2024, 3, 31, 2, 30, 0)) + aligned_time = job._correct_utc_offset(gap_time, fixate_time=True) + + assert aligned_time.utcoffset() == datetime.timedelta(hours=2) + assert aligned_time.day == 31 + assert aligned_time.hour == 3 + assert aligned_time.minute == 30 + + def test_align_utc_offset_with_dst_fold(self): + (job, tz) = self.setup_utc_offset_test() + # This time exists twice, this is the first occurance + overlap_time = tz.localize(datetime.datetime(2024, 10, 27, 2, 30)) + aligned_time = job._correct_utc_offset(overlap_time, fixate_time=False) + # Since the time exists twice, no fixate_time flag should yield the first occurrence + first_occurrence = tz.localize(datetime.datetime(2024, 10, 27, 2, 30, fold=0)) + self.assertEqual(first_occurrence, aligned_time) + + def test_align_utc_offset_with_dst_fold_fixate_1(self): + (job, tz) = self.setup_utc_offset_test() + # This time exists twice, this is the 1st occurance + overlap_time = tz.localize(datetime.datetime(2024, 10, 27, 1, 30), is_dst=True) + overlap_time += datetime.timedelta( + hours=1 + ) # puts it at 02:30+02:00 (Which exists once) + + aligned_time = job._correct_utc_offset(overlap_time, fixate_time=True) + # The time should not have moved, because the original time is valid + assert aligned_time.utcoffset() == datetime.timedelta(hours=2) + assert aligned_time.hour == 2 + assert aligned_time.minute == 30 + assert aligned_time.day == 27 + + def test_align_utc_offset_with_dst_fold_fixate_2(self): + (job, tz) = self.setup_utc_offset_test() + # 02:30 exists twice, this is the 2nd occurance + overlap_time = tz.localize(datetime.datetime(2024, 10, 27, 2, 30), is_dst=False) + # The time 2024-10-27 02:30:00+01:00 exists once + + aligned_time = job._correct_utc_offset(overlap_time, fixate_time=True) + # The time was valid, should not have been moved + assert aligned_time.utcoffset() == datetime.timedelta(hours=1) + assert aligned_time.hour == 2 + assert aligned_time.minute == 30 + assert aligned_time.day == 27 + + def test_align_utc_offset_after_fold_fixate(self): + (job, tz) = self.setup_utc_offset_test() + # This time is 30 minutes after a folded hour. + duplicate_time = tz.localize(datetime.datetime(2024, 10, 27, 2, 30)) + duplicate_time += datetime.timedelta(hours=1) + + aligned_time = job._correct_utc_offset(duplicate_time, fixate_time=False) + + assert aligned_time.utcoffset() == datetime.timedelta(hours=1) + assert aligned_time.hour == 3 + assert aligned_time.minute == 30 + assert aligned_time.day == 27 + def test_daylight_saving_time(self): mock_job = make_mock_job() # 27 March 2022, 02:00:00 clocks were turned forward 1 hour @@ -833,6 +1249,27 @@ def test_daylight_saving_time(self): with mock_datetime(2022, 10, 30, 0, 0): assert every(4).hours.do(mock_job).next_run.hour == 4 + def test_move_to_next_weekday_today(self): + monday = datetime.datetime(2024, 5, 13, 10, 27, 54) + tuesday = schedule._move_to_next_weekday(monday, "monday") + assert tuesday.day == 13 # today! Time didn't change. + assert tuesday.hour == 10 + assert tuesday.minute == 27 + + def test_move_to_next_weekday_tommorrow(self): + monday = datetime.datetime(2024, 5, 13, 10, 27, 54) + tuesday = schedule._move_to_next_weekday(monday, "tuesday") + assert tuesday.day == 14 # 1 day ahead + assert tuesday.hour == 10 + assert tuesday.minute == 27 + + def test_move_to_next_weekday_nextweek(self): + wednesday = datetime.datetime(2024, 5, 15, 10, 27, 54) + tuesday = schedule._move_to_next_weekday(wednesday, "tuesday") + assert tuesday.day == 21 # next week monday + assert tuesday.hour == 10 + assert tuesday.minute == 27 + def test_run_all(self): mock_job = make_mock_job() every().minute.do(mock_job) @@ -989,7 +1426,7 @@ def test_run_pending(self): def test_run_every_weekday_at_specific_time_today(self): mock_job = make_mock_job() - with mock_datetime(2010, 1, 6, 13, 16): + with mock_datetime(2010, 1, 6, 13, 16): # january 6 2010 == Wednesday every().wednesday.at("14:12").do(mock_job) schedule.run_pending() assert mock_job.call_count == 0