Skip to content
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ The following wonderful people contributed directly or indirectly to this projec
- `Alateas <https://github.com/alateas>`_
- `Ales Dokshanin <https://github.com/alesdokshanin>`_
- `Ambro17 <https://github.com/Ambro17>`_
- `Andrej Zhilenkov <https://github.com/Andrej730>`_
- `Anton Tagunov <https://github.com/anton-tagunov>`_
- `Avanatiker <https://github.com/Avanatiker>`_
- `Balduro <https://github.com/Balduro>`_
Expand Down
54 changes: 48 additions & 6 deletions telegram/ext/jobqueue.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def _put(self, job, time_spec=None, previous_t=None):
# enqueue:
self.logger.debug('Putting job %s with t=%s', job.name, time_spec)
self._queue.put((next_t, job))
job._set_next_t(next_t)

# Wake up the loop if this job should be executed next
self._set_next_peek(next_t)
Expand Down Expand Up @@ -135,6 +136,9 @@ def run_once(self, callback, when, context=None, name=None):
job should run. This could be either today or, if the time has already passed,
tomorrow. If the timezone (``time.tzinfo``) is ``None``, UTC will be assumed.

If ``when`` is :obj:`datetime.datetime` or :obj:`datetime.time` type
then ``when.tzinfo`` will define ``Job.tzinfo``. Otherwise UTC will be assumed.

context (:obj:`object`, optional): Additional data needed for the callback function.
Can be accessed through ``job.context`` in the callback. Defaults to ``None``.
name (:obj:`str`, optional): The name of the new job. Defaults to
Expand All @@ -145,7 +149,14 @@ def run_once(self, callback, when, context=None, name=None):
queue.

"""
job = Job(callback, repeat=False, context=context, name=name, job_queue=self)
tzinfo = when.tzinfo if isinstance(when, (datetime.datetime, datetime.time)) else None

job = Job(callback,
repeat=False,
context=context,
name=name,
job_queue=self,
tzinfo=tzinfo)
self._put(job, time_spec=when)
return job

Expand Down Expand Up @@ -179,6 +190,9 @@ def run_repeating(self, callback, interval, first=None, context=None, name=None)
job should run. This could be either today or, if the time has already passed,
tomorrow. If the timezone (``time.tzinfo``) is ``None``, UTC will be assumed.

If ``first`` is :obj:`datetime.datetime` or :obj:`datetime.time` type
then ``first.tzinfo`` will define ``Job.tzinfo``. Otherwise UTC will be assumed.

Defaults to ``interval``
context (:obj:`object`, optional): Additional data needed for the callback function.
Can be accessed through ``job.context`` in the callback. Defaults to ``None``.
Expand All @@ -195,12 +209,15 @@ def run_repeating(self, callback, interval, first=None, context=None, name=None)
to pin servers to UTC time, then time related behaviour can always be expected.

"""
tzinfo = first.tzinfo if isinstance(first, (datetime.datetime, datetime.time)) else None

job = Job(callback,
interval=interval,
repeat=True,
context=context,
name=name,
job_queue=self)
job_queue=self,
tzinfo=tzinfo)
self._put(job, time_spec=first)
return job

