diff --git a/docs/conf.py b/docs/conf.py
index 083d3532..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.0"
+version = u"1.2.2"
# The full version, including alpha/beta/rc tags.
-release = u"1.2.0"
+release = u"1.2.2"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
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..3e877597 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")
@@ -699,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.
@@ -717,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'")
@@ -739,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")
@@ -749,41 +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
- self.next_run = (
- self.at_time_zone.localize(self.next_run)
- .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 a56d374a..ddd89ed7 100644
--- a/setup.py
+++ b/setup.py
@@ -2,8 +2,7 @@
from setuptools import setup
-SCHEDULE_VERSION = "1.2.0"
-SCHEDULE_DOWNLOAD_URL = "https://github.com/dbader/schedule/tarball/" + SCHEDULE_VERSION
+SCHEDULE_VERSION = "1.2.2"
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 7d4617ec..3b29b3b8 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
@@ -286,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
@@ -565,6 +588,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