From 0a5bca14260cc0399312de0587d3825f7c20c882 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 4 Mar 2016 17:39:43 -0800 Subject: [PATCH] Make requests optional for backends that don't need it (Prep for installing backends as package extras) * Extract AnymailRequestsBackend and RequestsPayload to base_requests.py * Don't define/require requests exceptions when requests not available --- anymail/backends/base.py | 140 +---------------------------- anymail/backends/base_requests.py | 145 ++++++++++++++++++++++++++++++ anymail/backends/mandrill.py | 2 +- anymail/exceptions.py | 20 ++++- 4 files changed, 166 insertions(+), 141 deletions(-) create mode 100644 anymail/backends/base_requests.py diff --git a/anymail/backends/base.py b/anymail/backends/base.py index 255c7f1..745a6bc 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -1,15 +1,8 @@ -import json - -import requests -# noinspection PyUnresolvedReferences -from six.moves.urllib.parse import urljoin - from django.conf import settings from django.core.mail.backends.base import BaseEmailBackend -from ..exceptions import AnymailError, AnymailRequestsAPIError, AnymailSerializationError, AnymailUnsupportedFeature +from ..exceptions import AnymailError, AnymailUnsupportedFeature from ..utils import Attachment, ParsedEmail, UNSET, combine, last, get_anymail_setting -from .._version import __version__ class AnymailBaseBackend(BaseEmailBackend): @@ -180,78 +173,6 @@ class AnymailBaseBackend(BaseEmailBackend): return self.__class__.__name__.replace("Backend", "") -class AnymailRequestsBackend(AnymailBaseBackend): - """ - Base Anymail email backend for ESPs that use an HTTP API via requests - """ - - def __init__(self, api_url, **kwargs): - """Init options from Django settings""" - self.api_url = api_url - super(AnymailRequestsBackend, self).__init__(**kwargs) - self.session = None - - def open(self): - if self.session: - return False # already exists - - try: - self.session = requests.Session() - except requests.RequestException: - if not self.fail_silently: - raise - else: - self.session.headers["User-Agent"] = "Anymail/%s %s" % ( - __version__, self.session.headers.get("User-Agent", "")) - return True - - def close(self): - if self.session is None: - return - try: - self.session.close() - except requests.RequestException: - if not self.fail_silently: - raise - finally: - self.session = None - - def _send(self, message): - if self.session is None: - class_name = self.__class__.__name__ - raise RuntimeError( - "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) - - def post_to_esp(self, payload, message): - """Post payload to ESP send API endpoint, and return the raw response. - - payload is the result of build_message_payload - message is the original EmailMessage - return should be a requests.Response - - Can raise AnymailRequestsAPIError for HTTP errors in the post - """ - params = payload.get_request_params(self.api_url) - response = self.session.request(**params) - if response.status_code != 200: - raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response) - return response - - def deserialize_response(self, response, payload, message): - """Return parsed ESP API response - - Can raise AnymailRequestsAPIError if response is unparsable - """ - try: - return response.json() - except ValueError: - raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name, - email_message=message, payload=payload, response=response) - - class BasePayload(object): # attr, combiner, converter base_message_attrs = ( @@ -407,62 +328,3 @@ class BasePayload(object): # ESP-specific payload construction def set_esp_extra(self, extra): self.unsupported_feature("esp_extra") - - -class RequestsPayload(BasePayload): - """Abstract Payload for AnymailRequestsBackend""" - - def __init__(self, message, defaults, backend, - method="POST", params=None, data=None, - headers=None, files=None, auth=None): - self.method = method - self.params = params - self.data = data - self.headers = headers - self.files = files - self.auth = auth - super(RequestsPayload, self).__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. - - :param api_url: the base api_url for the backend - :return: dict - """ - api_endpoint = self.get_api_endpoint() - if api_endpoint is not None: - url = urljoin(api_url, api_endpoint) - else: - url = api_url - - return dict( - method=self.method, - url=url, - params=self.params, - data=self.serialize_data(), - headers=self.headers, - files=self.files, - auth=self.auth, - # json= is not here, because we prefer to do our own serialization - # to provide extra context in error messages - ) - - def get_api_endpoint(self): - """Returns a str that should be joined to the backend's api_url for sending this payload.""" - return None - - def serialize_data(self): - """Performs any necessary serialization on self.data, and returns the result.""" - return self.data - - def serialize_json(self, data): - """Returns data serialized to json, raising appropriate errors. - - Useful for implementing serialize_data in a subclass, - """ - try: - return json.dumps(data) - 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) diff --git a/anymail/backends/base_requests.py b/anymail/backends/base_requests.py new file mode 100644 index 0000000..294e823 --- /dev/null +++ b/anymail/backends/base_requests.py @@ -0,0 +1,145 @@ +import json + +# noinspection PyUnresolvedReferences +from six.moves.urllib.parse import urljoin + +from .base import AnymailBaseBackend, BasePayload +from ..exceptions import AnymailImproperlyInstalled, AnymailRequestsAPIError, AnymailSerializationError +from .._version import __version__ + +try: + # noinspection PyUnresolvedReferences + import requests +except ImportError: + raise AnymailImproperlyInstalled('requests') + + +class AnymailRequestsBackend(AnymailBaseBackend): + """ + Base Anymail email backend for ESPs that use an HTTP API via requests + """ + + def __init__(self, api_url, **kwargs): + """Init options from Django settings""" + self.api_url = api_url + super(AnymailRequestsBackend, self).__init__(**kwargs) + self.session = None + + def open(self): + if self.session: + return False # already exists + + try: + self.session = requests.Session() + except requests.RequestException: + if not self.fail_silently: + raise + else: + self.session.headers["User-Agent"] = "Anymail/%s %s" % ( + __version__, self.session.headers.get("User-Agent", "")) + return True + + def close(self): + if self.session is None: + return + try: + self.session.close() + except requests.RequestException: + if not self.fail_silently: + raise + finally: + self.session = None + + def _send(self, message): + if self.session is None: + class_name = self.__class__.__name__ + raise RuntimeError( + "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) + + def post_to_esp(self, payload, message): + """Post payload to ESP send API endpoint, and return the raw response. + + payload is the result of build_message_payload + message is the original EmailMessage + return should be a requests.Response + + Can raise AnymailRequestsAPIError for HTTP errors in the post + """ + params = payload.get_request_params(self.api_url) + response = self.session.request(**params) + if response.status_code != 200: + raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response) + return response + + def deserialize_response(self, response, payload, message): + """Return parsed ESP API response + + Can raise AnymailRequestsAPIError if response is unparsable + """ + try: + return response.json() + except ValueError: + raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name, + email_message=message, payload=payload, response=response) + + +class RequestsPayload(BasePayload): + """Abstract Payload for AnymailRequestsBackend""" + + def __init__(self, message, defaults, backend, + method="POST", params=None, data=None, + headers=None, files=None, auth=None): + self.method = method + self.params = params + self.data = data + self.headers = headers + self.files = files + self.auth = auth + super(RequestsPayload, self).__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. + + :param api_url: the base api_url for the backend + :return: dict + """ + api_endpoint = self.get_api_endpoint() + if api_endpoint is not None: + url = urljoin(api_url, api_endpoint) + else: + url = api_url + + return dict( + method=self.method, + url=url, + params=self.params, + data=self.serialize_data(), + headers=self.headers, + files=self.files, + auth=self.auth, + # json= is not here, because we prefer to do our own serialization + # to provide extra context in error messages + ) + + def get_api_endpoint(self): + """Returns a str that should be joined to the backend's api_url for sending this payload.""" + return None + + def serialize_data(self): + """Performs any necessary serialization on self.data, and returns the result.""" + return self.data + + def serialize_json(self, data): + """Returns data serialized to json, raising appropriate errors. + + Useful for implementing serialize_data in a subclass, + """ + try: + return json.dumps(data) + 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) diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py index fbaa9e6..bc8f16b 100644 --- a/anymail/backends/mandrill.py +++ b/anymail/backends/mandrill.py @@ -3,7 +3,7 @@ from datetime import date, datetime from ..exceptions import AnymailRequestsAPIError, AnymailRecipientsRefused from ..utils import last, combine, get_anymail_setting -from .base import AnymailRequestsBackend, RequestsPayload +from .base_requests import AnymailRequestsBackend, RequestsPayload class MandrillBackend(AnymailRequestsBackend): diff --git a/anymail/exceptions.py b/anymail/exceptions.py index 01e9833..e2487d7 100644 --- a/anymail/exceptions.py +++ b/anymail/exceptions.py @@ -1,5 +1,14 @@ import json -from requests import HTTPError + +from django.core.exceptions import ImproperlyConfigured + +try: + from requests import HTTPError +except ImportError: + # Backends that don't use requests aren't required to have it installed + # (and could never raise an AnymailRequestsAPIError) + class HTTPError(Exception): + pass class AnymailError(Exception): @@ -121,3 +130,12 @@ class AnymailSerializationError(AnymailError, TypeError): if orig_err is not None: message += "\n%s" % str(orig_err) super(AnymailSerializationError, self).__init__(message, *args, **kwargs) + + +# This deliberately doesn't inherit from AnymailError +class AnymailImproperlyInstalled(ImproperlyConfigured, ImportError): + def __init__(self, missing_package): + message = "The %s package is required to use this backend, but isn't installed.\n" \ + "(Be sure to use `pip install anymail[]` with your desired backends)" % missing_package + super(AnymailImproperlyInstalled, self).__init__(message) +