mirror of
https://github.com/python-telegram-bot/python-telegram-bot.git
synced 2024-12-22 14:35:00 +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>`_
|
||||
- `daimajia <https://github.com/daimajia>`_
|
||||
- `Daniel Reed <https://github.com/nmlorg>`_
|
||||
- `D David Livingston <https://github.com/daviddl9>`_
|
||||
- `Eana Hufwe <https://github.com/blueset>`_
|
||||
- `Ehsan Online <https://github.com/ehsanonline>`_
|
||||
- `Eli Gao <https://github.com/eligao>`_
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue