From 1c7fe8a759d7e719ffca33d4d56273097ddc4e2e Mon Sep 17 00:00:00 2001 From: medmunds Date: Sat, 27 Feb 2016 11:57:53 -0800 Subject: [PATCH] Rename exceptions * Change Djrill -> Mandrill in exception names * Don't re-export at package level (import from anymail.exceptions, not from anymail) --- anymail/__init__.py | 2 - anymail/backends/mandrill.py | 36 ++++++------ anymail/exceptions.py | 61 +++++++++++--------- anymail/tests/test_mandrill_integration.py | 14 ++--- anymail/tests/test_mandrill_send.py | 28 ++++----- anymail/tests/test_mandrill_send_template.py | 4 +- 6 files changed, 74 insertions(+), 71 deletions(-) diff --git a/anymail/__init__.py b/anymail/__init__.py index 951d0eb..1107fd2 100644 --- a/anymail/__init__.py +++ b/anymail/__init__.py @@ -1,3 +1 @@ from ._version import __version__, VERSION -from .exceptions import (MandrillAPIError, MandrillRecipientsRefused, - NotSerializableForMandrillError, NotSupportedByMandrillError) diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py index 4b1b707..e537406 100644 --- a/anymail/backends/mandrill.py +++ b/anymail/backends/mandrill.py @@ -16,8 +16,8 @@ from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE from .._version import __version__ -from ..exceptions import (DjrillError, MandrillAPIError, MandrillRecipientsRefused, - NotSerializableForMandrillError, NotSupportedByMandrillError) +from ..exceptions import (AnymailError, AnymailRequestsAPIError, AnymailRecipientsRefused, + AnymailSerializationError, AnymailUnsupportedFeature) class MandrillBackend(BaseEmailBackend): @@ -127,8 +127,8 @@ class MandrillBackend(BaseEmailBackend): message.mandrill_response = self.parse_response(response, payload, message) self.validate_response(message.mandrill_response, response, payload, message) - except DjrillError: - # every *expected* error is derived from DjrillError; + except AnymailError: + # every *expected* error is derived from AnymailError; # we deliberately don't silence unexpected errors if not self.fail_silently: raise @@ -153,7 +153,7 @@ class MandrillBackend(BaseEmailBackend): payload is a dict that will become the Mandrill send data message is an EmailMessage, possibly with additional Mandrill-specific attrs - Can raise NotSupportedByMandrillError for unsupported options in message. + Can raise AnymailUnsupportedFeature for unsupported options in message. """ msg_dict = self._build_standard_message_dict(message) self._add_mandrill_options(message, msg_dict) @@ -192,32 +192,32 @@ class MandrillBackend(BaseEmailBackend): message is the original EmailMessage return should be a requests.Response - Can raise NotSerializableForMandrillError if payload is not serializable - Can raise MandrillAPIError for HTTP errors in the post + Can raise AnymailSerializationError if payload is not serializable + Can raise AnymailRequestsAPIError for HTTP errors in the post """ api_url = self.get_api_url(payload, message) try: json_payload = self.serialize_payload(payload, message) except TypeError as err: # Add some context to the "not JSON serializable" message - raise NotSerializableForMandrillError( + raise AnymailSerializationError( orig_err=err, email_message=message, payload=payload) response = self.session.post(api_url, data=json_payload) if response.status_code != 200: - raise MandrillAPIError(email_message=message, payload=payload, response=response) + raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response) return response def parse_response(self, response, payload, message): """Return parsed json from Mandrill API response - Can raise MandrillAPIError if response is not valid JSON + Can raise AnymailRequestsAPIError if response is not valid JSON """ try: return response.json() except ValueError: - raise MandrillAPIError("Invalid JSON in Mandrill API response", - email_message=message, payload=payload, response=response) + raise AnymailRequestsAPIError("Invalid JSON in Mandrill API response", + email_message=message, payload=payload, response=response) def validate_response(self, parsed_response, response, payload, message): """Validate parsed_response, raising exceptions for any problems. @@ -233,12 +233,12 @@ class MandrillBackend(BaseEmailBackend): try: recipient_status = [item["status"] for item in parsed_response] except (KeyError, TypeError): - raise MandrillAPIError("Invalid Mandrill API response format", - email_message=message, payload=payload, response=response) + raise AnymailRequestsAPIError("Invalid Mandrill API response format", + email_message=message, payload=payload, response=response) # Error if *all* recipients are invalid or refused # (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend) if all([status in ('invalid', 'rejected') for status in recipient_status]): - raise MandrillRecipientsRefused(email_message=message, payload=payload, response=response) + raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response) # # Payload construction @@ -251,7 +251,7 @@ class MandrillBackend(BaseEmailBackend): use by default. Standard text email messages sent through Django will still work through Mandrill. - Raises NotSupportedByMandrillError for any standard EmailMessage + Raises AnymailUnsupportedFeature for any standard EmailMessage features that cannot be accurately communicated to Mandrill. """ sender = sanitize_address(message.from_email, message.encoding) @@ -381,14 +381,14 @@ class MandrillBackend(BaseEmailBackend): the HTML output for your email. """ if len(message.alternatives) > 1: - raise NotSupportedByMandrillError( + raise AnymailUnsupportedFeature( "Too many alternatives attached to the message. " "Mandrill only accepts plain text and html emails.", email_message=message) (content, mimetype) = message.alternatives[0] if mimetype != 'text/html': - raise NotSupportedByMandrillError( + raise AnymailUnsupportedFeature( "Invalid alternative mimetype '%s'. " "Mandrill only accepts plain text and html emails." % mimetype, diff --git a/anymail/exceptions.py b/anymail/exceptions.py index 3b16354..70e2300 100644 --- a/anymail/exceptions.py +++ b/anymail/exceptions.py @@ -2,28 +2,30 @@ import json from requests import HTTPError -class DjrillError(Exception): - """Base class for exceptions raised by Djrill +class AnymailError(Exception): + """Base class for exceptions raised by Anymail Overrides __str__ to provide additional information about - Mandrill API call and response. + the ESP API call and response. """ def __init__(self, *args, **kwargs): """ Optional kwargs: email_message: the original EmailMessage being sent - payload: data arg (*not* json-stringified) for the Mandrill send call + status_code: HTTP status code of response to ESP send call + payload: data arg (*not* json-stringified) for the ESP send call response: requests.Response from the send call """ self.email_message = kwargs.pop('email_message', None) self.payload = kwargs.pop('payload', None) + self.status_code = kwargs.pop('status_code', None) if isinstance(self, HTTPError): # must leave response in kwargs for HTTPError self.response = kwargs.get('response', None) else: self.response = kwargs.pop('response', None) - super(DjrillError, self).__init__(*args, **kwargs) + super(AnymailError, self).__init__(*args, **kwargs) def __str__(self): parts = [ @@ -34,7 +36,7 @@ class DjrillError(Exception): return "\n".join(filter(None, parts)) def describe_send(self): - """Return a string describing the Mandrill send in self.payload, or None""" + """Return a string describing the ESP send in self.payload, or None""" if self.payload is None: return None description = "Sending a message" @@ -50,10 +52,10 @@ class DjrillError(Exception): return description def describe_response(self): - """Return a formatted string of self.response, or None""" - if self.response is None: + """Return a formatted string of self.status_code and response, or None""" + if self.status_code is None: return None - description = "Mandrill API response %d:" % self.response.status_code + description = "ESP API response %d:" % self.status_code try: json_response = self.response.json() description += "\n" + json.dumps(json_response, indent=2) @@ -65,53 +67,56 @@ class DjrillError(Exception): return description -class MandrillAPIError(DjrillError, HTTPError): - """Exception for unsuccessful response from Mandrill API.""" +class AnymailAPIError(AnymailError): + """Exception for unsuccessful response from ESP's API.""" + + +class AnymailRequestsAPIError(AnymailAPIError, HTTPError): + """Exception for unsuccessful response from a requests API.""" def __init__(self, *args, **kwargs): - super(MandrillAPIError, self).__init__(*args, **kwargs) + super(AnymailRequestsAPIError, self).__init__(*args, **kwargs) if self.response is not None: self.status_code = self.response.status_code -class MandrillRecipientsRefused(DjrillError): +class AnymailRecipientsRefused(AnymailError): """Exception for send where all recipients are invalid or rejected.""" def __init__(self, message=None, *args, **kwargs): if message is None: message = "All message recipients were rejected or invalid" - super(MandrillRecipientsRefused, self).__init__(message, *args, **kwargs) + super(AnymailRecipientsRefused, self).__init__(message, *args, **kwargs) -class NotSupportedByMandrillError(DjrillError, ValueError): - """Exception for email features that Mandrill doesn't support. +class AnymailUnsupportedFeature(AnymailError, ValueError): + """Exception for Anymail features that the ESP doesn't support. This is typically raised when attempting to send a Django EmailMessage that uses options or values you might expect to work, but that are silently - ignored by or can't be communicated to Mandrill's API. (E.g., non-HTML - alternative parts.) + ignored by or can't be communicated to the ESP's API. - It's generally *not* raised for Mandrill-specific features, like limitations - on Mandrill tag names or restrictions on from emails. (Djrill expects - Mandrill to return an API error for these where appropriate, and tries to - avoid duplicating Mandrill's validation logic locally.) + It's generally *not* raised for ESP-specific limitations, like the number + of tags allowed on a message. (Anymail expects + the ESP to return an API error for these where appropriate, and tries to + avoid duplicating each ESP's validation logic locally.) """ -class NotSerializableForMandrillError(DjrillError, TypeError): - """Exception for data that Djrill doesn't know how to convert to JSON. +class AnymailSerializationError(AnymailError, TypeError): + """Exception for data that Anymail can't serialize for the ESP's API. This typically results from including something like a date or Decimal - in your merge_vars (or other Mandrill-specific EmailMessage option). + in your merge_vars. """ - # inherits from TypeError for backwards compatibility with Djrill 1.x + # inherits from TypeError for compatibility with JSON serialization error def __init__(self, message=None, orig_err=None, *args, **kwargs): if message is None: - message = "Don't know how to send this data to Mandrill. " \ + message = "Don't know how to send this data to your ESP. " \ "Try converting it to a string or number first." if orig_err is not None: message += "\n%s" % str(orig_err) - super(NotSerializableForMandrillError, self).__init__(message, *args, **kwargs) + super(AnymailSerializationError, self).__init__(message, *args, **kwargs) diff --git a/anymail/tests/test_mandrill_integration.py b/anymail/tests/test_mandrill_integration.py index e2283ea..c1b8514 100644 --- a/anymail/tests/test_mandrill_integration.py +++ b/anymail/tests/test_mandrill_integration.py @@ -7,7 +7,7 @@ from django.core import mail from django.test import TestCase from django.test.utils import override_settings -from anymail import MandrillAPIError, MandrillRecipientsRefused +from anymail.exceptions import AnymailAPIError, AnymailRecipientsRefused MANDRILL_TEST_API_KEY = os.getenv('MANDRILL_TEST_API_KEY') @@ -51,7 +51,7 @@ class DjrillIntegrationTests(TestCase): try: self.message.send() self.fail("This line will not be reached, because send() raised an exception") - except MandrillAPIError as err: + except AnymailAPIError as err: self.assertEqual(err.status_code, 500) self.assertIn("email address is invalid", str(err)) @@ -60,7 +60,7 @@ class DjrillIntegrationTests(TestCase): self.message.to = ['invalid@localhost'] try: self.message.send() - except MandrillRecipientsRefused: + except AnymailRecipientsRefused: # Mandrill refused to deliver the mail -- message.mandrill_response will tell you why: # noinspection PyUnresolvedReferences response = self.message.mandrill_response @@ -72,14 +72,14 @@ class DjrillIntegrationTests(TestCase): if response[0]['status'] == 'queued': self.skipTest("Mandrill queued the send -- can't complete this test") else: - self.fail("Djrill did not raise MandrillRecipientsRefused for invalid recipient") + self.fail("Djrill did not raise AnymailRecipientsRefused for invalid recipient") def test_rejected_to(self): # Example of detecting when a recipient is on Mandrill's rejection blacklist self.message.to = ['reject@test.mandrillapp.com'] try: self.message.send() - except MandrillRecipientsRefused: + except AnymailRecipientsRefused: # Mandrill refused to deliver the mail -- message.mandrill_response will tell you why: # noinspection PyUnresolvedReferences response = self.message.mandrill_response @@ -92,7 +92,7 @@ class DjrillIntegrationTests(TestCase): if response[0]['status'] == 'queued': self.skipTest("Mandrill queued the send -- can't complete this test") else: - self.fail("Djrill did not raise MandrillRecipientsRefused for blacklist recipient") + self.fail("Djrill did not raise AnymailRecipientsRefused for blacklist recipient") @override_settings(MANDRILL_API_KEY="Hey, that's not an API key!") def test_invalid_api_key(self): @@ -100,6 +100,6 @@ class DjrillIntegrationTests(TestCase): try: self.message.send() self.fail("This line will not be reached, because send() raised an exception") - except MandrillAPIError as err: + except AnymailAPIError as err: self.assertEqual(err.status_code, 500) self.assertIn("Invalid API key", str(err)) diff --git a/anymail/tests/test_mandrill_send.py b/anymail/tests/test_mandrill_send.py index 1fce013..283bd11 100644 --- a/anymail/tests/test_mandrill_send.py +++ b/anymail/tests/test_mandrill_send.py @@ -19,8 +19,8 @@ from django.core.mail import make_msgid from django.test import TestCase from django.test.utils import override_settings -from anymail import (MandrillAPIError, MandrillRecipientsRefused, - NotSerializableForMandrillError, NotSupportedByMandrillError) +from anymail.exceptions import (AnymailAPIError, AnymailRecipientsRefused, + AnymailSerializationError, AnymailUnsupportedFeature) from .mock_backend import DjrillBackendMockAPITestCase @@ -275,14 +275,14 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): 'from@example.com', ['to@example.com']) email.attach_alternative("

