From 85cec5e9dce73979a596101915cc1e483240ae27 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Sat, 1 Aug 2020 14:53:10 -0700 Subject: [PATCH] Drop Python 2 and Django 1.11 support Minimum supported versions are now Django 2.0, Python 3.5. This touches a lot of code, to: * Remove obsolete portability code and workarounds (six, backports of email parsers, test utils, etc.) * Use Python 3 syntax (class defs, raise ... from, etc.) * Correct inheritance for mixin classes * Fix outdated docs content and links * Suppress Python 3 "unclosed SSLSocket" ResourceWarnings that are beyond our control (in integration tests due to boto3, python-sparkpost) --- .travis.yml | 23 ++-- CHANGELOG.rst | 24 ++++ Pipfile | 1 - README.rst | 15 ++- anymail/_email_compat.py | 139 -------------------- anymail/_version.py | 2 +- anymail/backends/amazon_ses.py | 57 ++------- anymail/backends/base.py | 9 +- anymail/backends/base_requests.py | 36 +++--- anymail/backends/mailgun.py | 23 ++-- anymail/backends/mailjet.py | 26 ++-- anymail/backends/mandrill.py | 8 +- anymail/backends/postmark.py | 16 +-- anymail/backends/sendgrid.py | 10 +- anymail/backends/sendinblue.py | 8 +- anymail/backends/sparkpost.py | 14 +- anymail/backends/test.py | 6 +- anymail/exceptions.py | 31 ++--- anymail/inbound.py | 40 +++--- anymail/message.py | 9 +- anymail/signals.py | 18 ++- anymail/urls.py | 32 ++--- anymail/utils.py | 110 ++++------------ anymail/webhooks/amazon_ses.py | 27 ++-- anymail/webhooks/base.py | 108 ++++++++-------- anymail/webhooks/mailgun.py | 16 +-- anymail/webhooks/mandrill.py | 13 +- anymail/webhooks/sendgrid.py | 7 +- anymail/webhooks/sparkpost.py | 3 +- docs/conf.py | 27 ++-- docs/contributing.rst | 16 +-- docs/esps/mailgun.rst | 4 +- docs/esps/mandrill.rst | 2 +- docs/help.rst | 9 +- docs/inbound.rst | 17 +-- docs/installation.rst | 17 +-- docs/sending/anymail_additions.rst | 19 +-- docs/sending/django_email.rst | 22 ++-- docs/sending/templates.rst | 2 +- docs/sending/tracking.rst | 15 +-- docs/tips/django_templates.rst | 15 +-- docs/tips/securing_webhooks.rst | 4 +- runtests.py | 2 - setup.py | 10 +- tests/mock_requests_backend.py | 33 +++-- tests/test_amazon_ses_backend.py | 12 +- tests/test_amazon_ses_inbound.py | 9 +- tests/test_amazon_ses_integration.py | 25 ++-- tests/test_amazon_ses_webhooks.py | 13 +- tests/test_base_backends.py | 6 +- tests/test_checks.py | 4 +- tests/test_general_backend.py | 41 +++--- tests/test_inbound.py | 29 ++--- tests/test_mailgun_backend.py | 35 ++--- tests/test_mailgun_inbound.py | 14 +- tests/test_mailgun_integration.py | 16 +-- tests/test_mailgun_webhooks.py | 6 +- tests/test_mailjet_backend.py | 18 ++- tests/test_mailjet_inbound.py | 2 +- tests/test_mailjet_integration.py | 4 +- tests/test_mailjet_webhooks.py | 6 +- tests/test_mandrill_backend.py | 14 +- tests/test_mandrill_inbound.py | 4 +- tests/test_mandrill_integration.py | 4 +- tests/test_mandrill_webhooks.py | 17 ++- tests/test_message.py | 2 +- tests/test_postmark_backend.py | 17 ++- tests/test_postmark_inbound.py | 2 +- tests/test_postmark_integration.py | 4 +- tests/test_postmark_webhooks.py | 6 +- tests/test_sendgrid_backend.py | 31 ++--- tests/test_sendgrid_inbound.py | 26 ++-- tests/test_sendgrid_integration.py | 4 +- tests/test_sendgrid_webhooks.py | 6 +- tests/test_sendinblue_backend.py | 28 ++-- tests/test_sendinblue_integration.py | 4 +- tests/test_sendinblue_webhooks.py | 6 +- tests/test_settings/settings_1_11.py | 121 ------------------ tests/test_settings/urls.py | 4 +- tests/test_sparkpost_backend.py | 26 ++-- tests/test_sparkpost_inbound.py | 6 +- tests/test_sparkpost_integration.py | 21 ++- tests/test_sparkpost_webhooks.py | 6 +- tests/test_utils.py | 91 +++++-------- tests/utils.py | 183 +-------------------------- tests/webhook_cases.py | 17 ++- tox.ini | 15 +-- 87 files changed, 672 insertions(+), 1278 deletions(-) delete mode 100644 anymail/_email_compat.py delete mode 100644 tests/test_settings/settings_1_11.py diff --git a/.travis.yml b/.travis.yml index 18273e0..cc8d6cc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ -sudo: false language: python +os: linux dist: xenial branches: @@ -15,9 +15,9 @@ env: # Let Travis report failures that tox.ini would normally ignore: - TOX_FORCE_IGNORE_OUTCOME=false -matrix: +jobs: include: - - python: 3.6 + - python: 3.8 env: TOXENV="lint,docs" # Anymail supports the same Python versions as Django, plus PyPy. @@ -26,12 +26,6 @@ matrix: # Live API integration tests are only run on a few, representative Python/Django version # combinations, to avoid rapidly consuming the testing accounts' entire send allotments. - # Django 1.11: Python 2.7, 3.4, 3.5, or 3.6 - - { env: TOXENV=django111-py27-all RUN_LIVE_TESTS=true, python: 2.7 } - - { env: TOXENV=django111-py34-all, python: 3.4 } - - { env: TOXENV=django111-py35-all, python: 3.5 } - - { env: TOXENV=django111-py36-all, python: 3.6 } - - { env: TOXENV=django111-pypy-all, python: pypy2.7-6.0 } # Django 2.0: Python 3.5+ - { env: TOXENV=django20-py35-all, python: 3.5 } - { env: TOXENV=django20-py36-all, python: 3.6 } @@ -44,7 +38,7 @@ matrix: # Django 2.2: Python 3.5, 3.6, or 3.7 - { env: TOXENV=django22-py35-all, python: 3.5 } - { env: TOXENV=django22-py36-all, python: 3.6 } - - { env: TOXENV=django22-py37-all RUN_LIVE_TESTS=true, python: 3.7 } + - { env: TOXENV=django22-py37-all, python: 3.7 } - { env: TOXENV=django22-pypy3-all, python: pypy3 } # Django 3.0: Python 3.6, 3.7, or 3.8 - { env: TOXENV=django30-py36-all, python: 3.6 } @@ -54,16 +48,15 @@ matrix: # Django 3.1: Python 3.6, 3.7, or 3.8 - { env: TOXENV=django31-py36-all, python: 3.6 } - { env: TOXENV=django31-py37-all, python: 3.7 } - - { env: TOXENV=django31-py38-all, python: 3.8 } + - { env: TOXENV=django31-py38-all RUN_LIVE_TESTS=true, python: 3.8 } - { env: TOXENV=django31-pypy3-all, python: pypy3 } # Django current development (direct from GitHub source main branch): - { env: TOXENV=djangoDev-py37-all, python: 3.7 } # Install without optional extras (don't need to cover entire matrix) - - { env: TOXENV=django22-py37-none, python: 3.7 } - - { env: TOXENV=django22-py37-amazon_ses, python: 3.7 } - - { env: TOXENV=django22-py37-sparkpost, python: 3.7 } + - { env: TOXENV=django31-py37-none, python: 3.7 } + - { env: TOXENV=django31-py37-amazon_ses, python: 3.7 } + - { env: TOXENV=django31-py37-sparkpost, python: 3.7 } # Test some specific older package versions - - { env: TOXENV=django111-py27-all-old_urllib3, python: 3.7 } - { env: TOXENV=django22-py37-all-old_urllib3, python: 3.7 } allow_failures: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e7de7a7..9db47d0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -25,6 +25,30 @@ Release history ^^^^^^^^^^^^^^^ .. This extra heading level keeps the ToC from becoming unmanageably long +vNext +----- + +*Unreleased changes in development* + +Breaking changes +~~~~~~~~~~~~~~~~ + +* Drop support for Django versions older than Django 2.0, and for Python 2.7. + (For compatibility with Django 1.11, stay on the Anymail `v7.2 LTS`_ + extended support branch by setting your requirements to `django-anymail~=7.2`.) + +* Remove Anymail internal code related to supporting Python 2 and older Django + versions. This does not change the documented API, but may affect you if your + code borrowed from Anymail's undocumented internals. (You should be able to switch + to the Python standard library equivalents, as Anymail has done.) + +* AnymailMessageMixin now correctly subclasses Django's EmailMessage. If you use it + as part of your own custom EmailMessage-derived class, and you start getting errors + about "consistent method resolution order," you probably need to change your class's + inheritance. (For some helpful background, see this comment about + `mixin superclass ordering `__.) + + v7.2 LTS -------- diff --git a/Pipfile b/Pipfile index 55b687b..569d7cf 100644 --- a/Pipfile +++ b/Pipfile @@ -10,7 +10,6 @@ name = "pypi" boto3 = "*" django = "*" requests = "*" -six = "*" sparkpost = "*" [dev-packages] diff --git a/README.rst b/README.rst index 5e352cb..12bb418 100644 --- a/README.rst +++ b/README.rst @@ -40,9 +40,14 @@ built-in `django.core.mail` package. It includes: * Inbound message support, to receive email through your ESP's webhooks, with simplified, portable access to attachments and other inbound content -Anymail is released under the BSD license. It is extensively tested against -Django 1.11--3.0 on all Python versions supported by Django. -Anymail releases follow `semantic versioning `_. +Anymail maintains compatibility with all Django versions that are in mainstream +or extended support, plus (usually) a few older Django versions, and is extensively +tested on all Python versions supported by Django. (Even-older Django versions +may still be covered by an Anymail extended support release; consult the +`changelog `_ for details.) + +Anymail releases follow `semantic versioning `_. +The package is released under the BSD license. .. END shared-intro @@ -124,7 +129,7 @@ or SparkPost or any other supported ESP where you see "mailgun": msg = EmailMultiAlternatives( subject="Please activate your account", - body="Click to activate your account: http://example.com/activate", + body="Click to activate your account: https://example.com/activate", from_email="Example ", to=["New User ", "account.manager@example.com"], reply_to=["Helpdesk "]) @@ -132,7 +137,7 @@ or SparkPost or any other supported ESP where you see "mailgun": # Include an inline image in the html: logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg") html = """Logo -

Please activate +

Please activate your account

