Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

python-dateutil support for timezone in at function #585

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://pypi.org/project/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?
---------------------------------
Expand Down Expand Up @@ -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 <http://github.com/dbader/schedule/issues>`_ to see if your question has been asked before.
Feel free to create a new issue if that's not the case. Thank you 😃
Feel free to create a new issue if that's not the case. Thank you 😃
14 changes: 12 additions & 2 deletions docs/timezones.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,33 @@ Timezone in .at()

Schedule supports setting the job execution time in another timezone using the ``.at`` method.

**To work with timezones** `pytz <https://pypi.org/project/pytz/>`_ **must be installed!** Get it:
**To work with timezones** `pytz <https://pypi.org/project/pytz/>`_ or `python-dateutil <https://pypi.org/project/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

# 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 <https://docs.python.org/3/library/datetime.html>`_.
This causes the ``next_run`` and ``last_run`` to always be in Pythons local timezone.
Expand Down
3 changes: 2 additions & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ black==20.8b1
click==8.0.4
mypy
pytz
types-pytz
types-pytz
python-dateutil
81 changes: 48 additions & 33 deletions schedule/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand All @@ -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'")
Expand All @@ -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")
Expand All @@ -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

Expand Down
10 changes: 4 additions & 6 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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",
Expand Down
70 changes: 70 additions & 0 deletions test_schedule.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Unit tests for schedule.py"""
import datetime
import functools

import mock
import unittest
import os
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down