summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apscheduler/triggers/calendarinterval.py146
-rw-r--r--docs/modules/triggers/calendarinterval.rst64
-rw-r--r--docs/userguide.rst2
-rw-r--r--setup.cfg1
-rw-r--r--tests/triggers/test_calendarinterval.py95
5 files changed, 308 insertions, 0 deletions
diff --git a/apscheduler/triggers/calendarinterval.py b/apscheduler/triggers/calendarinterval.py
new file mode 100644
index 0000000..3d35712
--- /dev/null
+++ b/apscheduler/triggers/calendarinterval.py
@@ -0,0 +1,146 @@
+from datetime import timedelta, datetime, date, time
+from typing import Optional
+
+from pytz.exceptions import AmbiguousTimeError, NonExistentTimeError
+
+from apscheduler.triggers.base import BaseTrigger
+from apscheduler.util import astimezone
+
+
+class CalendarIntervalTrigger(BaseTrigger):
+ """
+ Runs the task on specified calendar-based intervals always at the same exact time of day.
+
+ When calculating the next date, the ``years`` and ``months`` parameters are first added to the
+ previous date while keeping the day of the month constant. This is repeated until the resulting
+ date is valid. After that, the ``weeks`` and ``days`` parameters are added to that date.
+ Finally, the date is combined with the given time (hour, minute, second) to form the final
+ datetime.
+
+ This means that if the ``days`` or ``weeks`` parameters are not used, the task will always be
+ executed on the same day of the month at the same wall clock time, assuming the date and time
+ are valid.
+
+ If the resulting datetime is invalid due to a daylight saving forward shift, the date is
+ discarded and the process moves on to the next date. If instead the datetime is ambiguous due
+ to a backward DST shift, the earlier of the two resulting datetimes is used.
+
+ If no previous run time is specified when requesting a new run time (like when starting for the
+ first time or resuming after being paused), ``start_date`` is used as a reference and the next
+ valid datetime equal to or later than the current time will be returned. Otherwise, the next
+ valid datetime starting from the previous run time is returned, even if it's in the past.
+
+ .. warning:: Be wary of setting a start date near the end of the month (29. – 31.) if you have
+ ``months`` specified in your interval, as this will skip the months where those days do not
+ exist. Likewise, setting the start date on the leap day (February 29th) and having
+ ``years`` defined may cause some years to be skipped.
+
+ Users are also discouraged from using a time inside the target timezone's DST switching
+ period (typically around 2 am) since a date could either be skipped or repeated due to the
+ specified wall clock time either occurring twice or not at all.
+
+ :param years: number of years to wait
+ :param months: number of months to wait
+ :param weeks: number of weeks to wait
+ :param days: number of days to wait
+ :param hour: hour to run the task at
+ :param minute: minute to run the task at
+ :param second: second to run the task at
+ :param start_date: starting point for the interval calculation (defaults to current date if
+ omitted)
+ :param end_date: latest possible date to trigger on
+ :param timezone: time zone to use for calculating the next fire time
+ (defaults to the scheduling's default time zone)
+ """
+
+ __slots__ = 'years', 'months', 'weeks', 'days', 'time', 'start_date', 'end_date', 'timezone'
+
+ def __init__(self, *, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0,
+ hour: int = 0, minute: int = 0, second: int = 0, start_date: date = None,
+ end_date: date = None, timezone=None) -> None:
+ self.years = years
+ self.months = months
+ self.weeks = weeks
+ self.days = days
+ self.time = time(hour, minute, second)
+ self.start_date = start_date
+ self.end_date = end_date
+ self.timezone = astimezone(timezone)
+
+ if self.years == self.months == self.weeks == self.days == 0:
+ raise ValueError('interval must be at least 1 day long')
+ if self.start_date and self.end_date and self.start_date > self.end_date:
+ raise ValueError('end_date cannot be earlier than start_date')
+
+ def get_next_fire_time(self, previous_fire_time, now):
+ # Determine the starting point of the calculations
+ timezone = self.timezone or now.tzinfo
+ previous_date = previous_fire_time.date() if previous_fire_time else None
+
+ while True:
+ if previous_date:
+ year, month = previous_date.year, previous_date.month
+ while True:
+ month += self.months
+ year += self.years + month // 12
+ month %= 12
+ try:
+ next_date = date(year, month, previous_date.day)
+ except ValueError:
+ pass # Nonexistent date
+ else:
+ next_date += timedelta(self.days + self.weeks * 7)
+ break
+ else:
+ next_date = self.start_date if self.start_date else now.date()
+
+ # Don't return any date past end_date
+ if self.end_date and next_date > self.end_date:
+ return None
+
+ next_time = datetime.combine(next_date, self.time)
+ try:
+ return timezone.localize(next_time, is_dst=None)
+ except AmbiguousTimeError:
+ # Return the daylight savings occurrence of the datetime
+ return timezone.localize(next_time, is_dst=True)
+ except NonExistentTimeError:
+ # This datetime does not exist (the DST shift jumps over it)
+ previous_date = next_date
+
+ def __getstate__(self):
+ return {
+ 'version': 1,
+ 'interval': [self.years, self.months, self.weeks, self.days],
+ 'time': self.time,
+ 'start_date': self.start_date,
+ 'end_date': self.end_date,
+ 'timezone': str(self.timezone)
+ }
+
+ def __setstate__(self, state):
+ if state.get('version', 1) > 1:
+ raise ValueError(
+ 'Got serialized data for version %s of %s, but only version 1 can be handled' %
+ (state['version'], self.__class__.__name__))
+
+ self.years, self.months, self.weeks, self.days = state['interval']
+ self.time = state['time']
+ self.start_date = state['start_date']
+ self.end_date = state['end_date']
+ self.timezone = astimezone(state['timezone'])
+
+ def __str__(self):
+ options = []
+ for field, suffix in [('years', 'y'), ('months', 'm'), ('weeks', 'w'), ('days', 'd')]:
+ value = getattr(self, field)
+ if value:
+ options.append('{}{}'.format(value, suffix))
+
+ return 'calendarinterval[{} at {}]'.format(', '.join(options), self.time)
+
+ def __repr__(self):
+ fields = 'years', 'months', 'weeks', 'days'
+ interval_repr = ', '.join('%s=%d' % (attr, getattr(self, attr))
+ for attr in fields if getattr(self, attr))
+ return '{self.__class__.__name__}({}, time={self.time})'.format(interval_repr, self=self)
diff --git a/docs/modules/triggers/calendarinterval.rst b/docs/modules/triggers/calendarinterval.rst
new file mode 100644
index 0000000..53d3c51
--- /dev/null
+++ b/docs/modules/triggers/calendarinterval.rst
@@ -0,0 +1,64 @@
+:mod:`apscheduler.triggers.calendarinterval`
+============================================
+
+.. automodule:: apscheduler.triggers.calendarinterval
+
+API
+---
+
+Trigger alias for :meth:`~apscheduler.schedulers.base.BaseScheduler.add_job`: ``calendarinterval``
+
+.. autoclass:: CalendarIntervalTrigger
+ :show-inheritance:
+
+
+Introduction
+------------
+
+This method schedules jobs to be run on calendar-based intervals, always at the same wall-clock
+time. You can specify years, months, weeks and days as the interval, and they will be added to the
+previous fire time in that order when calculating the next fire time.
+
+You can also specify the starting date and ending dates for the schedule through the ``start_date``
+and ``end_date`` parameters, respectively. They can be given as a date/datetime object or text (in
+the `ISO 8601 <https://en.wikipedia.org/wiki/ISO_8601>`_ format).
+
+If the start date is in the past, the trigger will not fire many times retroactively but instead
+calculates the next run time from the current time, based on the past start time.
+
+
+Examples
+--------
+
+::
+
+ from datetime import datetime
+
+ from apscheduler.schedulers.blocking import BlockingScheduler
+
+
+ def job_function():
+ print("Hello World")
+
+ sched = BlockingScheduler()
+
+ # Schedule job_function to be called every month at 15:36:00, starting from today
+ sched.add_job(job_function, 'calendarinterval', months=1, hour=15, minute=36)
+
+ sched.start()
+
+
+You can use ``start_date`` and ``end_date`` to limit the total time in which the schedule runs::
+
+ # The same as before, but starts on 2019-06-16 and stops on 2020-03-16
+   sched.add_job(job_function, 'calendarinterval', months=2, start_date='2019-06-16',
+ end_date='2020-03-16', hour=15, minute=36)
+
+
+The :meth:`~apscheduler.schedulers.base.BaseScheduler.scheduled_job` decorator works nicely too::
+
+ from apscheduler.scheduler import BlockingScheduler
+
+ @sched.scheduled_job('calendarinterval', id='my_job_id', weeks=2)
+ def job_function():
+ print("Hello World")
diff --git a/docs/userguide.rst b/docs/userguide.rst
index a1c147d..7061b0c 100644
--- a/docs/userguide.rst
+++ b/docs/userguide.rst
@@ -106,6 +106,8 @@ built-in trigger types:
use when you want to run the job at fixed intervals of time
* :mod:`~apscheduler.triggers.cron`:
use when you want to run the job periodically at certain time(s) of day
+* :mod:`~apscheduler.triggers.calendarinterval`:
+ use when you want to run the job on calendar-based intervals, at a specific time of day
It is also possible to combine multiple triggers into one which fires either on times agreed on by
all the participating triggers, or when any of the triggers would fire. For more information, see
diff --git a/setup.cfg b/setup.cfg
index f830638..1337ec4 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -49,6 +49,7 @@ apscheduler.triggers =
cron = apscheduler.triggers.cron:CronTrigger
and = apscheduler.triggers.combining:AndTrigger
or = apscheduler.triggers.combining:OrTrigger
+ calendarinterval = apscheduler.triggers.calendarinterval:CalendarIntervalTrigger
apscheduler.executors =
debug = apscheduler.executors.debug:DebugExecutor
threadpool = apscheduler.executors.pool:ThreadPoolExecutor
diff --git a/tests/triggers/test_calendarinterval.py b/tests/triggers/test_calendarinterval.py
new file mode 100644
index 0000000..154b989
--- /dev/null
+++ b/tests/triggers/test_calendarinterval.py
@@ -0,0 +1,95 @@
+import pickle
+from datetime import datetime, date
+
+import pytest
+from pytz.tzinfo import DstTzInfo
+
+from apscheduler.triggers.calendarinterval import CalendarIntervalTrigger
+
+
+@pytest.fixture
+def trigger(timezone: DstTzInfo):
+ return CalendarIntervalTrigger(
+ years=1, months=5, weeks=6, days=8, hour=3, second=8, start_date=date(2016, 3, 5),
+ end_date=date(2020, 12, 25), timezone=timezone)
+
+
+def test_bad_interval(timezone: DstTzInfo):
+ exc = pytest.raises(ValueError, CalendarIntervalTrigger, timezone=timezone)
+ exc.match('interval must be at least 1 day long')
+
+
+def test_bad_start_end_dates(timezone: DstTzInfo):
+ exc = pytest.raises(ValueError, CalendarIntervalTrigger, days=1,
+ start_date=date(2016, 3, 4), end_date=date(2016, 3, 3), timezone=timezone)
+ exc.match('end_date cannot be earlier than start_date')
+
+
+def test_pickle(trigger: CalendarIntervalTrigger):
+ payload = pickle.dumps(trigger)
+ deserialized = pickle.loads(payload)
+
+ for attr in CalendarIntervalTrigger.__slots__:
+ assert getattr(deserialized, attr) == getattr(trigger, attr)
+
+
+def test_setstate_unhandled_version(trigger: CalendarIntervalTrigger):
+ exc = pytest.raises(ValueError, trigger.__setstate__, {'version': 2})
+ exc.match('Got serialized data for version 2 of CalendarIntervalTrigger, '
+ 'but only version 1 can be handled')
+
+
+def test_start_date(trigger: CalendarIntervalTrigger, timezone: DstTzInfo):
+ """Test that start_date is respected."""
+ now = timezone.localize(datetime(2016, 1, 15))
+ expected = timezone.localize(datetime(2016, 3, 5, 3, 0, 8))
+ assert trigger.get_next_fire_time(None, now) == expected
+
+
+def test_end_date(trigger: CalendarIntervalTrigger, timezone: DstTzInfo):
+ """Test that end_date is respected."""
+ now = timezone.localize(datetime(2020, 12, 31))
+ previous = timezone.localize(datetime(2020, 12, 25))
+ assert trigger.get_next_fire_time(now, previous) is None
+
+
+def test_missing_time(timezone: DstTzInfo):
+ """
+ Test that if the designated time does not exist on a day due to a forward DST shift, the day is
+ skipped entirely.
+
+ """
+ trigger = CalendarIntervalTrigger(days=1, hour=2, minute=30)
+ now = timezone.localize(datetime(2016, 3, 27))
+ expected = timezone.localize(datetime(2016, 3, 28, 2, 30))
+ assert trigger.get_next_fire_time(None, now) == expected
+
+
+def test_repeated_time(timezone: DstTzInfo):
+ """
+ Test that if the designated time is repeated during a day due to a backward DST shift, the task
+ is executed on the earlier occurrence of that time.
+
+ """
+ trigger = CalendarIntervalTrigger(days=2, hour=2, minute=30)
+ now = timezone.localize(datetime(2016, 10, 30))
+ expected = timezone.localize(datetime(2016, 10, 30, 2, 30), is_dst=True)
+ assert trigger.get_next_fire_time(None, now) == expected
+
+
+def test_nonexistent_days(timezone: DstTzInfo):
+ """Test that invalid dates are skipped."""
+ trigger = CalendarIntervalTrigger(months=1)
+ now = timezone.localize(datetime(2016, 4, 30))
+ previous = timezone.localize(datetime(2016, 3, 31))
+ expected = timezone.localize(datetime(2016, 5, 31))
+ assert trigger.get_next_fire_time(previous, now) == expected
+
+
+def test_str(trigger):
+ assert str(trigger) == 'calendarinterval[1y, 5m, 6w, 8d at 03:00:08]'
+
+
+def test_repr(trigger):
+ assert repr(trigger) == ("CalendarIntervalTrigger(years=1, months=5, weeks=6, days=8, "
+ "time=03:00:08)")