""".format(logo_cid=logo_cid) msg.attach_alternative(html, "text/html") diff --git a/anymail/_email_compat.py b/anymail/_email_compat.py deleted file mode 100644 index a760a38..0000000 --- a/anymail/_email_compat.py +++ /dev/null @@ -1,139 +0,0 @@ -# Work around bugs in older versions of email.parser.Parser -# -# This module implements two classes: -# EmailParser -# EmailBytesParser -# which can be used like the Python 3.3+ email.parser.Parser -# and email.parser.BytesParser (with email.policy.default). -# -# On Python 2.7, they attempt to work around some bugs/limitations -# in email.parser.Parser, without trying to back-port the whole -# Python 3 email package. - -__all__ = ['EmailParser', 'EmailBytesParser'] - - -from email.parser import Parser - -try: - # With Python 3.3+ (email6) package, using `policy=email.policy.default` - # avoids earlier bugs. (Note that Parser defaults to policy=compat32, - # which *preserves* earlier bugs.) - from email.policy import default - from email.parser import BytesParser - - class EmailParser(Parser): - def __init__(self, _class=None, policy=default): # don't default to compat32 policy - super(EmailParser, self).__init__(_class, policy=policy) - - class EmailBytesParser(BytesParser): - def __init__(self, _class=None, policy=default): # don't default to compat32 policy - super(EmailBytesParser, self).__init__(_class, policy=policy) - -except ImportError: - # Pre-Python 3.3 email package: try to work around some bugs - from email.header import decode_header - from collections import deque - - class EmailParser(Parser): - def parse(self, fp, headersonly=False): - # Older Parser doesn't correctly unfold headers (RFC5322 section 2.2.3). - # Help it out by pre-unfolding the headers for it. - fp = HeaderUnfoldingWrapper(fp) - message = Parser.parse(self, fp, headersonly=headersonly) - - # Older Parser doesn't decode RFC2047 headers, so fix them up here. - # (Since messsage is fully parsed, can decode headers in all MIME subparts.) - for part in message.walk(): - part._headers = [ # doesn't seem to be a public API to easily replace all headers - (name, _decode_rfc2047(value)) - for name, value in part._headers] - return message - - class EmailBytesParser(EmailParser): - def parsebytes(self, text, headersonly=False): - # In Python 2, bytes is str, and Parser.parsestr uses bytes-friendly cStringIO.StringIO. - return self.parsestr(text, headersonly) - - class HeaderUnfoldingWrapper: - """ - A wrapper for file-like objects passed to email.parser.Parser.parse which works - around older Parser bugs with folded email headers by pre-unfolding them. - - This only works for headers at the message root, not ones within a MIME subpart. - (Accurately recognizing subpart headers would require parsing mixed-content boundaries.) - """ - - def __init__(self, fp): - self.fp = fp - self._in_headers = True - self._pushback = deque() - - def _readline(self, limit=-1): - try: - line = self._pushback.popleft() - except IndexError: - line = self.fp.readline(limit) - # cStringIO.readline doesn't recognize universal newlines; splitlines does - lines = line.splitlines(True) - if len(lines) > 1: - line = lines[0] - self._pushback.extend(lines[1:]) - return line - - def _peekline(self, limit=-1): - try: - line = self._pushback[0] - except IndexError: - line = self._readline(limit) - self._pushback.appendleft(line) - return line - - def readline(self, limit=-1): - line = self._readline(limit) - if self._in_headers: - line_without_end = line.rstrip("\r\n") # CRLF, CR, or LF -- "universal newlines" - if len(line_without_end) == 0: - # RFC5322 section 2.1: "The body ... is separated from the header section - # by an empty line (i.e., a line with nothing preceding the CRLF)." - self._in_headers = False - else: - # Is this header line folded? Need to check next line... - # RFC5322 section 2.2.3: "Unfolding is accomplished by simply removing any CRLF - # that is immediately followed by WSP." (WSP is space or tab) - next_line = self._peekline(limit) - if next_line.startswith((' ', '\t')): - line = line_without_end - return line - - def read(self, size): - if self._in_headers: - # For simplicity, just read a line at a time while in the header section. - # (This works because we know email.parser.Parser doesn't really care if it reads - # more or less data than it asked for -- it just pushes it into FeedParser either way.) - return self.readline(size) - elif len(self._pushback): - buf = ''.join(self._pushback) - self._pushback.clear() - return buf - else: - return self.fp.read(size) - - def _decode_rfc2047(value): - result = value - decoded_segments = decode_header(value) - if any(charset is not None for raw, charset in decoded_segments): - # At least one segment is an RFC2047 encoded-word. - # Reassemble the segments into a single decoded string. - unicode_segments = [] - prev_charset = None - for raw, charset in decoded_segments: - if (charset is None or prev_charset is None) and unicode_segments: - # Transitioning to, from, or between *non*-encoded segments: - # add back inter-segment whitespace that decode_header consumed - unicode_segments.append(u" ") - decoded = raw.decode(charset, 'replace') if charset is not None else raw - unicode_segments.append(decoded) - prev_charset = charset - result = u"".join(unicode_segments) - return result diff --git a/anymail/_version.py b/anymail/_version.py index e6d64ba..9d66b41 100644 --- a/anymail/_version.py +++ b/anymail/_version.py @@ -1,3 +1,3 @@ -VERSION = (7, 2) +VERSION = (8, 0, 0, 'dev0') __version__ = '.'.join([str(x) for x in VERSION]) # major.minor.patch or major.minor.devN __minor_version__ = '.'.join([str(x) for x in VERSION[:2]]) # Sphinx's X.Y "version" diff --git a/anymail/backends/amazon_ses.py b/anymail/backends/amazon_ses.py index b68743b..9063350 100644 --- a/anymail/backends/amazon_ses.py +++ b/anymail/backends/amazon_ses.py @@ -1,10 +1,6 @@ from email.charset import Charset, QP -from email.header import Header -from email.mime.base import MIMEBase from email.mime.text import MIMEText -from django.core.mail import BadHeaderError - from .base import AnymailBaseBackend, BasePayload from .._version import __version__ from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled @@ -15,42 +11,14 @@ try: import boto3 from botocore.client import Config from botocore.exceptions import BotoCoreError, ClientError, ConnectionError -except ImportError: - raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses') +except ImportError as err: + raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses') from err # boto3 has several root exception classes; this is meant to cover all of them BOTO_BASE_ERRORS = (BotoCoreError, ClientError, ConnectionError) -# Work around Python 2 bug in email.message.Message.to_string, where long headers -# containing commas or semicolons get an extra space inserted after every ',' or ';' -# not already followed by a space. https://bugs.python.org/issue25257 -if Header("test,Python2,header,comma,bug", maxlinelen=20).encode() == "test,Python2,header,comma,bug": - # no workaround needed - HeaderBugWorkaround = None - - def add_header(message, name, val): - message[name] = val - -else: - # workaround: custom Header subclass that won't consider ',' and ';' as folding candidates - - class HeaderBugWorkaround(Header): - def encode(self, splitchars=' ', **kwargs): # only split on spaces, rather than splitchars=';, ' - return Header.encode(self, splitchars, **kwargs) - - def add_header(message, name, val): - # Must bypass Django's SafeMIMEMessage.__set_item__, because its call to - # forbid_multi_line_headers converts the val back to a str, undoing this - # workaround. That makes this code responsible for sanitizing val: - if '\n' in val or '\r' in val: - raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (val, name)) - val = HeaderBugWorkaround(val, header_name=name) - assert isinstance(message, MIMEBase) - MIMEBase.__setitem__(message, name, val) - - class EmailBackend(AnymailBaseBackend): """ Amazon SES Email Backend (using boto3) @@ -60,7 +28,7 @@ class EmailBackend(AnymailBaseBackend): def __init__(self, **kwargs): """Init options from Django settings""" - super(EmailBackend, self).__init__(**kwargs) + super().__init__(**kwargs) # AMAZON_SES_CLIENT_PARAMS is optional - boto3 can find credentials several other ways self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs) self.configuration_set_name = get_anymail_setting("configuration_set_name", esp_name=self.esp_name, @@ -77,6 +45,8 @@ class EmailBackend(AnymailBaseBackend): except BOTO_BASE_ERRORS: if not self.fail_silently: raise + else: + return True # created client def close(self): if self.client is None: @@ -98,7 +68,7 @@ class EmailBackend(AnymailBaseBackend): except BOTO_BASE_ERRORS as err: # ClientError has a response attr with parsed json error response (other errors don't) raise AnymailAPIError(str(err), backend=self, email_message=message, payload=payload, - response=getattr(err, 'response', None), raised_from=err) + response=getattr(err, 'response', None)) from err return response def parse_recipient_status(self, response, payload, message): @@ -125,12 +95,9 @@ class AmazonSESBasePayload(BasePayload): class AmazonSESSendRawEmailPayload(AmazonSESBasePayload): def init_payload(self): - super(AmazonSESSendRawEmailPayload, self).init_payload() + super().init_payload() self.all_recipients = [] self.mime_message = self.message.message() - if HeaderBugWorkaround and "Subject" in self.mime_message: - # (message.message() will have already checked subject for BadHeaderError) - self.mime_message.replace_header("Subject", HeaderBugWorkaround(self.message.subject)) # Work around an Amazon SES bug where, if all of: # - the message body (text or html) contains non-ASCII characters @@ -165,7 +132,7 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload): except (KeyError, TypeError) as err: raise AnymailAPIError( "%s parsing Amazon SES send result %r" % (str(err), response), - backend=self.backend, email_message=self.message, payload=self) + backend=self.backend, email_message=self.message, payload=self) from None recipient_status = AnymailRecipientStatus(message_id=message_id, status="queued") return {recipient.addr_spec: recipient_status for recipient in self.all_recipients} @@ -248,14 +215,14 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload): # (See "How do message tags work?" in https://aws.amazon.com/blogs/ses/introducing-sending-metrics/ # and https://forums.aws.amazon.com/thread.jspa?messageID=782922.) # To support reliable retrieval in webhooks, just use custom headers for metadata. - add_header(self.mime_message, "X-Metadata", self.serialize_json(metadata)) + self.mime_message["X-Metadata"] = self.serialize_json(metadata) def set_tags(self, tags): # See note about Amazon SES Message Tags and custom headers in set_metadata above. # To support reliable retrieval in webhooks, use custom headers for tags. # (There are no restrictions on number or content for custom header tags.) for tag in tags: - add_header(self.mime_message, "X-Tag", tag) # creates multiple X-Tag headers, one per tag + self.mime_message.add_header("X-Tag", tag) # creates multiple X-Tag headers, one per tag # Also *optionally* pass a single Message Tag if the AMAZON_SES_MESSAGE_TAG_NAME # Anymail setting is set (default no). The AWS API restricts tag content in this case. @@ -278,7 +245,7 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload): class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload): def init_payload(self): - super(AmazonSESSendBulkTemplatedEmailPayload, self).init_payload() + super().init_payload() # late-bind recipients and merge_data in call_send_api self.recipients = {"to": [], "cc": [], "bcc": []} self.merge_data = {} @@ -311,7 +278,7 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload): except (KeyError, TypeError) as err: raise AnymailAPIError( "%s parsing Amazon SES send result %r" % (str(err), response), - backend=self.backend, email_message=self.message, payload=self) + backend=self.backend, email_message=self.message, payload=self) from None to_addrs = [to.addr_spec for to in self.recipients["to"]] if len(anymail_statuses) != len(to_addrs): diff --git a/anymail/backends/base.py b/anymail/backends/base.py index ddac3a7..fead8fa 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -1,7 +1,6 @@ import json from datetime import date, datetime -import six from django.conf import settings from django.core.mail.backends.base import BaseEmailBackend from django.utils.timezone import is_naive, get_current_timezone, make_aware, utc @@ -23,7 +22,7 @@ class AnymailBaseBackend(BaseEmailBackend): """ def __init__(self, *args, **kwargs): - super(AnymailBaseBackend, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.ignore_unsupported_features = get_anymail_setting('ignore_unsupported_features', kwargs=kwargs, default=False) @@ -207,7 +206,7 @@ class AnymailBaseBackend(BaseEmailBackend): (self.__class__.__module__, self.__class__.__name__)) -class BasePayload(object): +class BasePayload: # Listing of EmailMessage/EmailMultiAlternatives attributes # to process into Payload. Each item is in the form: # (attr, combiner, converter) @@ -365,7 +364,7 @@ class BasePayload(object): # TypeError: must be str, not list # TypeError: can only concatenate list (not "str") to list # TypeError: Can't convert 'list' object to str implicitly - if isinstance(value, six.string_types) or is_lazy(value): + if isinstance(value, str) or is_lazy(value): raise TypeError('"{attr}" attribute must be a list or other iterable'.format(attr=attr)) # @@ -538,7 +537,7 @@ class BasePayload(object): except TypeError as err: # Add some context to the "not JSON serializable" message raise AnymailSerializationError(orig_err=err, email_message=self.message, - backend=self.backend, payload=self) + backend=self.backend, payload=self) from None @staticmethod def _json_default(o): diff --git a/anymail/backends/base_requests.py b/anymail/backends/base_requests.py index 457e362..4ad46ea 100644 --- a/anymail/backends/base_requests.py +++ b/anymail/backends/base_requests.py @@ -1,13 +1,11 @@ -from __future__ import print_function +from urllib.parse import urljoin import requests -import six -from six.moves.urllib.parse import urljoin from anymail.utils import get_anymail_setting from .base import AnymailBaseBackend, BasePayload -from ..exceptions import AnymailRequestsAPIError from .._version import __version__ +from ..exceptions import AnymailRequestsAPIError class AnymailRequestsBackend(AnymailBaseBackend): @@ -19,7 +17,7 @@ class AnymailRequestsBackend(AnymailBaseBackend): """Init options from Django settings""" self.api_url = api_url self.timeout = get_anymail_setting('requests_timeout', kwargs=kwargs, default=30) - super(AnymailRequestsBackend, self).__init__(**kwargs) + super().__init__(**kwargs) self.session = None def open(self): @@ -57,7 +55,7 @@ class AnymailRequestsBackend(AnymailBaseBackend): "Session has not been opened in {class_name}._send. " "(This is either an implementation error in {class_name}, " "or you are incorrectly calling _send directly.)".format(class_name=class_name)) - return super(AnymailRequestsBackend, self)._send(message) + return super()._send(message) def post_to_esp(self, payload, message): """Post payload to ESP send API endpoint, and return the raw response. @@ -78,7 +76,7 @@ class AnymailRequestsBackend(AnymailBaseBackend): exc_class = type('AnymailRequestsAPIError', (AnymailRequestsAPIError, type(err)), {}) raise exc_class( "Error posting to %s:" % params.get('url', ''), - raised_from=err, email_message=message, payload=payload) + email_message=message, payload=payload) from err self.raise_for_status(response, payload, message) return response @@ -100,10 +98,10 @@ class AnymailRequestsBackend(AnymailBaseBackend): """ try: return response.json() - except ValueError: + except ValueError as err: raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name, email_message=message, payload=payload, response=response, - backend=self) + backend=self) from err @staticmethod def _dump_api_request(response, **kwargs): @@ -113,22 +111,22 @@ class AnymailRequestsBackend(AnymailBaseBackend): # If you need the raw bytes, configure HTTPConnection logging as shown # in http://docs.python-requests.org/en/v3.0.0/api/#api-changes) request = response.request # a PreparedRequest - print(u"\n===== Anymail API request") - print(u"{method} {url}\n{headers}".format( + print("\n===== Anymail API request") + print("{method} {url}\n{headers}".format( method=request.method, url=request.url, - headers=u"".join(u"{header}: {value}\n".format(header=header, value=value) - for (header, value) in request.headers.items()), + headers="".join("{header}: {value}\n".format(header=header, value=value) + for (header, value) in request.headers.items()), )) if request.body is not None: - body_text = (request.body if isinstance(request.body, six.text_type) + body_text = (request.body if isinstance(request.body, str) else request.body.decode("utf-8", errors="replace") ).replace("\r\n", "\n") print(body_text) - print(u"\n----- Response") - print(u"HTTP {status} {reason}\n{headers}\n{body}".format( + print("\n----- Response") + print("HTTP {status} {reason}\n{headers}\n{body}".format( status=response.status_code, reason=response.reason, - headers=u"".join(u"{header}: {value}\n".format(header=header, value=value) - for (header, value) in response.headers.items()), + headers="".join("{header}: {value}\n".format(header=header, value=value) + for (header, value) in response.headers.items()), body=response.text, # Let Requests decode body content for us )) @@ -145,7 +143,7 @@ class RequestsPayload(BasePayload): self.headers = headers self.files = files self.auth = auth - super(RequestsPayload, self).__init__(message, defaults, backend) + super().__init__(message, defaults, backend) def get_request_params(self, api_url): """Returns a dict of requests.request params that will send payload to the ESP. diff --git a/anymail/backends/mailgun.py b/anymail/backends/mailgun.py index eb4720a..d584e85 100644 --- a/anymail/backends/mailgun.py +++ b/anymail/backends/mailgun.py @@ -1,15 +1,14 @@ from datetime import datetime from email.utils import encode_rfc2231 -from six.moves.urllib.parse import quote +from urllib.parse import quote from requests import Request -from ..exceptions import AnymailRequestsAPIError, AnymailError +from .base_requests import AnymailRequestsBackend, RequestsPayload +from ..exceptions import AnymailError, AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting, rfc2822date -from .base_requests import AnymailRequestsBackend, RequestsPayload - # Feature-detect whether requests (urllib3) correctly uses RFC 7578 encoding for non- # ASCII filenames in Content-Disposition headers. (This was fixed in urllib3 v1.25.) @@ -17,7 +16,7 @@ from .base_requests import AnymailRequestsBackend, RequestsPayload # (Note: when this workaround is removed, please also remove the "old_urllib3" tox envs.) def is_requests_rfc_5758_compliant(): request = Request(method='POST', url='https://www.example.com', - files=[('attachment', (u'\N{NOT SIGN}.txt', 'test', 'text/plain'))]) + files=[('attachment', ('\N{NOT SIGN}.txt', 'test', 'text/plain'))]) prepared = request.prepare() form_data = prepared.body # bytes return b'filename*=' not in form_data @@ -43,7 +42,7 @@ class EmailBackend(AnymailRequestsBackend): default="https://api.mailgun.net/v3") if not api_url.endswith("/"): api_url += "/" - super(EmailBackend, self).__init__(api_url, **kwargs) + super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return MailgunPayload(message, defaults, self) @@ -62,10 +61,10 @@ class EmailBackend(AnymailRequestsBackend): try: message_id = parsed_response["id"] mailgun_message = parsed_response["message"] - except (KeyError, TypeError): + except (KeyError, TypeError) as err: raise AnymailRequestsAPIError("Invalid Mailgun API response format", email_message=message, payload=payload, response=response, - backend=self) + backend=self) from err if not mailgun_message.startswith("Queued"): raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message, email_message=message, payload=payload, response=response, @@ -89,7 +88,7 @@ class MailgunPayload(RequestsPayload): self.merge_metadata = {} self.to_emails = [] - super(MailgunPayload, self).__init__(message, defaults, backend, auth=auth, *args, **kwargs) + super().__init__(message, defaults, backend, auth=auth, *args, **kwargs) def get_api_endpoint(self): if self.sender_domain is None: @@ -105,7 +104,7 @@ class MailgunPayload(RequestsPayload): return "%s/messages" % quote(self.sender_domain, safe='') def get_request_params(self, api_url): - params = super(MailgunPayload, self).get_request_params(api_url) + params = super().get_request_params(api_url) non_ascii_filenames = [filename for (field, (filename, content, mimetype)) in params["files"] if filename is not None and not isascii(filename)] @@ -122,9 +121,7 @@ class MailgunPayload(RequestsPayload): prepared = Request(**params).prepare() form_data = prepared.body # bytes for filename in non_ascii_filenames: # text - rfc2231_filename = encode_rfc2231( # wants a str (text in PY3, bytes in PY2) - filename if isinstance(filename, str) else filename.encode("utf-8"), - charset="utf-8") + rfc2231_filename = encode_rfc2231(filename, charset="utf-8") form_data = form_data.replace( b'filename*=' + rfc2231_filename.encode("utf-8"), b'filename="' + filename.encode("utf-8") + b'"') diff --git a/anymail/backends/mailjet.py b/anymail/backends/mailjet.py index 62363e0..6ea16ac 100644 --- a/anymail/backends/mailjet.py +++ b/anymail/backends/mailjet.py @@ -1,12 +1,10 @@ from email.header import Header - -from six.moves.urllib.parse import quote - -from ..exceptions import AnymailRequestsAPIError -from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES -from ..utils import get_anymail_setting, EmailAddress, parse_address_list +from urllib.parse import quote from .base_requests import AnymailRequestsBackend, RequestsPayload +from ..exceptions import AnymailRequestsAPIError +from ..message import ANYMAIL_STATUSES, AnymailRecipientStatus +from ..utils import EmailAddress, get_anymail_setting, parse_address_list class EmailBackend(AnymailRequestsBackend): @@ -25,7 +23,7 @@ class EmailBackend(AnymailRequestsBackend): default="https://api.mailjet.com/v3") if not api_url.endswith("/"): api_url += "/" - super(EmailBackend, self).__init__(api_url, **kwargs) + super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return MailjetPayload(message, defaults, self) @@ -36,7 +34,7 @@ class EmailBackend(AnymailRequestsBackend): raise AnymailRequestsAPIError( "Invalid Mailjet API key or secret", email_message=message, payload=payload, response=response, backend=self) - super(EmailBackend, self).raise_for_status(response, payload, message) + super().raise_for_status(response, payload, message) def parse_recipient_status(self, response, payload, message): # Mailjet's (v3.0) transactional send API is not covered in their reference docs. @@ -61,10 +59,10 @@ class EmailBackend(AnymailRequestsBackend): message_id = str(item['MessageID']) email = item['Email'] recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status) - except (KeyError, TypeError): + except (KeyError, TypeError) as err: raise AnymailRequestsAPIError("Invalid Mailjet API response format", email_message=message, payload=payload, response=response, - backend=self) + backend=self) from err # Make sure we ended up with a status for every original recipient # (Mailjet only communicates "Sent") for recipients in payload.recipients.values(): @@ -88,8 +86,7 @@ class MailjetPayload(RequestsPayload): self.metadata = None self.merge_data = {} self.merge_metadata = {} - super(MailjetPayload, self).__init__(message, defaults, backend, - auth=auth, headers=http_headers, *args, **kwargs) + super().__init__(message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs) def get_api_endpoint(self): return "send" @@ -153,9 +150,10 @@ class MailjetPayload(RequestsPayload): parsed.addr_spec) else: parsed = EmailAddress(headers["SenderName"], headers["SenderEmail"]) - except KeyError: + except KeyError as err: raise AnymailRequestsAPIError("Invalid Mailjet template API response", - email_message=self.message, response=response, backend=self.backend) + email_message=self.message, response=response, + backend=self.backend) from err self.set_from_email(parsed) def _format_email_for_mailjet(self, email): diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py index 24a1d31..ec970f3 100644 --- a/anymail/backends/mandrill.py +++ b/anymail/backends/mandrill.py @@ -23,7 +23,7 @@ class EmailBackend(AnymailRequestsBackend): default="https://mandrillapp.com/api/1.0") if not api_url.endswith("/"): api_url += "/" - super(EmailBackend, self).__init__(api_url, **kwargs) + super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return MandrillPayload(message, defaults, self) @@ -40,10 +40,10 @@ class EmailBackend(AnymailRequestsBackend): status = 'unknown' message_id = item.get('_id', None) # can be missing for invalid/rejected recipients recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status) - except (KeyError, TypeError): + except (KeyError, TypeError) as err: raise AnymailRequestsAPIError("Invalid Mandrill API response format", email_message=message, payload=payload, response=response, - backend=self) + backend=self) from err return recipient_status @@ -69,7 +69,7 @@ class MandrillPayload(RequestsPayload): def __init__(self, *args, **kwargs): self.esp_extra = {} # late-bound in serialize_data - super(MandrillPayload, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def get_api_endpoint(self): if 'template_name' in self.data: diff --git a/anymail/backends/postmark.py b/anymail/backends/postmark.py index 2cae34c..3061451 100644 --- a/anymail/backends/postmark.py +++ b/anymail/backends/postmark.py @@ -22,7 +22,7 @@ class EmailBackend(AnymailRequestsBackend): default="https://api.postmarkapp.com/") if not api_url.endswith("/"): api_url += "/" - super(EmailBackend, self).__init__(api_url, **kwargs) + super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return PostmarkPayload(message, defaults, self) @@ -30,7 +30,7 @@ class EmailBackend(AnymailRequestsBackend): def raise_for_status(self, response, payload, message): # We need to handle 422 responses in parse_recipient_status if response.status_code != 422: - super(EmailBackend, self).raise_for_status(response, payload, message) + super().raise_for_status(response, payload, message) def parse_recipient_status(self, response, payload, message): # Default to "unknown" status for each recipient, unless/until we find otherwise. @@ -51,19 +51,19 @@ class EmailBackend(AnymailRequestsBackend): # these fields should always be present error_code = one_response["ErrorCode"] msg = one_response["Message"] - except (KeyError, TypeError): + except (KeyError, TypeError) as err: raise AnymailRequestsAPIError("Invalid Postmark API response format", email_message=message, payload=payload, response=response, - backend=self) + backend=self) from err if error_code == 0: # At least partial success, and (some) email was sent. try: message_id = one_response["MessageID"] - except KeyError: + except KeyError as err: raise AnymailRequestsAPIError("Invalid Postmark API success response format", email_message=message, payload=payload, - response=response, backend=self) + response=response, backend=self) from err # Assume all To recipients are "sent" unless proven otherwise below. # (Must use "To" from API response to get correct individual MessageIDs in batch send.) @@ -157,7 +157,7 @@ class PostmarkPayload(RequestsPayload): self.cc_and_bcc_emails = [] # need to track (separately) for parse_recipient_status self.merge_data = None self.merge_metadata = None - super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs) + super().__init__(message, defaults, backend, headers=headers, *args, **kwargs) def get_api_endpoint(self): batch_send = self.is_batch() and len(self.to_emails) > 1 @@ -174,7 +174,7 @@ class PostmarkPayload(RequestsPayload): return "email" def get_request_params(self, api_url): - params = super(PostmarkPayload, self).get_request_params(api_url) + params = super().get_request_params(api_url) params['headers']['X-Postmark-Server-Token'] = self.server_token return params diff --git a/anymail/backends/sendgrid.py b/anymail/backends/sendgrid.py index 6232969..acb8c05 100644 --- a/anymail/backends/sendgrid.py +++ b/anymail/backends/sendgrid.py @@ -7,7 +7,7 @@ from requests.structures import CaseInsensitiveDict from .base_requests import AnymailRequestsBackend, RequestsPayload from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning from ..message import AnymailRecipientStatus -from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, timestamp, update_deep +from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, update_deep class EmailBackend(AnymailRequestsBackend): @@ -47,7 +47,7 @@ class EmailBackend(AnymailRequestsBackend): default="https://api.sendgrid.com/v3/") if not api_url.endswith("/"): api_url += "/" - super(EmailBackend, self).__init__(api_url, **kwargs) + super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return SendGridPayload(message, defaults, self) @@ -84,9 +84,7 @@ class SendGridPayload(RequestsPayload): http_headers['Authorization'] = 'Bearer %s' % backend.api_key http_headers['Content-Type'] = 'application/json' http_headers['Accept'] = 'application/json' - super(SendGridPayload, self).__init__(message, defaults, backend, - headers=http_headers, - *args, **kwargs) + super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs) def get_api_endpoint(self): return "mail/send" @@ -294,7 +292,7 @@ class SendGridPayload(RequestsPayload): def set_send_at(self, send_at): # Backend has converted pretty much everything to # a datetime by here; SendGrid expects unix timestamp - self.data["send_at"] = int(timestamp(send_at)) # strip microseconds + self.data["send_at"] = int(send_at.timestamp()) # strip microseconds def set_tags(self, tags): self.data["categories"] = tags diff --git a/anymail/backends/sendinblue.py b/anymail/backends/sendinblue.py index 7974051..8722853 100644 --- a/anymail/backends/sendinblue.py +++ b/anymail/backends/sendinblue.py @@ -30,7 +30,7 @@ class EmailBackend(AnymailRequestsBackend): ) if not api_url.endswith("/"): api_url += "/" - super(EmailBackend, self).__init__(api_url, **kwargs) + super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return SendinBluePayload(message, defaults, self) @@ -53,10 +53,10 @@ class EmailBackend(AnymailRequestsBackend): parsed_response = self.deserialize_json_response(response, payload, message) try: message_id = parsed_response['messageId'] - except (KeyError, TypeError): + except (KeyError, TypeError) as err: raise AnymailRequestsAPIError("Invalid SendinBlue API response format", email_message=message, payload=payload, response=response, - backend=self) + backend=self) from err status = AnymailRecipientStatus(message_id=message_id, status="queued") return {recipient.addr_spec: status for recipient in payload.all_recipients} @@ -71,7 +71,7 @@ class SendinBluePayload(RequestsPayload): http_headers['api-key'] = backend.api_key http_headers['Content-Type'] = 'application/json' - super(SendinBluePayload, self).__init__(message, defaults, backend, headers=http_headers, *args, **kwargs) + super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs) def get_api_endpoint(self): return "smtp/email" diff --git a/anymail/backends/sparkpost.py b/anymail/backends/sparkpost.py index 8e11b5f..a0eba3b 100644 --- a/anymail/backends/sparkpost.py +++ b/anymail/backends/sparkpost.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import # we want the sparkpost package, not our own module - from .base import AnymailBaseBackend, BasePayload from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError from ..message import AnymailRecipientStatus @@ -7,8 +5,8 @@ from ..utils import get_anymail_setting try: from sparkpost import SparkPost, SparkPostException -except ImportError: - raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost') +except ImportError as err: + raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost') from err class EmailBackend(AnymailBaseBackend): @@ -20,7 +18,7 @@ class EmailBackend(AnymailBaseBackend): def __init__(self, **kwargs): """Init options from Django settings""" - super(EmailBackend, self).__init__(**kwargs) + super().__init__(**kwargs) # SPARKPOST_API_KEY is optional - library reads from env by default self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name, kwargs=kwargs, allow_bare=True, default=None) @@ -43,7 +41,7 @@ class EmailBackend(AnymailBaseBackend): "You may need to set ANYMAIL = {'SPARKPOST_API_KEY': ...} " "or ANYMAIL_SPARKPOST_API_KEY in your Django settings, " "or SPARKPOST_API_KEY in your environment." % str(err) - ) + ) from err # Note: SparkPost python API doesn't expose requests session sharing # (so there's no need to implement open/close connection management here) @@ -60,7 +58,7 @@ class EmailBackend(AnymailBaseBackend): str(err), backend=self, email_message=message, payload=payload, response=getattr(err, 'response', None), # SparkPostAPIException requests.Response status_code=getattr(err, 'status', None), # SparkPostAPIException HTTP status_code - ) + ) from err return response def parse_recipient_status(self, response, payload, message): @@ -72,7 +70,7 @@ class EmailBackend(AnymailBaseBackend): raise AnymailAPIError( "%s in SparkPost.transmissions.send result %r" % (str(err), response), backend=self, email_message=message, payload=payload, - ) + ) from err # SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected. # (* looks like undocumented 'rcpt_to_errors' might provide this info.) diff --git a/anymail/backends/test.py b/anymail/backends/test.py index 86b9299..8df150f 100644 --- a/anymail/backends/test.py +++ b/anymail/backends/test.py @@ -25,7 +25,7 @@ class EmailBackend(AnymailBaseBackend): # Allow replacing the payload, for testing. # (Real backends would generally not implement this option.) self._payload_class = kwargs.pop('payload_class', TestPayload) - super(EmailBackend, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if not hasattr(mail, 'outbox'): mail.outbox = [] # see django.core.mail.backends.locmem @@ -60,8 +60,8 @@ class EmailBackend(AnymailBaseBackend): def parse_recipient_status(self, response, payload, message): try: return response['recipient_status'] - except KeyError: - raise AnymailAPIError('Unparsable test response') + except KeyError as err: + raise AnymailAPIError('Unparsable test response') from err class TestPayload(BasePayload): diff --git a/anymail/exceptions.py b/anymail/exceptions.py index 705c5b8..085eb53 100644 --- a/anymail/exceptions.py +++ b/anymail/exceptions.py @@ -1,9 +1,6 @@ -from __future__ import unicode_literals - import json from traceback import format_exception_only -import six from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from requests import HTTPError @@ -23,14 +20,12 @@ class AnymailError(Exception): backend: the backend instance involved payload: data arg (*not* json-stringified) for the ESP send call response: requests.Response from the send call - raised_from: original/wrapped Exception esp_name: what to call the ESP (read from backend if provided) """ self.backend = kwargs.pop('backend', None) self.email_message = kwargs.pop('email_message', None) self.payload = kwargs.pop('payload', None) self.status_code = kwargs.pop('status_code', None) - self.raised_from = kwargs.pop('raised_from', None) self.esp_name = kwargs.pop('esp_name', self.backend.esp_name if self.backend else None) if isinstance(self, HTTPError): @@ -38,12 +33,12 @@ class AnymailError(Exception): self.response = kwargs.get('response', None) else: self.response = kwargs.pop('response', None) - super(AnymailError, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def __str__(self): parts = [ - " ".join([six.text_type(arg) for arg in self.args]), - self.describe_raised_from(), + " ".join([str(arg) for arg in self.args]), + self.describe_cause(), self.describe_send(), self.describe_response(), ] @@ -71,7 +66,7 @@ class AnymailError(Exception): # Decode response.reason to text -- borrowed from requests.Response.raise_for_status: reason = self.response.reason - if isinstance(reason, six.binary_type): + if isinstance(reason, bytes): try: reason = reason.decode('utf-8') except UnicodeDecodeError: @@ -88,11 +83,11 @@ class AnymailError(Exception): pass return description - def describe_raised_from(self): - """Return the original exception""" - if self.raised_from is None: + def describe_cause(self): + """Describe the original exception""" + if self.__cause__ is None: return None - return ''.join(format_exception_only(type(self.raised_from), self.raised_from)).strip() + return ''.join(format_exception_only(type(self.__cause__), self.__cause__)).strip() class AnymailAPIError(AnymailError): @@ -103,7 +98,7 @@ class AnymailRequestsAPIError(AnymailAPIError, HTTPError): """Exception for unsuccessful response from a requests API.""" def __init__(self, *args, **kwargs): - super(AnymailRequestsAPIError, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if self.response is not None: self.status_code = self.response.status_code @@ -114,7 +109,7 @@ class AnymailRecipientsRefused(AnymailError): def __init__(self, message=None, *args, **kwargs): if message is None: message = "All message recipients were rejected or invalid" - super(AnymailRecipientsRefused, self).__init__(message, *args, **kwargs) + super().__init__(message, *args, **kwargs) class AnymailInvalidAddress(AnymailError, ValueError): @@ -154,7 +149,7 @@ class AnymailSerializationError(AnymailError, TypeError): "Try converting it to a string or number first." % esp_name if orig_err is not None: message += "\n%s" % str(orig_err) - super(AnymailSerializationError, self).__init__(message, *args, **kwargs) + super().__init__(message, *args, **kwargs) class AnymailCancelSend(AnymailError): @@ -182,7 +177,7 @@ class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError): message = "The %s package is required to use this ESP, but isn't installed.\n" \ "(Be sure to use `pip install django-anymail[%s]` " \ "with your desired ESPs.)" % (missing_package, backend) - super(AnymailImproperlyInstalled, self).__init__(message) + super().__init__(message) # Warnings @@ -201,7 +196,7 @@ class AnymailDeprecationWarning(AnymailWarning, DeprecationWarning): # Helpers -class _LazyError(object): +class _LazyError: """An object that sits inert unless/until used, then raises an error""" def __init__(self, error): self._error = error diff --git a/anymail/inbound.py b/anymail/inbound.py index 24a8db0..c4f4b22 100644 --- a/anymail/inbound.py +++ b/anymail/inbound.py @@ -1,15 +1,15 @@ from base64 import b64decode from email.message import Message +from email.parser import BytesParser, Parser +from email.policy import default as default_policy from email.utils import unquote -import six from django.core.files.uploadedfile import SimpleUploadedFile -from ._email_compat import EmailParser, EmailBytesParser -from .utils import angle_wrap, get_content_disposition, parse_address_list, parse_rfc2822date +from .utils import angle_wrap, parse_address_list, parse_rfc2822date -class AnymailInboundMessage(Message, object): # `object` ensures new-style class in Python 2) +class AnymailInboundMessage(Message): """ A normalized, parsed inbound email message. @@ -31,7 +31,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas def __init__(self, *args, **kwargs): # Note: this must accept zero arguments, for use with message_from_string (email.parser) - super(AnymailInboundMessage, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Additional attrs provided by some ESPs: self.envelope_sender = None @@ -125,14 +125,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas return part.get_content_text() return None - # Backport from Python 3.5 email.message.Message - def get_content_disposition(self): - try: - return super(AnymailInboundMessage, self).get_content_disposition() - except AttributeError: - return get_content_disposition(self) - - # Backport from Python 3.4.2 email.message.MIMEPart + # Hoisted from email.message.MIMEPart def is_attachment(self): return self.get_content_disposition() == 'attachment' @@ -148,10 +141,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas # (Note that self.is_multipart() misleadingly returns True in this case.) payload = self.get_payload() assert len(payload) == 1 # should be exactly one message - try: - return payload[0].as_bytes() # Python 3 - except AttributeError: - return payload[0].as_string().encode('utf-8') + return payload[0].as_bytes() elif maintype == 'multipart': # The attachment itself is multipart; the payload is a list of parts, # and it's not clear which one is the "content". @@ -199,24 +189,24 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas @classmethod def parse_raw_mime(cls, s): """Returns a new AnymailInboundMessage parsed from str s""" - if isinstance(s, six.text_type): + if isinstance(s, str): # Avoid Python 3.x issue https://bugs.python.org/issue18271 # (See test_inbound: test_parse_raw_mime_8bit_utf8) return cls.parse_raw_mime_bytes(s.encode('utf-8')) - return EmailParser(cls).parsestr(s) + return Parser(cls, policy=default_policy).parsestr(s) @classmethod def parse_raw_mime_bytes(cls, b): """Returns a new AnymailInboundMessage parsed from bytes b""" - return EmailBytesParser(cls).parsebytes(b) + return BytesParser(cls, policy=default_policy).parsebytes(b) @classmethod def parse_raw_mime_file(cls, fp): """Returns a new AnymailInboundMessage parsed from file-like object fp""" - if isinstance(fp.read(0), six.binary_type): - return EmailBytesParser(cls).parse(fp) + if isinstance(fp.read(0), bytes): + return BytesParser(cls, policy=default_policy).parse(fp) else: - return EmailParser(cls).parse(fp) + return Parser(cls, policy=default_policy).parse(fp) @classmethod def construct(cls, raw_headers=None, from_email=None, to=None, cc=None, subject=None, headers=None, @@ -242,7 +232,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas :return: {AnymailInboundMessage} """ if raw_headers is not None: - msg = EmailParser(cls).parsestr(raw_headers, headersonly=True) + msg = Parser(cls, policy=default_policy).parsestr(raw_headers, headersonly=True) msg.set_payload(None) # headersonly forces an empty string payload, which breaks things later else: msg = cls() @@ -336,7 +326,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas if part.get_content_maintype() == 'message': # email.Message parses message/rfc822 parts as a "multipart" (list) payload # whose single item is the recursively-parsed message attachment - if isinstance(content, six.binary_type): + if isinstance(content, bytes): content = content.decode() payload = [cls.parse_raw_mime(content)] charset = None diff --git a/anymail/message.py b/anymail/message.py index b908526..7f8b35b 100644 --- a/anymail/message.py +++ b/anymail/message.py @@ -7,7 +7,7 @@ from django.core.mail import EmailMessage, EmailMultiAlternatives, make_msgid from .utils import UNSET -class AnymailMessageMixin(object): +class AnymailMessageMixin(EmailMessage): """Mixin for EmailMessage that exposes Anymail features. Use of this mixin is optional. You can always just set Anymail @@ -32,8 +32,7 @@ class AnymailMessageMixin(object): self.merge_metadata = kwargs.pop('merge_metadata', UNSET) self.anymail_status = AnymailStatus() - # noinspection PyArgumentList - super(AnymailMessageMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def attach_inline_image_file(self, path, subtype=None, idstring="img", domain=None): """Add inline image from file path to an EmailMessage, and return its content id""" @@ -82,7 +81,7 @@ ANYMAIL_STATUSES = [ ] -class AnymailRecipientStatus(object): +class AnymailRecipientStatus: """Information about an EmailMessage's send status for a single recipient""" def __init__(self, message_id, status): @@ -90,7 +89,7 @@ class AnymailRecipientStatus(object): self.status = status # one of ANYMAIL_STATUSES, or None for not yet sent to ESP -class AnymailStatus(object): +class AnymailStatus: """Information about an EmailMessage's send status for all recipients""" def __init__(self): diff --git a/anymail/signals.py b/anymail/signals.py index 4344606..41b1bed 100644 --- a/anymail/signals.py +++ b/anymail/signals.py @@ -2,19 +2,23 @@ from django.dispatch import Signal # Outbound message, before sending -pre_send = Signal(providing_args=['message', 'esp_name']) +# provides args: message, esp_name +pre_send = Signal() # Outbound message, after sending -post_send = Signal(providing_args=['message', 'status', 'esp_name']) +# provides args: message, status, esp_name +post_send = Signal() # Delivery and tracking events for sent messages -tracking = Signal(providing_args=['event', 'esp_name']) +# provides args: event, esp_name +tracking = Signal() # Event for receiving inbound messages -inbound = Signal(providing_args=['event', 'esp_name']) +# provides args: event, esp_name +inbound = Signal() -class AnymailEvent(object): +class AnymailEvent: """Base class for normalized Anymail webhook events""" def __init__(self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs): @@ -28,7 +32,7 @@ class AnymailTrackingEvent(AnymailEvent): """Normalized delivery and tracking event for sent messages""" def __init__(self, **kwargs): - super(AnymailTrackingEvent, self).__init__(**kwargs) + super().__init__(**kwargs) self.click_url = kwargs.pop('click_url', None) # str self.description = kwargs.pop('description', None) # str, usually human-readable, not normalized self.message_id = kwargs.pop('message_id', None) # str, format may vary @@ -44,7 +48,7 @@ class AnymailInboundEvent(AnymailEvent): """Normalized inbound message event""" def __init__(self, **kwargs): - super(AnymailInboundEvent, self).__init__(**kwargs) + super().__init__(**kwargs) self.message = kwargs.pop('message', None) # anymail.inbound.AnymailInboundMessage diff --git a/anymail/urls.py b/anymail/urls.py index 75eb99f..32ec564 100644 --- a/anymail/urls.py +++ b/anymail/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.urls import re_path from .webhooks.amazon_ses import AmazonSESInboundWebhookView, AmazonSESTrackingWebhookView from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView @@ -12,23 +12,23 @@ from .webhooks.sparkpost import SparkPostInboundWebhookView, SparkPostTrackingWe app_name = 'anymail' urlpatterns = [ - url(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'), - url(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'), - url(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'), - url(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'), - url(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'), - url(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_inbound_webhook'), + re_path(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'), + re_path(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'), + re_path(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'), + re_path(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'), + re_path(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'), + re_path(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_inbound_webhook'), - url(r'^amazon_ses/tracking/$', AmazonSESTrackingWebhookView.as_view(), name='amazon_ses_tracking_webhook'), - url(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'), - url(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'), - url(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'), - url(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'), - url(r'^sendinblue/tracking/$', SendinBlueTrackingWebhookView.as_view(), name='sendinblue_tracking_webhook'), - url(r'^sparkpost/tracking/$', SparkPostTrackingWebhookView.as_view(), name='sparkpost_tracking_webhook'), + re_path(r'^amazon_ses/tracking/$', AmazonSESTrackingWebhookView.as_view(), name='amazon_ses_tracking_webhook'), + re_path(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'), + re_path(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'), + re_path(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'), + re_path(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'), + re_path(r'^sendinblue/tracking/$', SendinBlueTrackingWebhookView.as_view(), name='sendinblue_tracking_webhook'), + re_path(r'^sparkpost/tracking/$', SparkPostTrackingWebhookView.as_view(), name='sparkpost_tracking_webhook'), # Anymail uses a combined Mandrill webhook endpoint, to simplify Mandrill's key-validation scheme: - url(r'^mandrill/$', MandrillCombinedWebhookView.as_view(), name='mandrill_webhook'), + re_path(r'^mandrill/$', MandrillCombinedWebhookView.as_view(), name='mandrill_webhook'), # This url is maintained for backwards compatibility with earlier Anymail releases: - url(r'^mandrill/tracking/$', MandrillCombinedWebhookView.as_view(), name='mandrill_tracking_webhook'), + re_path(r'^mandrill/tracking/$', MandrillCombinedWebhookView.as_view(), name='mandrill_tracking_webhook'), ] diff --git a/anymail/utils.py b/anymail/utils.py index 6774494..7c34249 100644 --- a/anymail/utils.py +++ b/anymail/utils.py @@ -1,33 +1,20 @@ import base64 import mimetypes from base64 import b64encode -from datetime import datetime +from collections.abc import Mapping, MutableMapping from email.mime.base import MIMEBase -from email.utils import formatdate, getaddresses, unquote -from time import mktime +from email.utils import formatdate, getaddresses, parsedate_to_datetime, unquote +from urllib.parse import urlsplit, urlunsplit -import six from django.conf import settings from django.core.mail.message import DEFAULT_ATTACHMENT_MIME_TYPE, sanitize_address +from django.utils.encoding import force_str from django.utils.functional import Promise -from django.utils.timezone import get_fixed_timezone, utc from requests.structures import CaseInsensitiveDict -from six.moves.urllib.parse import urlsplit, urlunsplit from .exceptions import AnymailConfigurationError, AnymailInvalidAddress -if six.PY2: - from django.utils.encoding import force_text as force_str -else: - from django.utils.encoding import force_str - -try: - from collections.abc import Mapping, MutableMapping # Python 3.3+ -except ImportError: - from collections import Mapping, MutableMapping - - -BASIC_NUMERIC_TYPES = six.integer_types + (float,) # int, float, and (on Python 2) long +BASIC_NUMERIC_TYPES = (int, float) UNSET = type('UNSET', (object,), {}) # Used as non-None default value @@ -141,7 +128,7 @@ def parse_address_list(address_list, field=None): :return list[:class:`EmailAddress`]: :raises :exc:`AnymailInvalidAddress`: """ - if isinstance(address_list, six.string_types) or is_lazy(address_list): + if isinstance(address_list, str) or is_lazy(address_list): address_list = [address_list] if address_list is None or address_list == [None]: @@ -162,13 +149,13 @@ def parse_address_list(address_list, field=None): for address in parsed: if address.username == '' or address.domain == '': # Django SMTP allows username-only emails, but they're not meaningful with an ESP - errmsg = u"Invalid email address '{problem}' parsed from '{source}'{where}.".format( + errmsg = "Invalid email address '{problem}' parsed from '{source}'{where}.".format( problem=address.addr_spec, - source=u", ".join(address_list_strings), - where=u" in `%s`" % field if field else "", + source=", ".join(address_list_strings), + where=" in `%s`" % field if field else "", ) if len(parsed) > len(address_list): - errmsg += u" (Maybe missing quotes around a display-name?)" + errmsg += " (Maybe missing quotes around a display-name?)" raise AnymailInvalidAddress(errmsg) return parsed @@ -192,7 +179,7 @@ def parse_single_address(address, field=None): return parsed[0] -class EmailAddress(object): +class EmailAddress: """A sanitized, complete email address with easy access to display-name, addr-spec (email), etc. @@ -249,9 +236,8 @@ class EmailAddress(object): This is essentially the same as :func:`email.utils.formataddr` on the EmailAddress's name and email properties, but uses Django's :func:`~django.core.mail.message.sanitize_address` - for improved PY2/3 compatibility, consistent handling of - encoding (a.k.a. charset), and proper handling of IDN - domain portions. + for consistent handling of encoding (a.k.a. charset) and + proper handling of IDN domain portions. :param str|None encoding: the charset to use for the display-name portion; @@ -264,7 +250,7 @@ class EmailAddress(object): return self.address -class Attachment(object): +class Attachment: """A normalized EmailMessage.attachments item with additional functionality Normalized to have these properties: @@ -289,14 +275,10 @@ class Attachment(object): self.name = attachment.get_filename() self.content = attachment.get_payload(decode=True) if self.content is None: - if hasattr(attachment, 'as_bytes'): - self.content = attachment.as_bytes() - else: - # Python 2.7 fallback - self.content = attachment.as_string().encode(self.encoding) + self.content = attachment.as_bytes() self.mimetype = attachment.get_content_type() - content_disposition = get_content_disposition(attachment) + content_disposition = attachment.get_content_disposition() if content_disposition == 'inline' or (not content_disposition and 'Content-ID' in attachment): self.inline = True self.content_id = attachment["Content-ID"] # probably including the <...> @@ -319,23 +301,11 @@ class Attachment(object): def b64content(self): """Content encoded as a base64 ascii string""" content = self.content - if isinstance(content, six.text_type): + if isinstance(content, str): content = content.encode(self.encoding) return b64encode(content).decode("ascii") -def get_content_disposition(mimeobj): - """Return the message's content-disposition if it exists, or None. - - Backport of py3.5 :func:`~email.message.Message.get_content_disposition` - """ - value = mimeobj.get('content-disposition') - if value is None: - return None - # _splitparam(value)[0].lower() : - return str(value).partition(';')[0].strip().lower() - - def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_bare=False): """Returns an Anymail option from kwargs or Django settings. @@ -388,7 +358,7 @@ def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_b if allow_bare: message += " or %s" % setting message += " in your Django settings" - raise AnymailConfigurationError(message) + raise AnymailConfigurationError(message) from None else: return default @@ -442,26 +412,11 @@ def querydict_getfirst(qdict, field, default=UNSET): return qdict[field] # raise appropriate KeyError -EPOCH = datetime(1970, 1, 1, tzinfo=utc) - - -def timestamp(dt): - """Return the unix timestamp (seconds past the epoch) for datetime dt""" - # This is the equivalent of Python 3.3's datetime.timestamp - try: - return dt.timestamp() - except AttributeError: - if dt.tzinfo is None: - return mktime(dt.timetuple()) - else: - return (dt - EPOCH).total_seconds() - - def rfc2822date(dt): """Turn a datetime into a date string as specified in RFC 2822.""" - # This is almost the equivalent of Python 3.3's email.utils.format_datetime, + # This is almost the equivalent of Python's email.utils.format_datetime, # but treats naive datetimes as local rather than "UTC with no information ..." - timeval = timestamp(dt) + timeval = dt.timestamp() return formatdate(timeval, usegmt=True) @@ -480,7 +435,7 @@ def angle_wrap(s): def is_lazy(obj): """Return True if obj is a Django lazy object.""" # See django.utils.functional.lazy. (This appears to be preferred - # to checking for `not isinstance(obj, six.text_type)`.) + # to checking for `not isinstance(obj, str)`.) return isinstance(obj, Promise) @@ -490,7 +445,7 @@ def force_non_lazy(obj): (Similar to django.utils.encoding.force_text, but doesn't alter non-text objects.) """ if is_lazy(obj): - return six.text_type(obj) + return str(obj) return obj @@ -541,27 +496,6 @@ def get_request_uri(request): return url -try: - from email.utils import parsedate_to_datetime # Python 3.3+ -except ImportError: - from email.utils import parsedate_tz - - # Backport Python 3.3+ email.utils.parsedate_to_datetime - def parsedate_to_datetime(s): - # *dtuple, tz = _parsedate_tz(data) - dtuple = parsedate_tz(s) - tz = dtuple[-1] - # if tz is None: # parsedate_tz returns 0 for "-0000" - if tz is None or (tz == 0 and "-0000" in s): - # "... indicates that the date-time contains no information - # about the local time zone" (RFC 2822 #3.3) - return datetime(*dtuple[:6]) - else: - # tzinfo = datetime.timezone(datetime.timedelta(seconds=tz)) # Python 3.2+ only - tzinfo = get_fixed_timezone(tz // 60) # don't use timedelta (avoid Django bug #28739) - return datetime(*dtuple[:6], tzinfo=tzinfo) - - def parse_rfc2822date(s): """Parses an RFC-2822 formatted date string into a datetime.datetime diff --git a/anymail/webhooks/amazon_ses.py b/anymail/webhooks/amazon_ses.py index 7cdeae2..d284493 100644 --- a/anymail/webhooks/amazon_ses.py +++ b/anymail/webhooks/amazon_ses.py @@ -11,7 +11,7 @@ from ..exceptions import ( _LazyError) from ..inbound import AnymailInboundMessage from ..signals import AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking -from ..utils import combine, get_anymail_setting, getfirst +from ..utils import get_anymail_setting, getfirst try: import boto3 @@ -37,7 +37,7 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView): "auto_confirm_sns_subscriptions", esp_name=self.esp_name, kwargs=kwargs, default=True) # boto3 params for connecting to S3 (inbound downloads) and SNS (auto-confirm subscriptions): self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs) - super(AmazonSESBaseWebhookView, self).__init__(**kwargs) + super().__init__(**kwargs) @staticmethod def _parse_sns_message(request): @@ -47,7 +47,7 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView): body = request.body.decode(request.encoding or 'utf-8') request._sns_message = json.loads(body) except (TypeError, ValueError, UnicodeDecodeError) as err: - raise AnymailAPIError("Malformed SNS message body %r" % request.body, raised_from=err) + raise AnymailAPIError("Malformed SNS message body %r" % request.body) from err return request._sns_message def validate_request(self, request): @@ -80,7 +80,7 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView): response = HttpResponse(status=401) response["WWW-Authenticate"] = 'Basic realm="Anymail WEBHOOK_SECRET"' return response - return super(AmazonSESBaseWebhookView, self).post(request, *args, **kwargs) + return super().post(request, *args, **kwargs) def parse_events(self, request): # request *has* been validated by now @@ -91,11 +91,11 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView): message_string = sns_message.get("Message") try: ses_event = json.loads(message_string) - except (TypeError, ValueError): + except (TypeError, ValueError) as err: if message_string == "Successfully validated SNS topic for Amazon SES event publishing.": pass # this Notification is generated after SubscriptionConfirmation else: - raise AnymailAPIError("Unparsable SNS Message %r" % message_string) + raise AnymailAPIError("Unparsable SNS Message %r" % message_string) from err else: events = self.esp_to_anymail_events(ses_event, sns_message) elif sns_type == "SubscriptionConfirmation": @@ -258,8 +258,7 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView): ) return [ - # AnymailTrackingEvent(**common_props, **recipient_props) # Python 3.5+ (PEP-448 syntax) - AnymailTrackingEvent(**combine(common_props, recipient_props)) + AnymailTrackingEvent(**common_props, **recipient_props) for recipient_props in per_recipient_props ] @@ -306,7 +305,7 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView): raise AnymailBotoClientAPIError( "Anymail AmazonSESInboundWebhookView couldn't download S3 object '{bucket_name}:{object_key}'" "".format(bucket_name=bucket_name, object_key=object_key), - raised_from=err) + client_error=err) from err finally: content.close() else: @@ -341,13 +340,9 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView): class AnymailBotoClientAPIError(AnymailAPIError, ClientError): """An AnymailAPIError that is also a Boto ClientError""" - def __init__(self, *args, **kwargs): - raised_from = kwargs.pop('raised_from') - assert isinstance(raised_from, ClientError) - assert len(kwargs) == 0 # can't support other kwargs + def __init__(self, *args, client_error): + assert isinstance(client_error, ClientError) # init self as boto ClientError (which doesn't cooperatively subclass): - super(AnymailBotoClientAPIError, self).__init__( - error_response=raised_from.response, operation_name=raised_from.operation_name) + super().__init__(error_response=client_error.response, operation_name=client_error.operation_name) # emulate AnymailError init: self.args = args - self.raised_from = raised_from diff --git a/anymail/webhooks/base.py b/anymail/webhooks/base.py index 966c41c..c565ec8 100644 --- a/anymail/webhooks/base.py +++ b/anymail/webhooks/base.py @@ -1,6 +1,5 @@ import warnings -import six from django.http import HttpResponse from django.utils.crypto import constant_time_compare from django.utils.decorators import method_decorator @@ -11,62 +10,21 @@ from ..exceptions import AnymailInsecureWebhookWarning, AnymailWebhookValidation from ..utils import get_anymail_setting, collect_all_methods, get_request_basic_auth -class AnymailBasicAuthMixin(object): - """Implements webhook basic auth as mixin to AnymailBaseWebhookView.""" - - # Whether to warn if basic auth is not configured. - # For most ESPs, basic auth is the only webhook security, - # so the default is True. Subclasses can set False if - # they enforce other security (like signed webhooks). - warn_if_no_basic_auth = True - - # List of allowable HTTP basic-auth 'user:pass' strings. - basic_auth = None # (Declaring class attr allows override by kwargs in View.as_view.) - - def __init__(self, **kwargs): - self.basic_auth = get_anymail_setting('webhook_secret', default=[], - kwargs=kwargs) # no esp_name -- auth is shared between ESPs - - # Allow a single string: - if isinstance(self.basic_auth, six.string_types): - self.basic_auth = [self.basic_auth] - if self.warn_if_no_basic_auth and len(self.basic_auth) < 1: - warnings.warn( - "Your Anymail webhooks are insecure and open to anyone on the web. " - "You should set WEBHOOK_SECRET in your ANYMAIL settings. " - "See 'Securing webhooks' in the Anymail docs.", - AnymailInsecureWebhookWarning) - # noinspection PyArgumentList - super(AnymailBasicAuthMixin, self).__init__(**kwargs) - - def validate_request(self, request): - """If configured for webhook basic auth, validate request has correct auth.""" - if self.basic_auth: - request_auth = get_request_basic_auth(request) - # Use constant_time_compare to avoid timing attack on basic auth. (It's OK that any() - # can terminate early: we're not trying to protect how many auth strings are allowed, - # just the contents of each individual auth string.) - auth_ok = any(constant_time_compare(request_auth, allowed_auth) - for allowed_auth in self.basic_auth) - if not auth_ok: - # noinspection PyUnresolvedReferences - raise AnymailWebhookValidationFailure( - "Missing or invalid basic auth in Anymail %s webhook" % self.esp_name) - - # Mixin note: Django's View.__init__ doesn't cooperate with chaining, # so all mixins that need __init__ must appear before View in MRO. -class AnymailBaseWebhookView(AnymailBasicAuthMixin, View): - """Base view for processing ESP event webhooks +class AnymailCoreWebhookView(View): + """Common view for processing ESP event webhooks - ESP-specific implementations should subclass - and implement parse_events. They may also - want to implement validate_request + ESP-specific implementations will need to implement parse_events. + + ESP-specific implementations should generally subclass + AnymailBaseWebhookView instead, to pick up basic auth. + They may also want to implement validate_request if additional security is available. """ def __init__(self, **kwargs): - super(AnymailBaseWebhookView, self).__init__(**kwargs) + super().__init__(**kwargs) self.validators = collect_all_methods(self.__class__, 'validate_request') # Subclass implementation: @@ -106,7 +64,7 @@ class AnymailBaseWebhookView(AnymailBasicAuthMixin, View): @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): - return super(AnymailBaseWebhookView, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def head(self, request, *args, **kwargs): # Some ESPs verify the webhook with a HEAD request at configuration time @@ -143,3 +101,51 @@ class AnymailBaseWebhookView(AnymailBasicAuthMixin, View): """ raise NotImplementedError("%s.%s must declare esp_name class attr" % (self.__class__.__module__, self.__class__.__name__)) + + +class AnymailBasicAuthMixin(AnymailCoreWebhookView): + """Implements webhook basic auth as mixin to AnymailCoreWebhookView.""" + + # Whether to warn if basic auth is not configured. + # For most ESPs, basic auth is the only webhook security, + # so the default is True. Subclasses can set False if + # they enforce other security (like signed webhooks). + warn_if_no_basic_auth = True + + # List of allowable HTTP basic-auth 'user:pass' strings. + basic_auth = None # (Declaring class attr allows override by kwargs in View.as_view.) + + def __init__(self, **kwargs): + self.basic_auth = get_anymail_setting('webhook_secret', default=[], + kwargs=kwargs) # no esp_name -- auth is shared between ESPs + + # Allow a single string: + if isinstance(self.basic_auth, str): + self.basic_auth = [self.basic_auth] + if self.warn_if_no_basic_auth and len(self.basic_auth) < 1: + warnings.warn( + "Your Anymail webhooks are insecure and open to anyone on the web. " + "You should set WEBHOOK_SECRET in your ANYMAIL settings. " + "See 'Securing webhooks' in the Anymail docs.", + AnymailInsecureWebhookWarning) + super().__init__(**kwargs) + + def validate_request(self, request): + """If configured for webhook basic auth, validate request has correct auth.""" + if self.basic_auth: + request_auth = get_request_basic_auth(request) + # Use constant_time_compare to avoid timing attack on basic auth. (It's OK that any() + # can terminate early: we're not trying to protect how many auth strings are allowed, + # just the contents of each individual auth string.) + auth_ok = any(constant_time_compare(request_auth, allowed_auth) + for allowed_auth in self.basic_auth) + if not auth_ok: + raise AnymailWebhookValidationFailure( + "Missing or invalid basic auth in Anymail %s webhook" % self.esp_name) + + +class AnymailBaseWebhookView(AnymailBasicAuthMixin, AnymailCoreWebhookView): + """ + Abstract base class for most webhook views, enforcing HTTP basic auth security + """ + pass diff --git a/anymail/webhooks/mailgun.py b/anymail/webhooks/mailgun.py index 123fb75..2435f8d 100644 --- a/anymail/webhooks/mailgun.py +++ b/anymail/webhooks/mailgun.py @@ -30,11 +30,11 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView): kwargs=kwargs, allow_bare=True, default=None) webhook_signing_key = get_anymail_setting('webhook_signing_key', esp_name=self.esp_name, kwargs=kwargs, default=UNSET if api_key is None else api_key) - self.webhook_signing_key = webhook_signing_key.encode('ascii') # hmac.new requires bytes key in python 3 - super(MailgunBaseWebhookView, self).__init__(**kwargs) + self.webhook_signing_key = webhook_signing_key.encode('ascii') # hmac.new requires bytes key + super().__init__(**kwargs) def validate_request(self, request): - super(MailgunBaseWebhookView, self).validate_request(request) # first check basic auth if enabled + super().validate_request(request) # first check basic auth if enabled if request.content_type == "application/json": # New-style webhook: json payload with separate signature block try: @@ -45,8 +45,7 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView): signature = signature_block['signature'] except (KeyError, ValueError, UnicodeDecodeError) as err: raise AnymailWebhookValidationFailure( - "Mailgun webhook called with invalid payload format", - raised_from=err) + "Mailgun webhook called with invalid payload format") from err else: # Legacy webhook: signature fields are interspersed with other POST data try: @@ -54,9 +53,10 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView): # (Fortunately, Django QueryDict is specced to return the last value.) token = request.POST['token'] timestamp = request.POST['timestamp'] - signature = str(request.POST['signature']) # force to same type as hexdigest() (for python2) - except KeyError: - raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields") + signature = request.POST['signature'] + except KeyError as err: + raise AnymailWebhookValidationFailure( + "Mailgun webhook called without required security fields") from err expected_signature = hmac.new(key=self.webhook_signing_key, msg='{}{}'.format(timestamp, token).encode('ascii'), digestmod=hashlib.sha256).hexdigest() diff --git a/anymail/webhooks/mandrill.py b/anymail/webhooks/mandrill.py index ee2dcef..25f2e0b 100644 --- a/anymail/webhooks/mandrill.py +++ b/anymail/webhooks/mandrill.py @@ -7,14 +7,14 @@ from base64 import b64encode from django.utils.crypto import constant_time_compare from django.utils.timezone import utc -from .base import AnymailBaseWebhookView +from .base import AnymailBaseWebhookView, AnymailCoreWebhookView from ..exceptions import AnymailWebhookValidationFailure from ..inbound import AnymailInboundMessage from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType from ..utils import get_anymail_setting, getfirst, get_request_uri -class MandrillSignatureMixin(object): +class MandrillSignatureMixin(AnymailCoreWebhookView): """Validates Mandrill webhook signature""" # These can be set from kwargs in View.as_view, or pulled from settings in init: @@ -22,29 +22,26 @@ class MandrillSignatureMixin(object): webhook_url = None # optional; defaults to actual url used def __init__(self, **kwargs): - # noinspection PyUnresolvedReferences esp_name = self.esp_name # webhook_key is required for POST, but not for HEAD when Mandrill validates webhook url. # Defer "missing setting" error until we actually try to use it in the POST... webhook_key = get_anymail_setting('webhook_key', esp_name=esp_name, default=None, kwargs=kwargs, allow_bare=True) if webhook_key is not None: - self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key in python 3 + self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key self.webhook_url = get_anymail_setting('webhook_url', esp_name=esp_name, default=None, kwargs=kwargs, allow_bare=True) - # noinspection PyArgumentList - super(MandrillSignatureMixin, self).__init__(**kwargs) + super().__init__(**kwargs) def validate_request(self, request): if self.webhook_key is None: # issue deferred "missing setting" error (re-call get-setting without a default) - # noinspection PyUnresolvedReferences get_anymail_setting('webhook_key', esp_name=self.esp_name, allow_bare=True) try: signature = request.META["HTTP_X_MANDRILL_SIGNATURE"] except KeyError: - raise AnymailWebhookValidationFailure("X-Mandrill-Signature header missing from webhook POST") + raise AnymailWebhookValidationFailure("X-Mandrill-Signature header missing from webhook POST") from None # Mandrill signs the exact URL (including basic auth, if used) plus the sorted POST params: url = self.webhook_url or get_request_uri(request) diff --git a/anymail/webhooks/sendgrid.py b/anymail/webhooks/sendgrid.py index c3d7322..a84434d 100644 --- a/anymail/webhooks/sendgrid.py +++ b/anymail/webhooks/sendgrid.py @@ -1,12 +1,13 @@ import json from datetime import datetime +from email.parser import BytesParser +from email.policy import default as default_policy from django.utils.timezone import utc from .base import AnymailBaseWebhookView -from .._email_compat import EmailBytesParser from ..inbound import AnymailInboundMessage -from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason +from ..signals import AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking class SendGridTrackingWebhookView(AnymailBaseWebhookView): @@ -204,7 +205,7 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView): b"\r\n\r\n", request.body ]) - parsed_parts = EmailBytesParser().parsebytes(raw_data).get_payload() + parsed_parts = BytesParser(policy=default_policy).parsebytes(raw_data).get_payload() for part in parsed_parts: name = part.get_param('name', header='content-disposition') if name == 'text': diff --git a/anymail/webhooks/sparkpost.py b/anymail/webhooks/sparkpost.py index 22f412e..d2c952f 100644 --- a/anymail/webhooks/sparkpost.py +++ b/anymail/webhooks/sparkpost.py @@ -40,7 +40,8 @@ class SparkPostBaseWebhookView(AnymailBaseWebhookView): # Empty event (SparkPost sometimes sends as a "ping") event_class = event = None else: - raise TypeError("Invalid SparkPost webhook event has multiple event classes: %r" % raw_event) + raise TypeError( + "Invalid SparkPost webhook event has multiple event classes: %r" % raw_event) from None return event_class, event, raw_event def esp_to_anymail_event(self, event_class, event, raw_event): diff --git a/docs/conf.py b/docs/conf.py index bd0c520..19f2d80 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Anymail documentation build configuration file, created by # sphinx-quickstart # @@ -50,9 +48,9 @@ source_suffix = '.rst' master_doc = 'index' # General information about the project. -project = u'Anymail' +project = 'Anymail' # noinspection PyShadowingBuiltins -copyright = u'Anymail contributors (see AUTHORS.txt)' +copyright = 'Anymail contributors (see AUTHORS.txt)' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -203,8 +201,8 @@ latex_elements = { # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'Anymail.tex', u'Anymail Documentation', - u'Anymail contributors (see AUTHORS.txt)', 'manual'), + ('index', 'Anymail.tex', 'Anymail Documentation', + 'Anymail contributors (see AUTHORS.txt)', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -233,8 +231,8 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'anymail', u'Anymail Documentation', - [u'Anymail contributors (see AUTHORS.txt)'], 1) + ('index', 'anymail', 'Anymail Documentation', + ['Anymail contributors (see AUTHORS.txt)'], 1) ] # If true, show URL addresses after external links. @@ -247,8 +245,8 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'Anymail', u'Anymail Documentation', - u'Anymail contributors (see AUTHORS.txt)', 'Anymail', 'Multi-ESP transactional email for Django.', + ('index', 'Anymail', 'Anymail Documentation', + 'Anymail contributors (see AUTHORS.txt)', 'Anymail', 'Multi-ESP transactional email for Django.', 'Miscellaneous'), ] @@ -270,14 +268,9 @@ extlinks = { # -- Options for Intersphinx ------------------------------------------------ intersphinx_mapping = { - 'python': ('https://docs.python.org/3.6', None), + 'python': ('https://docs.python.org/3.7', None), 'django': ('https://docs.djangoproject.com/en/stable/', 'https://docs.djangoproject.com/en/stable/_objects/'), - # Requests docs may be moving (Sep 2019): - # see https://github.com/psf/requests/issues/5212 - # and https://github.com/psf/requests/issues/5214 - 'requests': ('https://docs.python-requests.org/en/latest/', - ('https://docs.python-requests.org/en/latest/objects.inv', - 'https://requests.kennethreitz.org/en/latest/objects.inv')), + 'requests': ('https://requests.readthedocs.io/en/stable/', None), } diff --git a/docs/contributing.rst b/docs/contributing.rst index be5951d..544fc89 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -71,7 +71,7 @@ and Python versions. Tests are run at least once a week, to check whether ESP AP and other dependencies have changed out from under Anymail. For local development, the recommended test command is -:shell:`tox -e django22-py37-all,django111-py27-all,lint`, which tests a representative +:shell:`tox -e django31-py38-all,django20-py35-all,lint`, which tests a representative combination of Python and Django versions. It also runs :pypi:`flake8` and other code-style checkers. Some other test options are covered below, but using this tox command catches most problems, and is a good pre-pull-request check. @@ -98,16 +98,16 @@ Or: $ python runtests.py tests.test_mailgun_backend tests.test_mailgun_webhooks Or to test against multiple versions of Python and Django all at once, use :pypi:`tox`. -You'll need at least Python 2.7 and Python 3.6 available. (If your system doesn't come -with those, `pyenv`_ is a helpful way to install and manage multiple Python versions.) +You'll need some version of Python 3 available. (If your system doesn't come +with that, `pyenv`_ is a helpful way to install and manage multiple Python versions.) .. code-block:: console $ pip install tox # (if you haven't already) - $ tox -e django21-py36-all,django111-py27-all,lint # test recommended environments + $ tox -e django31-py38-all,django20-py35-all,lint # test recommended environments ## you can also run just some test cases, e.g.: - $ tox -e django21-py36-all,django111-py27-all tests.test_mailgun_backend tests.test_utils + $ tox -e django31-py38-all,django20-py35-all tests.test_mailgun_backend tests.test_utils ## to test more Python/Django versions: $ tox --parallel auto # ALL 20+ envs! (in parallel if possible) @@ -121,7 +121,7 @@ API keys or other settings. For example: $ export MAILGUN_TEST_API_KEY='your-Mailgun-API-key' $ export MAILGUN_TEST_DOMAIN='mail.example.com' # sending domain for that API key - $ tox -e django21-py36-all tests.test_mailgun_integration + $ tox -e django31-py38-all tests.test_mailgun_integration Check the ``*_integration_tests.py`` files in the `tests source`_ to see which variables are required for each ESP. Depending on the supported features, the integration tests for @@ -180,7 +180,7 @@ Anymail's Sphinx conf sets up a few enhancements you can use in the docs: .. _Django's added markup: https://docs.djangoproject.com/en/stable/internals/contributing/writing-documentation/#django-specific-markup -.. _extlinks: http://www.sphinx-doc.org/en/stable/ext/extlinks.html -.. _intersphinx: http://www.sphinx-doc.org/en/master/ext/intersphinx.html +.. _extlinks: https://www.sphinx-doc.org/en/stable/usage/extensions/extlinks.html +.. _intersphinx: https://www.sphinx-doc.org/en/stable/usage/extensions/intersphinx.html .. _Writing Documentation: https://docs.djangoproject.com/en/stable/internals/contributing/writing-documentation/ diff --git a/docs/esps/mailgun.rst b/docs/esps/mailgun.rst index 66d9dbd..c4496a0 100644 --- a/docs/esps/mailgun.rst +++ b/docs/esps/mailgun.rst @@ -425,9 +425,9 @@ The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will b the parsed `Mailgun webhook payload`_ as a Python `dict` with ``"signature"`` and ``"event-data"`` keys. -Anymail uses Mailgun's webhook `token` as its normalized +Anymail uses Mailgun's webhook ``token`` as its normalized :attr:`~anymail.signals.AnymailTrackingEvent.event_id`, rather than Mailgun's -event-data `id` (which is only guaranteed to be unique during a single day). +event-data ``id`` (which is only guaranteed to be unique during a single day). If you need the event-data id, it can be accessed in your webhook handler as ``event.esp_event["event-data"]["id"]``. (This can be helpful for working with Mailgun's other event APIs.) diff --git a/docs/esps/mandrill.rst b/docs/esps/mandrill.rst index 9d223fb..1f4d9d1 100644 --- a/docs/esps/mandrill.rst +++ b/docs/esps/mandrill.rst @@ -3,7 +3,7 @@ Mandrill ======== -Anymail integrates with the `Mandrill `__ +Anymail integrates with the `Mandrill `__ transactional email service from MailChimp. .. note:: **Limited Support for Mandrill** diff --git a/docs/help.rst b/docs/help.rst index 1170eae..cba4bf5 100644 --- a/docs/help.rst +++ b/docs/help.rst @@ -29,9 +29,10 @@ often help you pinpoint the problem... **Double-check common issues** * Did you add any required settings for your ESP to the `ANYMAIL` dict in your - settings.py? (E.g., ``"SENDGRID_API_KEY"`` for SendGrid.) See :ref:`supported-esps`. + settings.py? (E.g., ``"SENDGRID_API_KEY"`` for SendGrid.) Check the instructions + for the ESP you're using under :ref:`supported-esps`. * Did you add ``'anymail'`` to the list of :setting:`INSTALLED_APPS` in settings.py? - * Are you using a valid from address? Django's default is "webmaster@localhost", + * Are you using a valid *from* address? Django's default is "webmaster@localhost", which most ESPs reject. Either specify the ``from_email`` explicitly on every message you send, or add :setting:`DEFAULT_FROM_EMAIL` to your settings.py. @@ -61,8 +62,8 @@ Support If you've gone through the troubleshooting above and still aren't sure what's wrong, the Anymail community is happy to help. Anymail is supported and maintained by the -people who use it---like you! (The vast majority of Anymail contributors volunteer -their time, and are not employees of any ESP.) +people who use it---like you! (Anymail contributors volunteer their time, and are +not employees of any ESP.) Here's how to contact the Anymail community: diff --git a/docs/inbound.rst b/docs/inbound.rst index 4e4a964..d282c39 100644 --- a/docs/inbound.rst +++ b/docs/inbound.rst @@ -13,7 +13,7 @@ If you didn't set up webhooks when first installing Anymail, you'll need to (You should also review :ref:`securing-webhooks`.) Once you've enabled webhooks, Anymail will send a ``anymail.signals.inbound`` -custom Django :mod:`signal ` for each ESP inbound message it receives. +custom Django :doc:`signal ` for each ESP inbound message it receives. You can connect your own receiver function to this signal for further processing. (This is very much like how Anymail handles :ref:`status tracking ` events for sent messages. Inbound events just use a different signal receiver @@ -73,7 +73,7 @@ invoke your signal receiver once, separately, for each message in the batch. :ref:`user-supplied content security `. .. _using python-magic: - http://blog.hayleyanderson.us/2015/07/18/validating-file-types-in-django/ + https://blog.hayleyanderson.us/2015/07/18/validating-file-types-in-django/ .. _inbound-event: @@ -90,7 +90,7 @@ Normalized inbound event .. attribute:: message An :class:`~anymail.inbound.AnymailInboundMessage` representing the email - that was received. Most of what you're interested in will be on this `message` + that was received. Most of what you're interested in will be on this :attr:`!message` attribute. See the full details :ref:`below `. .. attribute:: event_type @@ -290,8 +290,6 @@ Handling Inbound Attachments Anymail converts each inbound attachment to a specialized MIME object with additional methods for handling attachments and integrating with Django. -It also backports some helpful MIME methods from newer versions of Python -to all versions supported by Anymail. The attachment objects in an AnymailInboundMessage's :attr:`~AnymailInboundMessage.attachments` list and @@ -346,8 +344,6 @@ have these methods: .. method:: is_attachment() Returns `True` for a (non-inline) attachment, `False` otherwise. - (Anymail back-ports Python 3.4.2's :meth:`~email.message.EmailMessage.is_attachment` method - to all supported versions.) .. method:: is_inline_attachment() @@ -360,9 +356,6 @@ have these methods: :mailheader:`Content-Disposition` header. The return value should be either "inline" or "attachment", or `None` if the attachment is somehow missing that header. - (Anymail back-ports Python 3.5's :meth:`~email.message.Message.get_content_disposition` - method to all supported versions.) - .. method:: get_content_text(charset=None, errors='replace') Returns the content of the attachment decoded to Unicode text. @@ -453,7 +446,7 @@ And they may then retry sending these "failed" events, which could cause duplicate processing in your code. If your signal receiver code might be slow, you should instead queue the event for later, asynchronous processing (e.g., using -something like `Celery`_). +something like :pypi:`celery`). If your signal receiver function is defined within some other function or instance method, you *must* use the `weak=False` @@ -461,5 +454,3 @@ option when connecting it. Otherwise, it might seem to work at first, but will unpredictably stop being called at some point---typically on your production server, in a hard-to-debug way. See Django's docs on :doc:`signals ` for more information. - -.. _Celery: http://www.celeryproject.org/ diff --git a/docs/installation.rst b/docs/installation.rst index 88bd0a3..eb929fe 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -142,15 +142,15 @@ If you want to use Anymail's inbound or tracking webhooks: .. code-block:: python - from django.conf.urls import include, url + from django.urls import include, re_path urlpatterns = [ ... - url(r'^anymail/', include('anymail.urls')), + re_path(r'^anymail/', include('anymail.urls')), ] (You can change the "anymail" prefix in the first parameter to - :func:`~django.conf.urls.url` if you'd like the webhooks to be served + :func:`~django.urls.re_path` if you'd like the webhooks to be served at some other URL. Just match whatever you use in the webhook URL you give your ESP in the next step.) @@ -186,7 +186,7 @@ See :ref:`event-tracking` for information on creating signal handlers and the status tracking events you can receive. See :ref:`inbound` for information on receiving inbound message events. -.. _mod_wsgi: http://modwsgi.readthedocs.io/en/latest/configuration-directives/WSGIPassAuthorization.html +.. _mod_wsgi: https://modwsgi.readthedocs.io/en/latest/configuration-directives/WSGIPassAuthorization.html .. setting:: ANYMAIL @@ -227,10 +227,11 @@ if you are using other Django apps that work with the same ESP.) Finally, for complex use cases, you can override most settings on a per-instance basis by providing keyword args where the instance is initialized (e.g., in a :func:`~django.core.mail.get_connection` call to create an email backend instance, -or in `View.as_view()` call to set up webhooks in a custom urls.py). To get the kwargs +or in a `View.as_view()` call to set up webhooks in a custom urls.py). To get the kwargs parameter for a setting, drop "ANYMAIL" and the ESP name, and lowercase the rest: -e.g., you can override ANYMAIL_MAILGUN_API_KEY by passing `api_key="abc"` to -:func:`~django.core.mail.get_connection`. See :ref:`multiple-backends` for an example. +e.g., you can override ANYMAIL_MAILGUN_API_KEY for a particular connection by calling +``get_connection("anymail.backends.mailgun.EmailBackend", api_key="abc")``. +See :ref:`multiple-backends` for an example. There are specific Anymail settings for each ESP (like API keys and urls). See the :ref:`supported ESPs ` section for details. @@ -253,7 +254,7 @@ See :ref:`recipients-refused`. } -.. rubric:: SEND_DEFAULTS and *ESP*\ _SEND_DEFAULTS` +.. rubric:: SEND_DEFAULTS and *ESP*\ _SEND_DEFAULTS A `dict` of default options to apply to all messages sent through Anymail. See :ref:`send-defaults`. diff --git a/docs/sending/anymail_additions.rst b/docs/sending/anymail_additions.rst index d4f0a2a..d6ddf69 100644 --- a/docs/sending/anymail_additions.rst +++ b/docs/sending/anymail_additions.rst @@ -26,18 +26,18 @@ The first approach is usually the simplest. The other two can be helpful if you are working with Python development tools that offer type checking or other static code analysis. -Availability of these features varies by ESP, and there may be additional -limitations even when an ESP does support a particular feature. Be sure -to check Anymail's docs for your :ref:`specific ESP `. -If you try to use a feature your ESP does not offer, Anymail will raise -an :ref:`unsupported feature ` error. - .. _anymail-send-options: ESP send options (AnymailMessage) --------------------------------- +Availability of each of these features varies by ESP, and there may be additional +limitations even when an ESP does support a particular feature. Be sure +to check Anymail's docs for your :ref:`specific ESP `. +If you try to use a feature your ESP does not offer, Anymail will raise +an :ref:`unsupported feature ` error. + .. class:: AnymailMessage A subclass of Django's :class:`~django.core.mail.EmailMultiAlternatives` @@ -167,7 +167,7 @@ ESP send options (AnymailMessage) ESPs have differing restrictions on tags. For portability, it's best to stick with strings that start with an alphanumeric - character. (Also, Postmark only allows a single tag per message.) + character. (Also, a few ESPs allow only a single tag per message.) .. caution:: @@ -359,7 +359,7 @@ ESP send status * `'queued'` the ESP has accepted the message and will try to send it asynchronously * `'invalid'` the ESP considers the sender or recipient email invalid - * `'rejected'` the recipient is on an ESP blacklist + * `'rejected'` the recipient is on an ESP suppression list (unsubscribe, previous bounces, etc.) * `'failed'` the attempt to send failed for some other reason * `'unknown'` anything else @@ -402,7 +402,8 @@ ESP send status .. code-block:: python - # This will work with a requests-based backend: + # This will work with a requests-based backend, + # for an ESP whose send API provides a JSON response: message.anymail_status.esp_response.json() diff --git a/docs/sending/django_email.rst b/docs/sending/django_email.rst index 439aa65..1fda8b5 100644 --- a/docs/sending/django_email.rst +++ b/docs/sending/django_email.rst @@ -10,7 +10,7 @@ email using Django's default SMTP :class:`~django.core.mail.backends.smtp.EmailB switching to Anymail will be easy. Anymail is designed to "just work" with Django. If you're not familiar with Django's email functions, please take a look at -":mod:`sending email `" in the Django docs first. +:doc:`django:topics/email` in the Django docs first. Anymail supports most of the functionality of Django's :class:`~django.core.mail.EmailMessage` and :class:`~django.core.mail.EmailMultiAlternatives` classes. @@ -39,8 +39,8 @@ function with the ``html_message`` parameter: send_mail("Subject", "text body", "from@example.com", ["to@example.com"], html_message="html body") -However, many Django email capabilities -- and additional Anymail features -- -are only available when working with an :class:`~django.core.mail.EmailMultiAlternatives` +However, many Django email capabilities---and additional Anymail features---are only +available when working with an :class:`~django.core.mail.EmailMultiAlternatives` object. Use its :meth:`~django.core.mail.EmailMultiAlternatives.attach_alternative` method to send HTML: @@ -168,7 +168,8 @@ raise :exc:`~exceptions.AnymailUnsupportedFeature`. .. setting:: ANYMAIL_IGNORE_UNSUPPORTED_FEATURES If you'd like to silently ignore :exc:`~exceptions.AnymailUnsupportedFeature` -errors and send the messages anyway, set :setting:`!ANYMAIL_IGNORE_UNSUPPORTED_FEATURES` +errors and send the messages anyway, set +:setting:`"IGNORE_UNSUPPORTED_FEATURES" ` to `True` in your settings.py: .. code-block:: python @@ -197,15 +198,16 @@ If a single message is sent to multiple recipients, and *any* recipient is valid You can still examine the message's :attr:`~message.AnymailMessage.anymail_status` property after the send to determine the status of each recipient. -You can disable this exception by setting :setting:`ANYMAIL_IGNORE_RECIPIENT_STATUS` -to `True` in your settings.py, which will cause Anymail to treat any non-API-error response -from your ESP as a successful send. +You can disable this exception by setting +:setting:`"IGNORE_RECIPIENT_STATUS" ` to `True` in +your settings.py `ANYMAIL` dict, which will cause Anymail to treat *any* +response from your ESP (other than an API error) as a successful send. .. note:: - Many ESPs don't check recipient status during the send API call. For example, + Most ESPs don't check recipient status during the send API call. For example, Mailgun always queues sent messages, so you'll never catch :exc:`AnymailRecipientsRefused` with the Mailgun backend. - For those ESPs, use Anymail's :ref:`delivery event tracking ` - if you need to be notified of sends to blacklisted or invalid emails. + You can use Anymail's :ref:`delivery event tracking ` + if you need to be notified of sends to suppression-listed or invalid emails. diff --git a/docs/sending/templates.rst b/docs/sending/templates.rst index f9b21e7..7c54917 100644 --- a/docs/sending/templates.rst +++ b/docs/sending/templates.rst @@ -211,7 +211,7 @@ for use as merge data: # Do something this instead: message.merge_global_data = { 'PRODUCT': product.name, # assuming name is a CharField - 'TOTAL_COST': "%.2f" % total_cost, + 'TOTAL_COST': "{cost:0.2f}".format(cost=total_cost), 'SHIP_DATE': ship_date.strftime('%B %d, %Y') # US-style "March 15, 2015" } diff --git a/docs/sending/tracking.rst b/docs/sending/tracking.rst index 6fbe0fa..b692a2e 100644 --- a/docs/sending/tracking.rst +++ b/docs/sending/tracking.rst @@ -14,7 +14,7 @@ Webhook support is optional. If you haven't yet, you'll need to project. (You may also want to review :ref:`securing-webhooks`.) Once you've enabled webhooks, Anymail will send an ``anymail.signals.tracking`` -custom Django :mod:`signal ` for each ESP tracking event it receives. +custom Django :doc:`signal ` for each ESP tracking event it receives. You can connect your own receiver function to this signal for further processing. Be sure to read Django's `listening to signals`_ docs for information on defining @@ -40,7 +40,7 @@ Example: event.recipient, event.click_url)) You can define individual signal receivers, or create one big one for all -event types, which ever you prefer. You can even handle the same event +event types, whichever you prefer. You can even handle the same event in multiple receivers, if that makes your code cleaner. These :ref:`signal receiver functions ` are documented in more detail below. @@ -189,8 +189,8 @@ Normalized tracking event .. attribute:: mta_response If available, a `str` with a raw (intended for email administrators) response - from the receiving MTA. Otherwise `None`. Often includes SMTP response codes, - but the exact format varies by ESP (and sometimes receiving MTA). + from the receiving mail transfer agent. Otherwise `None`. Often includes SMTP + response codes, but the exact format varies by ESP (and sometimes receiving MTA). .. attribute:: user_agent @@ -203,7 +203,7 @@ Normalized tracking event .. attribute:: esp_event - The "raw" event data from the ESP, deserialized into a python data structure. + The "raw" event data from the ESP, deserialized into a Python data structure. For most ESPs this is either parsed JSON (as a `dict`), or HTTP POST fields (as a Django :class:`~django.http.QueryDict`). @@ -230,7 +230,7 @@ Your Anymail signal receiver must be a function with this signature: :param AnymailTrackingEvent event: The normalized tracking event. Almost anything you'd be interested in will be in here. - :param str esp_name: e.g., "SendMail" or "Postmark". If you are working + :param str esp_name: e.g., "SendGrid" or "Postmark". If you are working with multiple ESPs, you can use this to distinguish ESP-specific handling in your shared event processing. :param \**kwargs: Required by Django's signal mechanism @@ -259,7 +259,7 @@ And will retry sending the "failed" events, which could cause duplicate processing in your code. If your signal receiver code might be slow, you should instead queue the event for later, asynchronous processing (e.g., using -something like `Celery`_). +something like :pypi:`celery`). If your signal receiver function is defined within some other function or instance method, you *must* use the `weak=False` @@ -268,7 +268,6 @@ but will unpredictably stop being called at some point---typically on your production server, in a hard-to-debug way. See Django's `listening to signals`_ docs for more information. -.. _Celery: http://www.celeryproject.org/ .. _listening to signals: https://docs.djangoproject.com/en/stable/topics/signals/#listening-to-signals diff --git a/docs/tips/django_templates.rst b/docs/tips/django_templates.rst index f0700a5..5a1fc7e 100644 --- a/docs/tips/django_templates.rst +++ b/docs/tips/django_templates.rst @@ -7,7 +7,7 @@ ESP's templating languages and merge capabilities are generally not compatible with each other, which can make it hard to move email templates between them. But since you're working in Django, you already have access to the -extremely-full-featured :mod:`Django templating system `. +extremely-full-featured :doc:`Django templating system `. You don't even have to use Django's template syntax: it supports other template languages (like Jinja2). @@ -15,7 +15,7 @@ You're probably already using Django's templating system for your HTML pages, so it can be an easy decision to use it for your email, too. To compose email using *Django* templates, you can use Django's -:func:`~django.template.loaders.django.template.loader.render_to_string` +:func:`~django.template.loader.render_to_string` template shortcut to build the body and html. Example that builds an email from the templates ``message_subject.txt``, @@ -24,16 +24,14 @@ Example that builds an email from the templates ``message_subject.txt``, .. code-block:: python from django.core.mail import EmailMultiAlternatives - from django.template import Context from django.template.loader import render_to_string merge_data = { 'ORDERNO': "12345", 'TRACKINGNO': "1Z987" } - plaintext_context = Context(autoescape=False) # HTML escaping not appropriate in plaintext - subject = render_to_string("message_subject.txt", merge_data, plaintext_context) - text_body = render_to_string("message_body.txt", merge_data, plaintext_context) + subject = render_to_string("message_subject.txt", merge_data).strip() + text_body = render_to_string("message_body.txt", merge_data) html_body = render_to_string("message_body.html", merge_data) msg = EmailMultiAlternatives(subject=subject, from_email="store@example.com", @@ -41,6 +39,9 @@ Example that builds an email from the templates ``message_subject.txt``, msg.attach_alternative(html_body, "text/html") msg.send() +Tip: use Django's :ttag:`{% autoescape off %}` template tag in your +plaintext ``.txt`` templates to avoid inappropriate HTML escaping. + Helpful add-ons --------------- @@ -48,8 +49,6 @@ Helpful add-ons These (third-party) packages can be helpful for building your email in Django: -.. TODO: flesh this out - * :pypi:`django-templated-mail`, :pypi:`django-mail-templated`, or :pypi:`django-mail-templated-simple` for building messages from sets of Django templates. * :pypi:`premailer` for inlining css before sending diff --git a/docs/tips/securing_webhooks.rst b/docs/tips/securing_webhooks.rst index 78accd6..9a4545d 100644 --- a/docs/tips/securing_webhooks.rst +++ b/docs/tips/securing_webhooks.rst @@ -73,10 +73,10 @@ Basic usage is covered in the :ref:`webhooks configuration ` docs. If something posts to your webhooks without the required shared -secret as basic auth in the HTTP_AUTHORIZATION header, Anymail will +secret as basic auth in the HTTP *Authorization* header, Anymail will raise an :exc:`AnymailWebhookValidationFailure` error, which is a subclass of Django's :exc:`~django.core.exceptions.SuspiciousOperation`. -This will result in an HTTP 400 response, without further processing +This will result in an HTTP 400 "bad request" response, without further processing the data or calling your signal receiver function. In addition to a single "random:random" string, you can give a list diff --git a/runtests.py b/runtests.py index 63647d1..c29ab98 100755 --- a/runtests.py +++ b/runtests.py @@ -4,7 +4,6 @@ # or # runtests.py [tests.test_x tests.test_y.SomeTestCase ...] -from __future__ import print_function import sys from distutils.util import strtobool @@ -33,7 +32,6 @@ def setup_and_run_tests(test_labels=None): warnings.simplefilter('default') # show DeprecationWarning and other default-ignored warnings - # noinspection PyStringFormat os.environ['DJANGO_SETTINGS_MODULE'] = \ 'tests.test_settings.settings_%d_%d' % django.VERSION[:2] django.setup() diff --git a/setup.py b/setup.py index dbb4725..5257558 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ setup( license="BSD License", packages=["anymail"], zip_safe=False, - install_requires=["django>=1.11", "requests>=2.4.3", "six"], + install_requires=["django>=2.0", "requests>=2.4.3"], extras_require={ # This can be used if particular backends have unique dependencies. # For simplicity, requests is included in the base requirements. @@ -64,21 +64,21 @@ setup( "Programming Language :: Python", "Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", "License :: OSI Approved :: BSD License", "Topic :: Communications :: Email", "Topic :: Software Development :: Libraries :: Python Modules", "Intended Audience :: Developers", "Framework :: Django", - "Framework :: Django :: 1.11", "Framework :: Django :: 2.0", "Framework :: Django :: 2.1", + "Framework :: Django :: 2.2", + "Framework :: Django :: 3.0", + "Framework :: Django :: 3.1", "Environment :: Web Environment", ], long_description=long_description, diff --git a/tests/mock_requests_backend.py b/tests/mock_requests_backend.py index 96ae76c..5dd4c39 100644 --- a/tests/mock_requests_backend.py +++ b/tests/mock_requests_backend.py @@ -1,9 +1,9 @@ import json +from io import BytesIO from django.core import mail from django.test import SimpleTestCase import requests -import six from mock import patch from anymail.exceptions import AnymailAPIError @@ -13,7 +13,7 @@ from .utils import AnymailTestMixin UNSET = object() -class RequestsBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin): +class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): """TestCase that mocks API calls through requests""" DEFAULT_RAW_RESPONSE = b"""{"subclass": "should override"}""" @@ -22,15 +22,14 @@ class RequestsBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin): class MockResponse(requests.Response): """requests.request return value mock sufficient for testing""" def __init__(self, status_code=200, raw=b"RESPONSE", encoding='utf-8', reason=None): - super(RequestsBackendMockAPITestCase.MockResponse, self).__init__() + super().__init__() self.status_code = status_code self.encoding = encoding self.reason = reason or ("OK" if 200 <= status_code < 300 else "ERROR") - # six.BytesIO(None) returns b'None' in PY2 (rather than b'') - self.raw = six.BytesIO(raw) if raw is not None else six.BytesIO() + self.raw = BytesIO(raw) def setUp(self): - super(RequestsBackendMockAPITestCase, self).setUp() + super().setUp() self.patch_request = patch('requests.Session.request', autospec=True) self.mock_request = self.patch_request.start() self.addCleanup(self.patch_request.stop) @@ -127,17 +126,25 @@ class RequestsBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin): raise AssertionError(msg or "ESP API was called and shouldn't have been") -# noinspection PyUnresolvedReferences -class SessionSharingTestCasesMixin(object): - """Mixin that tests connection sharing in any RequestsBackendMockAPITestCase +class SessionSharingTestCases(RequestsBackendMockAPITestCase): + """Common test cases for requests backend connection sharing. - (Contains actual test cases, so can't be included in RequestsBackendMockAPITestCase - itself, as that would re-run these tests several times for each backend, in - each TestCase for the backend.) + Instantiate for each ESP by: + - subclassing + - adding or overriding any tests as appropriate """ + def __init__(self, methodName='runTest'): + if self.__class__ is SessionSharingTestCases: + # don't run these tests on the abstract base implementation + methodName = 'runNoTestsInBaseClass' + super().__init__(methodName) + + def runNoTestsInBaseClass(self): + pass + def setUp(self): - super(SessionSharingTestCasesMixin, self).setUp() + super().setUp() self.patch_close = patch('requests.Session.close', autospec=True) self.mock_close = self.patch_close.start() self.addCleanup(self.patch_close.stop) diff --git a/tests/test_amazon_ses_backend.py b/tests/test_amazon_ses_backend.py index 0aa976a..9adecb4 100644 --- a/tests/test_amazon_ses_backend.py +++ b/tests/test_amazon_ses_backend.py @@ -1,11 +1,7 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import json from datetime import datetime from email.mime.application import MIMEApplication -import six from django.core import mail from django.core.mail import BadHeaderError from django.test import SimpleTestCase, override_settings, tag @@ -19,11 +15,11 @@ from .utils import AnymailTestMixin, SAMPLE_IMAGE_FILENAME, sample_image_content @tag('amazon_ses') @override_settings(EMAIL_BACKEND='anymail.backends.amazon_ses.EmailBackend') -class AmazonSESBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin): +class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): """TestCase that uses the Amazon SES EmailBackend with a mocked boto3 client""" def setUp(self): - super(AmazonSESBackendMockAPITestCase, self).setUp() + super().setUp() # Mock boto3.session.Session().client('ses').send_raw_email (and any other client operations) # (We could also use botocore.stub.Stubber, but mock works well with our test structure) @@ -122,7 +118,7 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): # send_raw_email takes a fully-formatted MIME message. # This is a simple (if inexact) way to check for expected headers and body: raw_mime = params['RawMessage']['Data'] - self.assertIsInstance(raw_mime, six.binary_type) # SendRawEmail expects Data as bytes + self.assertIsInstance(raw_mime, bytes) # SendRawEmail expects Data as bytes self.assertIn(b"\nFrom: from@example.com\n", raw_mime) self.assertIn(b"\nTo: to@example.com\n", raw_mime) self.assertIn(b"\nSubject: Subject here\n", raw_mime) @@ -259,7 +255,7 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): self.assertEqual(params['Source'], "from1@example.com") def test_commas_in_subject(self): - """Anymail works around a Python 2 email header bug that adds unwanted spaces after commas in long subjects""" + """There used to be a Python email header bug that added unwanted spaces after commas in long subjects""" self.message.subject = "100,000,000 isn't a number you'd really want to break up in this email subject, right?" self.message.send() sent_message = self.get_sent_message() diff --git a/tests/test_amazon_ses_inbound.py b/tests/test_amazon_ses_inbound.py index 925182e..c115a27 100644 --- a/tests/test_amazon_ses_inbound.py +++ b/tests/test_amazon_ses_inbound.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import json from base64 import b64encode from datetime import datetime @@ -22,7 +20,7 @@ from .webhook_cases import WebhookTestCase class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): def setUp(self): - super(AmazonSESInboundTests, self).setUp() + super().setUp() # Mock boto3.session.Session().client('s3').download_fileobj # (We could also use botocore.stub.Stubber, but mock works well with our test structure) self.patch_boto3_session = patch('anymail.webhooks.amazon_ses.boto3.session.Session', autospec=True) @@ -263,9 +261,8 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): self.assertEqual([str(to) for to in message.to], ['Recipient ', 'someone-else@example.org']) self.assertEqual(message.subject, 'Test inbound message') - # rstrip below because the Python 3 EmailBytesParser converts \r\n to \n, but the Python 2 version doesn't - self.assertEqual(message.text.rstrip(), "It's a body\N{HORIZONTAL ELLIPSIS}") - self.assertEqual(message.html.rstrip(), """
It's a body\N{HORIZONTAL ELLIPSIS}
""") + self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n") + self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") self.assertIsNone(message.spam_detected) def test_inbound_s3_failure_message(self): diff --git a/tests/test_amazon_ses_integration.py b/tests/test_amazon_ses_integration.py index 41c0e32..8a20d16 100644 --- a/tests/test_amazon_ses_integration.py +++ b/tests/test_amazon_ses_integration.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import os import unittest import warnings @@ -12,11 +9,6 @@ from anymail.message import AnymailMessage from .utils import AnymailTestMixin, sample_image_path -try: - ResourceWarning -except NameError: - ResourceWarning = Warning # Python 2 - AMAZON_SES_TEST_ACCESS_KEY_ID = os.getenv("AMAZON_SES_TEST_ACCESS_KEY_ID") AMAZON_SES_TEST_SECRET_ACCESS_KEY = os.getenv("AMAZON_SES_TEST_SECRET_ACCESS_KEY") @@ -42,7 +34,7 @@ AMAZON_SES_TEST_REGION_NAME = os.getenv("AMAZON_SES_TEST_REGION_NAME", "us-east- "AMAZON_SES_CONFIGURATION_SET_NAME": "TestConfigurationSet", # actual config set in Anymail test account }) @tag('amazon_ses', 'live') -class AmazonSESBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): +class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """Amazon SES API integration tests These tests run against the **live** Amazon SES API, using the environment @@ -63,14 +55,14 @@ class AmazonSESBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): """ def setUp(self): - super(AmazonSESBackendIntegrationTests, self).setUp() + super().setUp() self.message = AnymailMessage('Anymail Amazon SES integration test', 'Text content', 'test@test-ses.anymail.info', ['success@simulator.amazonses.com']) self.message.attach_alternative('

HTML content

', "text/html") # boto3 relies on GC to close connections. Python 3 warns about unclosed ssl.SSLSocket during cleanup. - # We don't care. (It might not be a real problem worth warning, but in any case it's not our problem.) - # https://www.google.com/search?q=unittest+boto3+ResourceWarning+unclosed+ssl.SSLSocket + # We don't care. (It may be a false positive, or it may be a botocore problem, but it's not *our* problem.) + # https://github.com/boto/boto3/issues/454#issuecomment-586033745 # Filter in TestCase.setUp because unittest resets the warning filters for each test. # https://stackoverflow.com/a/26620811/647002 warnings.filterwarnings("ignore", message=r"unclosed ') + self.message.from_email = gettext_lazy('"Global Sales" ') self.message.send() params = self.get_send_params() self.assertNotLazy(params['from'].address) def test_lazy_subject(self): - self.message.subject = ugettext_lazy("subject") + self.message.subject = gettext_lazy("subject") self.message.send() params = self.get_send_params() self.assertNotLazy(params['subject']) def test_lazy_body(self): - self.message.body = ugettext_lazy("text body") - self.message.attach_alternative(ugettext_lazy("html body"), "text/html") + self.message.body = gettext_lazy("text body") + self.message.attach_alternative(gettext_lazy("html body"), "text/html") self.message.send() params = self.get_send_params() self.assertNotLazy(params['text_body']) self.assertNotLazy(params['html_body']) def test_lazy_headers(self): - self.message.extra_headers['X-Test'] = ugettext_lazy("Test Header") + self.message.extra_headers['X-Test'] = gettext_lazy("Test Header") self.message.send() params = self.get_send_params() self.assertNotLazy(params['extra_headers']['X-Test']) def test_lazy_attachments(self): - self.message.attach(ugettext_lazy("test.csv"), ugettext_lazy("test,csv,data"), "text/csv") - self.message.attach(MIMEText(ugettext_lazy("contact info"))) + self.message.attach(gettext_lazy("test.csv"), gettext_lazy("test,csv,data"), "text/csv") + self.message.attach(MIMEText(gettext_lazy("contact info"))) self.message.send() params = self.get_send_params() self.assertNotLazy(params['attachments'][0].name) @@ -290,22 +289,22 @@ class LazyStringsTest(TestBackendTestCase): self.assertNotLazy(params['attachments'][1].content) def test_lazy_tags(self): - self.message.tags = [ugettext_lazy("Shipping"), ugettext_lazy("Sales")] + self.message.tags = [gettext_lazy("Shipping"), gettext_lazy("Sales")] self.message.send() params = self.get_send_params() self.assertNotLazy(params['tags'][0]) self.assertNotLazy(params['tags'][1]) def test_lazy_metadata(self): - self.message.metadata = {'order_type': ugettext_lazy("Subscription")} + self.message.metadata = {'order_type': gettext_lazy("Subscription")} self.message.send() params = self.get_send_params() self.assertNotLazy(params['metadata']['order_type']) def test_lazy_merge_data(self): self.message.merge_data = { - 'to@example.com': {'duration': ugettext_lazy("One Month")}} - self.message.merge_global_data = {'order_type': ugettext_lazy("Subscription")} + 'to@example.com': {'duration': gettext_lazy("One Month")}} + self.message.merge_global_data = {'order_type': gettext_lazy("Subscription")} self.message.send() params = self.get_send_params() self.assertNotLazy(params['merge_data']['to@example.com']['duration']) @@ -329,7 +328,7 @@ class CatchCommonErrorsTests(TestBackendTestCase): def test_explains_reply_to_must_be_list_lazy(self): """Same as previous tests, with lazy strings""" # Lazy strings can fool string/iterable detection - self.message.reply_to = ugettext_lazy("single-reply-to@example.com") + self.message.reply_to = gettext_lazy("single-reply-to@example.com") with self.assertRaisesMessage(TypeError, '"reply_to" attribute must be a list or other iterable'): self.message.send() @@ -431,7 +430,7 @@ class BatchSendDetectionTestCase(TestBackendTestCase): """Tests shared code to consistently determine whether to use batch send""" def setUp(self): - super(BatchSendDetectionTestCase, self).setUp() + super().setUp() self.backend = TestBackend() def test_default_is_not_batch(self): @@ -460,7 +459,7 @@ class BatchSendDetectionTestCase(TestBackendTestCase): def set_cc(self, emails): if self.is_batch(): # this won't work here! self.unsupported_feature("cc with batch send") - super(ImproperlyImplementedPayload, self).set_cc(emails) + super().set_cc(emails) connection = mail.get_connection('anymail.backends.test.EmailBackend', payload_class=ImproperlyImplementedPayload) diff --git a/tests/test_inbound.py b/tests/test_inbound.py index 7aee2db..7dd1c05 100644 --- a/tests/test_inbound.py +++ b/tests/test_inbound.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import quopri from base64 import b64encode from email.utils import collapse_rfc2231_value @@ -169,10 +166,9 @@ class AnymailInboundMessageConstructionTests(SimpleTestCase): def test_parse_raw_mime_8bit_utf8(self): # In come cases, the message below ends up with 'Content-Transfer-Encoding: 8bit', # so needs to be parsed as bytes, not text (see https://bugs.python.org/issue18271). - # Message.as_string() returns str, which is is bytes on Python 2 and text on Python 3. + # Message.as_string() returns str (text), not bytes. # (This might be a Django bug; plain old MIMEText avoids the problem by using - # 'Content-Transfer-Encoding: base64', which parses fine as text or bytes. - # Django <1.11 on Python 3 also used base64.) + # 'Content-Transfer-Encoding: base64', which parses fine as text or bytes.) # Either way, AnymailInboundMessage should try to sidestep the whole issue. raw = SafeMIMEText("Unicode ✓", "plain", "utf-8").as_string() msg = AnymailInboundMessage.parse_raw_mime(raw) @@ -495,9 +491,11 @@ class AnymailInboundMessageAttachedMessageTests(SimpleTestCase): self.assertEqual(orig_msg.get_content_type(), "multipart/related") -class EmailParserWorkaroundTests(SimpleTestCase): - # Anymail includes workarounds for (some of) the more problematic bugs - # in the Python 2 email.parser.Parser. +class EmailParserBehaviorTests(SimpleTestCase): + # Python 3.5+'s EmailParser should handle all of these, so long as it's not + # invoked with its default policy=compat32. This double checks we're using it + # properly. (Also, older versions of Anymail included workarounds for these + # in older, broken versions of the EmailParser.) def test_parse_folded_headers(self): raw = dedent("""\ @@ -540,16 +538,11 @@ class EmailParserWorkaroundTests(SimpleTestCase): self.assertEqual(msg.from_email.display_name, "Keith Moore") self.assertEqual(msg.from_email.addr_spec, "moore@example.com") - # When an RFC2047 encoded-word abuts an RFC5322 quoted-word in a *structured* header, - # Python 3's parser nicely recombines them into a single quoted word. That's way too - # complicated for our Python 2 workaround ... - self.assertIn(msg["To"], [ # `To` header will decode to one of these: - 'Keld Jørn Simonsen , "André Pirard, Jr." ', # Python 3 - 'Keld Jørn Simonsen , André "Pirard, Jr." ', # workaround version - ]) - # ... but the two forms are equivalent, and de-structure the same: + self.assertEqual(msg["To"], + 'Keld Jørn Simonsen , ' + '"André Pirard, Jr." ') self.assertEqual(msg.to[0].display_name, "Keld Jørn Simonsen") - self.assertEqual(msg.to[1].display_name, "André Pirard, Jr.") # correct in Python 3 *and* workaround! + self.assertEqual(msg.to[1].display_name, "André Pirard, Jr.") # Note: Like email.headerregistry.Address, Anymail decodes an RFC2047-encoded display_name, # but does not decode a punycode domain. (Use `idna.decode(domain)` if you need that.) diff --git a/tests/test_mailgun_backend.py b/tests/test_mailgun_backend.py index 48d5af0..0b8dcbb 100644 --- a/tests/test_mailgun_backend.py +++ b/tests/test_mailgun_backend.py @@ -1,16 +1,7 @@ -# -*- coding: utf-8 -*- - from datetime import date, datetime from textwrap import dedent -try: - from email import message_from_bytes -except ImportError: - from email import message_from_string - - def message_from_bytes(s): - return message_from_string(s.decode('utf-8')) - +from email import message_from_bytes from email.mime.base import MIMEBase from email.mime.image import MIMEImage @@ -24,7 +15,7 @@ from anymail.exceptions import ( AnymailRequestsAPIError, AnymailUnsupportedFeature) from anymail.message import attach_inline_image_file -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin +from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases from .utils import (AnymailTestMixin, sample_email_content, sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME) @@ -39,7 +30,7 @@ class MailgunBackendMockAPITestCase(RequestsBackendMockAPITestCase): }""" def setUp(self): - super(MailgunBackendMockAPITestCase, self).setUp() + super().setUp() # Simple message useful for many tests self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) @@ -178,7 +169,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): self.assertEqual(len(inlines), 0) def test_unicode_attachment_correctly_decoded(self): - self.message.attach(u"Une pièce jointe.html", u'

