summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlex Grönholm <alex.gronholm@nextday.fi>2017-12-19 23:09:17 +0200
committerAlex Grönholm <alex.gronholm@nextday.fi>2017-12-19 23:10:52 +0200
commit79629ad79640fc52ce9a35851978ca5d812a353d (patch)
tree62c5374b5cd8338c159173e3cf1b59dd9ac0dc6a
parent5e56860088bab438b0cf234a920d276359f897fd (diff)
downloadapscheduler-79629ad79640fc52ce9a35851978ca5d812a353d.tar.gz
Added combining triggers (AndTrigger + OrTrigger)
Fixes #119.
-rw-r--r--apscheduler/triggers/combining.py86
-rw-r--r--docs/conf.py2
-rw-r--r--docs/modules/triggers/combining.rst35
-rw-r--r--docs/userguide.rst6
-rw-r--r--docs/versionhistory.rst2
-rw-r--r--setup.py4
-rw-r--r--tests/test_triggers.py73
7 files changed, 205 insertions, 3 deletions
diff --git a/apscheduler/triggers/combining.py b/apscheduler/triggers/combining.py
new file mode 100644
index 0000000..ff01998
--- /dev/null
+++ b/apscheduler/triggers/combining.py
@@ -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))
diff --git a/docs/conf.py b/docs/conf.py
index 1aefd3d..756848c 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -194,5 +194,5 @@ latex_documents = [
# 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)}
diff --git a/docs/modules/triggers/combining.rst b/docs/modules/triggers/combining.rst
new file mode 100644
index 0000000..856cc60
--- /dev/null
+++ b/docs/modules/triggers/combining.rst
@@ -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)
diff --git a/docs/userguide.rst b/docs/userguide.rst
index 4c1a546..72399c8 100644
--- a/docs/userguide.rst
+++ b/docs/userguide.rst
@@ -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:
@@ -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.
diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst
index 9736736..4a2c233 100644
--- a/docs/versionhistory.rst
+++ b/docs/versionhistory.rst
@@ -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
diff --git a/setup.py b/setup.py
index 604d87f..a555ba5 100644
--- a/setup.py
+++ b/setup.py
@@ -66,7 +66,9 @@ setup(
'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',
diff --git a/tests/test_triggers.py b/tests/test_triggers.py
index a7f6c4f..bd7d5b6 100644
--- a/tests/test_triggers.py
+++ b/tests/test_triggers.py
@@ -9,6 +9,7 @@ from apscheduler.triggers.base import BaseTrigger
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
@@ -573,3 +574,75 @@ class TestIntervalTrigger(object):
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)