From ae17ce977e6dc207efb77688b87efec89e30c303 Mon Sep 17 00:00:00 2001 From: D David Livingston Date: Sat, 2 May 2020 14:59:50 +0800 Subject: [PATCH] Add JobQueue.run_monthly() (#1705) * added monthly job * removed fold argument * addressed pr comments * addressed pr comments * made changes from pr review * updated comments * clean up code * Update .pre-commit-config.yaml * Minor cleanup * Update according to #1685, minor robustness changes Co-authored-by: Hinrich Mahler --- AUTHORS.rst | 1 + telegram/ext/jobqueue.py | 128 ++++++++++++++++++++++++++++++++++++++- tests/test_jobqueue.py | 75 +++++++++++++++++++++-- 3 files changed, 199 insertions(+), 5 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index bbb9fd859..bb133c30d 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -27,6 +27,7 @@ The following wonderful people contributed directly or indirectly to this projec - `d-qoi `_ - `daimajia `_ - `Daniel Reed `_ +- `D David Livingston `_ - `Eana Hufwe `_ - `Ehsan Online `_ - `Eli Gao `_ diff --git a/telegram/ext/jobqueue.py b/telegram/ext/jobqueue.py index 6ed11adc0..e10444b02 100644 --- a/telegram/ext/jobqueue.py +++ b/telegram/ext/jobqueue.py @@ -18,6 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes JobQueue and Job.""" +import calendar import datetime import logging import time @@ -221,6 +222,115 @@ class JobQueue(object): self._put(job, time_spec=first) return job + def run_monthly(self, callback, when, day, context=None, name=None, day_is_strict=True): + """Creates a new ``Job`` that runs on a monthly basis and adds it to the queue. + + Args: + callback (:obj:`callable`): The callback function for this handler. Will be called when + :attr:`check_update` has determined that an update should be processed by this + handler. + Callback signature for context based API: + + ``def callback(update: Update, context: CallbackContext)`` + + The return value of the callback is usually ignored except for the special case of + :class:`telegram.ext.ConversationHandler`. + when (:obj:`datetime.time`): Time of day at which the job should run. If the timezone + (``when.tzinfo``) is ``None``, UTC will be assumed. This will also implicitly + define ``Job.tzinfo``. + day (:obj:`int`): Defines the day of the month whereby the job would run. It should + be within the range of 1 and 31, inclusive. + 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 + ``callback.__name__``. + day_is_strict (:obj:`bool`, optional): If ``False`` and day > month.days, will pick + the last day in the month. Defaults to ``True``. + + Returns: + :class:`telegram.ext.Job`: The new ``Job`` instance that has been added to the job + queue. + + """ + tzinfo = when.tzinfo if isinstance(when, (datetime.datetime, datetime.time)) else None + if 1 <= day <= 31: + next_dt = self._get_next_month_date(day, day_is_strict, when, allow_now=True) + job = Job(callback, repeat=False, context=context, name=name, job_queue=self, + is_monthly=True, day_is_strict=day_is_strict, tzinfo=tzinfo) + self._put(job, time_spec=next_dt) + return job + else: + raise ValueError("The elements of the 'day' argument should be from 1 up to" + " and including 31") + + def _get_next_month_date(self, day, day_is_strict, when, allow_now=False): + """This method returns the date that the next monthly job should be scheduled. + + Args: + day (:obj:`int`): The day of the month the job should run. + day_is_strict (:obj:`bool`): + Specification as to whether the specified day of job should be strictly + respected. If day_is_strict is ``True`` it ignores months whereby the + specified date does not exist (e.g February 31st). If it set to ``False``, + it returns the last valid date of the month instead. For example, + if the user runs a job on the 31st of every month, and sets + the day_is_strict variable to ``False``, April, for example, + the job would run on April 30th. + when (:obj:`datetime.time`): Time of day at which the job should run. If the + timezone (``time.tzinfo``) is ``None``, UTC will be assumed. + allow_now (:obj:`bool`): Whether executing the job right now is a feasible options. + For stability reasons, this defaults to :obj:`False`, but it needs to be :obj:`True` + on initializing a job. + + """ + dt = datetime.datetime.now(tz=when.tzinfo or datetime.timezone.utc) + dt_time = dt.time().replace(tzinfo=when.tzinfo) + days_in_current_month = calendar.monthrange(dt.year, dt.month)[1] + days_till_months_end = days_in_current_month - dt.day + if days_in_current_month < day: + # if the day does not exist in the current month (e.g Feb 31st) + if day_is_strict is False: + # set day as last day of month instead + next_dt = dt + datetime.timedelta(days=days_till_months_end) + else: + # else set as day in subsequent month. Subsequent month is + # guaranteed to have the date, if current month does not have the date. + next_dt = dt + datetime.timedelta(days=days_till_months_end + day) + else: + # if the day exists in the current month + if dt.day < day: + # day is upcoming + next_dt = dt + datetime.timedelta(day - dt.day) + elif dt.day > day or (dt.day == day and ((not allow_now and dt_time >= when) + or (allow_now and dt_time > when))): + # run next month if day has already passed + next_year = dt.year + 1 if dt.month == 12 else dt.year + next_month = 1 if dt.month == 12 else dt.month + 1 + days_in_next_month = calendar.monthrange(next_year, next_month)[1] + next_month_has_date = days_in_next_month >= day + if next_month_has_date: + next_dt = dt + datetime.timedelta(days=days_till_months_end + day) + elif day_is_strict: + # schedule the subsequent month if day is strict + next_dt = dt + datetime.timedelta( + days=days_till_months_end + days_in_next_month + day) + else: + # schedule in the next month last date if day is not strict + next_dt = dt + datetime.timedelta(days=days_till_months_end + + days_in_next_month) + + else: + # day is today but time has not yet come + next_dt = dt + + # Set the correct time + next_dt = next_dt.replace(hour=when.hour, minute=when.minute, second=when.second, + microsecond=when.microsecond) + # fold is new in Py3.6 + if hasattr(next_dt, 'fold'): + next_dt = next_dt.replace(fold=when.fold) + return next_dt + def run_daily(self, callback, time, days=Days.EVERY_DAY, context=None, name=None): """Creates a new ``Job`` that runs on a daily basis and adds it to the queue. @@ -318,6 +428,11 @@ class JobQueue(object): if job.repeat and not job.removed: self._put(job, previous_t=t) + elif job.is_monthly and not job.removed: + dt = datetime.datetime.now(tz=job.tzinfo) + dt_time = dt.time().replace(tzinfo=job.tzinfo) + self._put(job, time_spec=self._get_next_month_date(dt.day, job.day_is_strict, + dt_time)) else: job._set_next_t(None) self.logger.debug('Dropping non-repeating or removed job %s', job.name) @@ -386,6 +501,8 @@ class Job(object): callback (:obj:`callable`): The callback function that should be executed by the new job. context (:obj:`object`): Optional. Additional data needed for the callback function. name (:obj:`str`): Optional. The name of the new job. + is_monthly (:obj: `bool`): Optional. Indicates whether it is a monthly job. + day_is_strict (:obj: `bool`): Optional. Indicates whether the monthly jobs day is strict. Args: callback (:obj:`callable`): The callback function that should be executed by the new job. @@ -412,6 +529,11 @@ class Job(object): tzinfo (:obj:`datetime.tzinfo`, optional): timezone associated to this job. Used when checking the day of the week to determine whether a job should run (only relevant when ``days is not Days.EVERY_DAY``). Defaults to UTC. + is_monthly (:obj:`bool`, optional): If this job is supposed to be a monthly scheduled job. + Defaults to ``False``. + day_is_strict (:obj:`bool`, optional): If ``False`` and day > month.days, will pick the + last day in the month. Defaults to ``True``. Only relevant when ``is_monthly`` is + ``True``. """ def __init__(self, @@ -422,7 +544,9 @@ class Job(object): days=Days.EVERY_DAY, name=None, job_queue=None, - tzinfo=None): + tzinfo=None, + is_monthly=False, + day_is_strict=True): self.callback = callback self.context = context @@ -433,6 +557,8 @@ class Job(object): self.interval = interval self._next_t = None self.repeat = repeat + self.is_monthly = is_monthly + self.day_is_strict = day_is_strict self._days = None self.days = days diff --git a/tests/test_jobqueue.py b/tests/test_jobqueue.py index 844e56e26..1d96be7a8 100644 --- a/tests/test_jobqueue.py +++ b/tests/test_jobqueue.py @@ -16,7 +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 calendar import datetime as dtm import os import sys @@ -26,7 +26,6 @@ from time import sleep import pytest from flaky import flaky - from telegram.ext import JobQueue, Updater, Job, CallbackContext from telegram.utils.deprecate import TelegramDeprecationWarning @@ -288,6 +287,69 @@ class TestJobQueue(object): assert self.result == 1 assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time) + def test_run_monthly(self, job_queue): + delta, now = 0.1, time.time() + date_time = dtm.datetime.utcfromtimestamp(now) + time_of_day = (date_time + dtm.timedelta(seconds=delta)).time() + expected_reschedule_time = now + delta + + day = date_time.day + expected_reschedule_time += calendar.monthrange(date_time.year, + date_time.month)[1] * 24 * 60 * 60 + + job_queue.run_monthly(self.job_run_once, time_of_day, day) + sleep(0.2) + assert self.result == 1 + assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time) + + def test_run_monthly_and_not_strict(self, job_queue): + # This only really tests something in months with < 31 days. + # But the trouble of patching datetime is probably not worth it + + delta, now = 0.1, time.time() + date_time = dtm.datetime.utcfromtimestamp(now) + time_of_day = (date_time + dtm.timedelta(seconds=delta)).time() + expected_reschedule_time = now + delta + + day = date_time.day + date_time += dtm.timedelta(calendar.monthrange(date_time.year, + date_time.month)[1] - day) + # next job should be scheduled on last day of month if day_is_strict is False + expected_reschedule_time += (calendar.monthrange(date_time.year, + date_time.month)[1] - day) * 24 * 60 * 60 + + job_queue.run_monthly(self.job_run_once, time_of_day, 31, day_is_strict=False) + assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time) + + def test_run_monthly_with_timezone(self, job_queue): + """test that the day is retrieved based on the job's timezone + We set a job to run at the current UTC time of day (plus a small delay buffer) with a + timezone that is---approximately (see below)---UTC +24, and set it to run on the weekday + after the current UTC weekday. The job should therefore be executed now (because in UTC+24, + the time of day is the same as the current weekday is the one after the current UTC + weekday). + """ + now = time.time() + utcnow = dtm.datetime.utcfromtimestamp(now) + delta = 0.1 + + # must subtract one minute because the UTC offset has to be strictly less than 24h + # thus this test will xpass if run in the interval [00:00, 00:01) UTC time + # (because target time will be 23:59 UTC, so local and target weekday will be the same) + target_tzinfo = dtm.timezone(dtm.timedelta(days=1, minutes=-1)) + target_datetime = (utcnow + dtm.timedelta(days=1, minutes=-1, seconds=delta)).replace( + tzinfo=target_tzinfo) + target_time = target_datetime.timetz() + target_day = target_datetime.day + expected_reschedule_time = now + delta + expected_reschedule_time += calendar.monthrange(target_datetime.year, + target_datetime.month)[1] * 24 * 60 * 60 + + job_queue.run_monthly(self.job_run_once, target_time, target_day) + sleep(delta + 0.1) + assert self.result == 1 + assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time) + def test_warnings(self, job_queue): j = Job(self.job_run_once, repeat=False) with pytest.raises(ValueError, match='can not be set to'): @@ -384,8 +446,8 @@ class TestJobQueue(object): 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""" + """Test that tzinfo is correctly passed to job with run_once, run_daily, run_repeating + and run_monthly methods""" when_dt_tz_specific = dtm.datetime.now( tz=dtm.timezone(dtm.timedelta(hours=12)) @@ -426,6 +488,9 @@ class TestJobQueue(object): 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) + job_monthly1 = job_queue.run_monthly(self.job_run_once, time_tz_specific, 1) + job_monthly2 = job_queue.run_monthly(self.job_run_once, time_tz_utc, 1) + assert job_once1.tzinfo == when_dt_tz_specific.tzinfo assert job_once2.tzinfo == dtm.timezone.utc assert job_once3.tzinfo == when_time_tz_specific.tzinfo @@ -436,3 +501,5 @@ class TestJobQueue(object): assert job_repeating4.tzinfo == dtm.timezone.utc assert job_daily1.tzinfo == time_tz_specific.tzinfo assert job_daily2.tzinfo == dtm.timezone.utc + assert job_monthly1.tzinfo == time_tz_specific.tzinfo + assert job_monthly2.tzinfo == dtm.timezone.utc