\u2019

', mimetype='text/html') + self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') self.message.send() # Verify the RFC 7578 compliance workaround has kicked in: @@ -191,7 +182,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): workaround = True data = data.decode("utf-8").replace("\r\n", "\n") self.assertNotIn("filename*=", data) # No RFC 2231 encoding - self.assertIn(u'Content-Disposition: form-data; name="attachment"; filename="Une pièce jointe.html"', data) + self.assertIn('Content-Disposition: form-data; name="attachment"; filename="Une pièce jointe.html"', data) if workaround: files = self.get_api_call_files(required=False) @@ -199,8 +190,8 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): def test_rfc_7578_compliance(self): # Check some corner cases in the workaround that undoes RFC 2231 multipart/form-data encoding... - self.message.subject = u"Testing for filename*=utf-8''problems" - self.message.body = u"The attached message should have an attachment named 'vedhæftet fil.txt'" + self.message.subject = "Testing for filename*=utf-8''problems" + self.message.body = "The attached message should have an attachment named 'vedhæftet fil.txt'" # A forwarded message with its own attachment: forwarded_message = dedent("""\ MIME-Version: 1.0 @@ -219,7 +210,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): This is an attachment. --boundary-- """) - self.message.attach(u"besked med vedhæftede filer", forwarded_message, "message/rfc822") + self.message.attach("besked med vedhæftede filer", forwarded_message, "message/rfc822") self.message.send() data = self.get_api_call_data() @@ -230,13 +221,13 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): # Top-level attachment (in form-data) should have RFC 7578 filename (raw Unicode): self.assertIn( - u'Content-Disposition: form-data; name="attachment"; filename="besked med vedhæftede filer"', data) + 'Content-Disposition: form-data; name="attachment"; filename="besked med vedhæftede filer"', data) # Embedded message/rfc822 attachment should retain its RFC 2231 encoded filename: self.assertIn("Content-Type: text/plain; name*=utf-8''vedh%C3%A6ftet%20fil.txt", data) self.assertIn("Content-Disposition: attachment; filename*=utf-8''vedh%C3%A6ftet%20fil.txt", data) # References to RFC 2231 in message text should remain intact: self.assertIn("Testing for filename*=utf-8''problems", data) - self.assertIn(u"The attached message should have an attachment named 'vedhæftet fil.txt'", data) + self.assertIn("The attached message should have an attachment named 'vedhæftet fil.txt'", data) def test_attachment_missing_filename(self): """Mailgun silently drops attachments without filenames, so warn the caller""" @@ -767,14 +758,14 @@ class MailgunBackendRecipientsRefusedTests(MailgunBackendMockAPITestCase): @tag('mailgun') -class MailgunBackendSessionSharingTestCase(SessionSharingTestCasesMixin, MailgunBackendMockAPITestCase): +class MailgunBackendSessionSharingTestCase(SessionSharingTestCases, MailgunBackendMockAPITestCase): """Requests session sharing tests""" - pass # tests are defined in the mixin + pass # tests are defined in SessionSharingTestCases @tag('mailgun') @override_settings(EMAIL_BACKEND="anymail.backends.mailgun.EmailBackend") -class MailgunBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): +class MailgunBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test ESP backend without required settings in place""" def test_missing_api_key(self): diff --git a/tests/test_mailgun_inbound.py b/tests/test_mailgun_inbound.py index 23345ce..e9bf93b 100644 --- a/tests/test_mailgun_inbound.py +++ b/tests/test_mailgun_inbound.py @@ -1,8 +1,8 @@ import json from datetime import datetime +from io import BytesIO from textwrap import dedent -import six from django.test import override_settings, tag from django.utils.timezone import utc from mock import ANY @@ -97,13 +97,13 @@ class MailgunInboundTestCase(WebhookTestCase): ]) def test_attachments(self): - att1 = six.BytesIO('test attachment'.encode('utf-8')) + att1 = BytesIO('test attachment'.encode('utf-8')) att1.name = 'test.txt' image_content = sample_image_content() - att2 = six.BytesIO(image_content) + att2 = BytesIO(image_content) att2.name = 'image.png' email_content = sample_email_content() - att3 = six.BytesIO(email_content) + att3 = BytesIO(email_content) att3.content_type = 'message/rfc822; charset="us-ascii"' raw_event = mailgun_sign_legacy_payload({ 'message-headers': '[]', @@ -124,7 +124,7 @@ class MailgunInboundTestCase(WebhookTestCase): self.assertEqual(len(attachments), 2) self.assertEqual(attachments[0].get_filename(), 'test.txt') self.assertEqual(attachments[0].get_content_type(), 'text/plain') - self.assertEqual(attachments[0].get_content_text(), u'test attachment') + self.assertEqual(attachments[0].get_content_text(), 'test attachment') self.assertEqual(attachments[1].get_content_type(), 'message/rfc822') self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) @@ -176,8 +176,8 @@ class MailgunInboundTestCase(WebhookTestCase): self.assertEqual(message.envelope_sender, 'envelope-from@example.org') self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') self.assertEqual(message.subject, 'Raw MIME test') - self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n") - self.assertEqual(message.html, u"""
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") + self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n") + self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") def test_misconfigured_tracking(self): raw_event = mailgun_sign_payload({ diff --git a/tests/test_mailgun_integration.py b/tests/test_mailgun_integration.py index 16a5e2d..0df0c06 100644 --- a/tests/test_mailgun_integration.py +++ b/tests/test_mailgun_integration.py @@ -1,21 +1,17 @@ -# -*- coding: utf-8 -*- - -from __future__ import unicode_literals - -import os import logging +import os import unittest from datetime import datetime, timedelta -from time import mktime, sleep +from time import sleep import requests from django.test import SimpleTestCase, override_settings, tag from anymail.exceptions import AnymailAPIError from anymail.message import AnymailMessage - from .utils import AnymailTestMixin, sample_image_path + MAILGUN_TEST_API_KEY = os.getenv('MAILGUN_TEST_API_KEY') MAILGUN_TEST_DOMAIN = os.getenv('MAILGUN_TEST_DOMAIN') @@ -28,7 +24,7 @@ MAILGUN_TEST_DOMAIN = os.getenv('MAILGUN_TEST_DOMAIN') 'MAILGUN_SENDER_DOMAIN': MAILGUN_TEST_DOMAIN, 'MAILGUN_SEND_DEFAULTS': {'esp_extra': {'o:testmode': 'yes'}}}, EMAIL_BACKEND="anymail.backends.mailgun.EmailBackend") -class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): +class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """Mailgun API integration tests These tests run against the **live** Mailgun API, using the @@ -39,7 +35,7 @@ class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): """ def setUp(self): - super(MailgunBackendIntegrationTests, self).setUp() + super().setUp() self.message = AnymailMessage('Anymail Mailgun integration test', 'Text content', 'from@example.com', ['test+to1@anymail.info']) self.message.attach_alternative('

HTML content

', "text/html") @@ -101,7 +97,7 @@ class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): def test_all_options(self): send_at = datetime.now().replace(microsecond=0) + timedelta(minutes=2) - send_at_timestamp = mktime(send_at.timetuple()) # python3: send_at.timestamp() + send_at_timestamp = send_at.timestamp() message = AnymailMessage( subject="Anymail Mailgun all-options integration test", body="This is the text body", diff --git a/tests/test_mailgun_webhooks.py b/tests/test_mailgun_webhooks.py index 6225daa..7ef3809 100644 --- a/tests/test_mailgun_webhooks.py +++ b/tests/test_mailgun_webhooks.py @@ -12,7 +12,7 @@ from anymail.exceptions import AnymailConfigurationError from anymail.signals import AnymailTrackingEvent from anymail.webhooks.mailgun import MailgunTrackingWebhookView -from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin +from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase TEST_WEBHOOK_SIGNING_KEY = 'TEST_WEBHOOK_SIGNING_KEY' @@ -99,14 +99,14 @@ class MailgunWebhookSettingsTestCase(WebhookTestCase): @tag('mailgun') @override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY) -class MailgunWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin): +class MailgunWebhookSecurityTestCase(WebhookBasicAuthTestCase): should_warn_if_no_auth = False # because we check webhook signature def call_webhook(self): return self.client.post('/anymail/mailgun/tracking/', content_type="application/json", data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}}))) - # Additional tests are in WebhookBasicAuthTestsMixin + # Additional tests are in WebhookBasicAuthTestCase def test_verifies_correct_signature(self): response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json", diff --git a/tests/test_mailjet_backend.py b/tests/test_mailjet_backend.py index d18d157..403d398 100644 --- a/tests/test_mailjet_backend.py +++ b/tests/test_mailjet_backend.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from base64 import b64encode from decimal import Decimal from email.mime.base import MIMEBase @@ -14,7 +12,7 @@ from anymail.exceptions import (AnymailAPIError, AnymailSerializationError, AnymailRequestsAPIError) from anymail.message import attach_inline_image_file -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin +from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att @@ -49,7 +47,7 @@ class MailjetBackendMockAPITestCase(RequestsBackendMockAPITestCase): }""" def setUp(self): - super(MailjetBackendMockAPITestCase, self).setUp() + super().setUp() # Simple message useful for many tests self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) @@ -222,13 +220,13 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): self.assertNotIn('ContentID', attachments[2]) def test_unicode_attachment_correctly_decoded(self): - self.message.attach(u"Une pièce jointe.html", u'

