Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Merge branch 'release/4.0.0'

  • Loading branch information...
commit 43d2cb10f56e7ce9083108f599b0828d81386f1e 2 parents b7dd6ef + 5229955
@amcgregor amcgregor authored
Showing with 1,776 additions and 317 deletions.
  1. +38 −11 README.textile
  2. +4 −4 examples/imap.py
  3. +2 −2 examples/maildir.py
  4. +2 −2 examples/mbox.py
  5. +4 −4 examples/smtp.py
  6. +51 −14 marrow/mailer/__init__.py
  7. +9 −5 marrow/mailer/address.py
  8. +1 −1  marrow/mailer/exc.py
  9. +7 −29 marrow/mailer/logger.py
  10. +8 −7 marrow/mailer/manager/dynamic.py
  11. +4 −3 marrow/mailer/manager/futures.py
  12. +1 −2  marrow/mailer/manager/immediate.py
  13. +6 −1 marrow/mailer/manager/transactional.py
  14. +7 −4 marrow/mailer/manager/util.py
  15. +33 −31 marrow/mailer/message.py
  16. +1 −1  marrow/mailer/release.py
  17. +1 −1  marrow/mailer/transport/gae.py
  18. +2 −2 marrow/mailer/transport/imap.py
  19. +1 −1  marrow/mailer/transport/log.py
  20. +2 −2 marrow/mailer/transport/maildir.py
  21. +4 −1 marrow/mailer/transport/mbox.py
  22. +0 −3  marrow/mailer/transport/mock.py
  23. +2 −2 marrow/mailer/transport/sendmail.py
  24. +2 −2 marrow/mailer/transport/ses.py
  25. +20 −28 marrow/mailer/transport/smtp.py
  26. +105 −88 marrow/mailer/validator.py
  27. +0 −2  setup.cfg
  28. +19 −11 setup.py
  29. 0  tests/manager/__init__.py
  30. +169 −0 tests/manager/test_dynamic.py
  31. +116 −0 tests/manager/test_futures.py
  32. +115 −0 tests/manager/test_immediate.py
  33. +94 −48 tests/test_core.py
  34. +23 −0 tests/test_exceptions.py
  35. +26 −0 tests/test_issue_2.py
  36. +177 −5 tests/test_message.py
  37. +50 −0 tests/test_plugins.py
  38. +171 −0 tests/test_validator.py
  39. 0  tests/transport/__init__.py
  40. +1 −0  tests/transport/test_gae.py
  41. +55 −0 tests/transport/test_log.py
  42. +64 −0 tests/transport/test_maildir.py
  43. +54 −0 tests/transport/test_mbox.py
  44. +40 −0 tests/transport/test_mock.py
  45. +285 −0 tests/transport/test_smtp.py
