From a38862005fb775521d5482e5be4affcd10eebb15 Mon Sep 17 00:00:00 2001 From: Sijmen Date: Fri, 17 Nov 2023 23:04:13 +0100 Subject: [PATCH] Improved timezones handling in next_run (#604) --- docs/timezones.rst | 34 ++++--- schedule/__init__.py | 61 ++++++++++--- test_schedule.py | 205 +++++++++++++++++++++++++++++++++++++++++-- tox.ini | 10 +-- 4 files changed, 272 insertions(+), 38 deletions(-) diff --git a/docs/timezones.rst b/docs/timezones.rst index 854a55ca..98c080aa 100644 --- a/docs/timezones.rst +++ b/docs/timezones.rst @@ -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). diff --git a/schedule/__init__.py b/schedule/__init__.py index de082c1a..54760b59 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -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. @@ -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'") @@ -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") @@ -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() @@ -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 diff --git a/test_schedule.py b/test_schedule.py index 98856ccf..875d5ebc 100644 --- a/test_schedule.py +++ b/test_schedule.py @@ -58,8 +58,8 @@ 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, @@ -67,6 +67,9 @@ def now(cls): self.minute, self.second, ) + if tz: + return mock_date.astimezone(tz) + return mock_date self.original_datetime = datetime.datetime datetime.datetime = MockDate @@ -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 @@ -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 @@ -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) diff --git a/tox.ini b/tox.ini index e27768e5..a43e52dd 100644 --- a/tox.ini +++ b/tox.ini @@ -5,11 +5,11 @@ skip_missing_interpreters = true [gh-actions] python = - 3.7: py37 - 3.8: py38 - 3.9: py39 - 3.10: py310 - 3.11: py311 + 3.7: py37, py37-pytz + 3.8: py38, py38-pytz + 3.9: py39, py39-pytz + 3.10: py310, py310-pytz + 3.11: py311, py311-pytz [testenv] deps =