Improve and document JSON serialization for Mandrill API

* Add some context to exceptions on unserializable
  values (addresses #89).
* Document need to format merge data
  (into something JSON-serializable).
* Add RemovedInDjrill2 DeprecationWarning.
* Deprecate blanket date/datetime serialization.
This commit is contained in:
medmunds
2015-05-12 13:29:52 -07:00
parent 52de627af1
commit cc56b96efa
9 changed files with 214 additions and 13 deletions

View File

@@ -1,4 +1,5 @@
from requests import HTTPError
import warnings
class MandrillAPIError(HTTPError):
@@ -32,3 +33,11 @@ class NotSupportedByMandrillError(ValueError):
avoid duplicating Mandrill's validation logic locally.)
"""
class RemovedInDjrill2(DeprecationWarning):
"""Functionality due for deprecation in Djrill 2.0"""
def removed_in_djrill_2(message, stacklevel=1):
warnings.warn(message, category=RemovedInDjrill2, stacklevel=stacklevel + 1)

View File

@@ -6,6 +6,7 @@ from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_T
# Oops: this file has the same name as our app, and cannot be renamed.
#from djrill import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError
from ... import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError
from ...exceptions import removed_in_djrill_2
from base64 import b64encode
from datetime import date, datetime
@@ -18,22 +19,36 @@ import requests
DjrillBackendHTTPError = MandrillAPIError # Backwards-compat Djrill<=0.2.0
class JSONDateUTCEncoder(json.JSONEncoder):
"""JSONEncoder that encodes dates in string format used by Mandrill.
def encode_date_for_mandrill(dt):
"""Format a date or datetime for use as a Mandrill API date field
datetime becomes "YYYY-MM-DD HH:MM:SS"
converted to UTC, if timezone-aware
microseconds removed
date becomes "YYYY-MM-DD 00:00:00"
anything else gets returned intact
"""
if isinstance(dt, datetime):
dt = dt.replace(microsecond=0)
if dt.utcoffset() is not None:
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
return dt.isoformat(' ')
elif isinstance(dt, date):
return dt.isoformat() + ' 00:00:00'
else:
return dt
class JSONDateUTCEncoder(json.JSONEncoder):
"""[deprecated] JSONEncoder that encodes dates in string format used by Mandrill."""
def default(self, obj):
if isinstance(obj, datetime):
dt = obj.replace(microsecond=0)
if dt.utcoffset() is not None:
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
return dt.isoformat(' ')
elif isinstance(obj, date):
return obj.isoformat() + ' 00:00:00'
if isinstance(obj, date):
removed_in_djrill_2(
"You've used the date '%r' as a Djrill message attribute "
"(perhaps in merge vars or metadata). Djrill 2.0 will require "
"you to explicitly convert this date to a string." % obj
)
return encode_date_for_mandrill(obj)
return super(JSONDateUTCEncoder, self).default(obj)
@@ -104,7 +119,19 @@ class DjrillBackend(BaseEmailBackend):
raise
return False
response = requests.post(api_url, data=json.dumps(api_params, cls=JSONDateUTCEncoder))
try:
api_data = json.dumps(api_params, cls=JSONDateUTCEncoder)
except TypeError as err:
# Add some context to the "not JSON serializable" message
if not err.args:
err.args = ('',)
err.args = (
err.args[0] + " in a Djrill message (perhaps it's a merge var?)."
" Try converting it to a string or number first.",
) + err.args[1:]
raise err
response = requests.post(api_url, data=api_data)
if response.status_code != 200:
@@ -175,12 +202,17 @@ class DjrillBackend(BaseEmailBackend):
"""Extend api_params to include Mandrill global-send options set on message"""
# Mandrill attributes that can be copied directly:
mandrill_attrs = [
'async', 'ip_pool', 'send_at'
'async', 'ip_pool'
]
for attr in mandrill_attrs:
if hasattr(message, attr):
api_params[attr] = getattr(message, attr)
# Mandrill attributes that require conversion:
if hasattr(message, 'send_at'):
api_params['send_at'] = encode_date_for_mandrill(message.send_at)
def _make_mandrill_to_list(self, message, recipients, recipient_type="to"):
"""Create a Mandrill 'to' field from a list of emails.

View File

@@ -5,12 +5,12 @@ import six
from django.test import TestCase
from .utils import override_settings
from .utils import BackportedAssertions, override_settings
@override_settings(MANDRILL_API_KEY="FAKE_API_KEY_FOR_TESTING",
EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
class DjrillBackendMockAPITestCase(TestCase):
class DjrillBackendMockAPITestCase(TestCase, BackportedAssertions):
"""TestCase that uses Djrill EmailBackend with a mocked Mandrill API"""
class MockResponse(requests.Response):

View File

@@ -1,10 +1,56 @@
# Tests deprecated Djrill features
from datetime import date, datetime
import warnings
from django.core import mail
from django.test import TestCase
from djrill.mail import DjrillMessage
from djrill import MandrillAPIError, NotSupportedByMandrillError
from .mock_backend import DjrillBackendMockAPITestCase
class DjrillBackendDeprecationTests(DjrillBackendMockAPITestCase):
def test_deprecated_json_date_encoding(self):
"""Djrill 2.0+ avoids a blanket JSONDateUTCEncoder"""
# Djrill allows dates for send_at, so shouldn't warn:
message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com'])
message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567)
self.assertNotWarns(DeprecationWarning, message.send)
# merge_vars need to be json-serializable, so should generate a warning:
message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com'])
message.global_merge_vars = {'DATE': date(2022, 10, 11)}
self.assertWarnsMessage(DeprecationWarning,
"Djrill 2.0 will require you to explicitly convert this date to a string",
message.send)
# ... but should still encode the date (for now):
data = self.get_api_call_data()
self.assertEqual(data['message']['global_merge_vars'],
[{'name': 'DATE', 'content': "2022-10-11 00:00:00"}])
def assertWarnsMessage(self, warning, message, callable, *args, **kwds):
"""Checks that `callable` issues a warning of category `warning` containing `message`"""
with warnings.catch_warnings(record=True) as warned:
warnings.simplefilter("always")
callable(*args, **kwds)
self.assertGreater(len(warned), 0, msg="No warnings issued")
self.assertTrue(
any(issubclass(w.category, warning) and message in str(w.message) for w in warned),
msg="%r(%r) not found in %r" % (warning, message, [str(w) for w in warned]))
def assertNotWarns(self, warning, callable, *args, **kwds):
"""Checks that `callable` does not issue any warnings of category `warning`"""
with warnings.catch_warnings(record=True) as warned:
warnings.simplefilter("always")
callable(*args, **kwds)
relevant_warnings = [w for w in warned if issubclass(w.category, warning)]
self.assertEqual(len(relevant_warnings), 0,
msg="Unexpected warnings %r" % [str(w) for w in relevant_warnings])
class DjrillMessageTests(TestCase):
"""Test the DjrillMessage class (deprecated as of Djrill v0.2.0)

View File

@@ -4,6 +4,7 @@ from __future__ import unicode_literals
from base64 import b64decode
from datetime import date, datetime, timedelta, tzinfo
from decimal import Decimal
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
import json
@@ -507,6 +508,16 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
self.assertEqual(sent, 0)
self.assertIsNone(msg.mandrill_response)
def test_json_serialization_warnings(self):
"""Try to provide more information about non-json-serializable data"""
self.message.global_merge_vars = {'PRICE': Decimal('19.99')}
with self.assertRaisesMessage(
TypeError,
"Decimal('19.99') is not JSON serializable in a Djrill message (perhaps "
"it's a merge var?). Try converting it to a string or number first."
):
self.message.send()
@override_settings(EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
class DjrillImproperlyConfiguredTests(TestCase):

View File

@@ -1,4 +1,8 @@
import re
import six
__all__ = (
'BackportedAssertions',
'override_settings',
)
@@ -66,3 +70,19 @@ except ImportError:
# new_value = getattr(settings, key, None)
# setting_changed.send(sender=settings._wrapped.__class__,
# setting=key, value=new_value)
class BackportedAssertions(object):
"""Handful of useful TestCase assertions backported to Python 2.6/Django 1.3"""
# Backport from Python 2.7/3.1
def assertIn(self, member, container, msg=None):
"""Just like self.assertTrue(a in b), but with a nicer default message."""
if member not in container:
self.fail(msg or '%r not found in %r' % (member, container))
# Backport from Django 1.4
def assertRaisesMessage(self, expected_exception, expected_message,
callable_obj=None, *args, **kwargs):
return six.assertRaisesRegex(self, expected_exception, re.escape(expected_message),
callable_obj, *args, **kwargs)