From ef402f72c60385f6ad418532ecaaba2ccff654df Mon Sep 17 00:00:00 2001 From: Thomas Senfter Date: Tue, 6 Jun 2023 10:59:51 +0200 Subject: [PATCH 1/4] added dateutil fallback if pytz is not installed --- docs/faq.rst | 6 +++--- docs/timezones.rst | 14 +++++++++++-- requirements-dev.txt | 3 ++- schedule/__init__.py | 46 ++++++++++++++++++++++++++++-------------- test_schedule.py | 48 ++++++++++++++++++++++++++++++++++++++++++++ tox.ini | 1 + 6 files changed, 97 insertions(+), 21 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 98d65eeb..fed9200b 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -46,8 +46,8 @@ ModuleNotFoundError: ModuleNotFoundError: No module named 'pytz' ---------------------------------------------------------------- This error happens when you try to set a timezone in ``.at()`` without having the `pytz `_ package installed. -Pytz is a required dependency when working with timezones. -To resolve this issue, install the ``pytz`` module by running ``pip install pytz``. +Either pytz or python-dateutil is a required dependency when working with timezones. +To resolve this issue, install the ``pytz`` module by running ``pip install pytz`` or install ``python-dateutil`` module by running ``pip install python-dateutil``. Does schedule support time zones? --------------------------------- @@ -76,4 +76,4 @@ How to continuously run the scheduler without blocking the main thread? Another question? ----------------- If you are left with an unanswered question, `browse the issue tracker `_ to see if your question has been asked before. -Feel free to create a new issue if that's not the case. Thank you 😃 \ No newline at end of file +Feel free to create a new issue if that's not the case. Thank you 😃 diff --git a/docs/timezones.rst b/docs/timezones.rst index 854a55ca..4401f133 100644 --- a/docs/timezones.rst +++ b/docs/timezones.rst @@ -6,12 +6,18 @@ Timezone in .at() Schedule supports setting the job execution time in another timezone using the ``.at`` method. -**To work with timezones** `pytz `_ **must be installed!** Get it: +**To work with timezones** `pytz `_ or `python-dateutil `_ **must be installed!** Get it: .. code-block:: bash pip install pytz +or + +.. code-block:: bash + + pip install python-dateutil + Timezones are only available in the ``.at`` function, like so: .. code-block:: python @@ -19,10 +25,14 @@ Timezones are only available in the ``.at`` function, like so: # Pass a timezone as a string schedule.every().day.at("12:42", "Europe/Amsterdam").do(job) - # Pass an pytz timezone object + # Pass an pytz timezone object (only possible if using pytz) from pytz import timezone schedule.every().friday.at("12:42", timezone("Africa/Lagos")).do(job) + # Pass an dateutil timezone object (only possible if using python-dateutil) + from dateutil.tz import gettz + schedule.every().friday.at("12:42", gettz("Africa/Lagos")).do(job) + Schedule uses the timezone to calculate the next runtime in local time. All datetimes inside the library are stored `naive `_. This causes the ``next_run`` and ``last_run`` to always be in Pythons local timezone. diff --git a/requirements-dev.txt b/requirements-dev.txt index 467fb0b7..5f572bb8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,4 +9,5 @@ black==20.8b1 click==8.0.4 mypy pytz -types-pytz \ No newline at end of file +types-pytz +python-dateutil diff --git a/schedule/__init__.py b/schedule/__init__.py index 2db9b0ad..7d6a589f 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -498,16 +498,28 @@ def at(self, time_str: str, tz: Optional[str] = None): ) if tz is not None: - import pytz - - if isinstance(tz, str): - self.at_time_zone = pytz.timezone(tz) # type: ignore - elif isinstance(tz, pytz.BaseTzInfo): - self.at_time_zone = tz - else: - raise ScheduleValueError( - "Timezone must be string or pytz.timezone object" - ) + try: + import pytz + if isinstance(tz, str): + self.at_time_zone = pytz.timezone(tz) # type: ignore + elif isinstance(tz, pytz.BaseTzInfo): + self.at_time_zone = tz + else: + raise ScheduleValueError( + "Timezone must be string or pytz.timezone object" + ) + except ModuleNotFoundError: + import dateutil.tz + if isinstance(tz, str): + self.at_time_zone = dateutil.tz.gettz(tz) + elif isinstance(tz, dateutil.tz.tzfile): + self.at_time_zone = tz + else: + raise ScheduleValueError( + "Timezone must be string or dateutil.tz.tzfile object" + ) + if self.at_time_zone is None: + raise KeyError("Unknown timezone") if not isinstance(time_str, str): raise TypeError("at() should be passed a string") @@ -752,11 +764,15 @@ def _schedule_next_run(self) -> None: 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) - ) + try: + self.next_run = ( + self.at_time_zone.localize(self.next_run) + .astimezone() + .replace(tzinfo=None) + ) + except Exception: + # if the code above fails, we are using dateutil + self.next_run = self.next_run.replace(tzinfo=self.at_time_zone).astimezone().replace(tzinfo=None) # 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 diff --git a/test_schedule.py b/test_schedule.py index 7d4617ec..17fe88dc 100644 --- a/test_schedule.py +++ b/test_schedule.py @@ -1,6 +1,7 @@ """Unit tests for schedule.py""" import datetime import functools + import mock import unittest import os @@ -565,6 +566,53 @@ def test_at_timezone(self): with self.assertRaises(ScheduleValueError): every().day.at("10:30", 43).do(mock_job) + def test_at_timezone_dateutil(self): + mock_job = make_mock_job() + try: + from dateutil.tz import gettz + except ModuleNotFoundError: + self.skipTest("dateutil unavailable") + return + try: + import pytz + self.skipTest("pytz available, cannot do this test") + except ModuleNotFoundError: + pass + + with mock_datetime(2022, 2, 1, 23, 15): + # Current Berlin time: feb-1 23:15 (local) + # Current India time: feb-2 03:45 + # Expected to run India time: feb-2 06:30 + # Next run Berlin time: feb-2 02:00 + next = every().day.at("06:30", "Asia/Kolkata").do(mock_job).next_run + assert next.hour == 2 + assert next.minute == 0 + + with mock_datetime(2022, 4, 8, 10, 0): + # Current Berlin time: 10:00 (local) (during daylight saving) + # Current NY time: 04:00 + # Expected to run NY time: 10:30 + # Next run Berlin time: 16:30 + next = every().day.at("10:30", "America/New_York").do(mock_job).next_run + assert next.hour == 16 + assert next.minute == 30 + + 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) + # Expected to run NY time: 10:30 + # Next run Berlin time: 15:30 + tz = gettz("America/New_York") + next = every().day.at("10:30", tz).do(mock_job).next_run + assert next.hour == 15 + assert next.minute == 30 + + with self.assertRaises(KeyError): + every().day.at("10:30", "FakeZone").do(mock_job) + + with self.assertRaises(ScheduleValueError): + every().day.at("10:30", 43).do(mock_job) + def test_daylight_saving_time(self): mock_job = make_mock_job() # 27 March 2022, 02:00:00 clocks were turned forward 1 hour diff --git a/tox.ini b/tox.ini index e27768e5..7d0afa20 100644 --- a/tox.ini +++ b/tox.ini @@ -19,6 +19,7 @@ deps = mypy types-pytz pytz: pytz + python-dateutil commands = py.test test_schedule.py schedule -v --cov schedule --cov-report term-missing python -m mypy -p schedule --install-types --non-interactive From 3ce0a62151141f52a704a9c8911e5e04ce153e69 Mon Sep 17 00:00:00 2001 From: Thomas Senfter Date: Tue, 6 Jun 2023 11:47:47 +0200 Subject: [PATCH 2/4] version change --- docs/conf.py | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 083d3532..b5d1e6cd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = u"1.2.0" +version = u"1.2.1" # The full version, including alpha/beta/rc tags. -release = u"1.2.0" +release = u"1.2.1" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index a56d374a..1f5df096 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup -SCHEDULE_VERSION = "1.2.0" +SCHEDULE_VERSION = "1.2.1" SCHEDULE_DOWNLOAD_URL = "https://github.com/dbader/schedule/tarball/" + SCHEDULE_VERSION From 9a195bc21f398d497d00db2592d77ca9334976c3 Mon Sep 17 00:00:00 2001 From: Thomas Senfter Date: Wed, 2 Aug 2023 15:01:58 +0200 Subject: [PATCH 3/4] fix for schedule with timezone --- schedule/__init__.py | 53 ++++++++++++++++++++++---------------------- setup.py | 8 +++---- test_schedule.py | 22 ++++++++++++++++++ 3 files changed, 51 insertions(+), 32 deletions(-) diff --git a/schedule/__init__.py b/schedule/__init__.py index 7d6a589f..3e877597 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -711,6 +711,16 @@ def run(self): return CancelJob return ret + def _localize(self, dt: datetime.datetime): + if not self.at_time_zone: + return dt + try: + return self.at_time_zone.localize(dt) + except Exception: + # if the code above fails, we are using dateutil + from dateutil.tz import UTC, tzlocal + return dt.replace(tzinfo=tzlocal()).astimezone(self.at_time_zone) + def _schedule_next_run(self) -> None: """ Compute the instant when this job should run next. @@ -729,7 +739,9 @@ def _schedule_next_run(self) -> None: interval = self.interval self.period = datetime.timedelta(**{self.unit: interval}) - self.next_run = datetime.datetime.now() + self.period + # localize here to avoid errors due to daylight savings time + self.next_run = self._localize(datetime.datetime.now()) + self.next_run += self.period if self.start_day is not None: if self.unit != "weeks": raise ScheduleValueError("`unit` should be 'weeks'") @@ -751,6 +763,7 @@ 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 + 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") @@ -761,45 +774,31 @@ def _schedule_next_run(self) -> 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 - try: - self.next_run = ( - self.at_time_zone.localize(self.next_run) - .astimezone() - .replace(tzinfo=None) - ) - except Exception: - # if the code above fails, we are using dateutil - self.next_run = self.next_run.replace(tzinfo=self.at_time_zone).astimezone().replace(tzinfo=None) - # 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() - if ( - self.unit == "days" - and self.at_time > now.time() - and self.interval == 1 - ): + if not self.last_run or (self.next_run - self._localize(self.last_run)) > self.period: + now = self._localize(datetime.datetime.now()) + if self.unit == "days" and self.at_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.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 - datetime.datetime.now()).days >= 7: + if (self.next_run - self._localize(datetime.datetime.now())).days >= 7: self.next_run -= self.period + if self.at_time_zone is not None: + self.next_run = self.next_run.astimezone().replace(tzinfo=None) + def _is_overdue(self, when: datetime.datetime): return self.cancel_after is not None and when > self.cancel_after diff --git a/setup.py b/setup.py index 1f5df096..fe93bf63 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,6 @@ SCHEDULE_VERSION = "1.2.1" -SCHEDULE_DOWNLOAD_URL = "https://github.com/dbader/schedule/tarball/" + SCHEDULE_VERSION def read_file(filename): @@ -15,17 +14,16 @@ def read_file(filename): setup( - name="schedule", + name="arivo_schedule", packages=["schedule"], - package_data={"schedule": ["py.typed"]}, + package_data={"arivo-schedule": ["py.typed"]}, version=SCHEDULE_VERSION, description="Job scheduling for humans.", long_description=read_file("README.rst"), license="MIT", author="Daniel Bader", author_email="mail@dbader.org", - url="https://github.com/dbader/schedule", - download_url=SCHEDULE_DOWNLOAD_URL, + url="https://github.com/ts-accessio/schedule", keywords=[ "schedule", "periodic", diff --git a/test_schedule.py b/test_schedule.py index 17fe88dc..3b29b3b8 100644 --- a/test_schedule.py +++ b/test_schedule.py @@ -287,6 +287,28 @@ def test_at_time(self): with self.assertRaises(IntervalError): every(interval=2).sunday + def test_at_time_tz(self): + """Test schedule with utc time having different date than local time""" + mock_job = make_mock_job() + # mocked times are local time + from dateutil.tz import UTC, tzlocal + with mock_datetime(2023, 8, 1, 11, 30): + job = every().day.at("00:30", "Europe/Vienna").do(mock_job) + initial_run = datetime.datetime(2023, 8, 1, 22, 30, tzinfo=UTC).astimezone(tzlocal()).replace(tzinfo=None) + self.assertEqual(initial_run, job.next_run) + + rt1 = datetime.datetime(2023, 8, 1, 22, 15, tzinfo=UTC).astimezone(tzlocal()).replace(tzinfo=None) + with mock_datetime(rt1.year, rt1.month, rt1.day, rt1.hour, rt1.minute): + self.assertEqual(False, job.should_run) + + rt2 = datetime.datetime(2023, 8, 1, 22, 35, tzinfo=UTC).astimezone(tzlocal()).replace(tzinfo=None) + with mock_datetime(rt2.year, rt2.month, rt2.day, rt2.hour, rt2.minute): + self.assertEqual(True, job.should_run) + job.run() + self.assertEqual(datetime.datetime.now(), job.last_run) + next_run_time = datetime.datetime(2023, 8, 2, 22, 30, tzinfo=UTC).astimezone(tzlocal()).replace(tzinfo=None) + self.assertEqual(next_run_time, job.next_run) + def test_until_time(self): mock_job = make_mock_job() # Check argument parsing From 48651319f3f688e48327e22b052df9aa29afe77a Mon Sep 17 00:00:00 2001 From: Thomas Senfter Date: Wed, 2 Aug 2023 15:54:34 +0200 Subject: [PATCH 4/4] newer version --- docs/conf.py | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index b5d1e6cd..125e0a24 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = u"1.2.1" +version = u"1.2.2" # The full version, including alpha/beta/rc tags. -release = u"1.2.1" +release = u"1.2.2" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/setup.py b/setup.py index fe93bf63..ddd89ed7 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup -SCHEDULE_VERSION = "1.2.1" +SCHEDULE_VERSION = "1.2.2" def read_file(filename):