Expand All @@ -217,6 +234,7 @@ def run_daily(self, callback, time, days=Days.EVERY_DAY, context=None, name=None
its ``job.context`` or change it to a repeating job.
time (:obj:`datetime.time`): Time of day at which the job should run. If the timezone
(``time.tzinfo``) is ``None``, UTC will be assumed.
``time.tzinfo`` will implicitly define ``Job.tzinfo``.
days (Tuple[:obj:`int`], optional): Defines on which days of the week the job should
run. Defaults to ``EVERY_DAY``
context (:obj:`object`, optional): Additional data needed for the callback function.
Expand Down Expand Up @@ -301,6 +319,7 @@ def tick(self):
if job.repeat and not job.removed:
self._put(job, previous_t=t)
else:
job._set_next_t(None)
self.logger.debug('Dropping non-repeating or removed job %s', job.name)

def start(self):
Expand Down Expand Up @@ -412,6 +431,7 @@ def __init__(self,
self._repeat = None
self._interval = None
self.interval = interval
self._next_t = None
self.repeat = repeat

self._days = None
Expand All @@ -438,6 +458,7 @@ def schedule_removal(self):

"""
self._remove.set()
self._next_t = None

@property
def removed(self):
Expand Down Expand Up @@ -471,8 +492,8 @@ def interval(self, interval):
raise ValueError("The 'interval' can not be 'None' when 'repeat' is set to 'True'")

if not (interval is None or isinstance(interval, (Number, datetime.timedelta))):
raise ValueError("The 'interval' must be of type 'datetime.timedelta',"
" 'int' or 'float'")
raise TypeError("The 'interval' must be of type 'datetime.timedelta',"
" 'int' or 'float'")

self._interval = interval

Expand All @@ -485,6 +506,27 @@ def interval_seconds(self):
else:
return interval

@property
def next_t(self):
"""
::obj:`datetime.datetime`: Datetime for the next job execution.
Datetime is localized according to :attr:`tzinfo`.
If job is removed or already ran it equals to ``None``.

"""
return datetime.datetime.fromtimestamp(self._next_t, self.tzinfo) if self._next_t else None

def _set_next_t(self, next_t):
if isinstance(next_t, datetime.datetime):
# Set timezone to UTC in case datetime is in local timezone.
next_t = next_t.astimezone(_UTC)
next_t = to_float_timestamp(next_t)
elif not (isinstance(next_t, Number) or next_t is None):
raise TypeError("The 'next_t' argument should be one of the following types: "
"'float', 'int', 'datetime.datetime' or 'NoneType'")

self._next_t = next_t

@property
def repeat(self):
""":obj:`bool`: Optional. If this job should periodically execute its callback function."""
Expand All @@ -504,10 +546,10 @@ def days(self):
@days.setter
def days(self, days):
if not isinstance(days, tuple):
raise ValueError("The 'days' argument should be of type 'tuple'")
raise TypeError("The 'days' argument should be of type 'tuple'")

if not all(isinstance(day, int) for day in days):
raise ValueError("The elements of the 'days' argument should be of type 'int'")
raise TypeError("The elements of the 'days' argument should be of type 'int'")

if not all(0 <= day <= 6 for day in days):
raise ValueError("The elements of the 'days' argument should be from 0 up to and "
Expand Down
102 changes: 99 additions & 3 deletions tests/test_jobqueue.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].

import datetime as dtm
import os
import sys
Expand Down Expand Up @@ -298,18 +299,21 @@ def test_warnings(self, job_queue):
with pytest.raises(ValueError, match='can not be'):
j.interval = None
j.repeat = False
with pytest.raises(ValueError, match='must be of type'):
with pytest.raises(TypeError, match='must be of type'):
j.interval = 'every 3 minutes'
j.interval = 15
assert j.interval_seconds == 15

with pytest.raises(ValueError, match='argument should be of type'):
with pytest.raises(TypeError, match='argument should be of type'):
j.days = 'every day'
with pytest.raises(ValueError, match='The elements of the'):
with pytest.raises(TypeError, match='The elements of the'):
j.days = ('mon', 'wed')
with pytest.raises(ValueError, match='from 0 up to and'):
j.days = (0, 6, 12, 14)

with pytest.raises(TypeError, match='argument should be one of the'):
j._set_next_t('tomorrow')

def test_get_jobs(self, job_queue):
job1 = job_queue.run_once(self.job_run_once, 10, name='name1')
job2 = job_queue.run_once(self.job_run_once, 10, name='name1')
Expand Down Expand Up @@ -341,3 +345,95 @@ def test_job_default_tzinfo(self, job_queue):

for job in jobs:
assert job.tzinfo == _UTC

def test_job_next_t_property(self, job_queue):
# Testing:
# - next_t values match values from self._queue.queue (for run_once and run_repeating jobs)
# - next_t equals None if job is removed or if it's already ran

job1 = job_queue.run_once(self.job_run_once, 0.06, name='run_once job')
job2 = job_queue.run_once(self.job_run_once, 0.06, name='canceled run_once job')
job_queue.run_repeating(self.job_run_once, 0.04, name='repeatable job')

sleep(0.05)
job2.schedule_removal()

with job_queue._queue.mutex:
for t, job in job_queue._queue.queue:
t = dtm.datetime.fromtimestamp(t, job.tzinfo)

if job.removed:
assert job.next_t is None
else:
assert job.next_t == t

assert self.result == 1
sleep(0.02)

assert self.result == 2
assert job1.next_t is None
assert job2.next_t is None

def test_job_set_next_t(self, job_queue):
# Testing next_t setter for 'datetime.datetime' values

job = job_queue.run_once(self.job_run_once, 0.05)

t = dtm.datetime.now(tz=_UtcOffsetTimezone(dtm.timedelta(hours=12)))
job._set_next_t(t)
job.tzinfo = _UtcOffsetTimezone(dtm.timedelta(hours=5))
assert job.next_t == t.astimezone(job.tzinfo)

def test_passing_tzinfo_to_job(self, job_queue):
"""Test that tzinfo is correctly passed to job with run_once, run_daily
and run_repeating methods"""

when_dt_tz_specific = dtm.datetime.now(
tz=_UtcOffsetTimezone(dtm.timedelta(hours=12))
) + dtm.timedelta(seconds=2)
when_dt_tz_utc = dtm.datetime.now() + dtm.timedelta(seconds=2)
job_once1 = job_queue.run_once(self.job_run_once, when_dt_tz_specific)
job_once2 = job_queue.run_once(self.job_run_once, when_dt_tz_utc)

when_time_tz_specific = (dtm.datetime.now(
tz=_UtcOffsetTimezone(dtm.timedelta(hours=12))
) + dtm.timedelta(seconds=2)).timetz()
when_time_tz_utc = (dtm.datetime.now() + dtm.timedelta(seconds=2)).timetz()
job_once3 = job_queue.run_once(self.job_run_once, when_time_tz_specific)
job_once4 = job_queue.run_once(self.job_run_once, when_time_tz_utc)

first_dt_tz_specific = dtm.datetime.now(
tz=_UtcOffsetTimezone(dtm.timedelta(hours=12))
) + dtm.timedelta(seconds=2)
first_dt_tz_utc = dtm.datetime.now() + dtm.timedelta(seconds=2)
job_repeating1 = job_queue.run_repeating(
self.job_run_once, 2, first=first_dt_tz_specific)
job_repeating2 = job_queue.run_repeating(
self.job_run_once, 2, first=first_dt_tz_utc)

first_time_tz_specific = (dtm.datetime.now(
tz=_UtcOffsetTimezone(dtm.timedelta(hours=12))
) + dtm.timedelta(seconds=2)).timetz()
first_time_tz_utc = (dtm.datetime.now() + dtm.timedelta(seconds=2)).timetz()
job_repeating3 = job_queue.run_repeating(
self.job_run_once, 2, first=first_time_tz_specific)
job_repeating4 = job_queue.run_repeating(
self.job_run_once, 2, first=first_time_tz_utc)

time_tz_specific = (dtm.datetime.now(
tz=_UtcOffsetTimezone(dtm.timedelta(hours=12))
) + dtm.timedelta(seconds=2)).timetz()
time_tz_utc = (dtm.datetime.now() + dtm.timedelta(seconds=2)).timetz()
job_daily1 = job_queue.run_daily(self.job_run_once, time_tz_specific)
job_daily2 = job_queue.run_daily(self.job_run_once, time_tz_utc)

assert job_once1.tzinfo == when_dt_tz_specific.tzinfo
assert job_once2.tzinfo == _UTC
assert job_once3.tzinfo == when_time_tz_specific.tzinfo
assert job_once4.tzinfo == _UTC
assert job_repeating1.tzinfo == first_dt_tz_specific.tzinfo
assert job_repeating2.tzinfo == _UTC
assert job_repeating3.tzinfo == first_time_tz_specific.tzinfo
assert job_repeating4.tzinfo == _UTC
assert job_daily1.tzinfo == time_tz_specific.tzinfo
assert job_daily2.tzinfo == _UTC