Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More extensibility and Tornado integration #94

Merged
merged 13 commits into from
Mar 30, 2016
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ Patches and suggestions
- Simeon Visser `@svisser <https://github.com/svisser>`_
- `@gnarvaja <https://github.com/gnarvaja>`_
- `@puttu <https://github.com/puttu>`_
- Marko Mrdjenovic `@friedcell <https://github.com/friedcell>`_
- ADD YOURSELF HERE (and link to your github page)
1 change: 1 addition & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
wheel
twine
Django>=1.7,<1.10
tornado>=3.2
31 changes: 19 additions & 12 deletions sparkpost/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os

from .base import RequestsTransport
from .exceptions import SparkPostException
from .metrics import Metrics
from .recipient_lists import RecipientLists
Expand All @@ -8,31 +9,37 @@
from .transmissions import Transmissions


__version__ = '1.0.5'


def get_api_key():
"Get API key from environment variable"
return os.environ.get('SPARKPOST_API_KEY', None)
__version__ = '1.0.6.dev1'


class SparkPost(object):
TRANSPORT_CLASS = RequestsTransport

def __init__(self, api_key=None, base_uri='https://api.sparkpost.com',
version='1'):
"Set up the SparkPost API client"
if not api_key:
api_key = get_api_key()
api_key = self.get_api_key()
if not api_key:
raise SparkPostException("No API key. Improve message.")

self.base_uri = base_uri + '/api/v' + version
self.api_key = api_key

self.metrics = Metrics(self.base_uri, self.api_key)
self.recipient_lists = RecipientLists(self.base_uri, self.api_key)
self.suppression_list = SuppressionList(self.base_uri, self.api_key)
self.templates = Templates(self.base_uri, self.api_key)
self.transmissions = Transmissions(self.base_uri, self.api_key)
self.metrics = Metrics(self.base_uri, self.api_key,
self.TRANSPORT_CLASS)
self.recipient_lists = RecipientLists(self.base_uri, self.api_key,
self.TRANSPORT_CLASS)
self.suppression_list = SuppressionList(self.base_uri, self.api_key,
self.TRANSPORT_CLASS)
self.templates = Templates(self.base_uri, self.api_key,
self.TRANSPORT_CLASS)
self.transmissions = Transmissions(self.base_uri, self.api_key,
self.TRANSPORT_CLASS)
# Keeping self.transmission for backwards compatibility.
# Will be removed in a future release.
self.transmission = self.transmissions

def get_api_key(self):
"Get API key from environment variable"
return os.environ.get('SPARKPOST_API_KEY', None)
30 changes: 20 additions & 10 deletions sparkpost/base.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import requests
import sparkpost

from .exceptions import SparkPostAPIException


class RequestsTransport(object):
def request(self, method, uri, headers, **kwargs):
import requests
response = requests.request(method, uri, headers=headers, **kwargs)
if response.status_code == 204:
return True
if not response.ok:
raise SparkPostAPIException(response)
if 'results' in response.json():
return response.json()['results']
return response.json()


class Resource(object):
def __init__(self, base_uri, api_key):
key = ""

def __init__(self, base_uri, api_key, transport_class=RequestsTransport):
self.base_uri = base_uri
self.api_key = api_key
self.transport = transport_class()

@property
def uri(self):
Expand All @@ -19,14 +34,9 @@ def request(self, method, uri, **kwargs):
'Content-Type': 'application/json',
'Authorization': self.api_key
}
response = requests.request(method, uri, headers=headers, **kwargs)
if response.status_code == 204:
return True
if not response.ok:
raise SparkPostAPIException(response)
if 'results' in response.json():
return response.json()['results']
return response.json()
response = self.transport.request(method, uri, headers=headers,
**kwargs)
return response

def get(self):
raise NotImplementedError
Expand Down
2 changes: 1 addition & 1 deletion sparkpost/django/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,4 @@ def __init__(self, message):
'type': mimetype
})