View
49 README.textile
@@ -68,29 +68,45 @@ If you would like to make changes and contribute them back to the project, fork
h2(#basic). %3.% Basic Usage
-To use Marrow Mailer you instantiate a @marrow.mailer.Delivery@ object with the configuration, then pass @Message@ instances to the @Delivery@ instance's @send()@ method. This allows you to configure multiple delivery mechanisms and choose, within your code, how you want each message delivered. The configuration is a dictionary of dot-notation keys and their values. Each manager and transport has their own configuration keys.
+To use Marrow Mailer you instantiate a @marrow.mailer.Mailer@ object with the configuration, then pass @Message@ instances to the @Mailer@ instance's @send()@ method. This allows you to configure multiple delivery mechanisms and choose, within your code, how you want each message delivered. The configuration is a dictionary of dot-notation keys and their values. Each manager and transport has their own configuration keys.
-Configuration keys may utilize a shared, common prefix, such as @mail.@. By default no prefix is assumed. Manager and transport configurations are each additionally prefixed with @manager.@ and @transport.@, respectively.
+Configuration keys may utilize a shared, common prefix, such as @mail.@. By default no prefix is assumed. Manager and transport configurations are each additionally prefixed with @manager.@ and @transport.@, respectively. The following is an example of how to send a message by SMTP:
-<pre><code>from marrow.mailer import Delivery, Message
+<pre><code>from marrow.mailer import Mailer, Message
-mailer = Delivery({...}, prefix=None)
+mailer = Mailer(dict(
+ transport = dict(
+ use = 'smtp',
+ host = 'localhost')))
mailer.start()
-message = Message(...)
+message = Message(author="user@example.com", to="user-two@example.com")
+message.subject = "Testing Marrow Mailer"
+message.plain = "This is a test."
mailer.send(message)
mailer.stop()</code></pre>
-A full example configuration, delivering to an on-disk @maildir@ mailbox:
+Another example configuration, using a flat dictionary and delivering to an on-disk @maildir@ mailbox:
<pre><code>{
- 'manager': 'immediate',
- 'transport': 'maildir',
+ 'transport.use': 'maildir',
'transport.directory': 'data/maildir'
}</pre></code>
+h3(#mailer-methods). %3.1.% Mailer Methods
+
+table(methods).
+|_. Method |_. Description |
+| @__init__(config, prefix=None)@ | Create and configure a new Mailer. |
+| @start()@ | Start the mailer. Returns the Mailer instance and can thus be chained with construction. |
+| @stop()@ | Stop the mailer. This cascades through to the active manager and transports. |
+| @send(message)@ | Deliver the given Message instance. |
+| @new(author=None, to=None, subject=None, **kw)@ | Create a new bound instance of Message using configured default values. |
+
+
+
h2(#message). %4.% The Message Class
The original format for email messages was defined in "RFC 822":http://www.faqs.org/rfcs/rfc822.html which was superseded by "RFC 2822":http://www.faqs.org/rfcs/rfc2822.html. The newest standard document about the format is currently "RFC 5322":http://www.faqs.org/rfcs/rfc2822.html. But the basics of RFC 822 still apply, so for the sake of readability we will just use "RFC 822" to refer to all these RFCs. Please read the official standard documents if this text fails to explain some aspects.
@@ -105,6 +121,7 @@ table(methods).
| @__str__@ | You can easily get the MIME encoded version of the message using the @str()@ built-in. |
| @attach(name, data=None, maintype=None, subtype=None, inline=False)@ | Attach a file (data=None) or string-like. For on-disk files, mimetype will be guessed. |
| @embed(name, data=None)@ | Embed an image from disk or string-like. Only embed images! |
+| @send()@ | If the Message instance is bound to a Mailer instance, e.g. having been created by the @Mailer.new()@ factory method, deliver the message via that instance. |
h3(#message-attributes). %4.2.% Message Attributes
@@ -132,7 +149,17 @@ table(attributes).
| @sender@ | The designated sender of the message; may differ from @author@. This is primarily utilized by SMTP delivery. |
| @subject@ | The subject of the message. |
-fn1. The message bodies may be callables which will be executed when the message is delivered, allowing you to easily utilize templates. Pro tip: to pass arguments to your template, while still allowing for later execution, use @functools.partial@.
+fn1. The message bodies may be callables which will be executed when the message is delivered, allowing you to easily utilize templates. Pro tip: to pass arguments to your template, while still allowing for later execution, use @functools.partial@. When using a threaded manager please be aware of thread-safe issues within your templates.
+
+Any of these attributes can also be defined within your mailer configuration. When you wish to use default values from the configuration you must use the @Mailer.new()@ factory method. For example:
+
+<pre><code>mail = Mailer({
+ 'message.author': 'Example User <user@example.com',
+ 'message.subject': "Test subject."
+ })
+message = mail.new()
+message.subject == "Test subject."
+message.send()</code></pre>
h4. %4.2.2.% Read-Only Attributes
@@ -269,7 +296,7 @@ The @appengine@ transport translates between Mailer's Message representation and
* @subject@
* @plain@
* @rich@
-* @attachments@ (not counting embedded files)
+* @attachments@ (excluding inline/embedded files)
h4(#logging-transport). %6.3.1.% Python Logging
@@ -394,7 +421,7 @@ Additionally, a transport must not:
A transport may:
-# Return data from the @deliver()@ method; this data will be passed through as the return value of the @Delivery.send()@ call or Future callback response value.
+# Return data from the @deliver()@ method; this data will be passed through as the return value of the @Mailer.send()@ call or Future callback response value.
h3(#exceptions). %7.3.% Exceptions
View
8 examples/imap.py
@@ -1,10 +1,10 @@
import logging
-from marrow.mailer import Message, Delivery
+from marrow.mailer import Message, Mailer
logging.basicConfig(level=logging.DEBUG)
-mail = Delivery({
- 'manager': 'futures',
- 'transport': 'imap',
+mail = Mailer({
+ 'manager.use': 'futures',
+ 'transport.use': 'imap',
'transport.host': '',
'transport.ssl': True,
'transport.username': '',
View
4 examples/maildir.py
@@ -1,8 +1,8 @@
import logging
-from marrow.mailer import Message, Delivery
+from marrow.mailer import Message, Mailer
logging.basicConfig(level=logging.INFO)
-mail = Delivery({'manager': 'immediate', 'transport': 'maildir', 'transport.directory': 'data/maildir'})
+mail = Mailer({'manager.use': 'immediate', 'transport.use': 'maildir', 'transport.directory': 'data/maildir'})
mail.start()
message = Message([('Alice Bevan-McGregor', 'alice@gothcandy.com')], [('Alice Two', 'alice.mcgregor@me.com')], "This is a test message.", plain="Testing!")
View
4 examples/mbox.py
@@ -1,8 +1,8 @@
import logging
-from marrow.mailer import Message, Delivery
+from marrow.mailer import Message, Mailer
logging.basicConfig(level=logging.INFO)
-mail = Delivery({'manager': 'immediate', 'transport': 'mbox', 'transport.file': 'data/mbox'})
+mail = Mailer({'manager.use': 'immediate', 'transport.use': 'mbox', 'transport.file': 'data/mbox'})
mail.start()
message = Message([('Alice Bevan-McGregor', 'alice@gothcandy.com')], [('Alice Two', 'alice.mcgregor@me.com')], "This is a test message.", plain="Testing!")
View
8 examples/smtp.py
@@ -1,10 +1,10 @@
import logging
-from marrow.mailer import Message, Delivery
+from marrow.mailer import Message, Mailer
logging.basicConfig(level=logging.INFO)
-mail = Delivery({
- 'manager': 'futures',
- 'transport': 'smtp',
+mail = Mailer({
+ 'manager.use': 'futures',
+ 'transport.use': 'smtp',
'transport.host': '',
'transport.tls': 'ssl',
'transport.username': '',
View
65 marrow/mailer/__init__.py
@@ -3,7 +3,6 @@
"""marrow.mailer mail delivery framework and MIME message abstraction."""
-import logging
import warnings
import pkg_resources
@@ -19,12 +18,12 @@
from marrow.util.object import load_object
-__all__ = ['Delivery', 'Message']
+__all__ = ['Mailer', 'Delivery', 'Message']
log = __import__('logging').getLogger(__name__)
-class Delivery(object):
+class Mailer(object):
"""The primary marrow.mailer interface.
Instantiate and configure marrow.mailer, then use the instance to initiate mail delivery.
@@ -34,7 +33,7 @@ class Delivery(object):
"""
def __repr__(self):
- return "Delivery(manager=%s, transport=%s)" % (self.Manager.__name__, self.Transport.__name__)
+ return "Mailer(manager=%s, transport=%s)" % (self.Manager.__name__, self.Transport.__name__)
def __init__(self, config, prefix=None):
self.manager, self.Manager = None, None
@@ -46,21 +45,38 @@ def __init__(self, config, prefix=None):
self.config = config = Bunch.partial(prefix, config)
try:
- self.manager_config = manager_config = Bunch.partial('manager', config)
- except ValueError:
+ if 'manager' in config and isinstance(config.manager, dict):
+ self.manager_config = manager_config = config.manager
+ else:
+ self.manager_config = manager_config = Bunch.partial('manager', config)
+ except ValueError: # pragma: no cover
self.manager_config = manager_config = Bunch()
+ if isinstance(config.manager, basestring):
+ warnings.warn("Use of the manager directive is deprecated; use manager.use instead.", DeprecationWarning)
+ manager_config.use = config.manager
+
try:
- self.transport_config = transport_config = Bunch.partial('transport', config)
- except ValueError:
+ if 'transport' in config and isinstance(config.transport, dict):
+ self.transport_config = transport_config = Bunch(config.transport)
+ else:
+ self.transport_config = transport_config = Bunch.partial('transport', config)
+ except ValueError: # pragma: no cover
self.transport_config = transport_config = Bunch()
+ if isinstance(config.transport, basestring):
+ warnings.warn("Use of the transport directive is deprecated; use transport.use instead.", DeprecationWarning)
+ transport_config.use = config.transport
+
try:
- self.message_config = Bunch.partial('message', config)
- except ValueError:
+ if 'message' in config and isinstance(config.message, dict):
+ self.message_config = Bunch(config.message)
+ else:
+ self.message_config = Bunch.partial('message', config)
+ except ValueError: # pragma: no cover
self.message_config = Bunch()
- self.Manager = Manager = self._load(config.manager, 'marrow.mailer.manager')
+ self.Manager = Manager = self._load(manager_config.use if 'use' in manager_config else 'immediate', 'marrow.mailer.manager')
if not Manager:
raise LookupError("Unable to determine manager from specification: %r" % (config.manager, ))
@@ -68,7 +84,7 @@ def __init__(self, config, prefix=None):
if not isinstance(Manager, IManager):
raise TypeError("Chosen manager does not conform to the manager API.")
- self.Transport = Transport = self._load(config.transport, 'marrow.mailer.transport')
+ self.Transport = Transport = self._load(transport_config.use, 'marrow.mailer.transport')
if not Transport:
raise LookupError("Unable to determine transport from specification: %r" % (config.transport, ))
@@ -94,7 +110,7 @@ def _load(spec, group):
def start(self):
if self.running:
- log.warning("Attempt made to start an already running delivery service.")
+ log.warning("Attempt made to start an already running Mailer service.")
return
log.info("Mail delivery service starting.")
@@ -108,7 +124,7 @@ def start(self):
def stop(self):
if not self.running:
- log.warning("Attempt made to stop an already stopped delivery service.")
+ log.warning("Attempt made to stop an already stopped Mailer service.")
return
log.info("Mail delivery service stopping.")
@@ -135,6 +151,27 @@ def send(self, message):
log.debug("Message %s delivered.", message.id)
return result
+
+ def new(self, author=None, to=None, subject=None, **kw):
+ data = dict(self.message_config)
+ data['mailer'] = self
+
+ if author:
+ kw['author'] = author
+ if to:
+ kw['to'] = to
+ if subject:
+ kw['subject'] = subject
+
+ data.update(kw)
+
+ return Message(**data)
+
+
+class Delivery(Mailer):
+ def __init__(self, *args, **kw):
+ warnings.warn("Use of the Delivery class is deprecated; use Mailer instead.", DeprecationWarning)
+ super(Delivery, self).__init__(*args, **kw)
# Import-time side-effect: un-fscking the default use of base-64 encoding for UTF-8 e-mail.
View
14 marrow/mailer/address.py
@@ -25,6 +25,8 @@ class Address(object):
well."""
def __init__(self, name_or_email, email=None, encoding='utf-8'):
+ self.encoding = encoding
+
if email is None:
if isinstance(name_or_email, AddressList):
if not 0 < len(name_or_email) < 2:
@@ -74,13 +76,13 @@ def __ne__(self, other):
return not self == other
def __len__(self):
- return len(unicode(self))
+ return len(self.__unicode__())
def __repr__(self):
return 'Address("{0}")'.format(unicode(self).encode('ascii', 'backslashreplace'))
def __unicode__(self):
- return formataddr((self.name, self.address))
+ return self.encode('utf8').decode('utf8')
def __bytes__(self):
return self.encode()
@@ -91,9 +93,11 @@ def __bytes__(self):
else: # pragma: no cover
__str__ = __unicode__
- def encode(self, encoding='utf-8'):
+ def encode(self, encoding=None):
+ if encoding is None:
+ encoding = self.encoding
+
name_string = Header(self.name, encoding).encode()
- # print name_string
# Encode punycode for internationalized domains.
localpart, domain = self.address.split('@', 1)
@@ -133,7 +137,7 @@ def __repr__(self):
if not self:
return "AddressList()"
- return "AddressList(\"{0}\")".format(", ".join([unicode(i).encode('ascii', 'backslashreplace') for i in self]))
+ return "AddressList(\"{0}\")".format(", ".join([str(i) for i in self]))
def __bytes__(self):
return self.encode()
View
2  marrow/mailer/exc.py
@@ -34,7 +34,7 @@ class DeliveryFailedException(DeliveryException):
given in args[1]. (These can be accessed as e.msg and e.reason.)"""
def __init__(self, message, reason):
- self.msg = msg
+ self.msg = message
self.reason = reason
super(DeliveryFailedException, self).__init__(message, reason)
View
36 marrow/mailer/logger.py
@@ -2,28 +2,19 @@
import logging
+from marrow.mailer import Mailer
+
class MailHandler(logging.Handler):
"""A class which sends records out via e-mail.
This handler should be configured using the same configuration
- directives that TurboMail itself understands. If you do not specify
- `mail.on` in the configuration, this handler will attempt to use
- the most recently configured TurboMail environment.
-
- Be sure that TurboMail is running before messages are emitted using
- this handler, and be careful how many notifications get sent.
+ directives that Marrow Mailer itself understands.
- It is suggested to use background delivery using the 'demand' manager.
+ Be careful how many notifications get sent.
- Configuration options for this handler are as follows::
-
- * mail.handler.priorities = [True/False]
- Set message priority using the following formula:
- record.levelno / 10 - 3
-
- *
+ It is suggested to use background delivery using the 'dynamic' manager.
"""
def __init__(self, *args, **config):
@@ -44,11 +35,7 @@ def __init__(self, *args, **config):
if args:
config.update(dict(zip(*[iter(args)]*2)))
- if config and 'mail.on' in config:
- # Initilize TurboMail using the configuration directives passed
- # to this handler, generally from an INI configuration file.
- turbomail.interface.start(config)
- return
+ self.mailer = Mailer(config).start()
# If we get a configuration that doesn't explicitly start TurboMail
# we use the configuration to populate the Message instance.
@@ -58,16 +45,7 @@ def emit(self, record):
"""Emit a record."""
try:
- message = Message()
-
- if self.config:
- for i, j in self.config.iteritems():
- if i.startswith('mail.message'):
- i = i[13:]
- setattr(message, i, j)
-
- message.plain = self.format(record)
- message.send()
+ self.mailer.new(plain=self.format(record)).send()
except (KeyboardInterrupt, SystemExit):
raise
View
15 marrow/mailer/manager/dynamic.py
@@ -18,7 +18,7 @@
try:
from concurrent import futures
-except ImportError:
+except ImportError: # pragma: no cover
raise ImportError("You must install the futures package to use background delivery.")
@@ -45,20 +45,21 @@ def thread_worker(executor, jobs, timeout, maximum):
log.debug("Worker instructed to shut down.")
break
- del runner
- continue
+ # Can't think of a test case for this; best to be safe.
+ del runner # pragma: no cover
+ continue # pragma: no cover
- except queue.Empty:
+ except queue.Empty: # pragma: no cover
log.debug("Worker death from starvation.")
break
else:
work.run()
- else:
+ else: # pragma: no cover
log.debug("Worker death from exhaustion.")
- except:
+ except: # pragma: no cover
log.critical("Unhandled exception in worker.", exc_info=True)
runner = executor()
@@ -116,7 +117,7 @@ def shutdown(self, wait=True):
for thread in list(self._threads):
thread.join()
- def _atexit(self):
+ def _atexit(self): # pragma: no cover
self.shutdown(True)
def _spawn(self):
View
7 marrow/mailer/manager/futures.py
@@ -7,7 +7,7 @@
try:
from concurrent import futures
-except ImportError:
+except ImportError: # pragma: no cover
raise ImportError("You must install the futures package to use background delivery.")
@@ -20,13 +20,14 @@
def worker(pool, message):
# This may be non-obvious, but there are several conditions which
# we trap later that require us to retry the entire delivery.
+ result = None
+
while True:
with pool() as transport:
try:
result = transport.deliver(message)
- except MessageFailedException:
- e = sys.exc_info()[1]
+ except MessageFailedException as e:
raise DeliveryFailedException(message, e.args[0] if e.args else "No reason given.")
except TransportFailedException:
View
3  marrow/mailer/manager/immediate.py
@@ -42,8 +42,7 @@ def deliver(self, message):
try:
result = transport.deliver(message)
- except MessageFailedException:
- e = sys.exc_info()[1]
+ except MessageFailedException as e:
raise DeliveryFailedException(message, e.args[0] if e.args else "No reason given.")
except TransportFailedException:
View
7 marrow/mailer/manager/transactional.py
@@ -1,5 +1,10 @@
# encoding: utf-8
+"""Currently unsupported and non-functional."""
+
+raise ImportError("This module is currently unsupported.")
+
+
import transaction
from functools import partial
@@ -7,7 +12,7 @@
from zope.interface import implements
from transaction.interfaces import IDataManager
-from marorw.mailer.manager.dynamic import ScalingPoolExecutor
+from marrow.mailer.manager.dynamic import ScalingPoolExecutor, DynamicManager
__all__ = ['TransactionalDynamicManager']
View
11 marrow/mailer/manager/util.py
@@ -1,6 +1,9 @@
# encoding: utf-8
-from Queue import Queue, Empty
+try:
+ import queue
+except ImportError:
+ import Queue as queue
__all__ = ['TransportPool']
@@ -14,7 +17,7 @@ class TransportPool(object):
def __init__(self, factory):
self.factory = factory
- self.transports = Queue()
+ self.transports = queue.Queue()
def startup(self):
pass
@@ -25,7 +28,7 @@ def shutdown(self):
transport = self.transports.get(False)
transport.shutdown()
- except Empty:
+ except queue.Empty:
pass
class Context(object):
@@ -47,7 +50,7 @@ def __enter__(self):
transport = pool.transports.get(False)
log.debug("Acquired existing transport instance.")
- except Empty:
+ except queue.Empty:
# No transport is available, so we initialize another one.
log.debug("Unable to acquire existing transport, initalizing new instance.")
transport = pool.factory()
View
64 marrow/mailer/message.py
@@ -5,7 +5,6 @@
import imghdr
import os
import time
-import warnings
from datetime import datetime
from email.mime.text import MIMEText
@@ -17,7 +16,7 @@
from marrow.mailer import release
from marrow.mailer.address import Address, AddressList, AutoConverter
-from marrow.util.compat import basestring
+from marrow.util.compat import basestring, unicode
__all__ = ['Message']
@@ -48,6 +47,7 @@ def __init__(self, author=None, to=None, subject=None, **kw):
self._id = None
self._processed = False
self._dirty = False
+ self.mailer = None
# Default values.
@@ -109,6 +109,9 @@ def id(self):
def envelope(self):
"""Returns the address of the envelope sender address (SMTP from, if
not set the sender, if this one isn't set too, the author)."""
+ if not self.sender and not self.author:
+ raise ValueError("Unable to determine message sender; no author or sender defined.")
+
return self.sender or self.author[0]
@property
@@ -183,33 +186,22 @@ def _build_header_list(self, author, sender):
def _add_headers_to_message(self, message, headers):
for header in headers:
- if isinstance(header, (tuple, list)):
- if header[1] is None or (isinstance(header[1], list) and not header[1]):
- continue
-
- name, value = header
-
- if isinstance(value, Address):
- # print type(value), repr(value), value
- value = value.encode(self.encoding)
-
- elif isinstance(value, AddressList):
- # print type(value), repr(value), value
- value = value.encode(self.encoding)
- # print '->', type(value), repr(value), value
-
- if isinstance(value, unicode):
- # print type(value), repr(value), value
- value = Header(value, self.encoding)
-
- else:
- # print type(value), repr(value), value
- value = Header(value)
-
- message[name] = value
+ if header[1] is None or (isinstance(header[1], list) and not header[1]):
+ continue
+
+ name, value = header
+
+ if isinstance(value, Address):
+ value = value.encode(self.encoding)
+ elif isinstance(value, AddressList):
+ value = value.encode(self.encoding)
- elif isinstance(header, dict):
- message.add_header(**header)
+ if isinstance(value, unicode):
+ value = Header(value, self.encoding)
+ else:
+ value = Header(value)
+
+ message[name] = value
@property
def mime(self):
@@ -272,10 +264,14 @@ def attach(self, name, data=None, maintype=None, subtype=None, inline=False):
:param inline: Whether to set the Content-Disposition for the file to
"inline" (True) or "attachment" (False)
"""
+ self._dirty = True
+
if not maintype:
- maintype, subtype = guess_type(name)
+ maintype, _ = guess_type(name)
if not maintype:
maintype, subtype = 'application', 'octet-stream'
+ else:
+ maintype, _, subtype = maintype.partition('/')
part = MIMENonMultipart(maintype, subtype)
@@ -309,9 +305,9 @@ def embed(self, name, data=None):
be read from the file pointed to by the ``name`` argument
"""
if data is None:
- name = os.path.basename(name)
- with open(file, 'rb') as fp:
+ with open(name, 'rb') as fp:
data = fp.read()
+ name = os.path.basename(name)
elif isinstance(data, bytes):
pass
elif hasattr(data, 'read'):
@@ -328,3 +324,9 @@ def _callable(var):
return var()
return var
+
+ def send(self):
+ if not self.mailer:
+ raise NotImplementedError("Message instance is not bound to a Mailer. Use mailer.send() instead.")
+
+ return self.mailer.send(self)
View
2  marrow/mailer/release.py
@@ -8,6 +8,6 @@
__all__ = ['version_info', 'version']
-version_info = namedtuple('version_info', ('major', 'minor', 'micro', 'releaselevel', 'serial'))(4, 0, 0, 'beta', 3)
+version_info = namedtuple('version_info', ('major', 'minor', 'micro', 'releaselevel', 'serial'))(4, 0, 0, 'final', 0)
version = ".".join([str(i) for i in version_info[:3]]) + ((version_info.releaselevel[0] + str(version_info.serial)) if version_info.releaselevel != 'final' else '')
View
2  marrow/mailer/transport/gae.py
@@ -9,7 +9,7 @@
-class AppEngineTransport(object):
+class AppEngineTransport(object): # pragma: no cover
__slots__ = ('ephemeral', )
def __init__(self, config):
View
4 marrow/mailer/transport/imap.py
@@ -4,7 +4,7 @@
from datetime import datetime
-from marrow.mailer.exc import TransportException, MessageFailedException
+from marrow.mailer.exc import MailConfigurationException, TransportException, MessageFailedException
__all__ = ['IMAPTransport']
@@ -13,7 +13,7 @@
-class IMAPTransport(object):
+class IMAPTransport(object): # pragma: no cover
__slots__ = ('ephemeral', 'host', 'ssl', 'port', 'username', 'password', 'folder', 'connection')
def __init__(self, config):
View
2  marrow/mailer/transport/log.py
@@ -20,7 +20,7 @@ def deliver(self, message):
msg = str(message)
self.log.info("DELIVER %s %s %d %r %r", message.id, message.date.isoformat(),
len(msg), message.author, message.recipients)
- self.log.critical(str(message))
+ self.log.critical(msg)
def shutdown(self):
log.debug("Logging transport stopping.")
View
4 marrow/mailer/transport/maildir.py
@@ -32,7 +32,7 @@ def startup(self):
folder = self.box.get_folder(self.folder)
except mailbox.NoSuchMailboxError:
- if not self.create:
+ if not self.create: # pragma: no cover
raise # TODO: Raise appropraite internal exception.
folder = self.box.add_folder(self.folder)
@@ -44,7 +44,7 @@ def startup(self):
def deliver(self, message):
# TODO: Create an ID based on process and thread IDs.
# Current bhaviour may allow for name clashes in multi-threaded.
- self.box.add(mailbox.MaildirMessage(bytes(message)))
+ self.box.add(mailbox.MaildirMessage(str(message)))
def shutdown(self):
self.box = None
View
5 marrow/mailer/transport/mbox.py
@@ -30,9 +30,12 @@ def startup(self):
def deliver(self, message):
self.box.lock()
- self.box.add(mailbox.mboxMessage(bytes(message)))
+ self.box.add(mailbox.mboxMessage(str(message)))
self.box.unlock()
def shutdown(self):
+ if self.box is None:
+ return
+
self.box.close()
self.box = None
View
3  marrow/mailer/transport/mock.py
@@ -61,9 +61,6 @@ def deliver(self, message):
if success == 1.0:
return True
- if success == 0.0:
- return False
-
chance = random.randint(0,100001) / 100000.0
if chance <= success:
return True
View
4 marrow/mailer/transport/sendmail.py
@@ -11,7 +11,7 @@
-class SendmailTransport(object):
+class SendmailTransport(object): # pragma: no cover
__slots__ = ('ephemeral', 'executable')
def __init__(self, config):
@@ -20,7 +20,7 @@ def __init__(self, config):
def startup(self):
pass
- def __call__(self, message):
+ def deliver(self, message):
# TODO: Utilize -F full_name (sender full name), -f sender (envelope sender), -V envid (envelope ID), and space-separated BCC recipients
# TODO: Record the output of STDOUT and STDERR to capture errors.
proc = os.popen('%s -t -i' % (self.executable, ), 'w')
View
4 marrow/mailer/transport/ses.py
@@ -15,7 +15,7 @@
-class AmazonTransport(object):
+class AmazonTransport(object): # pragma: no cover
__slots__ = ('ephemeral', 'id', 'key', 'host', 'connection')
def __init__(self, config):
@@ -44,7 +44,7 @@ def deliver(self, message):
response['SendRawEmailResponse']['ResponseMetadata']['RequestId']
)
- except SESConnection.ResponseError, err:
+ except SESConnection.ResponseError:
raise # TODO: Raise appropriate internal exception.
# ['status', 'reason', 'body', 'request_id', 'error_code', 'error_message']
View
48 marrow/mailer/transport/smtp.py
@@ -2,17 +2,16 @@
"""Deliver messages using (E)SMTP."""
-from smtplib import (SMTP, SMTP_SSL, SMTPException, SMTPRecipientsRefused,
- SMTPSenderRefused, SMTPServerDisconnected)
import socket
-import sys
-from marrow.mailer.exc import (MailConfigurationException,
- TransportExhaustedException, TransportException, TransportFailedException)
+from smtplib import (SMTP, SMTP_SSL, SMTPException, SMTPRecipientsRefused,
+ SMTPSenderRefused, SMTPServerDisconnected)
from marrow.util.convert import boolean
from marrow.util.compat import native
+from marrow.mailer.exc import TransportExhaustedException, TransportException, TransportFailedException, MessageFailedException
+
log = __import__('logging').getLogger(__name__)
@@ -24,10 +23,7 @@ class SMTPTransport(object):
__slots__ = ('ephemeral', 'host', 'tls', 'certfile', 'keyfile', 'port', 'local_hostname', 'username', 'password', 'timeout', 'debug', 'pipeline', 'connection', 'sent')
def __init__(self, config):
- if not 'host' in config:
- raise MailConfigurationException('No server configured for SMTP')
-
- self.host = native(config.get('host'))
+ self.host = native(config.get('host', '127.0.0.1'))
self.tls = config.get('tls', 'optional')
self.certfile = config.get('certfile', None)
self.keyfile = config.get('keyfile', None)
@@ -43,7 +39,7 @@ def __init__(self, config):
self.debug = boolean(config.get('debug', False))
self.pipeline = config.get('pipeline', None)
- if self.pipeline is not None:
+ if self.pipeline not in (None, True, False):
self.pipeline = int(self.pipeline)
self.connection = None
@@ -61,17 +57,17 @@ def shutdown(self):
try:
self.connection.quit()
- except SMTPServerDisconnected:
+ except SMTPServerDisconnected: # pragma: no cover
pass
- except (SMTPException, socket.error):
+ except (SMTPException, socket.error): # pragma: no cover
log.exception("Unhandled error while closing connection.")
finally:
self.connection = None
def connect_to_server(self):
- if self.tls == 'ssl':
+ if self.tls == 'ssl': # pragma: no cover
connection = SMTP_SSL(local_hostname=self.local_hostname, keyfile=self.keyfile,
certfile=self.certfile, timeout=self.timeout)
else:
@@ -83,8 +79,8 @@ def connect_to_server(self):
# Do TLS handshake if configured
connection.ehlo()
- if self.tls in ('required', 'optional'):
- if connection.has_extn('STARTTLS'):
+ if self.tls in ('required', 'optional', True):
+ if connection.has_extn('STARTTLS'): # pragma: no cover
connection.starttls(self.keyfile, self.certfile)
elif self.tls == 'required':
raise TransportException('TLS is required but not available on the server -- aborting')
@@ -109,41 +105,37 @@ def deliver(self, message):
self.send_with_smtp(message)
finally:
- if self.pipeline is True:
- return
-
if not self.pipeline or self.sent >= self.pipeline:
raise TransportExhaustedException()
def send_with_smtp(self, message):
+ sender = bytes(message.envelope)
+ recipients = message.recipients.string_addresses
+ content = bytes(message)
+
try:
- sender = bytes(message.envelope)
- recipients = message.recipients.string_addresses
+ self.connection.sendmail(sender, recipients, content)
self.sent += 1
- self.connection.sendmail(sender, recipients, bytes(message))
- except SMTPSenderRefused:
+ except SMTPSenderRefused as e:
# The envelope sender was refused. This is bad.
- e = sys.exc_info()[1]
log.error("%s REFUSED %s %s", message.id, e.__class__.__name__, e)
raise MessageFailedException(str(e))
- except SMTPRecipientsRefused:
+ except SMTPRecipientsRefused as e:
# All recipients were refused. Log which recipients.
# This allows you to automatically parse your logs for bad e-mail addresses.
- e = sys.exc_info()[1]
log.warning("%s REFUSED %s %s", message.id, e.__class__.__name__, e)
raise MessageFailedException(str(e))
- except SMTPServerDisconnected:
+ except SMTPServerDisconnected as e: # pragma: no cover
if message.retries >= 0:
log.warning("%s DEFERRED %s", message.id, "SMTPServerDisconnected")
message.retries -= 1
raise TransportFailedException()
- except:
- e = sys.exc_info()[1]
+ except Exception as e: # pragma: no cover
cls_name = e.__class__.__name__
log.debug("%s EXCEPTION %s", message.id, cls_name, exc_info=True)
View
193 marrow/mailer/validator.py
@@ -100,7 +100,7 @@
import re
-__all__ = ['BaseValidator', 'ValidationException', 'EmailValidator']
+__all__ = ['ValidationException', 'BaseValidator', 'DomainValidator', 'EmailValidator', 'EmailHarvester']
class ValidationException(ValueError):
@@ -113,12 +113,13 @@ def validate_or_raise(self, *a, **k):
"Don't return success codes, use exceptions!"
This method allows them to be happy, too.
"""
-
- validatee, err = self.validate(*a, **k)
+
+ validate, err = self.validate(*a, **k)
+
if err:
raise ValidationException(err)
- else:
- return validatee
+
+ return validate
class DomainValidator(BaseValidator):
@@ -143,10 +144,17 @@ class DomainValidator(BaseValidator):
def __init__(self, fix=False, lookup_dns=None):
self.fix = fix
+
if lookup_dns:
+ try:
+ import DNS
+ except ImportError: # pragma: no cover
+ raise ImportError("To enable DNS lookup of domains install the PyDNS package.")
+
lookup_dns = lookup_dns.lower()
- if not lookup_dns == 'a' and not lookup_dns == 'mx':
+ if lookup_dns not in ('a', 'mx'):
raise RuntimeError("Not a valid *lookup_dns* value: " + lookup_dns)
+
self._lookup_dns = lookup_dns
def _apply_common_rules(self, part, maxlength):
@@ -154,37 +162,47 @@ def _apply_common_rules(self, part, maxlength):
domain and the local part of the e-mail address.
"""
part = part.strip()
+
if self.fix:
part = part.strip('.')
+
if not part:
return part, 'It cannot be empty.'
+
if len(part) > maxlength:
return part, 'It cannot be longer than %i chars.' % maxlength
+
if part[0] == '.':
return part, 'It cannot start with a dot.'
+
if part[-1] == '.':
return part, 'It cannot end with a dot.'
+
if '..' in part:
return part, 'It cannot contain consecutive dots.'
+
return part, ''
def validate_domain(self, part):
part, err = self._apply_common_rules(part, maxlength=255)
+
if err:
return part, 'Invalid domain: %s' % err
+
if not self.domain_regex.search(part):
return part, 'Invalid domain.'
+
if self._lookup_dns and not self.lookup_domain(part):
return part, 'Domain does not seem to exist.'
- else:
- return part.lower(), ''
+
+ return part.lower(), ''
validate = validate_domain
# TODO: As an option, DNS lookup on the domain:
# http://mail.python.org/pipermail/python-list/2008-July/497997.html
- def lookup_domain(self, domain, lookup_record=None):
+ def lookup_domain(self, domain, lookup_record=None, **kw):
"""Looks up the DNS record for *domain* and returns:
* None if it does not exist,
@@ -198,42 +216,40 @@ def lookup_domain(self, domain, lookup_record=None):
"a" means verify that the domain exists.
"mx" means verify that the domain exists and specifies mail servers.
"""
- if lookup_record:
- lookup_record = lookup_record.lower()
- else:
- lookup_record = self._lookup_dns
- result = None
+ import DNS
+
+ lookup_record = lookup_record.lower() if lookup_record else self._lookup_dns
+
+ if lookup_record not in ('a', 'mx'):
+ raise RuntimeError("Not a valid lookup_record value: " + lookup_record)
+
if lookup_record == "a":
- request = DNS.Request(domain)
+ request = DNS.Request(domain, **kw)
+
try:
answers = request.req().answers
- except DNS.Lib.PackError:
+
+ except (DNS.Lib.PackError, UnicodeError):
# A part of the domain name is longer than 63.
return False
- # raise RuntimeError(err)
- # print repr(answers)
- if answers:
- result = answers[0]['data'] # This is an IP address
- if result in self.false_positive_ips:
- result = None
- # print "Domain '%s' not found" % domain
- else:
- # print "Domain '%s' found with address: %s" \
- # % (domain, result)
- pass
- else:
- # print "Domain '%s' not found" % domain
- pass
- elif lookup_record == "mx":
- result = DNS.mxlookup(domain) # this is a list of mx records
- #if result:
- # print "Domain '%s' has MX records: %s" % (domain, result)
- #else:
- # print "Domain '%s' has no MX records." % domain
- else:
- raise RuntimeError("Not a valid lookup_record value: " \
- + lookup_record)
- return result
+
+ if not answers:
+ return False
+
+ result = answers[0]['data'] # This is an IP address
+
+ if result in self.false_positive_ips: # pragma: no cover
+ return False
+
+ return result
+
+ try:
+ return DNS.mxlookup(domain)
+
+ except UnicodeError:
+ pass
+
+ return False
class EmailValidator(DomainValidator):
@@ -246,11 +262,9 @@ class EmailValidator(DomainValidator):
def __init__(self, local_part_chars=".-+_!#$%&'/=`|~?^{}*", **k):
super(EmailValidator, self).__init__(**k)
# Add a backslash before the dash so it can go into the regex:
- self.local_part_pattern = '[a-z0-9' \
- + local_part_chars.replace('-', r'\-') + ']+'
+ self.local_part_pattern = '[a-z0-9' + local_part_chars.replace('-', r'\-') + ']+'
# Regular expression for validation:
- self.local_part_regex = \
- re.compile('^' + self.local_part_pattern + '$', re.IGNORECASE)
+ self.local_part_regex = re.compile('^' + self.local_part_pattern + '$', re.IGNORECASE)
def validate_local_part(self, part):
part, err = self._apply_common_rules(part, maxlength=64)
@@ -264,21 +278,24 @@ def validate_local_part(self, part):
def validate_email(self, email):
if not email:
return email, 'The e-mail is empty.'
+
parts = email.split('@')
+
if len(parts) != 2:
return email, 'An email address must contain a single @'
+
local, domain = parts
-
+
# Validate the domain
domain, err = self.validate_domain(domain)
if err:
- return email, \
- "The e-mail has a problem to the right of the @: %s" % err
+ return email, "The e-mail has a problem to the right of the @: %s" % err
+
# Validate the local part
local, err = self.validate_local_part(local)
if err:
- return email, \
- "The email has a problem to the left of the @: %s" % err
+ return email, "The email has a problem to the left of the @: %s" % err
+
# It is valid
return local + '@' + domain, ''
@@ -301,42 +318,42 @@ def harvest(self, text):
yield match.group().replace('..', '.')
-rfc822_specials = '()<>@,;:\\"[]'
-
-def is_address_valid(addr):
- # First we validate the name portion (name@domain)
- c = 0
- while c < len(addr):
- if addr[c] == '"' and (not c or addr[c - 1] == '.' or addr[c - 1] == '"'):
- c = c + 1
- while c < len(addr):
- if addr[c] == '"': break
- if addr[c] == '\\' and addr[c + 1] == ' ':
- c = c + 2
- continue
- if ord(addr[c]) < 32 or ord(addr[c]) >= 127: return 0
- c = c + 1
- else: return 0
- if addr[c] == '@': break
- if addr[c] != '.': return 0
- c = c + 1
- continue
- if addr[c] == '@': break
- if ord(addr[c]) <= 32 or ord(addr[c]) >= 127: return 0
- if addr[c] in rfc822_specials: return 0
- c = c + 1
- if not c or addr[c - 1] == '.': return 0
-
- # Next we validate the domain portion (name@domain)
- domain = c = c + 1
- if domain >= len(addr): return 0
- count = 0
- while c < len(addr):
- if addr[c] == '.':
- if c == domain or addr[c - 1] == '.': return 0
- count = count + 1
- if ord(addr[c]) <= 32 or ord(addr[c]) >= 127: return 0
- if addr[c] in rfc822_specials: return 0
- c = c + 1
-
- return count >= 1
+# rfc822_specials = '()<>@,;:\\"[]'
+
+# is_address_valid(addr):
+# # First we validate the name portion (name@domain)
+# c = 0
+# while c < len(addr):
+# if addr[c] == '"' and (not c or addr[c - 1] == '.' or addr[c - 1] == '"'):
+# c = c + 1
+# while c < len(addr):
+# if addr[c] == '"': break
+# if addr[c] == '\\' and addr[c + 1] == ' ':
+# c = c + 2
+# continue
+# if ord(addr[c]) < 32 or ord(addr[c]) >= 127: return 0
+# c = c + 1
+# else: return 0
+# if addr[c] == '@': break
+# if addr[c] != '.': return 0
+# c = c + 1
+# continue
+# if addr[c] == '@': break
+# if ord(addr[c]) <= 32 or ord(addr[c]) >= 127: return 0
+# if addr[c] in rfc822_specials: return 0
+# c = c + 1
+# if not c or addr[c - 1] == '.': return 0
+#
+# # Next we validate the domain portion (name@domain)
+# domain = c = c + 1
+# if domain >= len(addr): return 0
+# count = 0
+# while c < len(addr):
+# if addr[c] == '.':
+# if c == domain or addr[c - 1] == '.': return 0
+# count = count + 1
+# if ord(addr[c]) <= 32 or ord(addr[c]) >= 127: return 0
+# if addr[c] in rfc822_specials: return 0
+# c = c + 1
+#
+# return count >= 1
View
2  setup.cfg
@@ -1,11 +1,9 @@
[nosetests]
-quiet = true
with-coverage = true
cover-package = marrow.mailer
cover-inclusive = true
where = tests
detailed-errors = true
-with-achievements = true
[aliases]
test = nosetests
View
30 setup.py
@@ -3,6 +3,7 @@
import os
import sys
+import warnings
from setuptools import setup, find_packages
@@ -10,7 +11,10 @@
if sys.version_info < (2, 6):
raise SystemExit("Python 2.6 or later is required.")
-exec(open(os.path.join("marrow", "mailer", "release.py")))
+if sys.version_info > (3, 0):
+ warnings.warn("Marrow Mailer is untested on Python 3; some features may be broken.", RuntimeWarning)
+
+exec(open(os.path.join("marrow", "mailer", "release.py")).read())
@@ -27,7 +31,7 @@
author = "Alice Bevan-McGregor",
author_email = "alice+marrow@gothcandy.com",
- url = "https://github.com/marrow/marrow.wsgi.objects",
+ url = "https://github.com/marrow/marrow.mailer",
license = "MIT",
install_requires = [
@@ -38,11 +42,16 @@
test_suite = 'nose.collector',
tests_require = [
'nose',
- 'coverage'
- ],
+ 'coverage',
+ 'PyDNS',
+ 'transaction',
+ 'pymta'
+ ] + [
+ 'futures'
+ ] if sys.version_info < (3, 0) else [],
classifiers=[
- "Development Status :: 4 - Beta",
+ "Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
@@ -50,9 +59,9 @@
"Programming Language :: Python",
"Programming Language :: Python :: 2.6",
"Programming Language :: Python :: 2.7",
- "Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.1",
- "Programming Language :: Python :: 3.2",
+ # "Programming Language :: Python :: 3",
+ # "Programming Language :: Python :: 3.1",
+ # "Programming Language :: Python :: 3.2",
"Topic :: Software Development :: Libraries :: Python Modules"
],
@@ -68,7 +77,7 @@
'immediate = marrow.mailer.manager.immediate:ImmediateManager',
'futures = marrow.mailer.manager.futures:FuturesManager',
'dynamic = marrow.mailer.manager.dynamic:DynamicManager',
- 'transactional = marrow.mailer.manager.transactional:TransactionalDynamicManager'
+ # 'transactional = marrow.mailer.manager.transactional:TransactionalDynamicManager'
],
'marrow.mailer.transport': [
'amazon = marrow.mailer.transport.ses:AmazonTransport',
@@ -80,8 +89,7 @@
'sendmail = marrow.mailer.transport.sendmail:SendmailTransport',
'imap = marrow.mailer.transport.imap:IMAPTransport',
'appengine = marrow.mailer.transport.gae:AppEngineTransport',
- 'logging = marrow.mailer.transport.log:LoggingTransport',
- 'sms = marrow.mailer.transport.sms:SMSTransport',
+ 'logging = marrow.mailer.transport.log:LoggingTransport'
]
}
)
View
0  tests/manager/__init__.py
No changes.
View
169 tests/manager/test_dynamic.py
@@ -0,0 +1,169 @@
+# encoding: utf-8
+
+from __future__ import unicode_literals
+
+import logging
+import pkg_resources
+
+from functools import partial
+from unittest import TestCase
+from nose.tools import ok_, eq_, raises
+from nose.plugins.skip import Skip, SkipTest
+
+from marrow.mailer.exc import TransportExhaustedException, TransportFailedException, DeliveryFailedException, MessageFailedException
+from marrow.mailer.manager.dynamic import DynamicManager, WorkItem
+
+
+log = logging.getLogger('tests')
+
+
+
+class MockFuture(object):
+ def __init__(self):
+ self.cancelled = False
+ self.running = False
+ self.exception = None
+ self.result = None
+
+ super(MockFuture, self).__init__()
+
+ def set_running_or_notify_cancel(self):
+ if self.cancelled:
+ return False
+
+ self.running = True
+ return True
+
+ def set_exception(self, e):
+ self.exception = e
+
+ def set_result(self, r):
+ self.result = r
+
+
+class TestWorkItem(TestCase):
+ calls = list()
+
+ def closure(self):
+ self.calls.append(True)
+ return True
+
+ def setUp(self):
+ self.f = MockFuture()
+ self.wi = WorkItem(self.f, self.closure, (), {})
+
+ def test_success(self):
+ self.wi.run()
+
+ self.assertEquals(self.calls, [True])
+ self.assertTrue(self.f.result)
+
+ def test_cancelled(self):
+ self.f.cancelled = True
+ self.wi.run()
+
+ self.assertEquals(self.calls, [])
+
+ def test_exception(self):
+ self.wi.fn = lambda: 1/0
+ self.wi.run()
+
+ self.assertTrue(isinstance(self.f.exception, ZeroDivisionError))
+
+
+class ManagerTestCase(TestCase):
+ manager = None
+ config = dict()
+ states = []
+ messages = []
+
+ class MockTransport(object):
+ def __init__(self, states, messages):
+ self.ephemeral = False
+ self.states = states
+ self.messages = messages
+
+ def startup(self):
+ self.states.append('running')
+
+ def deliver(self, message):
+ self.messages.append(message)
+
+ if isinstance(message, Exception) and ( len(self.messages) < 2 or self.messages[-2] is not message):
+ raise message
+
+ def shutdown(self):
+ self.states.append('stopped')
+
+ def setUp(self):
+ self.manager = self.manager(self.config, partial(self.MockTransport, self.states, self.messages))
+
+ def tearDown(self):
+ del self.states[:]
+ del self.messages[:]
+
+
+class TestDynamicManager(ManagerTestCase):
+ manager = DynamicManager
+
+ def test_startup(self):
+ # TODO: Test logging messages.
+ self.manager.startup()
+ self.assertEquals(self.states, [])
+
+ def test_shutdown(self):
+ # TODO: Test logging messages.
+ self.manager.startup()
+ self.manager.shutdown()
+ self.assertEquals(self.states, [])
+
+ def test_success(self):
+ self.manager.startup()
+
+ self.manager.deliver("success")
+
+ self.assertEquals(self.states, ["running"])
+ self.assertEquals(self.messages, ["success"])
+
+ self.manager.shutdown()
+ self.assertEquals(self.states, ["running", "stopped"])
+
+ def test_message_failure(self):
+ self.manager.startup()
+
+ exc = MessageFailedException()
+
+ receipt = self.manager.deliver(exc)
+ self.assertRaises(DeliveryFailedException, receipt.result)
+
+ self.assertEquals(self.states, ['running', 'stopped'])
+ self.assertEquals(self.messages, [exc])
+
+ self.manager.shutdown()
+ self.assertEquals(self.states, ['running', 'stopped'])
+
+ def test_transport_failure(self):
+ self.manager.startup()
+
+ exc = TransportFailedException()
+
+ self.manager.deliver(exc).result()
+
+ self.assertEquals(self.states, ['running', 'stopped', 'running'])
+ self.assertEquals(self.messages, [exc, exc])
+
+ self.manager.shutdown()
+ self.assertEquals(self.states, ['running', 'stopped', 'running', 'stopped'])
+
+ def test_transport_exhaustion(self):
+ self.manager.startup()
+
+ exc = TransportExhaustedException()
+
+ self.manager.deliver(exc).result()
+
+ self.assertEquals(self.states, ['running', 'stopped'])
+ self.assertEquals(self.messages, [exc])
+
+ self.manager.shutdown()
+ self.assertEquals(self.states, ['running', 'stopped'])
View
116 tests/manager/test_futures.py
@@ -0,0 +1,116 @@
+# encoding: utf-8
+
+from __future__ import unicode_literals
+
+import logging
+import pkg_resources
+
+from functools import partial
+from unittest import TestCase
+from nose.tools import ok_, eq_, raises
+from nose.plugins.skip import Skip, SkipTest
+
+from marrow.mailer.exc import TransportExhaustedException, TransportFailedException, DeliveryFailedException, MessageFailedException
+from marrow.mailer.manager.futures import FuturesManager
+
+
+log = logging.getLogger('tests')
+
+
+
+class ManagerTestCase(TestCase):
+ manager = None
+ config = dict()
+ states = []
+ messages = []
+
+ class MockTransport(object):
+ def __init__(self, states, messages):
+ self.ephemeral = False
+ self.states = states
+ self.messages = messages
+
+ def startup(self):
+ self.states.append('running')
+
+ def deliver(self, message):
+ self.messages.append(message)
+
+ if isinstance(message, Exception) and ( len(self.messages) < 2 or self.messages[-2] is not message):
+ raise message
+
+ def shutdown(self):
+ self.states.append('stopped')
+
+ def setUp(self):
+ self.manager = self.manager(self.config, partial(self.MockTransport, self.states, self.messages))
+
+ def tearDown(self):
+ del self.states[:]
+ del self.messages[:]
+
+
+class TestImmediateManager(ManagerTestCase):
+ manager = FuturesManager
+
+ def test_startup(self):
+ # TODO: Test logging messages.
+ self.manager.startup()
+ self.assertEquals(self.states, [])
+
+ def test_shutdown(self):
+ # TODO: Test logging messages.
+ self.manager.startup()
+ self.manager.shutdown()
+ self.assertEquals(self.states, [])
+
+ def test_success(self):
+ self.manager.startup()
+
+ self.manager.deliver("success")
+
+ self.assertEquals(self.states, ["running"])
+ self.assertEquals(self.messages, ["success"])
+
+ self.manager.shutdown()
+ self.assertEquals(self.states, ["running", "stopped"])
+
+ def test_message_failure(self):
+ self.manager.startup()
+
+ exc = MessageFailedException()
+
+ receipt = self.manager.deliver(exc)
+ self.assertRaises(DeliveryFailedException, receipt.result)
+
+ self.assertEquals(self.states, ['running', 'stopped'])
+ self.assertEquals(self.messages, [exc])
+
+ self.manager.shutdown()
+ self.assertEquals(self.states, ['running', 'stopped'])
+
+ def test_transport_failure(self):
+ self.manager.startup()
+
+ exc = TransportFailedException()
+
+ self.manager.deliver(exc).result()
+
+ self.assertEquals(self.states, ['running', 'stopped', 'running'])
+ self.assertEquals(self.messages, [exc, exc])
+
+ self.manager.shutdown()
+ self.assertEquals(self.states, ['running', 'stopped', 'running', 'stopped'])
+
+ def test_transport_exhaustion(self):
+ self.manager.startup()
+
+ exc = TransportExhaustedException()
+
+ self.manager.deliver(exc).result()
+
+ self.assertEquals(self.states, ['running', 'stopped'])
+ self.assertEquals(self.messages, [exc])
+
+ self.manager.shutdown()
+ self.assertEquals(self.states, ['running', 'stopped'])
View
115 tests/manager/test_immediate.py
@@ -0,0 +1,115 @@
+# encoding: utf-8
+
+from __future__ import unicode_literals
+
+import logging
+import pkg_resources
+
+from functools import partial
+from unittest import TestCase
+from nose.tools import ok_, eq_, raises
+from nose.plugins.skip import Skip, SkipTest
+
+from marrow.mailer.exc import TransportExhaustedException, TransportFailedException, DeliveryFailedException, MessageFailedException
+from marrow.mailer.manager.immediate import ImmediateManager
+
+
+log = logging.getLogger('tests')
+
+
+
+class ManagerTestCase(TestCase):
+ manager = None
+ config = dict()
+ states = []
+ messages = []
+
+ class MockTransport(object):
+ def __init__(self, states, messages):
+ self.ephemeral = False
+ self.states = states
+ self.messages = messages
+
+ def startup(self):
+ self.states.append('running')
+
+ def deliver(self, message):
+ self.messages.append(message)
+
+ if isinstance(message, Exception) and ( len(self.messages) < 2 or self.messages[-2] is not message):
+ raise message
+
+ def shutdown(self):
+ self.states.append('stopped')
+
+ def setUp(self):
+ self.manager = ImmediateManager(self.config, partial(self.MockTransport, self.states, self.messages))
+
+ def tearDown(self):
+ del self.states[:]
+ del self.messages[:]
+
+
+class TestImmediateManager(ManagerTestCase):
+ manager = ImmediateManager
+
+ def test_startup(self):
+ # TODO: Test logging messages.
+ self.manager.startup()
+ self.assertEquals(self.states, [])
+
+ def test_shutdown(self):
+ # TODO: Test logging messages.
+ self.manager.startup()
+ self.manager.shutdown()
+ self.assertEquals(self.states, [])
+
+ def test_success(self):
+ self.manager.startup()
+
+ self.manager.deliver("success")
+
+ self.assertEquals(self.states, ["running"])
+ self.assertEquals(self.messages, ["success"])
+
+ self.manager.shutdown()
+ self.assertEquals(self.states, ["running", "stopped"])
+
+ def test_message_failure(self):
+ self.manager.startup()
+
+ exc = MessageFailedException()
+
+ self.assertRaises(DeliveryFailedException, self.manager.deliver, exc)
+
+ self.assertEquals(self.states, ['running', 'stopped'])
+ self.assertEquals(self.messages, [exc])
+
+ self.manager.shutdown()
+ self.assertEquals(self.states, ['running', 'stopped'])
+
+ def test_transport_failure(self):
+ self.manager.startup()
+
+ exc = TransportFailedException()
+
+ self.manager.deliver(exc)
+
+ self.assertEquals(self.states, ['running', 'stopped', 'running'])
+ self.assertEquals(self.messages, [exc, exc])
+
+ self.manager.shutdown()
+ self.assertEquals(self.states, ['running', 'stopped', 'running', 'stopped'])
+
+ def test_transport_exhaustion(self):
+ self.manager.startup()
+
+ exc = TransportExhaustedException()
+
+ self.manager.deliver(exc)
+
+ self.assertEquals(self.states, ['running', 'stopped'])
+ self.assertEquals(self.messages, [exc])
+
+ self.manager.shutdown()
+ self.assertEquals(self.states, ['running', 'stopped'])
View
142 tests/test_core.py
@@ -1,12 +1,13 @@
# encoding: utf-8
-"""Test the primary configurator interface, Delivery."""
+"""Test the primary configurator interface, Mailer."""
import logging
+import warnings
from unittest import TestCase
-from marrow.mailer import Delivery
+from marrow.mailer import Mailer, Delivery, Message
from marrow.mailer.exc import MailerNotRunning
from marrow.mailer.manager.immediate import ImmediateManager
from marrow.mailer.transport.mock import MockTransport
@@ -17,121 +18,145 @@
log = logging.getLogger('tests')
-base_config = dict(manager='immediate', transport='mock')
+base_config = dict(manager=dict(use='immediate'), transport=dict(use='mock'))
class TestLookup(TestCase):
def test_load_literal(self):
- self.assertEqual(Delivery._load(ImmediateManager, None), ImmediateManager)
+ self.assertEqual(Mailer._load(ImmediateManager, None), ImmediateManager)
def test_load_dotcolon(self):
- self.assertEqual(Delivery._load('marrow.mailer.manager.immediate:ImmediateManager', None), ImmediateManager)
+ self.assertEqual(Mailer._load('marrow.mailer.manager.immediate:ImmediateManager', None), ImmediateManager)
def test_load_entrypoint(self):
- self.assertEqual(Delivery._load('immediate', 'marrow.mailer.manager'), ImmediateManager)
+ self.assertEqual(Mailer._load('immediate', 'marrow.mailer.manager'), ImmediateManager)
class TestInitialization(TestCase):
+ def test_deprecation(self):
+ with warnings.catch_warnings(record=True) as w:
+ warnings.simplefilter("always")
+
+ Delivery(base_config)
+
+ self.assertEqual(len(w), 1, "No, or more than one, warning issued.")
+ self.assertTrue(issubclass(w[-1].category, DeprecationWarning), "Category of warning is not DeprecationWarning.")
+ self.assertTrue('deprecated' in str(w[-1].message), "Warning does not include 'deprecated'.")
+ self.assertTrue('Mailer' in str(w[-1].message), "Warning does not include correct class name.")
+ self.assertTrue('Delivery' in str(w[-1].message), "Warning does not include old class name.")
+
+ def test_use_deprecation(self):
+ with warnings.catch_warnings(record=True) as w:
+ warnings.simplefilter("always")
+
+ Mailer(dict(manager='immediate', transport='mock'))
+
+ self.assertEqual(len(w), 2, "Too few or too many warnings issued.")
+
+ self.assertTrue(issubclass(w[0].category, DeprecationWarning), "Category of warning is not DeprecationWarning.")
+ self.assertTrue('deprecated' in str(w[0].message), "Warning does not include 'deprecated'.")
+ self.assertTrue('manager.use' in str(w[0].message), "Warning does not include correct use.")
+
+ self.assertTrue(issubclass(w[1].category, DeprecationWarning), "Category of warning is not DeprecationWarning.")
+ self.assertTrue('deprecated' in str(w[1].message), "Warning does not include 'deprecated'.")
+ self.assertTrue('transport.use' in str(w[1].message), "Warning does not include correct use.")
+
def test_standard(self):
log.info("Testing configuration: %r", dict(base_config))
- a = Delivery(base_config)
+ a = Mailer(base_config)
self.assertEqual(a.Manager, ImmediateManager)
self.assertEqual(a.Transport, MockTransport)
def test_bad_manager(self):
- config = dict(manager=object(), transport='mock')
+ config = dict(manager=dict(use=object()), transport=dict(use='mock'))
log.info("Testing configuration: %r", dict(config))
-
- with self.assertRaises(TypeError):
- a = Delivery(config)
+ self.assertRaises(TypeError, Mailer, config)
def test_bad_transport(self):
- config = dict(manager='immediate', transport=object())
+ config = dict(manager=dict(use='immediate'), transport=dict(use=object()))
log.info("Testing configuration: %r", dict(config))
-
- with self.assertRaises(TypeError):
- a = Delivery(config)
+ self.assertRaises(TypeError, Mailer, config)
def test_repr(self):
- a = Delivery(base_config)
- self.assertEqual(repr(a), "Delivery(manager=ImmediateManager, transport=MockTransport)")
+ a = Mailer(base_config)
+ self.assertEqual(repr(a), "Mailer(manager=ImmediateManager, transport=MockTransport)")
def test_prefix(self):
config = {
- 'mail.manager': 'immediate',
- 'mail.transport': 'mock'
+ 'mail.manager.use': 'immediate',
+ 'mail.transport.use': 'mock'
}
log.info("Testing configuration: %r", dict(config))
- a = Delivery(config, 'mail')
+ a = Mailer(config, 'mail')
self.assertEqual(a.Manager, ImmediateManager)
self.assertEqual(a.Transport, MockTransport)
def test_deep_prefix(self):
config = {
- 'marrow.mailer.manager': 'immediate',
- 'marrow.mailer.transport': 'mock'
+ 'marrow.mailer.manager.use': 'immediate',
+ 'marrow.mailer.transport.use': 'mock'
}
log.info("Testing configuration: %r", dict(config))
- a = Delivery(config, 'marrow.mailer')
+ a = Mailer(config, 'marrow.mailer')
self.assertEqual(a.Manager, ImmediateManager)
self.assertEqual(a.Transport, MockTransport)
def test_manager_entrypoint_failure(self):
config = {
- 'manager': 'immediate2',
- 'transport': 'mock'
+ 'manager.use': 'immediate2',
+ 'transport.use': 'mock'
}
log.info("Testing configuration: %r", dict(config))
- self.assertRaises(LookupError, lambda: Delivery(config))
+ self.assertRaises(LookupError, Mailer, config)
def test_manager_dotcolon_failure(self):
config = {
- 'manager': 'marrow.mailer.manager.foo:FooManager',
- 'transport': 'mock'
+ 'manager.use': 'marrow.mailer.manager.foo:FooManager',
+ 'transport.use': 'mock'
}
log.info("Testing configuration: %r", dict(config))
- self.assertRaises(ImportError, lambda: Delivery(config))
+ self.assertRaises(ImportError, Mailer, config)
- config['manager'] = 'marrow.mailer.manager.immediate:FooManager'
+ config['manager.use'] = 'marrow.mailer.manager.immediate:FooManager'
log.info("Testing configuration: %r", dict(config))
- self.assertRaises(AttributeError, lambda: Delivery(config))
+ self.assertRaises(AttributeError, Mailer, config)
def test_transport_entrypoint_failure(self):
config = {
- 'manager': 'immediate',
- 'transport': 'mock2'
+ 'manager.use': 'immediate',
+ 'transport.use': 'mock2'
}
log.info("Testing configuration: %r", dict(config))
- self.assertRaises(LookupError, lambda: Delivery(config))
+ self.assertRaises(LookupError, Mailer, config)
def test_transport_dotcolon_failure(self):
config = {
- 'manager': 'immediate',
- 'transport': 'marrow.mailer.transport.foo:FooTransport'
+ 'manager.use': 'immediate',
+ 'transport.use': 'marrow.mailer.transport.foo:FooTransport'
}
log.info("Testing configuration: %r", dict(config))
- self.assertRaises(ImportError, lambda: Delivery(config))
+ self.assertRaises(ImportError, Mailer, config)
- config['manager'] = 'marrow.mailer.transport.mock:FooTransport'
+ config['manager.use'] = 'marrow.mailer.transport.mock:FooTransport'
log.info("Testing configuration: %r", dict(config))
- self.assertRaises(AttributeError, lambda: Delivery(config))
+ self.assertRaises(AttributeError, Mailer, config)
class TestMethods(TestCase):
def test_startup(self):
messages = logging.getLogger().handlers[0].buffer
- interface = Delivery(base_config)
+ interface = Mailer(base_config)
interface.start()
self.assertEqual(len(messages), 5)
@@ -141,12 +166,12 @@ def test_startup(self):
interface.start()
self.assertEqual(len(messages), 6)
- self.assertEqual(messages[-1].getMessage(), "Attempt made to start an already running delivery service.")
+ self.assertEqual(messages[-1].getMessage(), "Attempt made to start an already running Mailer service.")
interface.stop()
def test_shutdown(self):
- interface = Delivery(base_config)
+ interface = Mailer(base_config)
interface.start()
logging.getLogger().handlers[0].truncate()
@@ -161,14 +186,14 @@ def test_shutdown(self):
interface.stop()
self.assertEqual(len(messages), 6)
- self.assertEqual(messages[-1].getMessage(), "Attempt made to stop an already stopped delivery service.")
+ self.assertEqual(messages[-1].getMessage(), "Attempt made to stop an already stopped Mailer service.")
def test_send(self):
message = Bunch(id='foo')
- interface = Delivery(base_config)
+ interface = Mailer(base_config)
- self.assertRaises(MailerNotRunning, lambda: interface.send(message))
+ self.assertRaises(MailerNotRunning, interface.send, message)
interface.start()