Skip to content

Commit

Permalink
Added combining triggers (AndTrigger + OrTrigger)
Browse files Browse the repository at this point in the history
  • Loading branch information
agronholm committed Dec 19, 2017
1 parent 5e56860 commit 79629ad
Show file tree
Hide file tree
Showing 7 changed files with 205 additions and 3 deletions.
86 changes: 86 additions & 0 deletions apscheduler/triggers/combining.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from apscheduler.triggers.base import BaseTrigger
from apscheduler.util import obj_to_ref, ref_to_obj


class BaseMultiTrigger(BaseTrigger):
__slots__ = 'triggers'

def __init__(self, triggers):
self.triggers = triggers

def __getstate__(self):
return {
'version': 1,
'triggers': [(obj_to_ref(trigger.__class__), trigger.__getstate__())
for trigger in self.triggers]
}

def __setstate__(self, state):
if state.get('version', 1) > 1:
raise ValueError(
'Got serialized data for version %s of %s, but only versions up to 1 can be '
'handled' % (state['version'], self.__class__.__name__))

self.triggers = []
for clsref, state in state['triggers']:
cls = ref_to_obj(clsref)
trigger = cls.__new__(cls)
trigger.__setstate__(state)
self.triggers.append(trigger)

def __repr__(self):
return '<{}({})>'.format(self.__class__.__name__, self.triggers)


class AndTrigger(BaseMultiTrigger):
"""
Always returns the earliest next fire time that all the given triggers can agree on.
The trigger is considered to be finished when any of the given triggers has finished its
schedule.
Trigger alias: ``and``
:param list triggers: triggers to combine
"""

__slots__ = ()

def get_next_fire_time(self, previous_fire_time, now):
while True:
fire_times = [trigger.get_next_fire_time(previous_fire_time, now)
for trigger in self.triggers]
if None in fire_times:
return None
elif min(fire_times) == max(fire_times):
return fire_times[0]
else:
now = max(fire_times)

def __str__(self):
return 'and[{}]'.format(', '.join(str(trigger) for trigger in self.triggers))


class OrTrigger(BaseMultiTrigger):
"""
Always returns the earliest next fire time produced by any of the given triggers.
The trigger is considered finished when all the given triggers have finished their schedules.
Trigger alias: ``or``
:param list triggers: triggers to combine
.. note:: Triggers that depends on the previous fire time, such as the interval trigger, may
seem to behave strangely since they are always passed the previous fire time produced by
any of the given triggers.
"""

__slots__ = ()

def get_next_fire_time(self, previous_fire_time, now):
fire_times = [trigger.get_next_fire_time(previous_fire_time, now)
for trigger in self.triggers]
fire_times = [fire_time for fire_time in fire_times if fire_time is not None]
return min(fire_times) if fire_times else None

def __str__(self):
return 'or[{}]'.format(', '.join(str(trigger) for trigger in self.triggers))
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,5 +194,5 @@
# If false, no module index is generated.
#latex_use_modindex = True