\u2019

', mimetype='text/html') + self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') self.message.send() data = self.get_api_call_json() self.assertEqual(data['Attachments'], [{ - 'Filename': u'Une pièce jointe.html', + 'Filename': 'Une pièce jointe.html', 'Content-type': 'text/html', - 'content': b64encode(u'

\u2019

'.encode('utf-8')).decode('ascii') + 'content': b64encode('

\u2019

'.encode('utf-8')).decode('ascii') }]) def test_embedded_images(self): @@ -656,14 +654,14 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): @tag('mailjet') -class MailjetBackendSessionSharingTestCase(SessionSharingTestCasesMixin, MailjetBackendMockAPITestCase): +class MailjetBackendSessionSharingTestCase(SessionSharingTestCases, MailjetBackendMockAPITestCase): """Requests session sharing tests""" - pass # tests are defined in the mixin + pass # tests are defined in SessionSharingTestCases @tag('mailjet') @override_settings(EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend") -class MailjetBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): +class MailjetBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test ESP backend without required settings in place""" def test_missing_api_key(self): diff --git a/tests/test_mailjet_inbound.py b/tests/test_mailjet_inbound.py index 8117ce3..84dcef9 100644 --- a/tests/test_mailjet_inbound.py +++ b/tests/test_mailjet_inbound.py @@ -160,7 +160,7 @@ class MailjetInboundTestCase(WebhookTestCase): self.assertEqual(len(attachments), 2) self.assertEqual(attachments[0].get_filename(), 'test.txt') self.assertEqual(attachments[0].get_content_type(), 'text/plain') - self.assertEqual(attachments[0].get_content_text(), u'test attachment') + self.assertEqual(attachments[0].get_content_text(), 'test attachment') self.assertEqual(attachments[1].get_content_type(), 'message/rfc822') self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) diff --git a/tests/test_mailjet_integration.py b/tests/test_mailjet_integration.py index 3773b2c..dc7c806 100644 --- a/tests/test_mailjet_integration.py +++ b/tests/test_mailjet_integration.py @@ -19,7 +19,7 @@ MAILJET_TEST_SECRET_KEY = os.getenv('MAILJET_TEST_SECRET_KEY') @override_settings(ANYMAIL_MAILJET_API_KEY=MAILJET_TEST_API_KEY, ANYMAIL_MAILJET_SECRET_KEY=MAILJET_TEST_SECRET_KEY, EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend") -class MailjetBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): +class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """Mailjet API integration tests These tests run against the **live** Mailjet API, using the @@ -36,7 +36,7 @@ class MailjetBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): """ def setUp(self): - super(MailjetBackendIntegrationTests, self).setUp() + super().setUp() self.message = AnymailMessage('Anymail Mailjet integration test', 'Text content', 'test@test-mj.anymail.info', ['test+to1@anymail.info']) self.message.attach_alternative('

HTML content

', "text/html") diff --git a/tests/test_mailjet_webhooks.py b/tests/test_mailjet_webhooks.py index 4033d0b..f5c9e2b 100644 --- a/tests/test_mailjet_webhooks.py +++ b/tests/test_mailjet_webhooks.py @@ -7,16 +7,16 @@ from mock import ANY from anymail.signals import AnymailTrackingEvent from anymail.webhooks.mailjet import MailjetTrackingWebhookView -from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase +from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase @tag('mailjet') -class MailjetWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin): +class MailjetWebhookSecurityTestCase(WebhookBasicAuthTestCase): def call_webhook(self): return self.client.post('/anymail/mailjet/tracking/', content_type='application/json', data=json.dumps([])) - # Actual tests are in WebhookBasicAuthTestsMixin + # Actual tests are in WebhookBasicAuthTestCase @tag('mailjet') diff --git a/tests/test_mandrill_backend.py b/tests/test_mandrill_backend.py index d175d8e..4bcba44 100644 --- a/tests/test_mandrill_backend.py +++ b/tests/test_mandrill_backend.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from datetime import date, datetime from decimal import Decimal from email.mime.base import MIMEBase @@ -14,7 +12,7 @@ from anymail.exceptions import (AnymailAPIError, AnymailRecipientsRefused, AnymailSerializationError, AnymailUnsupportedFeature) from anymail.message import attach_inline_image -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin +from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att @@ -30,7 +28,7 @@ class MandrillBackendMockAPITestCase(RequestsBackendMockAPITestCase): }]""" def setUp(self): - super(MandrillBackendMockAPITestCase, self).setUp() + super().setUp() # Simple message useful for many tests self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) @@ -170,7 +168,7 @@ class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase): self.assertFalse('images' in data['message']) def test_unicode_attachment_correctly_decoded(self): - self.message.attach(u"Une pièce jointe.html", u'

\u2019

', mimetype='text/html') + self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') self.message.send() data = self.get_api_call_json() attachments = data['message']['attachments'] @@ -610,14 +608,14 @@ class MandrillBackendRecipientsRefusedTests(MandrillBackendMockAPITestCase): @tag('mandrill') -class MandrillBackendSessionSharingTestCase(SessionSharingTestCasesMixin, MandrillBackendMockAPITestCase): +class MandrillBackendSessionSharingTestCase(SessionSharingTestCases, MandrillBackendMockAPITestCase): """Requests session sharing tests""" - pass # tests are defined in the mixin + pass # tests are defined in SessionSharingTestCases @tag('mandrill') @override_settings(EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend") -class MandrillBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): +class MandrillBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test backend without required settings""" def test_missing_api_key(self): diff --git a/tests/test_mandrill_inbound.py b/tests/test_mandrill_inbound.py index 444c43c..cf875a5 100644 --- a/tests/test_mandrill_inbound.py +++ b/tests/test_mandrill_inbound.py @@ -75,8 +75,8 @@ class MandrillInboundTestCase(WebhookTestCase): self.assertEqual(message.to[1].addr_spec, 'other@example.com') self.assertEqual(message.subject, 'Test subject') self.assertEqual(message.date.isoformat(" "), "2017-10-12 18:03:30-07:00") - self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n") - self.assertEqual(message.html, u"""
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") + self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n") + self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") self.assertIsNone(message.envelope_sender) # Mandrill doesn't provide sender self.assertEqual(message.envelope_recipient, 'delivered-to@example.com') diff --git a/tests/test_mandrill_integration.py b/tests/test_mandrill_integration.py index fe33a17..27aef00 100644 --- a/tests/test_mandrill_integration.py +++ b/tests/test_mandrill_integration.py @@ -17,7 +17,7 @@ MANDRILL_TEST_API_KEY = os.getenv('MANDRILL_TEST_API_KEY') "Set MANDRILL_TEST_API_KEY environment variable to run integration tests") @override_settings(MANDRILL_API_KEY=MANDRILL_TEST_API_KEY, EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend") -class MandrillBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): +class MandrillBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """Mandrill API integration tests These tests run against the **live** Mandrill API, using the @@ -30,7 +30,7 @@ class MandrillBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): """ def setUp(self): - super(MandrillBackendIntegrationTests, self).setUp() + super().setUp() self.message = mail.EmailMultiAlternatives('Anymail Mandrill integration test', 'Text content', 'from@example.com', ['test+to1@anymail.info']) self.message.attach_alternative('

HTML content

', "text/html") diff --git a/tests/test_mandrill_webhooks.py b/tests/test_mandrill_webhooks.py index 9e6e9ad..fc5a677 100644 --- a/tests/test_mandrill_webhooks.py +++ b/tests/test_mandrill_webhooks.py @@ -1,10 +1,10 @@ -import json -from datetime import datetime -from six.moves.urllib.parse import urljoin - import hashlib import hmac +import json from base64 import b64encode +from datetime import datetime +from urllib.parse import urljoin + from django.core.exceptions import ImproperlyConfigured from django.test import override_settings, tag from django.utils.timezone import utc @@ -12,8 +12,7 @@ from mock import ANY from anymail.signals import AnymailTrackingEvent from anymail.webhooks.mandrill import MandrillCombinedWebhookView, MandrillTrackingWebhookView - -from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin +from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase TEST_WEBHOOK_KEY = 'TEST_WEBHOOK_KEY' @@ -65,14 +64,14 @@ class MandrillWebhookSettingsTestCase(WebhookTestCase): @tag('mandrill') @override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY) -class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin): +class MandrillWebhookSecurityTestCase(WebhookBasicAuthTestCase): should_warn_if_no_auth = False # because we check webhook signature def call_webhook(self): kwargs = mandrill_args([{'event': 'send'}]) return self.client.post(**kwargs) - # Additional tests are in WebhookBasicAuthTestsMixin + # Additional tests are in WebhookBasicAuthTestCase def test_verifies_correct_signature(self): kwargs = mandrill_args([{'event': 'send'}]) @@ -112,7 +111,7 @@ class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixi response = self.client.post(SERVER_NAME="127.0.0.1", **kwargs) self.assertEqual(response.status_code, 200) - # override WebhookBasicAuthTestsMixin version of this test + # override WebhookBasicAuthTestCase version of this test @override_settings(ANYMAIL={'WEBHOOK_SECRET': ['cred1:pass1', 'cred2:pass2']}) def test_supports_credential_rotation(self): """You can supply a list of basic auth credentials, and any is allowed""" diff --git a/tests/test_message.py b/tests/test_message.py index b82313c..328e1fb 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -10,7 +10,7 @@ from .utils import AnymailTestMixin, sample_image_content class InlineImageTests(AnymailTestMixin, SimpleTestCase): def setUp(self): self.message = EmailMultiAlternatives() - super(InlineImageTests, self).setUp() + super().setUp() @patch("email.utils.socket.getfqdn") def test_default_domain(self, mock_getfqdn): diff --git a/tests/test_postmark_backend.py b/tests/test_postmark_backend.py index 3c28a18..46a00a2 100644 --- a/tests/test_postmark_backend.py +++ b/tests/test_postmark_backend.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import json from base64 import b64encode from decimal import Decimal @@ -14,7 +13,7 @@ from anymail.exceptions import ( AnymailUnsupportedFeature, AnymailRecipientsRefused, AnymailInvalidAddress) from anymail.message import attach_inline_image_file, AnymailMessage -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin +from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att @@ -31,7 +30,7 @@ class PostmarkBackendMockAPITestCase(RequestsBackendMockAPITestCase): }""" def setUp(self): - super(PostmarkBackendMockAPITestCase, self).setUp() + super().setUp() # Simple message useful for many tests self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) @@ -182,13 +181,13 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase): self.assertNotIn('ContentID', attachments[2]) def test_unicode_attachment_correctly_decoded(self): - self.message.attach(u"Une pièce jointe.html", u'

\u2019

', mimetype='text/html') + self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') self.message.send() data = self.get_api_call_json() self.assertEqual(data['Attachments'], [{ - 'Name': u'Une pièce jointe.html', + 'Name': 'Une pièce jointe.html', 'ContentType': 'text/html', - 'Content': b64encode(u'

\u2019

'.encode('utf-8')).decode('ascii') + 'Content': b64encode('

\u2019

'.encode('utf-8')).decode('ascii') }]) def test_embedded_images(self): @@ -758,14 +757,14 @@ class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase): @tag('postmark') -class PostmarkBackendSessionSharingTestCase(SessionSharingTestCasesMixin, PostmarkBackendMockAPITestCase): +class PostmarkBackendSessionSharingTestCase(SessionSharingTestCases, PostmarkBackendMockAPITestCase): """Requests session sharing tests""" - pass # tests are defined in the mixin + pass # tests are defined in SessionSharingTestCases @tag('postmark') @override_settings(EMAIL_BACKEND="anymail.backends.postmark.EmailBackend") -class PostmarkBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): +class PostmarkBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test ESP backend without required settings in place""" def test_missing_api_key(self): diff --git a/tests/test_postmark_inbound.py b/tests/test_postmark_inbound.py index 1e680af..8ec13a6 100644 --- a/tests/test_postmark_inbound.py +++ b/tests/test_postmark_inbound.py @@ -163,7 +163,7 @@ class PostmarkInboundTestCase(WebhookTestCase): self.assertEqual(len(attachments), 2) self.assertEqual(attachments[0].get_filename(), 'test.txt') self.assertEqual(attachments[0].get_content_type(), 'text/plain') - self.assertEqual(attachments[0].get_content_text(), u'test attachment') + self.assertEqual(attachments[0].get_content_text(), 'test attachment') self.assertEqual(attachments[1].get_content_type(), 'message/rfc822') self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) diff --git a/tests/test_postmark_integration.py b/tests/test_postmark_integration.py index 80ace20..4494423 100644 --- a/tests/test_postmark_integration.py +++ b/tests/test_postmark_integration.py @@ -18,7 +18,7 @@ POSTMARK_TEST_TEMPLATE_ID = os.getenv('POSTMARK_TEST_TEMPLATE_ID') @tag('postmark', 'live') @override_settings(ANYMAIL_POSTMARK_SERVER_TOKEN="POSTMARK_API_TEST", EMAIL_BACKEND="anymail.backends.postmark.EmailBackend") -class PostmarkBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): +class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """Postmark API integration tests These tests run against the **live** Postmark API, but using a @@ -26,7 +26,7 @@ class PostmarkBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): """ def setUp(self): - super(PostmarkBackendIntegrationTests, self).setUp() + super().setUp() self.message = AnymailMessage('Anymail Postmark integration test', 'Text content', 'from@example.com', ['test+to1@anymail.info']) self.message.attach_alternative('

HTML content

', "text/html") diff --git a/tests/test_postmark_webhooks.py b/tests/test_postmark_webhooks.py index 3f6fff9..31680cb 100644 --- a/tests/test_postmark_webhooks.py +++ b/tests/test_postmark_webhooks.py @@ -8,16 +8,16 @@ from mock import ANY from anymail.exceptions import AnymailConfigurationError from anymail.signals import AnymailTrackingEvent from anymail.webhooks.postmark import PostmarkTrackingWebhookView -from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase +from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase @tag('postmark') -class PostmarkWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin): +class PostmarkWebhookSecurityTestCase(WebhookBasicAuthTestCase): def call_webhook(self): return self.client.post('/anymail/postmark/tracking/', content_type='application/json', data=json.dumps({})) - # Actual tests are in WebhookBasicAuthTestsMixin + # Actual tests are in WebhookBasicAuthTestCase @tag('postmark') diff --git a/tests/test_sendgrid_backend.py b/tests/test_sendgrid_backend.py index b8a1b5f..3493e58 100644 --- a/tests/test_sendgrid_backend.py +++ b/tests/test_sendgrid_backend.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from base64 import b64encode, b64decode from calendar import timegm from datetime import date, datetime @@ -7,7 +5,6 @@ from decimal import Decimal from email.mime.base import MIMEBase from email.mime.image import MIMEImage -import six from django.core import mail from django.test import SimpleTestCase, override_settings, tag from django.utils.timezone import get_fixed_timezone, override as override_current_timezone @@ -17,12 +14,9 @@ from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, Anym AnymailUnsupportedFeature, AnymailWarning) from anymail.message import attach_inline_image_file -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin +from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin -# noinspection PyUnresolvedReferences -longtype = int if six.PY3 else long # NOQA: F821 - @tag('sendgrid') @override_settings(EMAIL_BACKEND='anymail.backends.sendgrid.EmailBackend', @@ -32,7 +26,7 @@ class SendGridBackendMockAPITestCase(RequestsBackendMockAPITestCase): DEFAULT_STATUS_CODE = 202 # SendGrid v3 uses '202 Accepted' for success (in most cases) def setUp(self): - super(SendGridBackendMockAPITestCase, self).setUp() + super().setUp() # Patch uuid4 to generate predictable anymail_ids for testing patch_uuid4 = patch('anymail.backends.sendgrid.uuid.uuid4', @@ -153,13 +147,12 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): self.assertEqual(data['content'][0], {'type': "text/html", 'value': html_content}) def test_extra_headers(self): - self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123, 'X-Long': longtype(123), + self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123, 'Reply-To': '"Do Not Reply" '} self.message.send() data = self.get_api_call_json() self.assertEqual(data['headers']['X-Custom'], 'string') self.assertEqual(data['headers']['X-Num'], '123') # converted to string (undoc'd SendGrid requirement) - self.assertEqual(data['headers']['X-Long'], '123') # converted to string (undoc'd SendGrid requirement) # Reply-To must be moved to separate param self.assertNotIn('Reply-To', data['headers']) self.assertEqual(data['reply_to'], {'name': "Do Not Reply", 'email': "noreply@example.com"}) @@ -222,11 +215,11 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): 'type': "application/pdf"}) def test_unicode_attachment_correctly_decoded(self): - self.message.attach(u"Une pièce jointe.html", u'

\u2019

', mimetype='text/html') + self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') self.message.send() attachment = self.get_api_call_json()['attachments'][0] - self.assertEqual(attachment['filename'], u'Une pièce jointe.html') - self.assertEqual(b64decode(attachment['content']).decode('utf-8'), u'

\u2019

') + self.assertEqual(attachment['filename'], 'Une pièce jointe.html') + self.assertEqual(b64decode(attachment['content']).decode('utf-8'), '

\u2019

') def test_embedded_images(self): image_filename = SAMPLE_IMAGE_FILENAME @@ -348,14 +341,14 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): self.message.send() def test_metadata(self): - self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)} + self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6} self.message.send() data = self.get_api_call_json() data['custom_args'].pop('anymail_id', None) # remove anymail_id we added for tracking self.assertEqual(data['custom_args'], {'user_id': "12345", 'items': "6", # int converted to a string, 'float': "98.6", # float converted to a string (watch binary rounding!) - 'long': "123"}) # long converted to string + }) def test_send_at(self): utc_plus_6 = get_fixed_timezone(6 * 60) @@ -879,14 +872,14 @@ class SendGridBackendRecipientsRefusedTests(SendGridBackendMockAPITestCase): @tag('sendgrid') -class SendGridBackendSessionSharingTestCase(SessionSharingTestCasesMixin, SendGridBackendMockAPITestCase): +class SendGridBackendSessionSharingTestCase(SessionSharingTestCases, SendGridBackendMockAPITestCase): """Requests session sharing tests""" - pass # tests are defined in the mixin + pass # tests are defined in SessionSharingTestCases @tag('sendgrid') @override_settings(EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend") -class SendGridBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): +class SendGridBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test ESP backend without required settings in place""" def test_missing_auth(self): @@ -896,7 +889,7 @@ class SendGridBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin) @tag('sendgrid') @override_settings(EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend") -class SendGridBackendDisallowsV2Tests(SimpleTestCase, AnymailTestMixin): +class SendGridBackendDisallowsV2Tests(AnymailTestMixin, SimpleTestCase): """Using v2-API-only features should cause errors with v3 backend""" @override_settings(ANYMAIL={'SENDGRID_USERNAME': 'sg_username', 'SENDGRID_PASSWORD': 'sg_password'}) diff --git a/tests/test_sendgrid_inbound.py b/tests/test_sendgrid_inbound.py index f91d1d8..ecac317 100644 --- a/tests/test_sendgrid_inbound.py +++ b/tests/test_sendgrid_inbound.py @@ -1,9 +1,7 @@ -# -*- coding: utf-8 -*- - import json +from io import BytesIO from textwrap import dedent -import six from django.test import tag from mock import ANY @@ -91,13 +89,13 @@ class SendgridInboundTestCase(WebhookTestCase): ]) def test_attachments(self): - att1 = six.BytesIO('test attachment'.encode('utf-8')) + att1 = BytesIO('test attachment'.encode('utf-8')) att1.name = 'test.txt' image_content = sample_image_content() - att2 = six.BytesIO(image_content) + att2 = BytesIO(image_content) att2.name = 'image.png' email_content = sample_email_content() - att3 = six.BytesIO(email_content) + att3 = BytesIO(email_content) att3.content_type = 'message/rfc822; charset="us-ascii"' raw_event = { 'headers': '', @@ -124,7 +122,7 @@ class SendgridInboundTestCase(WebhookTestCase): self.assertEqual(len(attachments), 2) self.assertEqual(attachments[0].get_filename(), 'test.txt') self.assertEqual(attachments[0].get_content_type(), 'text/plain') - self.assertEqual(attachments[0].get_content_text(), u'test attachment') + self.assertEqual(attachments[0].get_content_text(), 'test attachment') self.assertEqual(attachments[1].get_content_type(), 'message/rfc822') self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) @@ -183,8 +181,8 @@ class SendgridInboundTestCase(WebhookTestCase): self.assertEqual(message.envelope_sender, 'envelope-from@example.org') self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') self.assertEqual(message.subject, 'Raw MIME test') - self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n") - self.assertEqual(message.html, u"""
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") + self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n") + self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") def test_inbound_charsets(self): # Captured (sanitized) from actual SendGrid inbound webhook payload 7/2020, @@ -233,11 +231,11 @@ class SendgridInboundTestCase(WebhookTestCase): event = kwargs['event'] message = event.message - self.assertEqual(message.from_email.display_name, u"Opérateur de test") + self.assertEqual(message.from_email.display_name, "Opérateur de test") self.assertEqual(message.from_email.addr_spec, "sender@example.com") self.assertEqual(len(message.to), 1) - self.assertEqual(message.to[0].display_name, u"Récipiendaire précieux") + self.assertEqual(message.to[0].display_name, "Récipiendaire précieux") self.assertEqual(message.to[0].addr_spec, "inbound@sg.example.com") - self.assertEqual(message.subject, u"Como usted pidió") - self.assertEqual(message.text, u"Test the ESP’s inbound charset handling…") - self.assertEqual(message.html, u"

¿Esto se ve como esperabas?

") + self.assertEqual(message.subject, "Como usted pidió") + self.assertEqual(message.text, "Test the ESP’s inbound charset handling…") + self.assertEqual(message.html, "

¿Esto se ve como esperabas?

") diff --git a/tests/test_sendgrid_integration.py b/tests/test_sendgrid_integration.py index 95c8748..c6434fe 100644 --- a/tests/test_sendgrid_integration.py +++ b/tests/test_sendgrid_integration.py @@ -22,7 +22,7 @@ SENDGRID_TEST_TEMPLATE_ID = os.getenv('SENDGRID_TEST_TEMPLATE_ID') "mail_settings": {"sandbox_mode": {"enable": True}}, }}, EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend") -class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): +class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """SendGrid v3 API integration tests These tests run against the **live** SendGrid API, using the @@ -38,7 +38,7 @@ class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): """ def setUp(self): - super(SendGridBackendIntegrationTests, self).setUp() + super().setUp() self.message = AnymailMessage('Anymail SendGrid integration test', 'Text content', 'from@example.com', ['to@sink.sendgrid.net']) self.message.attach_alternative('

HTML content

', "text/html") diff --git a/tests/test_sendgrid_webhooks.py b/tests/test_sendgrid_webhooks.py index 151d7b2..9d8750b 100644 --- a/tests/test_sendgrid_webhooks.py +++ b/tests/test_sendgrid_webhooks.py @@ -7,16 +7,16 @@ from mock import ANY from anymail.signals import AnymailTrackingEvent from anymail.webhooks.sendgrid import SendGridTrackingWebhookView -from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase +from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase @tag('sendgrid') -class SendGridWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin): +class SendGridWebhookSecurityTestCase(WebhookBasicAuthTestCase): def call_webhook(self): return self.client.post('/anymail/sendgrid/tracking/', content_type='application/json', data=json.dumps([])) - # Actual tests are in WebhookBasicAuthTestsMixin + # Actual tests are in WebhookBasicAuthTestCase @tag('sendgrid') diff --git a/tests/test_sendinblue_backend.py b/tests/test_sendinblue_backend.py index c9c70f3..b83be90 100644 --- a/tests/test_sendinblue_backend.py +++ b/tests/test_sendinblue_backend.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import json from base64 import b64encode, b64decode from datetime import datetime @@ -7,7 +5,6 @@ from decimal import Decimal from email.mime.base import MIMEBase from email.mime.image import MIMEImage -import six from django.core import mail from django.test import SimpleTestCase, override_settings, tag from django.utils.timezone import get_fixed_timezone, override as override_current_timezone @@ -15,12 +12,9 @@ from django.utils.timezone import get_fixed_timezone, override as override_curre from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, AnymailSerializationError, AnymailUnsupportedFeature) from anymail.message import attach_inline_image_file -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin +from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin -# noinspection PyUnresolvedReferences -longtype = int if six.PY3 else long # NOQA: F821 - @tag('sendinblue') @override_settings(EMAIL_BACKEND='anymail.backends.sendinblue.EmailBackend', @@ -31,7 +25,7 @@ class SendinBlueBackendMockAPITestCase(RequestsBackendMockAPITestCase): DEFAULT_STATUS_CODE = 201 # SendinBlue v3 uses '201 Created' for success (in most cases) def setUp(self): - super(SendinBlueBackendMockAPITestCase, self).setUp() + super().setUp() # Simple message useful for many tests self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) @@ -119,13 +113,12 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase): self.assertNotIn('textContent', data) def test_extra_headers(self): - self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123, 'X-Long': longtype(123), + self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123, 'Reply-To': '"Do Not Reply" '} self.message.send() data = self.get_api_call_json() self.assertEqual(data['headers']['X-Custom'], 'string') self.assertEqual(data['headers']['X-Num'], 123) - self.assertEqual(data['headers']['X-Long'], 123) # Reply-To must be moved to separate param self.assertNotIn('Reply-To', data['headers']) self.assertEqual(data['replyTo'], {'name': "Do Not Reply", 'email': "noreply@example.com"}) @@ -185,11 +178,11 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase): 'content': b64encode(pdf_content).decode('ascii')}) def test_unicode_attachment_correctly_decoded(self): - self.message.attach(u"Une pièce jointe.html", u'

\u2019

', mimetype='text/html') + self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') self.message.send() attachment = self.get_api_call_json()['attachment'][0] - self.assertEqual(attachment['name'], u'Une pièce jointe.html') - self.assertEqual(b64decode(attachment['content']).decode('utf-8'), u'

\u2019

') + self.assertEqual(attachment['name'], 'Une pièce jointe.html') + self.assertEqual(b64decode(attachment['content']).decode('utf-8'), '

\u2019

') def test_embedded_images(self): # SendinBlue doesn't support inline image @@ -284,7 +277,7 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase): self.message.send() def test_metadata(self): - self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)} + self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6} self.message.send() data = self.get_api_call_json() @@ -293,7 +286,6 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase): self.assertEqual(metadata['user_id'], "12345") self.assertEqual(metadata['items'], 6) self.assertEqual(metadata['float'], 98.6) - self.assertEqual(metadata['long'], longtype(123)) def test_send_at(self): utc_plus_6 = get_fixed_timezone(6 * 60) @@ -451,14 +443,14 @@ class SendinBlueBackendRecipientsRefusedTests(SendinBlueBackendMockAPITestCase): @tag('sendinblue') -class SendinBlueBackendSessionSharingTestCase(SessionSharingTestCasesMixin, SendinBlueBackendMockAPITestCase): +class SendinBlueBackendSessionSharingTestCase(SessionSharingTestCases, SendinBlueBackendMockAPITestCase): """Requests session sharing tests""" - pass # tests are defined in the mixin + pass # tests are defined in SessionSharingTestCases @tag('sendinblue') @override_settings(EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend") -class SendinBlueBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): +class SendinBlueBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test ESP backend without required settings in place""" def test_missing_auth(self): diff --git a/tests/test_sendinblue_integration.py b/tests/test_sendinblue_integration.py index 0d7b2e0..1375931 100644 --- a/tests/test_sendinblue_integration.py +++ b/tests/test_sendinblue_integration.py @@ -18,7 +18,7 @@ SENDINBLUE_TEST_API_KEY = os.getenv('SENDINBLUE_TEST_API_KEY') @override_settings(ANYMAIL_SENDINBLUE_API_KEY=SENDINBLUE_TEST_API_KEY, ANYMAIL_SENDINBLUE_SEND_DEFAULTS=dict(), EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend") -class SendinBlueBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): +class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """SendinBlue v3 API integration tests SendinBlue doesn't have sandbox so these tests run @@ -31,7 +31,7 @@ class SendinBlueBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): """ def setUp(self): - super(SendinBlueBackendIntegrationTests, self).setUp() + super().setUp() self.message = AnymailMessage('Anymail SendinBlue integration test', 'Text content', 'from@test-sb.anymail.info', ['test+to1@anymail.info']) diff --git a/tests/test_sendinblue_webhooks.py b/tests/test_sendinblue_webhooks.py index 5c20d7b..307cf4b 100644 --- a/tests/test_sendinblue_webhooks.py +++ b/tests/test_sendinblue_webhooks.py @@ -7,16 +7,16 @@ from mock import ANY from anymail.signals import AnymailTrackingEvent from anymail.webhooks.sendinblue import SendinBlueTrackingWebhookView -from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase +from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase @tag('sendinblue') -class SendinBlueWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin): +class SendinBlueWebhookSecurityTestCase(WebhookBasicAuthTestCase): def call_webhook(self): return self.client.post('/anymail/sendinblue/tracking/', content_type='application/json', data=json.dumps({})) - # Actual tests are in WebhookBasicAuthTestsMixin + # Actual tests are in WebhookBasicAuthTestCase @tag('sendinblue') diff --git a/tests/test_settings/settings_1_11.py b/tests/test_settings/settings_1_11.py deleted file mode 100644 index 0b51515..0000000 --- a/tests/test_settings/settings_1_11.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Django settings for Anymail tests. - -Generated by 'django-admin startproject' using Django 1.11. - -For more information on this file, see -https://docs.djangoproject.com/en/1.11/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.11/ref/settings/ -""" - -import os - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'NOT_FOR_PRODUCTION_USE' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] - - -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'anymail', -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'tests.test_settings.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'tests.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/1.11/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } -} - - -# Password validation -# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/1.11/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.11/howto/static-files/ - -STATIC_URL = '/static/' diff --git a/tests/test_settings/urls.py b/tests/test_settings/urls.py index 996fcc5..d6402f7 100644 --- a/tests/test_settings/urls.py +++ b/tests/test_settings/urls.py @@ -1,5 +1,5 @@ -from django.conf.urls import include, url +from django.urls import include, re_path urlpatterns = [ - url(r'^anymail/', include('anymail.urls')), + re_path(r'^anymail/', include('anymail.urls')), ] diff --git a/tests/test_sparkpost_backend.py b/tests/test_sparkpost_backend.py index 2fdb69c..b7261f4 100644 --- a/tests/test_sparkpost_backend.py +++ b/tests/test_sparkpost_backend.py @@ -1,32 +1,30 @@ -# -*- coding: utf-8 -*- - -from datetime import datetime, date +import os +from datetime import date, datetime from email.mime.base import MIMEBase from email.mime.image import MIMEImage -import os +from io import BytesIO import requests -import six from django.core import mail from django.test import SimpleTestCase, override_settings, tag from django.utils.timezone import get_fixed_timezone, override as override_current_timezone, utc from mock import patch -from anymail.exceptions import (AnymailAPIError, AnymailUnsupportedFeature, AnymailRecipientsRefused, - AnymailConfigurationError, AnymailInvalidAddress) +from anymail.exceptions import ( + AnymailAPIError, AnymailConfigurationError, AnymailInvalidAddress, AnymailRecipientsRefused, + AnymailUnsupportedFeature) from anymail.message import attach_inline_image_file - -from .utils import AnymailTestMixin, decode_att, SAMPLE_IMAGE_FILENAME, sample_image_path, sample_image_content +from .utils import AnymailTestMixin, SAMPLE_IMAGE_FILENAME, decode_att, sample_image_content, sample_image_path @tag('sparkpost') @override_settings(EMAIL_BACKEND='anymail.backends.sparkpost.EmailBackend', ANYMAIL={'SPARKPOST_API_KEY': 'test_api_key'}) -class SparkPostBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin): +class SparkPostBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): """TestCase that uses SparkPostEmailBackend with a mocked transmissions.send API""" def setUp(self): - super(SparkPostBackendMockAPITestCase, self).setUp() + super().setUp() self.patch_send = patch('sparkpost.Transmissions.send', autospec=True) self.mock_send = self.patch_send.start() self.addCleanup(self.patch_send.stop) @@ -52,7 +50,7 @@ class SparkPostBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin): response = requests.Response() response.status_code = status_code response.encoding = encoding - response.raw = six.BytesIO(raw) + response.raw = BytesIO(raw) response.url = "/mock/send" self.mock_send.side_effect = SparkPostAPIException(response) @@ -205,7 +203,7 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): def test_unicode_attachment_correctly_decoded(self): # Slight modification from the Django unicode docs: # http://django.readthedocs.org/en/latest/ref/unicode.html#email - self.message.attach(u"Une pièce jointe.html", u'

