From 110e2df443befda2a15e7de9535eae1b0aabf161 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Sat, 18 Apr 2020 16:08:16 +0300 Subject: [PATCH] Job.next_t (#1685) * next_t property is added to Job class Added new property to Job class - next_t, it will show the datetime when the job will be executed next time. The property is updated during JobQueue._put method, right after job is added to queue. Related to #1676 * Fixed newline and trailing whitespace * Fixed PR issues, added test 1. Added setter for next_t - now JobQueue doesn't access protected Job._next_t. 2. Fixed Job class docstring. 3. Added test for next_t property. 4. Set next_t to None for run_once jobs that already ran. * Fixed Flake8 issues * Added next_t setter for datetime, added test 1. next_t setter now can accept datetime type. 2. added test for setting datetime to next_t and added some asserts that check tests results. 3. Also noticed Job.days setter raises ValueError when it's more appropriate to raise TypeError. * Fixed test_warnings, added Number type to next_t setter 1. Changed type of error raised by interval setter from ValueError to TypeError.. 2. Fixed test_warning after changing type of errors in Job.days and Job.interval. 3. Added Number type to next_t setter - now it can accept int too. * Python 2 compatibility for test_job_next_t_property Added _UTC and _UtcOffsetTimezone for python 2 compatibility * Fixed PR issues 1. Replaced "datetime.replace tzinfo" with "datetime.astimezone" 2. Moved testing next_t setter to separate test. 3. Changed test_job_next_t_setter so it now uses non UTC timezone. * Defining tzinfo from run_once, run_repeating 1. Added option to define Job.tzinfo from run_once (by when.tzinfo) and run_repeating (first.tzinfo) 2. Added test to check that tzinfo is always passed correctly. * address review Co-authored-by: Hinrich Mahler --- AUTHORS.rst | 1 + telegram/ext/jobqueue.py | 54 ++++++++++++++++++--- tests/test_jobqueue.py | 102 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 148 insertions(+), 9 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index f9e3cfe9c..bbb9fd859 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -18,6 +18,7 @@ The following wonderful people contributed directly or indirectly to this projec - `Alateas `_ - `Ales Dokshanin `_ - `Ambro17 `_ +- `Andrej Zhilenkov `_ - `Anton Tagunov `_ - `Avanatiker `_ - `Balduro `_ diff --git a/telegram/ext/jobqueue.py b/telegram/ext/jobqueue.py index f27e0caf5..03598db96 100644 --- a/telegram/ext/jobqueue.py +++ b/telegram/ext/jobqueue.py @@ -104,6 +104,7 @@ class JobQueue(object): # 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) @@ -135,6 +136,9 @@ class JobQueue(object): 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 @@ -145,7 +149,14 @@ class JobQueue(object): 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 @@ -179,6 +190,9 @@ class JobQueue(object): 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``. @@ -195,12 +209,15 @@ class JobQueue(object): 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 @@ -217,6 +234,7 @@ class JobQueue(object): 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. @@ -301,6 +319,7 @@ class JobQueue(object): 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): @@ -412,6 +431,7 @@ class Job(object): self._repeat = None self._interval = None self.interval = interval + self._next_t = None self.repeat = repeat self._days = None @@ -438,6 +458,7 @@ class Job(object): """ self._remove.set() + self._next_t = None @property def removed(self): @@ -471,8 +492,8 @@ class Job(object): 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 @@ -485,6 +506,27 @@ class Job(object): 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.""" @@ -504,10 +546,10 @@ class Job(object): @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 " diff --git a/tests/test_jobqueue.py b/tests/test_jobqueue.py index 7179f53bd..26ad75f77 100644 --- a/tests/test_jobqueue.py +++ b/tests/test_jobqueue.py @@ -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 @@ -298,18 +299,21 @@ class TestJobQueue(object): 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') @@ -341,3 +345,95 @@ class TestJobQueue(object): 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