Skip to content

Commit

Permalink
Add timezone support for .at() (#517)
Browse files Browse the repository at this point in the history
Co-authored-by: Chris Mahoney <44449504+chrimaho@users.noreply.github.com>
  • Loading branch information
SijmenHuizenga and chrimaho authored Apr 23, 2022
1 parent 2f23c12 commit 3eac646
Show file tree
Hide file tree
Showing 11 changed files with 178 additions and 12 deletions.
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Usage
schedule.every(5).to(10).minutes.do(job)
schedule.every().monday.do(job)
schedule.every().wednesday.at("13:15").do(job)
schedule.every().day.at("12:42", "Europe/Amsterdam").do(job)
schedule.every().minute.at(":17").do(job)
def job_with_argument(name):
Expand Down
3 changes: 0 additions & 3 deletions docs/_static/.gitkeep

This file was deleted.

3 changes: 3 additions & 0 deletions docs/_static/custom.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.toctree-l1 {
padding-bottom: 4px;
}
1 change: 1 addition & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Run a job every x minute
# Run job every day at specific HH:MM and next HH:MM:SS
schedule.every().day.at("10:30").do(job)
schedule.every().day.at("10:30:42").do(job)
schedule.every().day.at("12:42", "Europe/Amsterdam").do(job)
# Run job on a specific day of the week
schedule.every().monday.do(job)
Expand Down
11 changes: 8 additions & 3 deletions docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,16 @@ It might be that your IDE uses a different Python interpreter installation.

Still having problems? Use Google and StackOverflow before submitting an issue.

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``.

Does schedule support time zones?
---------------------------------
Vanilla schedule doesn’t support time zones at the moment.
If you need this functionality please check out @imiric’s work `here <https://github.com/dbader/schedule/pull/16>`_.
He added time zone support to schedule using python-dateutil.
Yes! See :doc:`Timezones <timezones>`.

What if my task throws an exception?
------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Python job scheduling for humans. Run Python functions (or any other callable) p
schedule.every().day.at("10:30").do(job)
schedule.every().monday.do(job)
schedule.every().wednesday.at("13:15").do(job)
schedule.every().day.at("12:42", "Europe/Amsterdam").do(job)
schedule.every().minute.at(":17").do(job)
while True:
Expand All @@ -58,7 +59,7 @@ You should probably look somewhere else if you need:
* Job persistence (remember schedule between restarts)
* Exact timing (sub-second precision execution)
* Concurrent execution (multiple threads)
* Localization (time zones, workdays or holidays)
* Localization (workdays or holidays)


**Schedule does not account for the time it takes for the job function to execute.**
Expand All @@ -75,6 +76,7 @@ Read More
examples
background-execution
parallel-execution
timezones
exception-handling
logging
multiple-schedulers
Expand Down
61 changes: 61 additions & 0 deletions docs/timezones.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
Timezone & Daylight Saving Time
===============================

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:

.. code-block:: bash
pip install pytz
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
from pytz import timezone
schedule.every().friday.at("12:42", timezone("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.

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
~~~~~~~
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).

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).

.. code-block:: python
s = every().day.at("10:30", "America/New_York").do(job)
Because of the 5 hour time difference between Berlin and New York the job should effectively run at ``15:30:00``.
So the next run in Berlin time is ``2022 march 20, 15:30:00``:

.. code-block:: python
print(s.next_run)
# 2022-03-20 15:30:00
print(repr(s))
# Every 1 day at 10:30:00 do job() (last run: [never], next run: 2022-03-20 15:30:00)
4 changes: 3 additions & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ pytest-flake8
Sphinx
black==20.8b1
click==8.0.4
mypy
mypy
pytz
types-pytz
32 changes: 31 additions & 1 deletion schedule/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ def __init__(self, interval: int, scheduler: Scheduler = None):
# optional time at which this job runs
self.at_time: Optional[datetime.time] = None

# optional time zone of the self.at_time field. Only relevant when at_time is not None
self.at_time_zone = None

# datetime of the last run
self.last_run: Optional[datetime.datetime] = None

Expand Down Expand Up @@ -462,7 +465,7 @@ def tag(self, *tags: Hashable):
self.tags.update(tags)
return self

def at(self, time_str):
def at(self, time_str: str, tz: str = None):

"""
Specify a particular time that the job should be run at.
Expand All @@ -480,12 +483,28 @@ def at(self, time_str):
selected time-unit (e.g. `every().hour.at(':30')` vs.
`every().minute.at(':30')`).
:param tz: The timezone that this timestamp refers to. Can be
a string that can be parsed by pytz.timezone(), or a pytz.BaseTzInfo object
:return: The invoked job instance
"""
if self.unit not in ("days", "hours", "minutes") and not self.start_day:
raise ScheduleValueError(
"Invalid unit (valid units are `days`, `hours`, and `minutes`)"
)

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"
)

if not isinstance(time_str, str):
raise TypeError("at() should be passed a string")
if self.unit == "days" or self.start_day:
Expand Down Expand Up @@ -531,6 +550,7 @@ def at(self, time_str):
elif self.unit == "minutes":
hour = 0
minute = 0
hour = int(hour)
minute = int(minute)
second = int(second)
self.at_time = datetime.time(hour, minute, second)
Expand Down Expand Up @@ -724,6 +744,16 @@ def _schedule_next_run(self) -> None:
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)
)

# 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.
Expand Down
58 changes: 58 additions & 0 deletions test_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import functools
import mock
import unittest
import os
import time

# Silence "missing docstring", "method could be a function",
# "class already defined", and "too many public methods" messages:
Expand All @@ -17,6 +19,10 @@
IntervalError,
)

# Set timezone to Europe/Berlin (CEST) to ensure global reproducibility
os.environ["TZ"] = "CET-1CEST,M3.5.0,M10.5.0"
time.tzset()


def make_mock_job(name=None):
job = mock.Mock()
Expand Down Expand Up @@ -516,6 +522,58 @@ def test_next_run_time_minute_end(self):
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

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 = pytz.timezone("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(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_daylight_saving_time(self):
mock_job = make_mock_job()
# 27 March 2022, 02:00:00 clocks were turned forward 1 hour
with mock_datetime(2022, 3, 27, 0, 0):
assert every(4).hours.do(mock_job).next_run.hour == 4

# Sunday, 30 October 2022, 03:00:00 clocks were turned backward 1 hour
with mock_datetime(2022, 10, 30, 0, 0):
assert every(4).hours.do(mock_job).next_run.hour == 4

def test_run_all(self):
mock_job = make_mock_job()
every().minute.do(mock_job)
Expand Down
12 changes: 9 additions & 3 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tox]
envlist = py3{6,7,8,9}
envlist = py3{6,7,8,9}{,-pytz}
skip_missing_interpreters = true


Expand All @@ -11,10 +11,16 @@ python =
3.9: py39

[testenv]
deps = -rrequirements-dev.txt
deps =
mock
pytest
pytest-cov
mypy
types-pytz
pytz: pytz
commands =
py.test test_schedule.py schedule -v --cov schedule --cov-report term-missing
python -m mypy -p schedule
python -m mypy -p schedule --install-types --non-interactive

[testenv:docs]
changedir = docs
Expand Down

0 comments on commit 3eac646

Please sign in to comment.