Skip to content

Commit b88da67

Browse files
committed
Handle Django lazy strings.
In BasePayload, ensure any Django ugettext_lazy (or similar) are converted to real strings before handing off to ESP code. This resolves problems where calling code expects it can use lazy strings "anywhere", but non-Django code (requests, ESP packages) don't always handle them correctly. * Add utils helpers for lazy objects (is_lazy, force_non_lazy*) * Add lazy object handling to utils.Attachment * Add lazy object handling converters to BasePayload attr processing where appropriate. (This ends up varying by the expected attribute type.) Fixes #34.
1 parent 146afba commit b88da67

File tree

4 files changed

+210
-17
lines changed

4 files changed

+210
-17
lines changed

anymail/backends/base.py

+34-15
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from ..exceptions import AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused
88
from ..message import AnymailStatus
99
from ..signals import pre_send, post_send
10-
from ..utils import Attachment, ParsedEmail, UNSET, combine, last, get_anymail_setting
10+
from ..utils import (Attachment, ParsedEmail, UNSET, combine, last, get_anymail_setting,
11+
force_non_lazy, force_non_lazy_list, force_non_lazy_dict)
1112

1213

1314
class AnymailBaseBackend(BaseEmailBackend):
@@ -195,31 +196,43 @@ def esp_name(self):
195196

196197

197198
class BasePayload(object):
198-
# attr, combiner, converter
199+
# Listing of EmailMessage/EmailMultiAlternatives attributes
200+
# to process into Payload. Each item is in the form:
201+
# (attr, combiner, converter)
202+
# attr: the property name
203+
# combiner: optional function(default_value, value) -> value
204+
# to combine settings defaults with the EmailMessage property value
205+
# (usually `combine` to merge, or `last` for message value to override default;
206+
# use `None` if settings defaults aren't supported)
207+
# converter: optional function(value) -> value transformation
208+
# (can be a callable or the string name of a Payload method, or `None`)
209+
# The converter must force any Django lazy translation strings to text.
210+
# The Payload's `set_<attr>` method will be called with
211+
# the combined/converted results for each attr.
199212
base_message_attrs = (
200213
# Standard EmailMessage/EmailMultiAlternatives props
201214
('from_email', last, 'parsed_email'),
202215
('to', combine, 'parsed_emails'),
203216
('cc', combine, 'parsed_emails'),
204217
('bcc', combine, 'parsed_emails'),
205-
('subject', last, None),
218+
('subject', last, force_non_lazy),
206219
('reply_to', combine, 'parsed_emails'),
207-
('extra_headers', combine, None),
208-
('body', last, None), # special handling below checks message.content_subtype
209-
('alternatives', combine, None),
220+
('extra_headers', combine, force_non_lazy_dict),
221+
('body', last, force_non_lazy), # special handling below checks message.content_subtype
222+
('alternatives', combine, 'prepped_alternatives'),
210223
('attachments', combine, 'prepped_attachments'),
211224
)
212225
anymail_message_attrs = (
213226
# Anymail expando-props
214-
('metadata', combine, None),
227+
('metadata', combine, force_non_lazy_dict),
215228
('send_at', last, 'aware_datetime'),
216-
('tags', combine, None),
229+
('tags', combine, force_non_lazy_list),
217230
('track_clicks', last, None),
218231
('track_opens', last, None),
219-
('template_id', last, None),
220-
('merge_data', combine, None),
221-
('merge_global_data', combine, None),
222-
('esp_extra', combine, None),
232+
('template_id', last, force_non_lazy),
233+
('merge_data', combine, force_non_lazy_dict),
234+
('merge_global_data', combine, force_non_lazy_dict),
235+
('esp_extra', combine, force_non_lazy_dict),
223236
)
224237
esp_message_attrs = () # subclasses can override
225238

@@ -261,15 +274,21 @@ def unsupported_feature(self, feature):
261274
#
262275

263276
def parsed_email(self, address):
264-
return ParsedEmail(address, self.message.encoding)
277+
return ParsedEmail(address, self.message.encoding) # (handles lazy address)
265278

266279
def parsed_emails(self, addresses):
267280
encoding = self.message.encoding
268-
return [ParsedEmail(address, encoding) for address in addresses]
281+
return [ParsedEmail(address, encoding) # (handles lazy address)
282+
for address in addresses]
283+
284+
def prepped_alternatives(self, alternatives):
285+
return [(force_non_lazy(content), mimetype)
286+
for (content, mimetype) in alternatives]
269287

270288
def prepped_attachments(self, attachments):
271289
str_encoding = self.message.encoding or settings.DEFAULT_CHARSET
272-
return [Attachment(attachment, str_encoding) for attachment in attachments]
290+
return [Attachment(attachment, str_encoding) # (handles lazy content, filename)
291+
for attachment in attachments]
273292