First html is OK

", "text/html") email.attach_alternative("

But not second html

", "text/html") - with self.assertRaises(NotSupportedByMandrillError): + with self.assertRaises(AnymailUnsupportedFeature): email.send() # Only html alternatives allowed email = mail.EmailMultiAlternatives('Subject', 'Body', 'from@example.com', ['to@example.com']) email.attach_alternative("{'not': 'allowed'}", "application/json") - with self.assertRaises(NotSupportedByMandrillError): + with self.assertRaises(AnymailUnsupportedFeature): email.send() # Make sure fail_silently is respected @@ -296,7 +296,7 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): def test_mandrill_api_failure(self): self.mock_post.return_value = self.MockResponse(status_code=400) - with self.assertRaises(MandrillAPIError): + with self.assertRaises(AnymailAPIError): sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) self.assertEqual(sent, 0) @@ -319,17 +319,17 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase): "message": "Helpful explanation from Mandrill" }""" self.mock_post.return_value = self.MockResponse(status_code=400, raw=error_response) - with self.assertRaisesMessage(MandrillAPIError, "Helpful explanation from Mandrill"): + with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from Mandrill"): msg.send() # Non-JSON error response: self.mock_post.return_value = self.MockResponse(status_code=500, raw=b"Invalid API key") - with self.assertRaisesMessage(MandrillAPIError, "Invalid API key"): + with self.assertRaisesMessage(AnymailAPIError, "Invalid API key"): msg.send() # No content in the error response: self.mock_post.return_value = self.MockResponse(status_code=502, raw=None) - with self.assertRaises(MandrillAPIError): + with self.assertRaises(AnymailAPIError): msg.send() @@ -539,29 +539,29 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): """If the send succeeds, but a non-JSON API response, should raise an API exception""" self.mock_post.return_value = self.MockResponse(status_code=500, raw=b"this isn't json") msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) - with self.assertRaises(MandrillAPIError): + with self.assertRaises(AnymailAPIError): msg.send() self.assertIsNone(msg.mandrill_response) def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data""" self.message.global_merge_vars = {'PRICE': Decimal('19.99')} - with self.assertRaises(NotSerializableForMandrillError) as cm: + with self.assertRaises(AnymailSerializationError) as cm: self.message.send() err = cm.exception self.assertTrue(isinstance(err, TypeError)) # Djrill 1.x re-raised TypeError from json.dumps - self.assertStrContains(str(err), "Don't know how to send this data to Mandrill") # our added context + self.assertStrContains(str(err), "Don't know how to send this data to your ESP") # our added context self.assertStrContains(str(err), "Decimal('19.99') is not JSON serializable") # original message def test_dates_not_serialized(self): """Pre-2.0 Djrill accidentally serialized dates to ISO""" self.message.global_merge_vars = {'SHIP_DATE': date(2015, 12, 2)} - with self.assertRaises(NotSerializableForMandrillError): + with self.assertRaises(AnymailSerializationError): self.message.send() class DjrillRecipientsRefusedTests(DjrillBackendMockAPITestCase): - """Djrill raises MandrillRecipientsRefused when *all* recipients are rejected or invalid""" + """Djrill raises AnymailRecipientsRefused when *all* recipients are rejected or invalid""" def test_recipients_refused(self): msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', @@ -569,7 +569,7 @@ class DjrillRecipientsRefusedTests(DjrillBackendMockAPITestCase): self.mock_post.return_value = self.MockResponse(status_code=200, raw=b""" [{ "email": "invalid@localhost", "status": "invalid" }, { "email": "reject@test.mandrillapp.com", "status": "rejected" }]""") - with self.assertRaises(MandrillRecipientsRefused): + with self.assertRaises(AnymailRecipientsRefused): msg.send() def test_fail_silently(self): diff --git a/anymail/tests/test_mandrill_send_template.py b/anymail/tests/test_mandrill_send_template.py index 3ed4256..325edb4 100644 --- a/anymail/tests/test_mandrill_send_template.py +++ b/anymail/tests/test_mandrill_send_template.py @@ -1,6 +1,6 @@ from django.core import mail -from anymail import MandrillAPIError +from anymail.exceptions import AnymailAPIError from .mock_backend import DjrillBackendMockAPITestCase @@ -47,7 +47,7 @@ class DjrillMandrillSendTemplateTests(DjrillBackendMockAPITestCase): 'from@example.com', ['to@example.com']) msg.template_name = "PERSONALIZED_SPECIALS" msg.use_template_from = True - with self.assertRaises(MandrillAPIError): + with self.assertRaises(AnymailAPIError): msg.send() def test_send_template_without_subject_field(self):