return super(SparkPostMessage, self).__init__(formatted)
super(SparkPostMessage, self).__init__(formatted)
15 changes: 12 additions & 3 deletions sparkpost/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,18 @@ class SparkPostException(Exception):
class SparkPostAPIException(SparkPostException):
"Handle 4xx and 5xx errors from the SparkPost API"
def __init__(self, response, *args, **kwargs):
errors = response.json()['errors']
errors = [e['message'] + ': ' + e.get('description', '')
for e in errors]
errors = None
try:
errors = response.json()['errors']
errors = [e['message'] + ': ' + e.get('description', '')
for e in errors]
except:
pass
if not errors:
errors = [response.text or ""]
self.status = response.status_code
self.response = response
self.errors = errors
message = """Call to {uri} returned {status_code}, errors:

{errors}
Expand Down
8 changes: 4 additions & 4 deletions sparkpost/metrics.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from .base import Resource
from .base import Resource, RequestsTransport


class Metrics(object):
"Wrapper for sub-resources"

def __init__(self, base_uri, api_key):
def __init__(self, base_uri, api_key, transport_class=RequestsTransport):
self.base_uri = "%s/%s" % (base_uri, 'metrics')
self.campaigns = Campaigns(self.base_uri, api_key)
self.domains = Domains(self.base_uri, api_key)
self.campaigns = Campaigns(self.base_uri, api_key, transport_class)
self.domains = Domains(self.base_uri, api_key, transport_class)


class Campaigns(Resource):
Expand Down
18 changes: 18 additions & 0 deletions sparkpost/tornado/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import sparkpost

from .exceptions import SparkPostAPIException
from .base import TornadoTransport
from .transmissions import Transmissions

__all__ = ["SparkPost", "TornadoTransport", "SparkPostAPIException",
"Transmissions"]


class SparkPost(sparkpost.SparkPost):
TRANSPORT_CLASS = TornadoTransport

def __init__(self, *args, **kwargs):
super(SparkPost, self).__init__(*args, **kwargs)
self.transmissions = Transmissions(self.base_uri, self.api_key,
self.TRANSPORT_CLASS)
self.transmission = self.transmissions
31 changes: 31 additions & 0 deletions sparkpost/tornado/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import json
from tornado import gen
from tornado.httpclient import AsyncHTTPClient, HTTPError

from .exceptions import SparkPostAPIException


class TornadoTransport(object):
@gen.coroutine
def request(self, method, uri, headers, **kwargs):
if "data" in kwargs:
kwargs["body"] = kwargs.pop("data")
client = AsyncHTTPClient()
try:
response = yield client.fetch(uri, method=method, headers=headers,
**kwargs)
except HTTPError as ex:
raise SparkPostAPIException(ex.response)
if response.code == 204:
raise gen.Return(True)
if response.code == 200:
result = None
try:
result = json.loads(response.body.decode("utf-8"))
except:
pass
if result:
if 'results' in result:
raise gen.Return(result['results'])
raise gen.Return(result)
raise SparkPostAPIException(response)
31 changes: 31 additions & 0 deletions sparkpost/tornado/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import json

from ..exceptions import SparkPostAPIException as RequestsSparkPostAPIException


class SparkPostAPIException(RequestsSparkPostAPIException):
def __init__(self, response, *args, **kwargs):
errors = None
try:
data = json.loads(response.body.decode("utf-8"))
if data:
errors = data['errors']
errors = [e['message'] + ': ' + e.get('description', '')
for e in errors]
except:
pass
if not errors:
errors = [response.body.decode("utf-8") or ""]
self.status = response.code
self.response = response
self.errors = errors
message = """Call to {uri} returned {status_code}, errors:

{errors}
""".format(
uri=response.effective_url,
status_code=response.code,
errors='\n'.join(errors)
)
super(RequestsSparkPostAPIException, self).__init__(message, *args,
**kwargs)
8 changes: 8 additions & 0 deletions sparkpost/tornado/transmissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from .utils import wrap_future
from ..transmissions import Transmissions as SyncTransmissions


class Transmissions(SyncTransmissions):
def get(self, transmission_id):
results = self._fetch_get(transmission_id)
return wrap_future(results, lambda f: f["transmission"])
14 changes: 14 additions & 0 deletions sparkpost/tornado/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from tornado.concurrent import Future


def wrap_future(future, convert):
wrapper = Future()

def handle_future(future):
try:
wrapper.set_result(convert(future.result()))
except Exception as ex:
wrapper.set_exception(ex)

future.add_done_callback(handle_future)
return wrapper
8 changes: 6 additions & 2 deletions sparkpost/transmissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ def send(self, **kwargs):
results = self.request('POST', self.uri, data=json.dumps(payload))
return results

def _fetch_get(self, transmission_id):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason this had to be moved out into a separate function?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To support async transports fetch methods should only return whatever they fetched - in async it will be a future, not a real value that can be manipulated. It enables code in sparkpost/tornado/transmissions.py

uri = "%s/%s" % (self.uri, transmission_id)
results = self.request('GET', uri)
return results

def get(self, transmission_id):
"""
Get a transmission by ID
Expand All @@ -214,8 +219,7 @@ def get(self, transmission_id):
:returns: the requested transmission if found
:raises: :exc:`SparkPostAPIException` if transmission is not found
"""
uri = "%s/%s" % (self.uri, transmission_id)
results = self.request('GET', uri)
results = self._fetch_get(transmission_id)
return results['transmission']

def list(self):
Expand Down
28 changes: 28 additions & 0 deletions test/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,34 @@ def test_fail_request():
resource.request('GET', resource.uri)


@responses.activate
def test_fail_wrongjson_request():
responses.add(
responses.GET,
fake_uri,
status=500,
content_type='application/json',
body='{"errors": ["Error!"]}'
)
resource = create_resource()
with pytest.raises(SparkPostAPIException):
resource.request('GET', resource.uri)


@responses.activate
def test_fail_nojson_request():
responses.add(
responses.GET,
fake_uri,
status=500,
content_type='application/json',
body='{"errors": '
)
resource = create_resource()
with pytest.raises(SparkPostAPIException):
resource.request('GET', resource.uri)


def test_fail_get():
resource = create_resource()
with pytest.raises(NotImplementedError):
Expand Down
Empty file added test/tornado/__init__.py
Empty file.
Loading