mirror of
https://github.com/python-telegram-bot/python-telegram-bot.git
synced 2025-02-16 18:31:45 +01:00
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 <hinrich.mahler@freenet.de>
This commit is contained in:
parent
7e231183c4
commit
ae17ce977e
3 changed files with 199 additions and 5 deletions
|
@ -27,6 +27,7 @@ The following wonderful people contributed directly or indirectly to this projec
|
||||||
- `d-qoi <https://github.com/d-qoi>`_
|
- `d-qoi <https://github.com/d-qoi>`_
|
||||||
- `daimajia <https://github.com/daimajia>`_
|
- `daimajia <https://github.com/daimajia>`_
|
||||||
- `Daniel Reed <https://github.com/nmlorg>`_
|
- `Daniel Reed <https://github.com/nmlorg>`_
|
||||||
|
- `D David Livingston <https://github.com/daviddl9>`_
|
||||||
- `Eana Hufwe <https://github.com/blueset>`_
|
- `Eana Hufwe <https://github.com/blueset>`_
|
||||||
- `Ehsan Online <https://github.com/ehsanonline>`_
|
- `Ehsan Online <https://github.com/ehsanonline>`_
|
||||||
- `Eli Gao <https://github.com/eligao>`_
|
- `Eli Gao <https://github.com/eligao>`_
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
||||||
"""This module contains the classes JobQueue and Job."""
|
"""This module contains the classes JobQueue and Job."""
|
||||||
|
|
||||||
|
import calendar
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
@ -221,6 +222,115 @@ class JobQueue(object):
|
||||||
self._put(job, time_spec=first)
|
self._put(job, time_spec=first)
|
||||||
return job
|
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):
|
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.
|
"""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:
|
if job.repeat and not job.removed:
|
||||||
self._put(job, previous_t=t)
|
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:
|
else:
|
||||||
job._set_next_t(None)
|
job._set_next_t(None)
|
||||||
self.logger.debug('Dropping non-repeating or removed job %s', job.name)
|
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.
|
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.
|
context (:obj:`object`): Optional. Additional data needed for the callback function.
|
||||||
name (:obj:`str`): Optional. The name of the new job.
|
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:
|
Args:
|
||||||
callback (:obj:`callable`): The callback function that should be executed by the new job.
|
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
|
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
|
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.
|
``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,
|
def __init__(self,
|
||||||
|
@ -422,7 +544,9 @@ class Job(object):
|
||||||
days=Days.EVERY_DAY,
|
days=Days.EVERY_DAY,
|
||||||
name=None,
|
name=None,
|
||||||
job_queue=None,
|
job_queue=None,
|
||||||
tzinfo=None):
|
tzinfo=None,
|
||||||
|
is_monthly=False,
|
||||||
|
day_is_strict=True):
|
||||||
|
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
self.context = context
|
self.context = context
|
||||||
|
@ -433,6 +557,8 @@ class Job(object):
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
self._next_t = None
|
self._next_t = None
|
||||||
self.repeat = repeat
|
self.repeat = repeat
|
||||||
|
self.is_monthly = is_monthly
|
||||||
|
self.day_is_strict = day_is_strict
|
||||||
|
|
||||||
self._days = None
|
self._days = None
|
||||||
self.days = days
|
self.days = days
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Lesser Public License
|
# You should have received a copy of the GNU Lesser Public License
|
||||||
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
||||||
|
import calendar
|
||||||
import datetime as dtm
|
import datetime as dtm
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
@ -26,7 +26,6 @@ from time import sleep
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from flaky import flaky
|
from flaky import flaky
|
||||||
|
|
||||||
from telegram.ext import JobQueue, Updater, Job, CallbackContext
|
from telegram.ext import JobQueue, Updater, Job, CallbackContext
|
||||||
from telegram.utils.deprecate import TelegramDeprecationWarning
|
from telegram.utils.deprecate import TelegramDeprecationWarning
|
||||||
|
|
||||||
|
@ -288,6 +287,69 @@ class TestJobQueue(object):
|
||||||
assert self.result == 1
|
assert self.result == 1
|
||||||
assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time)
|
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):
|
def test_warnings(self, job_queue):
|
||||||
j = Job(self.job_run_once, repeat=False)
|
j = Job(self.job_run_once, repeat=False)
|
||||||
with pytest.raises(ValueError, match='can not be set to'):
|
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)
|
assert job.next_t == t.astimezone(job.tzinfo)
|
||||||
|
|
||||||
def test_passing_tzinfo_to_job(self, job_queue):
|
def test_passing_tzinfo_to_job(self, job_queue):
|
||||||
"""Test that tzinfo is correctly passed to job with run_once, run_daily
|
"""Test that tzinfo is correctly passed to job with run_once, run_daily, run_repeating
|
||||||
and run_repeating methods"""
|
and run_monthly methods"""
|
||||||
|
|
||||||
when_dt_tz_specific = dtm.datetime.now(
|
when_dt_tz_specific = dtm.datetime.now(
|
||||||
tz=dtm.timezone(dtm.timedelta(hours=12))
|
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_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_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_once1.tzinfo == when_dt_tz_specific.tzinfo
|
||||||
assert job_once2.tzinfo == dtm.timezone.utc
|
assert job_once2.tzinfo == dtm.timezone.utc
|
||||||
assert job_once3.tzinfo == when_time_tz_specific.tzinfo
|
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_repeating4.tzinfo == dtm.timezone.utc
|
||||||
assert job_daily1.tzinfo == time_tz_specific.tzinfo
|
assert job_daily1.tzinfo == time_tz_specific.tzinfo
|
||||||
assert job_daily2.tzinfo == dtm.timezone.utc
|
assert job_daily2.tzinfo == dtm.timezone.utc
|
||||||
|
assert job_monthly1.tzinfo == time_tz_specific.tzinfo
|
||||||
|
assert job_monthly2.tzinfo == dtm.timezone.utc
|
||||||
|
|
Loading…
Add table
Reference in a new issue