\u2019

', mimetype='text/html') + self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') self.message.send() params = self.get_send_params() attachments = params['attachments'] @@ -609,7 +607,7 @@ class SparkPostBackendRecipientsRefusedTests(SparkPostBackendMockAPITestCase): @tag('sparkpost') @override_settings(EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend") -class SparkPostBackendConfigurationTests(SimpleTestCase, AnymailTestMixin): +class SparkPostBackendConfigurationTests(AnymailTestMixin, SimpleTestCase): """Test various SparkPost client options""" def test_missing_api_key(self): diff --git a/tests/test_sparkpost_inbound.py b/tests/test_sparkpost_inbound.py index c36aacf..0fdb04f 100644 --- a/tests/test_sparkpost_inbound.py +++ b/tests/test_sparkpost_inbound.py @@ -80,8 +80,8 @@ class SparkpostInboundTestCase(WebhookTestCase): ['cc@example.com']) self.assertEqual(message.subject, 'Test subject') self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00") - self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n") - self.assertEqual(message.html, u"""
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") + self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n") + self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") self.assertEqual(message.envelope_sender, 'envelope-from@example.org') self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') @@ -158,7 +158,7 @@ class SparkpostInboundTestCase(WebhookTestCase): self.assertEqual(len(attachments), 2) self.assertEqual(attachments[0].get_filename(), 'test.txt') self.assertEqual(attachments[0].get_content_type(), 'text/plain') - self.assertEqual(attachments[0].get_content_text(), u'test attachment') + self.assertEqual(attachments[0].get_content_text(), 'test attachment') self.assertEqual(attachments[1].get_content_type(), 'message/rfc822') self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) diff --git a/tests/test_sparkpost_integration.py b/tests/test_sparkpost_integration.py index efabad0..453047f 100644 --- a/tests/test_sparkpost_integration.py +++ b/tests/test_sparkpost_integration.py @@ -1,5 +1,6 @@ import os import unittest +import warnings from datetime import datetime, timedelta from django.test import SimpleTestCase, override_settings, tag @@ -18,7 +19,7 @@ SPARKPOST_TEST_API_KEY = os.getenv('SPARKPOST_TEST_API_KEY') "to run SparkPost integration tests") @override_settings(ANYMAIL_SPARKPOST_API_KEY=SPARKPOST_TEST_API_KEY, EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend") -class SparkPostBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): +class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """SparkPost API integration tests These tests run against the **live** SparkPost API, using the @@ -28,19 +29,31 @@ class SparkPostBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): SparkPost doesn't offer a test mode -- it tries to send everything you ask. To avoid stacking up a pile of undeliverable @example.com emails, the tests use SparkPost's "sink domain" @*.sink.sparkpostmail.com. - https://support.sparkpost.com/customer/en/portal/articles/2361300-how-to-test-integrations + https://www.sparkpost.com/docs/faq/using-sink-server/ SparkPost also doesn't support arbitrary senders (so no from@example.com). We've set up @test-sp.anymail.info as a validated sending domain for these tests. - """ def setUp(self): - super(SparkPostBackendIntegrationTests, self).setUp() + super().setUp() self.message = AnymailMessage('Anymail SparkPost integration test', 'Text content', 'test@test-sp.anymail.info', ['to@test.sink.sparkpostmail.com']) self.message.attach_alternative('

HTML content

', "text/html") + # The SparkPost Python package uses requests directly, without managing sessions, and relies + # on GC to close connections. This leads to harmless (but valid) warnings about unclosed + # ssl.SSLSocket during cleanup: https://github.com/psf/requests/issues/1882 + # There's not much we can do about that, short of switching from the SparkPost package + # to our own requests backend implementation (which *does* manage sessions properly). + # Unless/until we do that, filter the warnings to avoid test noise. + # Filter in TestCase.setUp because unittest resets the warning filters for each test. + # https://stackoverflow.com/a/26620811/647002 + from anymail.backends.base_requests import AnymailRequestsBackend + from anymail.backends.sparkpost import EmailBackend as SparkPostBackend + assert not issubclass(SparkPostBackend, AnymailRequestsBackend) # else this filter can be removed + warnings.filterwarnings("ignore", message=r"unclosed = 1.11 -except ImportError: - format_lazy = None - -try: - from django.utils.translation import string_concat # Django < 2.1 -except ImportError: - string_concat = None +from django.utils.text import format_lazy +from django.utils.translation import gettext_lazy from anymail.exceptions import AnymailInvalidAddress, _LazyError from anymail.utils import ( @@ -66,11 +55,11 @@ class ParseAddressListTests(SimpleTestCase): self.assertEqual(parsed.address, 'Display Name ') def test_unicode_display_name(self): - parsed_list = parse_address_list([u'"Unicode \N{HEAVY BLACK HEART}" ']) + parsed_list = parse_address_list(['"Unicode \N{HEAVY BLACK HEART}" ']) self.assertEqual(len(parsed_list), 1) parsed = parsed_list[0] self.assertEqual(parsed.addr_spec, "test@example.com") - self.assertEqual(parsed.display_name, u"Unicode \N{HEAVY BLACK HEART}") + self.assertEqual(parsed.display_name, "Unicode \N{HEAVY BLACK HEART}") # formatted display-name automatically shifts to quoted-printable/base64 for non-ascii chars: self.assertEqual(parsed.address, '=?utf-8?b?VW5pY29kZSDinaQ=?= ') @@ -83,13 +72,13 @@ class ParseAddressListTests(SimpleTestCase): parse_address_list(['Display Name, Inc. ']) def test_idn(self): - parsed_list = parse_address_list([u"idn@\N{ENVELOPE}.example.com"]) + parsed_list = parse_address_list(["idn@\N{ENVELOPE}.example.com"]) self.assertEqual(len(parsed_list), 1) parsed = parsed_list[0] - self.assertEqual(parsed.addr_spec, u"idn@\N{ENVELOPE}.example.com") + self.assertEqual(parsed.addr_spec, "idn@\N{ENVELOPE}.example.com") self.assertEqual(parsed.address, "idn@xn--4bi.example.com") # punycode-encoded domain self.assertEqual(parsed.username, "idn") - self.assertEqual(parsed.domain, u"\N{ENVELOPE}.example.com") + self.assertEqual(parsed.domain, "\N{ENVELOPE}.example.com") def test_none_address(self): # used for, e.g., telling Mandrill to use template default from_email @@ -139,10 +128,9 @@ class ParseAddressListTests(SimpleTestCase): parse_address_list(['"Display Name"', '']) def test_invalid_with_unicode(self): - # (assertRaisesMessage can't handle unicode in Python 2) - with self.assertRaises(AnymailInvalidAddress) as cm: - parse_address_list([u"\N{ENVELOPE}"]) - self.assertIn(u"Invalid email address '\N{ENVELOPE}'", six.text_type(cm.exception)) + with self.assertRaisesMessage(AnymailInvalidAddress, + "Invalid email address '\N{ENVELOPE}'"): + parse_address_list(["\N{ENVELOPE}"]) def test_single_string(self): # bare strings are used by the from_email parsing in BasePayload @@ -151,12 +139,12 @@ class ParseAddressListTests(SimpleTestCase): self.assertEqual(parsed_list[0].addr_spec, "one@example.com") def test_lazy_strings(self): - parsed_list = parse_address_list([ugettext_lazy('"Example, Inc." ')]) + parsed_list = parse_address_list([gettext_lazy('"Example, Inc." ')]) self.assertEqual(len(parsed_list), 1) self.assertEqual(parsed_list[0].display_name, "Example, Inc.") self.assertEqual(parsed_list[0].addr_spec, "one@example.com") - parsed_list = parse_address_list(ugettext_lazy("one@example.com")) + parsed_list = parse_address_list(gettext_lazy("one@example.com")) self.assertEqual(len(parsed_list), 1) self.assertEqual(parsed_list[0].display_name, "") self.assertEqual(parsed_list[0].addr_spec, "one@example.com") @@ -221,47 +209,38 @@ class LazyCoercionTests(SimpleTestCase): """Test utils.is_lazy and force_non_lazy*""" def test_is_lazy(self): - self.assertTrue(is_lazy(ugettext_lazy("lazy string is lazy"))) + self.assertTrue(is_lazy(gettext_lazy("lazy string is lazy"))) def test_not_lazy(self): - self.assertFalse(is_lazy(u"text not lazy")) + self.assertFalse(is_lazy("text not lazy")) self.assertFalse(is_lazy(b"bytes not lazy")) self.assertFalse(is_lazy(None)) self.assertFalse(is_lazy({'dict': "not lazy"})) self.assertFalse(is_lazy(["list", "not lazy"])) self.assertFalse(is_lazy(object())) - self.assertFalse(is_lazy([ugettext_lazy("doesn't recurse")])) + self.assertFalse(is_lazy([gettext_lazy("doesn't recurse")])) def test_force_lazy(self): - result = force_non_lazy(ugettext_lazy(u"text")) - self.assertIsInstance(result, six.text_type) - self.assertEqual(result, u"text") + result = force_non_lazy(gettext_lazy("text")) + self.assertIsInstance(result, str) + self.assertEqual(result, "text") - @skipIf(string_concat is None, "string_concat not in this Django version") - def test_force_concat(self): - self.assertTrue(is_lazy(string_concat(ugettext_lazy("concatenation"), - ugettext_lazy("is lazy")))) - result = force_non_lazy(string_concat(ugettext_lazy(u"text"), ugettext_lazy("concat"))) - self.assertIsInstance(result, six.text_type) - self.assertEqual(result, u"textconcat") - - @skipIf(format_lazy is None, "format_lazy not in this Django version") def test_format_lazy(self): self.assertTrue(is_lazy(format_lazy("{0}{1}", - ugettext_lazy("concatenation"), ugettext_lazy("is lazy")))) + gettext_lazy("concatenation"), gettext_lazy("is lazy")))) result = force_non_lazy(format_lazy("{first}/{second}", - first=ugettext_lazy(u"text"), second=ugettext_lazy("format"))) - self.assertIsInstance(result, six.text_type) - self.assertEqual(result, u"text/format") + first=gettext_lazy("text"), second=gettext_lazy("format"))) + self.assertIsInstance(result, str) + self.assertEqual(result, "text/format") def test_force_string(self): - result = force_non_lazy(u"text") - self.assertIsInstance(result, six.text_type) - self.assertEqual(result, u"text") + result = force_non_lazy("text") + self.assertIsInstance(result, str) + self.assertEqual(result, "text") def test_force_bytes(self): result = force_non_lazy(b"bytes \xFE") - self.assertIsInstance(result, six.binary_type) + self.assertIsInstance(result, bytes) self.assertEqual(result, b"bytes \xFE") def test_force_none(self): @@ -269,16 +248,16 @@ class LazyCoercionTests(SimpleTestCase): self.assertIsNone(result) def test_force_dict(self): - result = force_non_lazy_dict({'a': 1, 'b': ugettext_lazy(u"b"), - 'c': {'c1': ugettext_lazy(u"c1")}}) - self.assertEqual(result, {'a': 1, 'b': u"b", 'c': {'c1': u"c1"}}) - self.assertIsInstance(result['b'], six.text_type) - self.assertIsInstance(result['c']['c1'], six.text_type) + result = force_non_lazy_dict({'a': 1, 'b': gettext_lazy("b"), + 'c': {'c1': gettext_lazy("c1")}}) + self.assertEqual(result, {'a': 1, 'b': "b", 'c': {'c1': "c1"}}) + self.assertIsInstance(result['b'], str) + self.assertIsInstance(result['c']['c1'], str) def test_force_list(self): - result = force_non_lazy_list([0, ugettext_lazy(u"b"), u"c"]) - self.assertEqual(result, [0, u"b", u"c"]) # coerced to list - self.assertIsInstance(result[1], six.text_type) + result = force_non_lazy_list([0, gettext_lazy("b"), "c"]) + self.assertEqual(result, [0, "b", "c"]) # coerced to list + self.assertIsInstance(result[1], str) class UpdateDeepTests(SimpleTestCase): @@ -313,7 +292,7 @@ class RequestUtilsTests(SimpleTestCase): def setUp(self): self.request_factory = RequestFactory() - super(RequestUtilsTests, self).setUp() + super().setUp() @staticmethod def basic_auth(username, password): diff --git a/tests/utils.py b/tests/utils.py index f0ac31c..c0bbacb 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,6 +1,4 @@ # Anymail test utils -import collections -import logging import os import re import sys @@ -8,10 +6,10 @@ import uuid import warnings from base64 import b64decode from contextlib import contextmanager +from io import StringIO +from unittest import TestCase -import six from django.test import Client -from six.moves import StringIO def decode_att(att): @@ -71,36 +69,9 @@ def sample_email_content(filename=SAMPLE_EMAIL_FILENAME): # TestCase helpers # -# noinspection PyUnresolvedReferences -class AnymailTestMixin: +class AnymailTestMixin(TestCase): """Helpful additional methods for Anymail tests""" - def assertLogs(self, logger=None, level=None): - # Note: django.utils.log.DEFAULT_LOGGING config is set to *not* propagate certain - # logging records. That means you *can't* capture those logs at the root (None) logger. - assert logger is not None # `None` root logger won't reliably capture - try: - return super(AnymailTestMixin, self).assertLogs(logger, level) - except (AttributeError, TypeError): - # Python <3.4: use our backported assertLogs - return _AssertLogsContext(self, logger, level) - - def assertWarns(self, expected_warning, msg=None): - # We only support the context-manager version - try: - return super(AnymailTestMixin, self).assertWarns(expected_warning, msg=msg) - except TypeError: - # Python 2.x: use our backported assertWarns - return _AssertWarnsContext(expected_warning, self, msg=msg) - - def assertWarnsRegex(self, expected_warning, expected_regex, msg=None): - # We only support the context-manager version - try: - return super(AnymailTestMixin, self).assertWarnsRegex(expected_warning, expected_regex, msg=msg) - except TypeError: - # Python 2.x: use our backported assertWarns - return _AssertWarnsContext(expected_warning, self, expected_regex=expected_regex, msg=msg) - @contextmanager def assertDoesNotWarn(self, disallowed_warning=Warning): """Makes test error (rather than fail) if disallowed_warning occurs. @@ -115,31 +86,13 @@ class AnymailTestMixin: finally: warnings.resetwarnings() - def assertCountEqual(self, *args, **kwargs): - try: - return super(AnymailTestMixin, self).assertCountEqual(*args, **kwargs) - except TypeError: - return self.assertItemsEqual(*args, **kwargs) # Python 2 - - def assertRaisesRegex(self, *args, **kwargs): - try: - return super(AnymailTestMixin, self).assertRaisesRegex(*args, **kwargs) - except TypeError: - return self.assertRaisesRegexp(*args, **kwargs) # Python 2 - - def assertRegex(self, *args, **kwargs): - try: - return super(AnymailTestMixin, self).assertRegex(*args, **kwargs) - except TypeError: - return self.assertRegexpMatches(*args, **kwargs) # Python 2 - def assertEqualIgnoringHeaderFolding(self, first, second, msg=None): # Unfold (per RFC-8222) all text first and second, then compare result. # Useful for message/rfc822 attachment tests, where various Python email # versions handled folding slightly differently. # (Technically, this is unfolding both headers and (incorrectly) bodies, # but that doesn't really affect the tests.) - if isinstance(first, six.binary_type) and isinstance(second, six.binary_type): + if isinstance(first, bytes) and isinstance(second, bytes): first = first.decode('utf-8') second = second.decode('utf-8') first = rfc822_unfold(first) @@ -190,131 +143,6 @@ class AnymailTestMixin: sys.stdout = old_stdout -# Backported from Python 3.4 -class _AssertLogsContext(object): - """A context manager used to implement TestCase.assertLogs().""" - - LOGGING_FORMAT = "%(levelname)s:%(name)s:%(message)s" - - def __init__(self, test_case, logger_name, level): - self.test_case = test_case - self.logger_name = logger_name - if level: - self.level = logging._nameToLevel.get(level, level) - else: - self.level = logging.INFO - self.msg = None - - def _raiseFailure(self, standardMsg): - msg = self.test_case._formatMessage(self.msg, standardMsg) - raise self.test_case.failureException(msg) - - class _CapturingHandler(logging.Handler): - """A logging handler capturing all (raw and formatted) logging output.""" - - _LoggingWatcher = collections.namedtuple("_LoggingWatcher", ["records", "output"]) - - def __init__(self): - logging.Handler.__init__(self) - self.watcher = self._LoggingWatcher([], []) - - def flush(self): - pass - - def emit(self, record): - self.watcher.records.append(record) - msg = self.format(record) - self.watcher.output.append(msg) - - def __enter__(self): - if isinstance(self.logger_name, logging.Logger): - logger = self.logger = self.logger_name - else: - logger = self.logger = logging.getLogger(self.logger_name) - formatter = logging.Formatter(self.LOGGING_FORMAT) - handler = self._CapturingHandler() - handler.setFormatter(formatter) - self.watcher = handler.watcher - self.old_handlers = logger.handlers[:] - self.old_level = logger.level - self.old_propagate = logger.propagate - logger.handlers = [handler] - logger.setLevel(self.level) - logger.propagate = False - return handler.watcher - - def __exit__(self, exc_type, exc_value, tb): - self.logger.handlers = self.old_handlers - self.logger.propagate = self.old_propagate - self.logger.setLevel(self.old_level) - if exc_type is not None: - # let unexpected exceptions pass through - return False - if len(self.watcher.records) == 0: - self._raiseFailure( - "no logs of level {} or higher triggered on {}" - .format(logging.getLevelName(self.level), self.logger.name)) - - -# Backported from python 3.5 -class _AssertWarnsContext(object): - """A context manager used to implement TestCase.assertWarns* methods.""" - - def __init__(self, expected, test_case, expected_regex=None, msg=None): - self.test_case = test_case - self.expected = expected - self.test_case = test_case - if expected_regex is not None: - expected_regex = re.compile(expected_regex) - self.expected_regex = expected_regex - self.msg = msg - - def _raiseFailure(self, standardMsg): - # msg = self.test_case._formatMessage(self.msg, standardMsg) - msg = self.msg or standardMsg - raise self.test_case.failureException(msg) - - def __enter__(self): - # The __warningregistry__'s need to be in a pristine state for tests - # to work properly. - for v in sys.modules.values(): - if getattr(v, '__warningregistry__', None): - v.__warningregistry__ = {} - self.warnings_manager = warnings.catch_warnings(record=True) - self.warnings = self.warnings_manager.__enter__() - warnings.simplefilter("always", self.expected) - return self - - def __exit__(self, exc_type, exc_value, tb): - self.warnings_manager.__exit__(exc_type, exc_value, tb) - if exc_type is not None: - # let unexpected exceptions pass through - return - try: - exc_name = self.expected.__name__ - except AttributeError: - exc_name = str(self.expected) - first_matching = None - for m in self.warnings: - w = m.message - if not isinstance(w, self.expected): - continue - if first_matching is None: - first_matching = w - if self.expected_regex is not None and not self.expected_regex.search(str(w)): - continue - # store warning for later retrieval - self.warning = w - self.filename = m.filename - self.lineno = m.lineno - return - # Now we simply try to choose a helpful failure message - if first_matching is not None: - self._raiseFailure('"{}" does not match "{}"'.format( - self.expected_regex.pattern, str(first_matching))) - self._raiseFailure("{} not triggered".format(exc_name)) - - class ClientWithCsrfChecks(Client): """Django test Client that enforces CSRF checks @@ -322,8 +150,7 @@ class ClientWithCsrfChecks(Client): """ def __init__(self, **defaults): - super(ClientWithCsrfChecks, self).__init__( - enforce_csrf_checks=True, **defaults) + super().__init__(enforce_csrf_checks=True, **defaults) # dedent for bytestrs diff --git a/tests/webhook_cases.py b/tests/webhook_cases.py index e8f145c..30b4b97 100644 --- a/tests/webhook_cases.py +++ b/tests/webhook_cases.py @@ -25,7 +25,7 @@ class WebhookTestCase(AnymailTestMixin, SimpleTestCase): client_class = ClientWithCsrfChecks def setUp(self): - super(WebhookTestCase, self).setUp() + super().setUp() # Use correct basic auth by default (individual tests can override): self.set_basic_auth() @@ -71,15 +71,24 @@ class WebhookTestCase(AnymailTestMixin, SimpleTestCase): return actual_kwargs -# noinspection PyUnresolvedReferences -class WebhookBasicAuthTestsMixin(object): +class WebhookBasicAuthTestCase(WebhookTestCase): """Common test cases for webhook basic authentication. Instantiate for each ESP's webhooks by: - - mixing into WebhookTestCase + - subclassing - defining call_webhook to invoke the ESP's webhook + - adding or overriding any tests as appropriate """ + def __init__(self, methodName='runTest'): + if self.__class__ is WebhookBasicAuthTestCase: + # don't run these tests on the abstract base implementation + methodName = 'runNoTestsInBaseClass' + super().__init__(methodName) + + def runNoTestsInBaseClass(self): + pass + should_warn_if_no_auth = True # subclass set False if other webhook verification used def call_webhook(self): diff --git a/tox.ini b/tox.ini index c977d65..99e8a79 100644 --- a/tox.ini +++ b/tox.ini @@ -3,27 +3,24 @@ envlist = # Factors: django-python-extras # Test these environments first, to catch most errors early... lint - django30-py37-all - django111-py27-all + django31-py38-all + django20-py35-all docs # ... then test all the other supported combinations: - django30-py{36,38,py3}-all + django31-py{36,37,py3}-all + django30-py{36,37,38,py3}-all django22-py{35,36,37,py3}-all django21-py{35,36,37,py3}-all - django20-py{35,36,py3}-all - django111-py{34,35,36,py}-all + django20-py{36,py3}-all # ... then prereleases (if available): - django31-py{36,37,38,py3}-all djangoDev-py{36,37,38}-all # ... then partial installation (limit extras): - django22-py37-{none,amazon_ses,sparkpost} + django31-py37-{none,amazon_ses,sparkpost} # ... then older versions of some dependencies: - django111-py27-all-old_urllib3 django22-py37-all-old_urllib3 [testenv] deps = - django111: django~=1.11.0 django20: django~=2.0.0 django21: django~=2.1.0 django22: django~=2.2.0