summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/mailman/config/configure.zcml23
-rw-r--r--src/mailman/database/mailman.sql11
-rw-r--r--src/mailman/database/types.py5
-rw-r--r--src/mailman/interfaces/bounce.py63
-rw-r--r--src/mailman/model/bounce.py66
-rw-r--r--src/mailman/model/docs/bounce.rst84
-rw-r--r--src/mailman/model/mailinglist.py2
-rw-r--r--src/mailman/model/tests/test_bounce.py46
-rw-r--r--src/mailman/queue/outgoing.py11
-rw-r--r--src/mailman/queue/tests/__init__.py0
-rw-r--r--src/mailman/queue/tests/test_outgoing.py245
-rw-r--r--src/mailman/testing/helpers.py11
12 files changed, 548 insertions, 19 deletions
diff --git a/src/mailman/config/configure.zcml b/src/mailman/config/configure.zcml
index 3b4497ab8..299a0ce67 100644
--- a/src/mailman/config/configure.zcml
+++ b/src/mailman/config/configure.zcml
@@ -22,6 +22,11 @@
/>
<utility
+ factory="mailman.model.bounce.BounceProcessor"
+ provides="mailman.interfaces.bounce.IBounceProcessor"
+ />
+
+ <utility
factory="mailman.model.domain.DomainManager"
provides="mailman.interfaces.domain.IDomainManager"
/>
@@ -37,11 +42,6 @@
/>
<utility
- factory="mailman.model.usermanager.UserManager"
- provides="mailman.interfaces.usermanager.IUserManager"
- />
-
- <utility
factory="mailman.model.messagestore.MessageStore"
provides="mailman.interfaces.messages.IMessageStore"
/>
@@ -52,13 +52,13 @@
/>
<utility
- factory="mailman.model.requests.Requests"
- provides="mailman.interfaces.requests.IRequests"
+ factory="mailman.app.registrar.Registrar"
+ provides="mailman.interfaces.registrar.IRegistrar"
/>
<utility
- factory="mailman.app.registrar.Registrar"
- provides="mailman.interfaces.registrar.IRegistrar"
+ factory="mailman.model.requests.Requests"
+ provides="mailman.interfaces.requests.IRequests"
/>
<utility
@@ -67,6 +67,11 @@
/>
<utility
+ factory="mailman.model.usermanager.UserManager"
+ provides="mailman.interfaces.usermanager.IUserManager"
+ />
+
+ <utility
factory="mailman.email.validate.Validator"
provides="mailman.interfaces.address.IEmailValidator"
/>
diff --git a/src/mailman/database/mailman.sql b/src/mailman/database/mailman.sql
index 5d07c607a..6312066c0 100644
--- a/src/mailman/database/mailman.sql
+++ b/src/mailman/database/mailman.sql
@@ -54,6 +54,17 @@ CREATE INDEX ix_autoresponserecord_address_id
CREATE INDEX ix_autoresponserecord_mailing_list_id
ON autoresponserecord (mailing_list_id);
+CREATE TABLE bounceevent (
+ id INTEGER NOT NULL,
+ list_name TEXT,
+ email TEXT,
+ 'timestamp' TIMESTAMP,
+ message_id TEXT,
+ context TEXT,
+ processed BOOLEAN,
+ PRIMARY KEY (id)
+ );
+
CREATE TABLE contentfilter (
id INTEGER NOT NULL,
mailing_list_id INTEGER,
diff --git a/src/mailman/database/types.py b/src/mailman/database/types.py
index 1195802ff..f126cc05a 100644
--- a/src/mailman/database/types.py
+++ b/src/mailman/database/types.py
@@ -34,7 +34,10 @@ from mailman.utilities.modules import find_name
class _EnumVariable(Variable):
- """Storm variable."""
+ """Storm variable.
+
+ To use this, make the database column a TEXT.
+ """
def parse_set(self, value, from_db):
if value is None:
diff --git a/src/mailman/interfaces/bounce.py b/src/mailman/interfaces/bounce.py
index 22e2467b8..168ca7ee0 100644
--- a/src/mailman/interfaces/bounce.py
+++ b/src/mailman/interfaces/bounce.py
@@ -21,13 +21,16 @@ from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
+ 'BounceContext',
'IBounceDetector',
+ 'IBounceEvent',
+ 'IBounceProcessor',
'Stop',
]
from flufl.enum import Enum
-from zope.interface import Interface
+from zope.interface import Attribute, Interface
@@ -39,6 +42,19 @@ Stop = object()
+class BounceContext(Enum):
+ """The context in which the bounce was detected."""
+
+ # This is a normal bounce detection. IOW, Mailman received a bounce in
+ # response to a mailing list post.
+ normal = 1
+
+ # A probe email bounced. This can be considered a bit more serious, since
+ # it occurred in response to a specific message to a specific user.
+ probe = 2
+
+
+
class IBounceDetector(Interface):
"""Detect a bounce in an email message."""
@@ -52,3 +68,48 @@ class IBounceDetector(Interface):
returned to halt any bounce processing pipeline.
:rtype: A set strings, or `Stop`
"""
+
+
+
+class IBounceEvent(Interface):
+ """Registration record for a single bounce event."""
+
+ list_name = Attribute(
+ """The name of the mailing list that received this bounce.""")
+
+ email = Attribute(
+ """The email address that bounced.""")
+
+ timestamp = Attribute(
+ """The timestamp for when the bounce was received.""")
+
+ message_id = Attribute(
+ """The Message-ID of the bounce message.""")
+
+ context = Attribute(
+ """Where was the bounce detected?""")
+
+ processed = Attribute(
+ """Has this bounce event been processed?""")
+
+
+
+class IBounceProcessor(Interface):
+ """Manager/processor of bounce events."""
+
+ def register(mlist, email, msg, context=None):
+ """Register a bounce event.
+
+ :param mlist: The mailing list that the bounce occurred on.
+ :type mlist: IMailingList
+ :param email: The email address that is bouncing.
+ :type email: str
+ :param msg: The bounce message.
+ :type msg: email.message.Message
+ :param context: In what context was the bounce detected? The default
+ is 'normal' context (i.e. we received a normal bounce for the
+ address).
+ :type context: BounceContext
+ :return: The registered bounce event.
+ :rtype: IBounceEvent
+ """
diff --git a/src/mailman/model/bounce.py b/src/mailman/model/bounce.py
new file mode 100644
index 000000000..8abe3d149
--- /dev/null
+++ b/src/mailman/model/bounce.py
@@ -0,0 +1,66 @@
+# Copyright (C) 2011 by the Free Software Foundation, Inc.
+#
+# This file is part of GNU Mailman.
+#
+# GNU Mailman is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+
+"""Bounce support."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'BounceEvent',
+ 'BounceProcessor',
+ ]
+
+
+from storm.locals import Bool, Int, DateTime, Unicode
+from zope.interface import implements
+
+from mailman.interfaces.bounce import (
+ BounceContext, IBounceEvent, IBounceProcessor)
+from mailman.database.model import Model
+from mailman.database.types import Enum
+from mailman.utilities.datetime import now
+
+
+
+class BounceEvent(Model):
+ implements(IBounceEvent)
+
+ id = Int(primary=True)
+ list_name = Unicode()
+ email = Unicode()
+ timestamp = DateTime()
+ message_id = Unicode()
+ context = Enum()
+ processed = Bool()
+
+ def __init__(self, list_name, email, msg, context=None):
+ self.list_name = list_name
+ self.email = email
+ self.timestamp = now()
+ self.message_id = msg['message-id']
+ self.context = (BounceContext.normal if context is None else context)
+ self.processed = False
+
+
+
+class BounceProcessor:
+ implements(IBounceProcessor)
+
+ def register(self, mlist, email, msg, where=None):
+ """See `IBounceProcessor`."""
+ return BounceEvent(mlist.fqdn_listname, email, msg, where)
diff --git a/src/mailman/model/docs/bounce.rst b/src/mailman/model/docs/bounce.rst
new file mode 100644
index 000000000..41784cd9c
--- /dev/null
+++ b/src/mailman/model/docs/bounce.rst
@@ -0,0 +1,84 @@
+=======
+Bounces
+=======
+
+When a message to an email address bounces, Mailman's bounce runner will
+register a bounce event. This registration is done through a utility.
+
+ >>> from zope.component import getUtility
+ >>> from zope.interface.verify import verifyObject
+ >>> from mailman.interfaces.bounce import IBounceProcessor
+ >>> processor = getUtility(IBounceProcessor)
+ >>> verifyObject(IBounceProcessor, processor)
+ True
+
+
+Registration
+============
+
+When a bounce occurs, it's always within the context of a specific mailing
+list.
+
+ >>> mlist = create_list('test@example.com')
+
+The bouncing email contains useful information that will be registered as
+well. In particular, the Message-ID is a key piece of data that needs to be
+recorded.
+
+ >>> msg = message_from_string("""\
+ ... From: mail-daemon@example.org
+ ... To: test-bounces@example.com
+ ... Message-ID: <first>
+ ...
+ ... """)
+
+There is a suite of bounce detectors that are used to heuristically extract
+the bouncing email addresses. Various techniques are employed including VERP,
+DSN, and magic. It is the bounce queue's responsibility to extract the set of
+bouncing email addrsses. These are passed one-by-one to the registration
+interface.
+
+ >>> event = processor.register(mlist, 'anne@example.com', msg)
+ >>> print event.list_name
+ test@example.com
+ >>> print event.email
+ anne@example.com
+ >>> print event.message_id
+ <first>
+
+Bounce events have a timestamp.
+
+ >>> print event.timestamp
+ 2005-08-01 07:49:23
+
+Bounce events have a flag indicating whether they've been processed or not.
+
+ >>> event.processed
+ False
+
+When a bounce is registered, you can indicate the bounce context.
+
+ >>> msg = message_from_string("""\
+ ... From: mail-daemon@example.org
+ ... To: test-bounces@example.com
+ ... Message-ID: <second>
+ ...
+ ... """)
+
+If no context is given, then a default one is used.
+
+ >>> event = processor.register(mlist, 'bart@example.com', msg)
+ >>> print event.message_id
+ <second>
+ >>> print event.context
+ BounceContext.normal
+
+A probe bounce carries more weight than just a normal bounce.
+
+ >>> from mailman.interfaces.bounce import BounceContext
+ >>> event = processor.register(
+ ... mlist, 'bart@example.com', msg, BounceContext.probe)
+ >>> print event.message_id
+ <second>
+ >>> print event.context
+ BounceContext.probe
diff --git a/src/mailman/model/mailinglist.py b/src/mailman/model/mailinglist.py
index 9294fe7cc..6952abcf0 100644
--- a/src/mailman/model/mailinglist.py
+++ b/src/mailman/model/mailinglist.py
@@ -195,7 +195,7 @@ class MailingList(Model):
# For the pending database
self.next_request_id = 1
self._restore()
- self.personalization = Personalization.none
+ self.personalize = Personalization.none
self.real_name = string.capwords(
SPACE.join(listname.split(UNDERSCORE)))
makedirs(self.data_path)
diff --git a/src/mailman/model/tests/test_bounce.py b/src/mailman/model/tests/test_bounce.py
new file mode 100644
index 000000000..fb0bf0875
--- /dev/null
+++ b/src/mailman/model/tests/test_bounce.py
@@ -0,0 +1,46 @@
+# Copyright (C) 2011 by the Free Software Foundation, Inc.
+#
+# This file is part of GNU Mailman.
+#
+# GNU Mailman is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+
+"""Test bounce model objects."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'test_suite',
+ ]
+
+
+import unittest
+
+from mailman.model.bounce import BounceEvent
+from mailman.testing.layers import ConfigLayer
+
+
+
+class TestBounceEvents(unittest.TestCase):
+ layer = ConfigLayer
+
+
+
+
+
+
+def test_suite():
+ suite = unittest.TestSuite()
+ suite.addTest(unittest.makeSuite(TestBounceEvents))
+ return suite
diff --git a/src/mailman/queue/outgoing.py b/src/mailman/queue/outgoing.py
index 7ff194219..dfa60a02d 100644
--- a/src/mailman/queue/outgoing.py
+++ b/src/mailman/queue/outgoing.py
@@ -28,6 +28,7 @@ from mailman.interfaces.mailinglist import Personalization
from mailman.interfaces.mta import SomeRecipientsFailed
from mailman.queue import Runner
from mailman.queue.bounce import BounceMixin
+from mailman.utilities.datetime import now
from mailman.utilities.modules import find_name
@@ -56,7 +57,7 @@ class OutgoingRunner(Runner, BounceMixin):
def _dispose(self, mlist, msg, msgdata):
# See if we should retry delivery of this message again.
deliver_after = msgdata.get('deliver_after', datetime.fromtimestamp(0))
- if datetime.now() < deliver_after:
+ if now() < deliver_after:
return True
# Calculate whether we should VERP this message or not. The results of
# this set the 'verp' key in the message metadata.
@@ -69,7 +70,7 @@ class OutgoingRunner(Runner, BounceMixin):
# Also, if personalization is /not/ enabled, but
# verp_delivery_interval is set (and we've hit this interval), then
# again, this message should be VERP'd. Otherwise, no.
- elif mlist.personalize <> Personalization.none:
+ elif mlist.personalize != Personalization.none:
if as_boolean(config.mta.verp_personalized_deliveries):
msgdata['verp'] = True
elif interval == 0:
@@ -118,15 +119,15 @@ class OutgoingRunner(Runner, BounceMixin):
# occasionally move them back here for another shot at
# delivery.
if error.temporary_failures:
- now = datetime.now()
+ current_time = now()
recips = error.temporary_failures
last_recip_count = msgdata.get('last_recip_count', 0)
- deliver_until = msgdata.get('deliver_until', now)
+ deliver_until = msgdata.get('deliver_until', current_time)
if len(recips) == last_recip_count:
# We didn't make any progress, so don't attempt
# delivery any longer. BAW: is this the best
# disposition?
- if now > deliver_until:
+ if current_time > deliver_until:
return False
else:
# Keep trying to delivery this message for a while
diff --git a/src/mailman/queue/tests/__init__.py b/src/mailman/queue/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/mailman/queue/tests/__init__.py
diff --git a/src/mailman/queue/tests/test_outgoing.py b/src/mailman/queue/tests/test_outgoing.py
new file mode 100644
index 000000000..da090f31e
--- /dev/null
+++ b/src/mailman/queue/tests/test_outgoing.py
@@ -0,0 +1,245 @@
+# Copyright (C) 2011 by the Free Software Foundation, Inc.
+#
+# This file is part of GNU Mailman.
+#
+# GNU Mailman is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+
+"""Test the outgoing queue runner."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'test_suite',
+ ]
+
+
+import unittest
+
+from contextlib import contextmanager
+from datetime import timedelta
+
+from mailman.app.lifecycle import create_list
+from mailman.config import config
+from mailman.interfaces.mailinglist import Personalization
+from mailman.queue.outgoing import OutgoingRunner
+from mailman.testing.helpers import (
+ get_queue_messages,
+ make_testable_runner,
+ specialized_message_from_string as message_from_string)
+from mailman.testing.layers import ConfigLayer, SMTPLayer
+from mailman.utilities.datetime import now
+
+
+
+def run_once(qrunner):
+ """Predicate for make_testable_runner().
+
+ Ensures that the queue runner only runs once.
+ """
+ return True
+
+
+@contextmanager
+def temporary_config(name, settings):
+ """Temporarily set a configuration (use in a with-statement)."""
+ config.push(name, settings)
+ try:
+ yield
+ finally:
+ config.pop(name)
+
+
+
+class TestOnce(unittest.TestCase):
+ """Test outgoing runner message disposition."""
+
+ layer = SMTPLayer
+
+ def setUp(self):
+ self._mlist = create_list('test@example.com')
+ self._outq = config.switchboards['out']
+ self._runner = make_testable_runner(OutgoingRunner, 'out', run_once)
+ self._msg = message_from_string("""\
+From: anne@example.com
+To: test@example.com
+Message-Id: <first>
+
+""")
+ self._msgdata = {}
+
+ def test_deliver_after(self):
+ # When the metadata has a deliver_after key in the future, the queue
+ # runner will re-enqueue the message rather than delivering it.
+ deliver_after = now() + timedelta(days=10)
+ self._msgdata['deliver_after'] = deliver_after
+ self._outq.enqueue(self._msg, self._msgdata,
+ tolist=True, listname='test@example.com')
+ self._runner.run()
+ items = get_queue_messages('out')
+ self.assertEqual(len(items), 1)
+ self.assertEqual(items[0].msgdata['deliver_after'], deliver_after)
+ self.assertEqual(items[0].msg['message-id'], '<first>')
+
+
+
+captured_mlist = None
+captured_msg = None
+captured_msgdata = None
+
+def capture(mlist, msg, msgdata):
+ global captured_mlist, captured_msg, captured_msgdata
+ captured_mlist = mlist
+ captured_msg = msg
+ captured_msgdata = msgdata
+
+
+class TestVERPSettings(unittest.TestCase):
+ """Test the selection of VERP based on various criteria."""
+
+ layer = ConfigLayer
+
+ def setUp(self):
+ global captured_mlist, captured_msg, captured_msgdata
+ # Push a config where actual delivery is handled by a dummy function.
+ # We generally don't care what this does, since we're just testing the
+ # setting of the 'verp' key in the metadata.
+ config.push('fake outgoing', """
+ [mta]
+ outgoing: mailman.queue.tests.test_outgoing.capture
+ """)
+ # Reset the captured data.
+ captured_mlist = None
+ captured_msg = None
+ captured_msgdata = None
+ self._mlist = create_list('test@example.com')
+ self._outq = config.switchboards['out']
+ self._runner = make_testable_runner(OutgoingRunner, 'out')
+ self._msg = message_from_string("""\
+From: anne@example.com
+To: test@example.com
+Message-Id: <first>
+
+""")
+
+ def tearDown(self):
+ config.pop('fake outgoing')
+
+ def test_delivery_callback(self):
+ # Test that the configuration variable calls the appropriate callback.
+ self._outq.enqueue(self._msg, {}, listname='test@example.com')
+ self._runner.run()
+ self.assertEqual(captured_mlist, self._mlist)
+ self.assertEqual(captured_msg.as_string(), self._msg.as_string())
+ # Of course, the message metadata will contain a bunch of keys added
+ # by the processing. We don't really care about the details, so this
+ # test is a good enough stand-in.
+ self.assertEqual(captured_msgdata['listname'], 'test@example.com')
+
+ def test_verp_in_metadata(self):
+ # Test that if the metadata has a 'verp' key, it is unchanged.
+ marker = 'yepper'
+ msgdata = dict(verp=marker)
+ self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+ self._runner.run()
+ self.assertEqual(captured_msgdata['verp'], marker)
+
+ def test_personalized_individual_deliveries_verp(self):
+ # When deliveries are personalized, and the configuration setting
+ # indicates, messages will be VERP'd.
+ msgdata = {}
+ self._mlist.personalize = Personalization.individual
+ self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+ with temporary_config('personalize', """
+ [mta]
+ verp_personalized_deliveries: yes
+ """):
+ self._runner.run()
+ self.assertTrue(captured_msgdata['verp'])
+
+ def test_personalized_full_deliveries_verp(self):
+ # When deliveries are personalized, and the configuration setting
+ # indicates, messages will be VERP'd.
+ msgdata = {}
+ self._mlist.personalize = Personalization.full
+ self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+ with temporary_config('personalize', """
+ [mta]
+ verp_personalized_deliveries: yes
+ """):
+ self._runner.run()
+ self.assertTrue(captured_msgdata['verp'])
+
+ def test_personalized_deliveries_no_verp(self):
+ # When deliveries are personalized, but the configuration setting
+ # does not indicate, messages will not be VERP'd.
+ msgdata = {}
+ self._mlist.personalize = Personalization.full
+ self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+ self._runner.run()
+ self.assertFalse('verp' in captured_msgdata)
+
+ def test_verp_never(self):
+ # Never VERP when the interval is zero.
+ msgdata = {}
+ self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+ with temporary_config('personalize', """
+ [mta]
+ verp_delivery_interval: 0
+ """):
+ self._runner.run()
+ self.assertEqual(captured_msgdata['verp'], False)
+
+ def test_verp_always(self):
+ # Always VERP when the interval is one.
+ msgdata = {}
+ self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+ with temporary_config('personalize', """
+ [mta]
+ verp_delivery_interval: 1
+ """):
+ self._runner.run()
+ self.assertEqual(captured_msgdata['verp'], True)
+
+ def test_verp_on_interval_match(self):
+ # VERP every so often, when the post_id matches.
+ self._mlist.post_id = 5
+ msgdata = {}
+ self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+ with temporary_config('personalize', """
+ [mta]
+ verp_delivery_interval: 5
+ """):
+ self._runner.run()
+ self.assertEqual(captured_msgdata['verp'], True)
+
+ def test_no_verp_on_interval_miss(self):
+ # VERP every so often, when the post_id matches.
+ self._mlist.post_id = 4
+ msgdata = {}
+ self._outq.enqueue(self._msg, msgdata, listname='test@example.com')
+ with temporary_config('personalize', """
+ [mta]
+ verp_delivery_interval: 5
+ """):
+ self._runner.run()
+ self.assertEqual(captured_msgdata['verp'], False)
+
+
+
+def test_suite():
+ suite = unittest.TestSuite()
+ suite.addTest(unittest.makeSuite(TestOnce))
+ suite.addTest(unittest.makeSuite(TestVERPSettings))
+ return suite
diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py
index 71cddd0f4..9f9ea6181 100644
--- a/src/mailman/testing/helpers.py
+++ b/src/mailman/testing/helpers.py
@@ -63,7 +63,7 @@ from mailman.utilities.mailbox import Mailbox
-def make_testable_runner(runner_class, name=None):
+def make_testable_runner(runner_class, name=None, predicate=None):
"""Create a queue runner that runs until its queue is empty.
:param runner_class: The queue runner's class.
@@ -71,6 +71,10 @@ def make_testable_runner(runner_class, name=None):
:param name: Optional queue name; if not given, it is calculated from the
class name.
:type name: string or None
+ :param predicate: Optional alternative predicate for deciding when to stop
+ the queue runner. When None (the default) it stops when the queue is
+ empty.
+ :type predicate: callable that gets one argument, the queue runner.
:return: A runner instance.
"""
@@ -90,7 +94,10 @@ def make_testable_runner(runner_class, name=None):
def _do_periodic(self):
"""Stop when the queue is empty."""
- self._stop = (len(self.switchboard.files) == 0)
+ if predicate is None:
+ self._stop = (len(self.switchboard.files) == 0)
+ else:
+ self._stop = predicate(self)
return EmptyingRunner(name)