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
This commit is contained in:
medmunds
2016-03-04 17:39:43 -08:00
parent 38729df93c
commit 0a5bca1426
4 changed files with 166 additions and 141 deletions

View File

@@ -1,15 +1,8 @@
import json
import requests
# noinspection PyUnresolvedReferences
from six.moves.urllib.parse import urljoin
from django.conf import settings from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend 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 ..utils import Attachment, ParsedEmail, UNSET, combine, last, get_anymail_setting
from .._version import __version__
class AnymailBaseBackend(BaseEmailBackend): class AnymailBaseBackend(BaseEmailBackend):
@@ -180,78 +173,6 @@ class AnymailBaseBackend(BaseEmailBackend):
return self.__class__.__name__.replace("Backend", "") 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): class BasePayload(object):
# attr, combiner, converter # attr, combiner, converter
base_message_attrs = ( base_message_attrs = (
@@ -407,62 +328,3 @@ class BasePayload(object):
# ESP-specific payload construction # ESP-specific payload construction
def set_esp_extra(self, extra): def set_esp_extra(self, extra):
self.unsupported_feature("esp_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)

View File

@@ -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)

View File

@@ -3,7 +3,7 @@ from datetime import date, datetime
from ..exceptions import AnymailRequestsAPIError, AnymailRecipientsRefused from ..exceptions import AnymailRequestsAPIError, AnymailRecipientsRefused
from ..utils import last, combine, get_anymail_setting from ..utils import last, combine, get_anymail_setting
from .base import AnymailRequestsBackend, RequestsPayload from .base_requests import AnymailRequestsBackend, RequestsPayload
class MandrillBackend(AnymailRequestsBackend): class MandrillBackend(AnymailRequestsBackend):

View File

@@ -1,5 +1,14 @@
import json import json
from django.core.exceptions import ImproperlyConfigured
try:
from requests import HTTPError 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): class AnymailError(Exception):
@@ -121,3 +130,12 @@ class AnymailSerializationError(AnymailError, TypeError):
if orig_err is not None: if orig_err is not None:
message += "\n%s" % str(orig_err) message += "\n%s" % str(orig_err)
super(AnymailSerializationError, self).__init__(message, *args, **kwargs) 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[<backend>]` with your desired backends)" % missing_package
super(AnymailImproperlyInstalled, self).__init__(message)