diff options
author | Alex Grönholm <alex.gronholm@nextday.fi> | 2017-12-19 23:09:17 +0200 |
---|---|---|
committer | Alex Grönholm <alex.gronholm@nextday.fi> | 2017-12-19 23:10:52 +0200 |
commit | 79629ad79640fc52ce9a35851978ca5d812a353d (patch) | |
tree | 62c5374b5cd8338c159173e3cf1b59dd9ac0dc6a | |
parent | 5e56860088bab438b0cf234a920d276359f897fd (diff) | |
download | apscheduler-79629ad79640fc52ce9a35851978ca5d812a353d.tar.gz |
Added combining triggers (AndTrigger + OrTrigger)
Fixes #119.
-rw-r--r-- | apscheduler/triggers/combining.py | 86 | ||||
-rw-r--r-- | docs/conf.py | 2 | ||||
-rw-r--r-- | docs/modules/triggers/combining.rst | 35 | ||||
-rw-r--r-- | docs/userguide.rst | 6 | ||||
-rw-r--r-- | docs/versionhistory.rst | 2 | ||||
-rw-r--r-- | setup.py | 4 | ||||
-rw-r--r-- | tests/test_triggers.py | 73 |
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 @@ -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) |