274293
def aware_datetime(self, value):
275294
"""Converts a date or datetime or timestamp to an aware datetime.

anymail/utils.py

+38
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from django.conf import settings
1010
from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE
1111
from django.utils.encoding import force_text
12+
from django.utils.functional import Promise
1213
from django.utils.timezone import utc
1314

1415
from .exceptions import AnymailConfigurationError, AnymailInvalidAddress
@@ -162,6 +163,9 @@ def __init__(self, attachment, encoding):
162163
else:
163164
(self.name, self.content, self.mimetype) = attachment
164165

166+
self.name = force_non_lazy(self.name)
167+
self.content = force_non_lazy(self.content)
168+
165169
# Guess missing mimetype from filename, borrowed from
166170
# django.core.mail.EmailMessage._create_attachment()
167171
if self.mimetype is None and self.name is not None:
@@ -289,3 +293,37 @@ def rfc2822date(dt):
289293
# but treats naive datetimes as local rather than "UTC with no information ..."
290294
timeval = timestamp(dt)
291295
return formatdate(timeval, usegmt=True)
296+
297+
298+
def is_lazy(obj):
299+
"""Return True if obj is a Django lazy object."""
300+
# See django.utils.functional.lazy. (This appears to be preferred
301+
# to checking for `not isinstance(obj, six.text_type)`.)
302+
return isinstance(obj, Promise)
303+
304+
305+
def force_non_lazy(obj):
306+
"""If obj is a Django lazy object, return it coerced to text; otherwise return it unchanged.
307+
308+
(Similar to django.utils.encoding.force_text, but doesn't alter non-text objects.)
309+
"""
310+
if is_lazy(obj):
311+
return six.text_type(obj)
312+
313+
return obj
314+
315+
316+
def force_non_lazy_list(obj):
317+
"""Return a (shallow) copy of sequence obj, with all values forced non-lazy."""
318+
try:
319+
return [force_non_lazy(item) for item in obj]
320+
except (AttributeError, TypeError):
321+
return force_non_lazy(obj)
322+
323+
324+
def force_non_lazy_dict(obj):
325+
"""Return a (deep) copy of dict obj, with all values forced non-lazy."""
326+
try:
327+
return {key: force_non_lazy_dict(value) for key, value in obj.items()}
328+
except (AttributeError, TypeError):
329+
return force_non_lazy(obj)

tests/test_general_backend.py

+81
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
from datetime import datetime
2+
from email.mime.text import MIMEText
23

4+
import six
35
from django.core.exceptions import ImproperlyConfigured
46
from django.core.mail import get_connection, send_mail
57
from django.test import SimpleTestCase
68
from django.test.utils import override_settings
9+
from django.utils.functional import Promise
710
from django.utils.timezone import utc
11+
from django.utils.translation import ugettext_lazy
812

913
from anymail.exceptions import AnymailConfigurationError, AnymailUnsupportedFeature
1014
from anymail.message import AnymailMessage
@@ -212,3 +216,80 @@ def test_esp_send_defaults_override_globals(self):
212216
self.assertEqual(params['template_id'], 'global-template') # global-defaults only
213217
self.assertEqual(params['espextra'], 'espsetting')
214218
self.assertNotIn('globalextra', params) # entire esp_extra is overriden by esp-send-defaults
219+
220+
221+
class LazyStringsTest(TestBackendTestCase):
222+
"""
223+
Tests ugettext_lazy strings forced real before passing to ESP transport.
224+
225+
Docs notwithstanding, Django lazy strings *don't* work anywhere regular
226+
strings would. In particular, they aren't instances of unicode/str.
227+
There are some cases (e.g., urllib.urlencode, requests' _encode_params)
228+
where this can cause encoding errors or just very wrong results.
229+
230+
Since Anymail sits on the border between Django app code and non-Django
231+
ESP code (e.g., requests), it's responsible for converting lazy text
232+
to actual strings.
233+
"""
234+
235+
def assertNotLazy(self, s, msg=None):
236+
self.assertNotIsInstance(s, Promise,
237+
msg=msg or "String %r is lazy" % six.text_type(s))
238+
239+
def test_lazy_from(self):
240+
# This sometimes ends up lazy when settings.DEFAULT_FROM_EMAIL is meant to be localized
241+
self.message.from_email = ugettext_lazy(u'"Global Sales" <[email protected]>')
242+
self.message.send()
243+
params = self.get_send_params()
244+
self.assertNotLazy(params['from'].address)
245+
246+
def test_lazy_subject(self):
247+
self.message.subject = ugettext_lazy("subject")
248+
self.message.send()
249+
params = self.get_send_params()
250+
self.assertNotLazy(params['subject'])
251+
252+
def test_lazy_body(self):
253+
self.message.body = ugettext_lazy("text body")
254+
self.message.attach_alternative(ugettext_lazy("html body"), "text/html")
255+
self.message.send()
256+
params = self.get_send_params()
257+
self.assertNotLazy(params['text_body'])
258+
self.assertNotLazy(params['html_body'])
259+
260+
def test_lazy_headers(self):
261+
self.message.extra_headers['X-Test'] = ugettext_lazy("Test Header")
262+
self.message.send()
263+
params = self.get_send_params()
264+
self.assertNotLazy(params['extra_headers']['X-Test'])
265+
266+
def test_lazy_attachments(self):
267+
self.message.attach(ugettext_lazy("test.csv"), ugettext_lazy("test,csv,data"), "text/csv")
268+
self.message.attach(MIMEText(ugettext_lazy("contact info")))
269+
self.message.send()
270+
params = self.get_send_params()
271+
self.assertNotLazy(params['attachments'][0].name)
272+
self.assertNotLazy(params['attachments'][0].content)
273+
self.assertNotLazy(params['attachments'][1].content)
274+
275+
def test_lazy_tags(self):
276+
self.message.tags = [ugettext_lazy("Shipping"), ugettext_lazy("Sales")]
277+
self.message.send()
278+
params = self.get_send_params()
279+
self.assertNotLazy(params['tags'][0])
280+
self.assertNotLazy(params['tags'][1])
281+
282+
def test_lazy_metadata(self):
283+
self.message.metadata = {'order_type': ugettext_lazy("Subscription")}
284+
self.message.send()
285+
params = self.get_send_params()
286+
self.assertNotLazy(params['metadata']['order_type'])
287+
288+
def test_lazy_merge_data(self):
289+
self.message.merge_data = {
290+
'[email protected]': {'duration': ugettext_lazy("One Month")}}
291+
self.message.merge_global_data = {'order_type': ugettext_lazy("Subscription")}
292+
self.message.send()
293+
params = self.get_send_params()
294+
self.assertNotLazy(params['merge_data']['[email protected]']['duration'])
295+
self.assertNotLazy(params['merge_global_data']['order_type'])

tests/test_utils.py

+57-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# Tests for the anymail/utils.py module
22
# (not to be confused with utilities for testing found in in tests/utils.py)
3-
3+
import six
44
from django.test import SimpleTestCase
5+
from django.utils.translation import ugettext_lazy, string_concat
56

67
from anymail.exceptions import AnymailInvalidAddress
7-
from anymail.utils import ParsedEmail
8+
from anymail.utils import ParsedEmail, is_lazy, force_non_lazy, force_non_lazy_dict, force_non_lazy_list
89

910

1011
class ParsedEmailTests(SimpleTestCase):
@@ -61,3 +62,57 @@ def test_empty_address(self):
6162
def test_whitespace_only_address(self):
6263
with self.assertRaises(AnymailInvalidAddress):
6364
ParsedEmail(' ', self.ADDRESS_ENCODING)
65+
66+
67+
class LazyCoercionTests(SimpleTestCase):
68+
"""Test utils.is_lazy and force_non_lazy*"""
69+
70+
def test_is_lazy(self):
71+
self.assertTrue(is_lazy(ugettext_lazy("lazy string is lazy")))
72+
self.assertTrue(is_lazy(string_concat(ugettext_lazy("concatenation"),
73+
ugettext_lazy("is lazy"))))
74+
75+
def test_not_lazy(self):
76+
self.assertFalse(is_lazy(u"text not lazy"))
77+
self.assertFalse(is_lazy(b"bytes not lazy"))
78+
self.assertFalse(is_lazy(None))
79+
self.assertFalse(is_lazy({'dict': "not lazy"}))
80+
self.assertFalse(is_lazy(["list", "not lazy"]))
81+
self.assertFalse(is_lazy(object()))
82+
self.assertFalse(is_lazy([ugettext_lazy("doesn't recurse")]))
83+
84+
def test_force_lazy(self):
85+
result = force_non_lazy(ugettext_lazy(u"text"))
86+
self.assertIsInstance(result, six.text_type)
87+
self.assertEqual(result, u"text")
88+
89+
def test_force_concat(self):
90+
result = force_non_lazy(string_concat(ugettext_lazy(u"text"), ugettext_lazy("concat")))
91+
self.assertIsInstance(result, six.text_type)
92+
self.assertEqual(result, u"textconcat")
93+
94+
def test_force_string(self):
95+
result = force_non_lazy(u"text")
96+
self.assertIsInstance(result, six.text_type)
97+
self.assertEqual(result, u"text")
98+
99+
def test_force_bytes(self):
100+
result = force_non_lazy(b"bytes \xFE")
101+
self.assertIsInstance(result, six.binary_type)
102+
self.assertEqual(result, b"bytes \xFE")
103+
104+
def test_force_none(self):
105+
result = force_non_lazy(None)
106+
self.assertIsNone(result)
107+
108+
def test_force_dict(self):
109+
result = force_non_lazy_dict({'a': 1, 'b': ugettext_lazy(u"b"),
110+
'c': {'c1': ugettext_lazy(u"c1")}})
111+
self.assertEqual(result, {'a': 1, 'b': u"b", 'c': {'c1': u"c1"}})
112+
self.assertIsInstance(result['b'], six.text_type)
113+
self.assertIsInstance(result['c']['c1'], six.text_type)
114+
115+
def test_force_list(self):
116+
result = force_non_lazy_list([0, ugettext_lazy(u"b"), u"c"])
117+
self.assertEqual(result, [0, u"b", u"c"]) # coerced to list
118+
self.assertIsInstance(result[1], six.text_type)

0 commit comments

Comments
 (0)