intersphinx_mapping = {'python': ('http://docs.python.org/', None),
intersphinx_mapping = {'python': ('https://docs.python.org/', None),
'sqlalchemy': ('http://docs.sqlalchemy.org/en/latest/', None)}
35 changes: 35 additions & 0 deletions docs/modules/triggers/combining.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
:mod:`apscheduler.triggers.combining`
=====================================

These triggers combine the behavior of other triggers in different ways to produce schedules more
complex than would be possible with any single built-in trigger.

.. automodule:: apscheduler.triggers.combining

API
---

.. autoclass:: AndTrigger

.. autoclass:: OrTrigger


Examples
--------

Run ``job_function`` every 2 hours, but only on Saturdays and Sundays::

from apscheduler.triggers.combining import AndTrigger
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger


trigger = AndTrigger([IntervalTrigger(hours=2),
CronTrigger(day_of_week='sat,sun')])
scheduler.add_job(job_function, trigger)

Run ``job_function`` every Monday at 2pm and every Tuesday at 3pm::

trigger = OrTrigger([CronTrigger(day_of_week='mon', hour=2),
CronTrigger(day_of_week='tue', hour=3)])
scheduler.add_job(job_function, trigger)
6 changes: 5 additions & 1 deletion docs/userguide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ enough for most purposes. If your workload involves CPU intensive operations, yo
using :class:`~apscheduler.executors.pool.ProcessPoolExecutor` instead to make use of multiple CPU
cores. You could even use both at once, adding the process pool executor as a secondary executor.

When you schedule a job, you need to choose a _trigger_ for it. The trigger determines the logic by
When you schedule a job, you need to choose a *trigger* for it. The trigger determines the logic by
which the dates/times are calculated when the job will be run. APScheduler comes with three
built-in trigger types:

Expand All @@ -107,6 +107,10 @@ built-in trigger types:
* :mod:`~apscheduler.triggers.cron`:
use when you want to run the job periodically at certain time(s) 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
the documentation for :mod:`combining triggers <apscheduler.triggers.combining>`.

You can find the plugin names of each job store, executor and trigger type on their respective API
documentation pages.

Expand Down
2 changes: 2 additions & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ APScheduler, see the :doc:`migration section <migration>`.

* Added the ``jitter`` options to ``IntervalTrigger`` and ``CronTrigger`` (thanks to gilbsgilbs)

* Added combining triggers (``AndTrigger`` and ``OrTrigger``)

* Added better validation for the steps and ranges of different expressions in ``CronTrigger``

* Added support for named months (``january`` – ``december``) in ``CronTrigger`` month expressions
Expand Down
4 changes: 3 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@
'apscheduler.triggers': [
'date = apscheduler.triggers.date:DateTrigger',
'interval = apscheduler.triggers.interval:IntervalTrigger',
'cron = apscheduler.triggers.cron:CronTrigger'
'cron = apscheduler.triggers.cron:CronTrigger',
'and = apscheduler.triggers.combining:AndTrigger',
'or = apscheduler.triggers.combining:OrTrigger'
],
'apscheduler.executors': [
'debug = apscheduler.executors.debug:DebugExecutor',
Expand Down
73 changes: 73 additions & 0 deletions tests/test_triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.combining import AndTrigger, OrTrigger

try:
from unittest.mock import Mock
Expand Down Expand Up @@ -573,3 +574,75 @@ def test_jitter_dst_change(self, trigger_args, start_date, start_date_dst, corre
for _ in range(0, 100):
next_fire_time = trigger.get_next_fire_time(None, start_date + epsilon)
assert abs(next_fire_time - correct_next_date) <= timedelta(seconds=5)


class TestAndTrigger(object):
@pytest.fixture
def trigger(self, timezone):
return AndTrigger([
CronTrigger(month='5-8', day='6-15',
end_date=timezone.localize(datetime(2017, 8, 10))),
CronTrigger(month='6-9', day='*/3', end_date=timezone.localize(datetime(2017, 9, 7)))
])

@pytest.mark.parametrize('start_time, expected', [
(datetime(2017, 8, 6), datetime(2017, 8, 7)),
(datetime(2017, 8, 10, 1), None)
], ids=['firstmatch', 'end'])
def test_next_fire_time(self, trigger, timezone, start_time, expected):
expected = timezone.localize(expected) if expected else None
assert trigger.get_next_fire_time(None, timezone.localize(start_time)) == expected

def test_repr(self, trigger):
assert repr(trigger) == (
"<AndTrigger([<CronTrigger (month='5-8', day='6-15', "
"end_date='2017-08-10 00:00:00 CEST', timezone='Europe/Berlin')>, <CronTrigger "
"(month='6-9', day='*/3', end_date='2017-09-07 00:00:00 CEST', "
"timezone='Europe/Berlin')>])>")

def test_str(self, trigger):
assert str(trigger) == "and[cron[month='5-8', day='6-15'], cron[month='6-9', day='*/3']]"

def test_pickle(self, trigger):
"""Test that the trigger is pickleable."""
data = pickle.dumps(trigger, 2)
trigger2 = pickle.loads(data)

for attr in AndTrigger.__slots__:
assert getattr(trigger2, attr) == getattr(trigger, attr)


class TestOrTrigger(object):
@pytest.fixture
def trigger(self, timezone):
return OrTrigger([
CronTrigger(month='5-8', day='6-15',
end_date=timezone.localize(datetime(2017, 8, 10))),
CronTrigger(month='6-9', day='*/3', end_date=timezone.localize(datetime(2017, 9, 7)))
])

@pytest.mark.parametrize('start_time, expected', [
(datetime(2017, 8, 6), datetime(2017, 8, 6)),
(datetime(2017, 9, 7, 1), None)
], ids=['earliest', 'end'])
def test_next_fire_time(self, trigger, timezone, start_time, expected):
expected = timezone.localize(expected) if expected else None
assert trigger.get_next_fire_time(None, timezone.localize(start_time)) == expected

def test_repr(self, trigger):
assert repr(trigger) == (
"<OrTrigger([<CronTrigger (month='5-8', day='6-15', "
"end_date='2017-08-10 00:00:00 CEST', timezone='Europe/Berlin')>, <CronTrigger "
"(month='6-9', day='*/3', end_date='2017-09-07 00:00:00 CEST', "
"timezone='Europe/Berlin')>])>")

def test_str(self, trigger):
assert str(trigger) == "or[cron[month='5-8', day='6-15'], cron[month='6-9', day='*/3']]"

def test_pickle(self, trigger):
"""Test that the trigger is pickleable."""
data = pickle.dumps(trigger, 2)
trigger2 = pickle.loads(data)

for attr in OrTrigger.__slots__:
assert getattr(trigger2, attr) == getattr(trigger, attr)

0 comments on commit 79629ad

Please sign in to comment.