From 248eaeebb2404a9b9818749929eed5c21fa169a7 Mon Sep 17 00:00:00 2001 From: Marko Mrdjenovic Date: Wed, 23 Mar 2016 11:51:47 +0100 Subject: [PATCH 01/12] removing an extra print --- sparkpost/django/message.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sparkpost/django/message.py b/sparkpost/django/message.py index 4e12f56..9b41dbb 100644 --- a/sparkpost/django/message.py +++ b/sparkpost/django/message.py @@ -53,6 +53,5 @@ def __init__(self, message): 'data': content, 'type': mimetype }) - print(message.attachments) return super(SparkPostMessage, self).__init__(formatted) From ef018fc52ef964651a79d0e7f8c28faba2b9445c Mon Sep 17 00:00:00 2001 From: Marko Mrdjenovic Date: Wed, 23 Mar 2016 12:25:52 +0100 Subject: [PATCH 02/12] moved get_api_key to class for easier subclassing --- sparkpost/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/sparkpost/__init__.py b/sparkpost/__init__.py index 750f83d..77a1676 100644 --- a/sparkpost/__init__.py +++ b/sparkpost/__init__.py @@ -11,17 +11,12 @@ __version__ = '1.0.5' -def get_api_key(): - "Get API key from environment variable" - return os.environ.get('SPARKPOST_API_KEY', None) - - class SparkPost(object): 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.") @@ -36,3 +31,7 @@ def __init__(self, api_key=None, base_uri='https://api.sparkpost.com', # 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) From 78f6f918e9a23c767dc59821207db30864697de5 Mon Sep 17 00:00:00 2001 From: Marko Mrdjenovic Date: Wed, 23 Mar 2016 12:28:57 +0100 Subject: [PATCH 03/12] split out transport for easier subclassing --- sparkpost/__init__.py | 12 +++++++----- sparkpost/base.py | 30 ++++++++++++++++++++---------- sparkpost/metrics.py | 8 ++++---- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/sparkpost/__init__.py b/sparkpost/__init__.py index 77a1676..34af6c8 100644 --- a/sparkpost/__init__.py +++ b/sparkpost/__init__.py @@ -1,5 +1,6 @@ import os +from .base import RequestsTransport from .exceptions import SparkPostException from .metrics import Metrics from .recipient_lists import RecipientLists @@ -12,6 +13,7 @@ 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" @@ -23,11 +25,11 @@ def __init__(self, api_key=None, base_uri='https://api.sparkpost.com', 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 diff --git a/sparkpost/base.py b/sparkpost/base.py index e7c3b87..e686b34 100644 --- a/sparkpost/base.py +++ b/sparkpost/base.py @@ -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): @@ -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 diff --git a/sparkpost/metrics.py b/sparkpost/metrics.py index ec3953c..0ce851c 100644 --- a/sparkpost/metrics.py +++ b/sparkpost/metrics.py @@ -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): From 3b6561e8db5c9b9f97e77243d8fb3692c8f20120 Mon Sep 17 00:00:00 2001 From: Marko Mrdjenovic Date: Wed, 23 Mar 2016 16:56:54 +0100 Subject: [PATCH 04/12] split out _fetch_get to help with async transport subclassing --- sparkpost/transmissions.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sparkpost/transmissions.py b/sparkpost/transmissions.py index 28654c9..8799955 100644 --- a/sparkpost/transmissions.py +++ b/sparkpost/transmissions.py @@ -196,6 +196,11 @@ def send(self, **kwargs): results = self.request('POST', self.uri, data=json.dumps(payload)) return results + def _fetch_get(self, transmission_id): + uri = "%s/%s" % (self.uri, transmission_id) + results = self.request('GET', uri) + return results + def get(self, transmission_id): """ Get a transmission by ID @@ -205,8 +210,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): From 47ee0e85520fe18e3183166475bebce0208c3889 Mon Sep 17 00:00:00 2001 From: Marko Mrdjenovic Date: Wed, 23 Mar 2016 17:31:08 +0100 Subject: [PATCH 05/12] make APIException more resilient --- sparkpost/exceptions.py | 8 ++++++-- test/test_base.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/sparkpost/exceptions.py b/sparkpost/exceptions.py index 5202018..0c738f5 100644 --- a/sparkpost/exceptions.py +++ b/sparkpost/exceptions.py @@ -5,9 +5,13 @@ 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', '') + errors = [response.text or ""] + try: + errors = response.json()['errors'] + errors = [e['message'] + ': ' + e.get('description', '') for e in errors] + except: + pass message = """Call to {uri} returned {status_code}, errors: {errors} diff --git a/test/test_base.py b/test/test_base.py index ea818af..0bd59cd 100644 --- a/test/test_base.py +++ b/test/test_base.py @@ -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): From 32ae8aefac5200a03acfbde32fa196b054275c95 Mon Sep 17 00:00:00 2001 From: Marko Mrdjenovic Date: Wed, 23 Mar 2016 17:33:22 +0100 Subject: [PATCH 06/12] add tornado integration that uses async transport --- dev-requirements.txt | 1 + sparkpost/tornado/__init__.py | 16 +++ sparkpost/tornado/base.py | 30 +++++ sparkpost/tornado/exceptions.py | 25 ++++ sparkpost/tornado/transmissions.py | 8 ++ sparkpost/tornado/utils.py | 12 ++ test/tornado/__init__.py | 0 test/tornado/test_tornado.py | 185 +++++++++++++++++++++++++++++ test/tornado/utils.py | 41 +++++++ 9 files changed, 318 insertions(+) create mode 100644 sparkpost/tornado/__init__.py create mode 100644 sparkpost/tornado/base.py create mode 100644 sparkpost/tornado/exceptions.py create mode 100644 sparkpost/tornado/transmissions.py create mode 100644 sparkpost/tornado/utils.py create mode 100644 test/tornado/__init__.py create mode 100644 test/tornado/test_tornado.py create mode 100644 test/tornado/utils.py diff --git a/dev-requirements.txt b/dev-requirements.txt index ec3ac07..7187845 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,3 +2,4 @@ wheel twine Django>=1.7,<1.10 +tornado>=3.2 \ No newline at end of file diff --git a/sparkpost/tornado/__init__.py b/sparkpost/tornado/__init__.py new file mode 100644 index 0000000..099b4d3 --- /dev/null +++ b/sparkpost/tornado/__init__.py @@ -0,0 +1,16 @@ +import sparkpost + +from .exceptions import SparkPostAPIException +from .base import TornadoTransport +from .utils import wrap_future +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 diff --git a/sparkpost/tornado/base.py b/sparkpost/tornado/base.py new file mode 100644 index 0000000..765b9a8 --- /dev/null +++ b/sparkpost/tornado/base.py @@ -0,0 +1,30 @@ +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) + except: + pass + if result: + if 'results' in result: + raise gen.Return(result['results']) + raise gen.Return(result) + raise SparkPostAPIException(response) diff --git a/sparkpost/tornado/exceptions.py b/sparkpost/tornado/exceptions.py new file mode 100644 index 0000000..b2c26e7 --- /dev/null +++ b/sparkpost/tornado/exceptions.py @@ -0,0 +1,25 @@ +import json + +from ..exceptions import SparkPostAPIException as RequestsSparkPostAPIException + + +class SparkPostAPIException(RequestsSparkPostAPIException): + def __init__(self, response, *args, **kwargs): + errors = [response.body or ""] + try: + data = json.loads(response.body) + if data: + errors = data['errors'] + errors = [e['message'] + ': ' + e.get('description', '') + for e in errors] + except: + pass + 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) diff --git a/sparkpost/tornado/transmissions.py b/sparkpost/tornado/transmissions.py new file mode 100644 index 0000000..9d44198 --- /dev/null +++ b/sparkpost/tornado/transmissions.py @@ -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"]) diff --git a/sparkpost/tornado/utils.py b/sparkpost/tornado/utils.py new file mode 100644 index 0000000..401e1d9 --- /dev/null +++ b/sparkpost/tornado/utils.py @@ -0,0 +1,12 @@ +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 diff --git a/test/tornado/__init__.py b/test/tornado/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/tornado/test_tornado.py b/test/tornado/test_tornado.py new file mode 100644 index 0000000..da63b6d --- /dev/null +++ b/test/tornado/test_tornado.py @@ -0,0 +1,185 @@ +import base64 +import json +import os +import tempfile + +import pytest +import six + +from sparkpost.tornado import SparkPost, SparkPostAPIException +from tornado import ioloop +from .utils import AsyncClientMock + +responses = AsyncClientMock() + + +@responses.activate +def test_success_send(): + responses.add( + responses.POST, + 'https://api.sparkpost.com/api/v1/transmissions', + status=200, + content_type='application/json', + body='{"results": "yay"}' + ) + sp = SparkPost('fake-key') + results = ioloop.IOLoop().run_sync(sp.transmission.send) + assert results == 'yay' + + +@responses.activate +def test_success_send_with_attachments(): + try: + # Let's compare unicode for Python 2 / 3 compatibility + test_content = six.u("Hello \nWorld\n") + (_, temp_file_path) = tempfile.mkstemp() + with open(temp_file_path, "w") as temp_file: + temp_file.write(test_content) + + responses.add( + responses.POST, + 'https://api.sparkpost.com/api/v1/transmissions', + status=200, + content_type='application/json', + body='{"results": "yay"}' + ) + sp = SparkPost('fake-key') + + attachment = { + "name": "test.txt", + "type": "text/plain", + "filename": temp_file_path + } + def send(): + return sp.transmission.send(attachments=[attachment]) + results = ioloop.IOLoop().run_sync(send) + + request_params = json.loads(responses.calls[0].request.body) + content = base64.b64decode( + request_params["content"]["attachments"][0]["data"]) + # Let's compare unicode for Python 2 / 3 compatibility + assert test_content == content.decode("ascii") + + assert results == 'yay' + + attachment = { + "name": "test.txt", + "type": "text/plain", + "data": base64.b64encode( + test_content.encode("ascii")).decode("ascii") + } + def send(): + return sp.transmission.send(attachments=[attachment]) + results = ioloop.IOLoop().run_sync(send) + + request_params = json.loads(responses.calls[1].request.body) + content = base64.b64decode( + request_params["content"]["attachments"][0]["data"]) + # Let's compare unicode for Python 2 / 3 compatibility + assert test_content == content.decode("ascii") + + assert results == 'yay' + finally: + os.unlink(temp_file_path) + + +@responses.activate +def test_fail_send(): + responses.add( + responses.POST, + 'https://api.sparkpost.com/api/v1/transmissions', + status=500, + content_type='application/json', + body='{"errors": [{"message": "You failed", "description": "More Info"}]}' + ) + with pytest.raises(SparkPostAPIException): + sp = SparkPost('fake-key') + ioloop.IOLoop().run_sync(sp.transmission.send) + + +@responses.activate +def test_success_get(): + responses.add( + responses.GET, + 'https://api.sparkpost.com/api/v1/transmissions/foobar', + status=200, + content_type='application/json', + body='{"results": {"transmission": {}}}' + ) + sp = SparkPost('fake-key') + def send(): + return sp.transmission.get('foobar') + results = ioloop.IOLoop().run_sync(send, timeout=3) + assert results == {} + + +@responses.activate +def test_fail_get(): + responses.add( + responses.GET, + 'https://api.sparkpost.com/api/v1/transmissions/foobar', + status=404, + content_type='application/json', + body='{"errors": [{"message": "cant find", "description": "where you go"}]}' + ) + with pytest.raises(SparkPostAPIException): + sp = SparkPost('fake-key') + def send(): + return sp.transmission.get('foobar') + ioloop.IOLoop().run_sync(send, timeout=3) + + +@responses.activate +def test_nocontent_get(): + responses.add( + responses.GET, + 'https://api.sparkpost.com/api/v1/transmissions', + status=204, + content_type='application/json', + body='' + ) + sp = SparkPost('fake-key') + response = ioloop.IOLoop().run_sync(sp.transmission.list) + assert response == True + + +@responses.activate +def test_brokenjson_get(): + responses.add( + responses.GET, + 'https://api.sparkpost.com/api/v1/transmissions', + status=200, + content_type='application/json', + body='{"results":' + ) + with pytest.raises(SparkPostAPIException): + sp = SparkPost('fake-key') + response = ioloop.IOLoop().run_sync(sp.transmission.list) + + +@responses.activate +def test_noresults_get(): + responses.add( + responses.GET, + 'https://api.sparkpost.com/api/v1/transmissions', + status=200, + content_type='application/json', + body='{"ok": false}' + ) + sp = SparkPost('fake-key') + response = ioloop.IOLoop().run_sync(sp.transmission.list) + assert response == {"ok": False} + + +@responses.activate +def test_success_list(): + responses.add( + responses.GET, + 'https://api.sparkpost.com/api/v1/transmissions', + status=200, + content_type='application/json', + body='{"results": []}' + ) + sp = SparkPost('fake-key') + response = ioloop.IOLoop().run_sync(sp.transmission.list) + assert response == [] diff --git a/test/tornado/utils.py b/test/tornado/utils.py new file mode 100644 index 0000000..dc30be2 --- /dev/null +++ b/test/tornado/utils.py @@ -0,0 +1,41 @@ +from __future__ import print_function +from collections import namedtuple + +from responses import RequestsMock +from tornado import ioloop +from tornado.concurrent import Future +from tornado.httpclient import HTTPRequest, HTTPResponse, HTTPError + +Request = namedtuple("Request", ["url", "method", "headers", "body"]) + + +class ResponseGenerator(object): + def get_adapter(self, url): + return self + + def build_response(self, request, response): + resp = HTTPResponse(request, response.status, headers=response.headers, effective_url=request.url, error=None, buffer="") + resp._body = response.data + f = Future() + f.content = None + if response.status < 200 or response.status >= 300: + resp.error = HTTPError(response.status, response=resp) + ioloop.IOLoop().current().add_callback(f.set_exception, resp.error) + else: + ioloop.IOLoop().current().add_callback(f.set_result, resp) + return f + + +class AsyncClientMock(RequestsMock): + def start(self): + import mock + + def unbound_on_send(client, request, callback=None, **kwargs): + if not isinstance(request, HTTPRequest): + request = Request(request, kwargs.get("method", "GET"), kwargs.get("headers", []), kwargs.get("body", "")) + return self._on_request(ResponseGenerator(), request) + self._patcher = mock.patch('tornado.httpclient.AsyncHTTPClient.fetch', unbound_on_send) + self._patcher.start() + + def stop(self): + self._patcher.stop() From d4759af47caad01a50d05320f363fc475788d0e7 Mon Sep 17 00:00:00 2001 From: Marko Mrdjenovic Date: Wed, 23 Mar 2016 17:34:38 +0100 Subject: [PATCH 07/12] update authors and version --- AUTHORS.rst | 1 + sparkpost/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index a69c2e3..bd6a760 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -15,4 +15,5 @@ Patches and suggestions - Simeon Visser `@svisser `_ - `@gnarvaja `_ - `@puttu `_ +- Marko Mrdjenovic `@friedcell `_ - ADD YOURSELF HERE (and link to your github page) diff --git a/sparkpost/__init__.py b/sparkpost/__init__.py index 34af6c8..c89f017 100644 --- a/sparkpost/__init__.py +++ b/sparkpost/__init__.py @@ -9,7 +9,7 @@ from .transmissions import Transmissions -__version__ = '1.0.5' +__version__ = '1.0.6' class SparkPost(object): From 59893b3f9c2043335511f0a98e6f35d3631b9472 Mon Sep 17 00:00:00 2001 From: Marko Mrdjenovic Date: Thu, 24 Mar 2016 09:21:55 +0100 Subject: [PATCH 08/12] pep8 --- sparkpost/__init__.py | 18 ++++++++++++------ sparkpost/base.py | 4 ++-- sparkpost/exceptions.py | 2 +- sparkpost/tornado/__init__.py | 8 +++++--- sparkpost/tornado/base.py | 3 ++- sparkpost/tornado/exceptions.py | 5 +++-- sparkpost/tornado/utils.py | 2 ++ sparkpost/transmissions.py | 2 +- test/tornado/test_tornado.py | 16 ++++++++++++---- test/tornado/utils.py | 11 ++++++++--- 10 files changed, 48 insertions(+), 23 deletions(-) diff --git a/sparkpost/__init__.py b/sparkpost/__init__.py index c89f017..fb6e67f 100644 --- a/sparkpost/__init__.py +++ b/sparkpost/__init__.py @@ -14,6 +14,7 @@ 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" @@ -25,15 +26,20 @@ def __init__(self, api_key=None, base_uri='https://api.sparkpost.com', self.base_uri = base_uri + '/api/v' + version self.api_key = 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) + 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) diff --git a/sparkpost/base.py b/sparkpost/base.py index e686b34..918d3db 100644 --- a/sparkpost/base.py +++ b/sparkpost/base.py @@ -34,10 +34,10 @@ def request(self, method, uri, **kwargs): 'Content-Type': 'application/json', 'Authorization': self.api_key } - response = self.transport.request(method, uri, headers=headers, **kwargs) + response = self.transport.request(method, uri, headers=headers, + **kwargs) return response - def get(self): raise NotImplementedError diff --git a/sparkpost/exceptions.py b/sparkpost/exceptions.py index 0c738f5..6e701b3 100644 --- a/sparkpost/exceptions.py +++ b/sparkpost/exceptions.py @@ -9,7 +9,7 @@ def __init__(self, response, *args, **kwargs): try: errors = response.json()['errors'] errors = [e['message'] + ': ' + e.get('description', '') - for e in errors] + for e in errors] except: pass message = """Call to {uri} returned {status_code}, errors: diff --git a/sparkpost/tornado/__init__.py b/sparkpost/tornado/__init__.py index 099b4d3..ff52e53 100644 --- a/sparkpost/tornado/__init__.py +++ b/sparkpost/tornado/__init__.py @@ -2,15 +2,17 @@ from .exceptions import SparkPostAPIException from .base import TornadoTransport -from .utils import wrap_future from .transmissions import Transmissions -__all__ = ["SparkPost", "TornadoTransport", "SparkPostAPIException", "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.transmissions = Transmissions(self.base_uri, self.api_key, + self.TRANSPORT_CLASS) self.transmission = self.transmissions diff --git a/sparkpost/tornado/base.py b/sparkpost/tornado/base.py index 765b9a8..8e1fb34 100644 --- a/sparkpost/tornado/base.py +++ b/sparkpost/tornado/base.py @@ -12,7 +12,8 @@ def request(self, method, uri, headers, **kwargs): kwargs["body"] = kwargs.pop("data") client = AsyncHTTPClient() try: - response = yield client.fetch(uri, method=method, headers=headers, **kwargs) + response = yield client.fetch(uri, method=method, headers=headers, + **kwargs) except HTTPError as ex: raise SparkPostAPIException(ex.response) if response.code == 204: diff --git a/sparkpost/tornado/exceptions.py b/sparkpost/tornado/exceptions.py index b2c26e7..ad59679 100644 --- a/sparkpost/tornado/exceptions.py +++ b/sparkpost/tornado/exceptions.py @@ -11,7 +11,7 @@ def __init__(self, response, *args, **kwargs): if data: errors = data['errors'] errors = [e['message'] + ': ' + e.get('description', '') - for e in errors] + for e in errors] except: pass message = """Call to {uri} returned {status_code}, errors: @@ -22,4 +22,5 @@ def __init__(self, response, *args, **kwargs): status_code=response.code, errors='\n'.join(errors) ) - super(RequestsSparkPostAPIException, self).__init__(message, *args, **kwargs) + super(RequestsSparkPostAPIException, self).__init__(message, *args, + **kwargs) diff --git a/sparkpost/tornado/utils.py b/sparkpost/tornado/utils.py index 401e1d9..21bf019 100644 --- a/sparkpost/tornado/utils.py +++ b/sparkpost/tornado/utils.py @@ -3,10 +3,12 @@ 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 diff --git a/sparkpost/transmissions.py b/sparkpost/transmissions.py index 8799955..59b18f9 100644 --- a/sparkpost/transmissions.py +++ b/sparkpost/transmissions.py @@ -200,7 +200,7 @@ def _fetch_get(self, transmission_id): uri = "%s/%s" % (self.uri, transmission_id) results = self.request('GET', uri) return results - + def get(self, transmission_id): """ Get a transmission by ID diff --git a/test/tornado/test_tornado.py b/test/tornado/test_tornado.py index da63b6d..73b88f1 100644 --- a/test/tornado/test_tornado.py +++ b/test/tornado/test_tornado.py @@ -50,6 +50,7 @@ def test_success_send_with_attachments(): "type": "text/plain", "filename": temp_file_path } + def send(): return sp.transmission.send(attachments=[attachment]) results = ioloop.IOLoop().run_sync(send) @@ -68,6 +69,7 @@ def send(): "data": base64.b64encode( test_content.encode("ascii")).decode("ascii") } + def send(): return sp.transmission.send(attachments=[attachment]) results = ioloop.IOLoop().run_sync(send) @@ -90,7 +92,9 @@ def test_fail_send(): 'https://api.sparkpost.com/api/v1/transmissions', status=500, content_type='application/json', - body='{"errors": [{"message": "You failed", "description": "More Info"}]}' + body=""" + {"errors": [{"message": "You failed", "description": "More Info"}]} + """ ) with pytest.raises(SparkPostAPIException): sp = SparkPost('fake-key') @@ -107,6 +111,7 @@ def test_success_get(): body='{"results": {"transmission": {}}}' ) sp = SparkPost('fake-key') + def send(): return sp.transmission.get('foobar') results = ioloop.IOLoop().run_sync(send, timeout=3) @@ -120,10 +125,13 @@ def test_fail_get(): 'https://api.sparkpost.com/api/v1/transmissions/foobar', status=404, content_type='application/json', - body='{"errors": [{"message": "cant find", "description": "where you go"}]}' + body=""" + {"errors": [{"message": "cant find", "description": "where you go"}]} + """ ) with pytest.raises(SparkPostAPIException): sp = SparkPost('fake-key') + def send(): return sp.transmission.get('foobar') ioloop.IOLoop().run_sync(send, timeout=3) @@ -140,7 +148,7 @@ def test_nocontent_get(): ) sp = SparkPost('fake-key') response = ioloop.IOLoop().run_sync(sp.transmission.list) - assert response == True + assert response is True @responses.activate @@ -154,7 +162,7 @@ def test_brokenjson_get(): ) with pytest.raises(SparkPostAPIException): sp = SparkPost('fake-key') - response = ioloop.IOLoop().run_sync(sp.transmission.list) + ioloop.IOLoop().run_sync(sp.transmission.list) @responses.activate diff --git a/test/tornado/utils.py b/test/tornado/utils.py index dc30be2..c3f74b5 100644 --- a/test/tornado/utils.py +++ b/test/tornado/utils.py @@ -14,7 +14,8 @@ def get_adapter(self, url): return self def build_response(self, request, response): - resp = HTTPResponse(request, response.status, headers=response.headers, effective_url=request.url, error=None, buffer="") + resp = HTTPResponse(request, response.status, headers=response.headers, + effective_url=request.url, error=None, buffer="") resp._body = response.data f = Future() f.content = None @@ -32,9 +33,13 @@ def start(self): def unbound_on_send(client, request, callback=None, **kwargs): if not isinstance(request, HTTPRequest): - request = Request(request, kwargs.get("method", "GET"), kwargs.get("headers", []), kwargs.get("body", "")) + request = Request(request, + kwargs.get("method", "GET"), + kwargs.get("headers", []), + kwargs.get("body", "")) return self._on_request(ResponseGenerator(), request) - self._patcher = mock.patch('tornado.httpclient.AsyncHTTPClient.fetch', unbound_on_send) + self._patcher = mock.patch('tornado.httpclient.AsyncHTTPClient.fetch', + unbound_on_send) self._patcher.start() def stop(self): From 256a9d8baebb45ee77398997a0cc40a3a459f92b Mon Sep 17 00:00:00 2001 From: Marko Mrdjenovic Date: Thu, 24 Mar 2016 09:35:11 +0100 Subject: [PATCH 09/12] fixed py3 - tornado body is bytes, not str --- sparkpost/exceptions.py | 4 +++- sparkpost/tornado/base.py | 2 +- sparkpost/tornado/exceptions.py | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/sparkpost/exceptions.py b/sparkpost/exceptions.py index 6e701b3..e743ecd 100644 --- a/sparkpost/exceptions.py +++ b/sparkpost/exceptions.py @@ -5,13 +5,15 @@ class SparkPostException(Exception): class SparkPostAPIException(SparkPostException): "Handle 4xx and 5xx errors from the SparkPost API" def __init__(self, response, *args, **kwargs): - errors = [response.text or ""] + 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 ""] message = """Call to {uri} returned {status_code}, errors: {errors} diff --git a/sparkpost/tornado/base.py b/sparkpost/tornado/base.py index 8e1fb34..d4bc331 100644 --- a/sparkpost/tornado/base.py +++ b/sparkpost/tornado/base.py @@ -21,7 +21,7 @@ def request(self, method, uri, headers, **kwargs): if response.code == 200: result = None try: - result = json.loads(response.body) + result = json.loads(response.body.decode("utf-8")) except: pass if result: diff --git a/sparkpost/tornado/exceptions.py b/sparkpost/tornado/exceptions.py index ad59679..3919c61 100644 --- a/sparkpost/tornado/exceptions.py +++ b/sparkpost/tornado/exceptions.py @@ -5,15 +5,17 @@ class SparkPostAPIException(RequestsSparkPostAPIException): def __init__(self, response, *args, **kwargs): - errors = [response.body or ""] + errors = None try: - data = json.loads(response.body) + 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 ""] message = """Call to {uri} returned {status_code}, errors: {errors} From d1dc0e5f59a250947d582e089df7534b1e8b5c8d Mon Sep 17 00:00:00 2001 From: Marko Mrdjenovic Date: Wed, 30 Mar 2016 00:05:13 +0200 Subject: [PATCH 10/12] updated version --- sparkpost/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sparkpost/__init__.py b/sparkpost/__init__.py index fb6e67f..8c7a31d 100644 --- a/sparkpost/__init__.py +++ b/sparkpost/__init__.py @@ -9,7 +9,7 @@ from .transmissions import Transmissions -__version__ = '1.0.6' +__version__ = '1.0.6.dev1' class SparkPost(object): From 5ee88459ac4e1e8118ca9814dd285cd50e5dda96 Mon Sep 17 00:00:00 2001 From: Marko Mrdjenovic Date: Wed, 30 Mar 2016 00:06:04 +0200 Subject: [PATCH 11/12] __init__ never returns --- sparkpost/django/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sparkpost/django/message.py b/sparkpost/django/message.py index 9b41dbb..d6f5e63 100644 --- a/sparkpost/django/message.py +++ b/sparkpost/django/message.py @@ -54,4 +54,4 @@ def __init__(self, message): 'type': mimetype }) - return super(SparkPostMessage, self).__init__(formatted) + super(SparkPostMessage, self).__init__(formatted) From 9d7a191837331fcc3bc3fe66112a7b403ab423e2 Mon Sep 17 00:00:00 2001 From: Marko Mrdjenovic Date: Wed, 30 Mar 2016 01:02:47 +0200 Subject: [PATCH 12/12] save details in error for possible inspection later --- sparkpost/exceptions.py | 3 +++ sparkpost/tornado/exceptions.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/sparkpost/exceptions.py b/sparkpost/exceptions.py index e743ecd..5927e98 100644 --- a/sparkpost/exceptions.py +++ b/sparkpost/exceptions.py @@ -14,6 +14,9 @@ def __init__(self, response, *args, **kwargs): 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} diff --git a/sparkpost/tornado/exceptions.py b/sparkpost/tornado/exceptions.py index 3919c61..bf028e3 100644 --- a/sparkpost/tornado/exceptions.py +++ b/sparkpost/tornado/exceptions.py @@ -16,6 +16,9 @@ def __init__(self, response, *args, **kwargs): 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}