From aa8e608ef5f9e63764e407d0378240bdb278c620 Mon Sep 17 00:00:00 2001 From: gckwan Date: Tue, 27 Sep 2016 16:03:30 -0400 Subject: [PATCH 01/10] Add config options to python client --- pybutton/client.py | 24 ++++++++++++++-- pybutton/request.py | 8 +++--- pybutton/resources/resource.py | 14 +++++---- test/client_test.py | 39 +++++++++++++++++++++++++ test/resources/orders_test.py | 16 +++++++---- test/resources/resource_test.py | 51 ++++++++++++++++++++++----------- 6 files changed, 120 insertions(+), 32 deletions(-) diff --git a/pybutton/client.py b/pybutton/client.py index 7466947..264b50b 100644 --- a/pybutton/client.py +++ b/pybutton/client.py @@ -17,6 +17,13 @@ class Client(object): api_key (string): Your organization's API key. Do find yours at https://app.usebutton.com/settings/organization. + config (dict): Configuration options for the client. Options include: + timeout: The time in ms for network requests to abort. Defaults to None. + hostname: Defaults to api.usebutton.com. + port: Defaults to 443 if config.secure, else defaults to 80. + secure: Whether or not to use HTTPS. Defaults to True. + (N.B: Button's API is only exposed through HTTPS. This option is provided purely as a convenience for testing and development.) + Attributes: orders (pybutton.Resource): Resource for managing Button Orders. @@ -25,7 +32,7 @@ class Client(object): ''' - def __init__(self, api_key): + def __init__(self, api_key, config={}): if not api_key: raise ButtonClientError(( @@ -33,4 +40,17 @@ def __init__(self, api_key): ' https://app.usebutton.com/settings/organization' )) - self.orders = Orders(api_key) + config = self._config_with_defaults(config) + + self.orders = Orders(api_key, config) + + def _config_with_defaults(self, config): + secure = config.get('secure', True) + defaultPort = 443 if secure else 80 + + return { + 'secure': secure, + 'timeout': config.get('timeout'), + 'hostname': config.get('hostname', 'api.usebutton.com'), + 'port': config.get('port', defaultPort) + } diff --git a/pybutton/request.py b/pybutton/request.py index 02ba6a6..1d7d0da 100644 --- a/pybutton/request.py +++ b/pybutton/request.py @@ -21,7 +21,7 @@ from urllib.request import urlopen from urllib.error import HTTPError - def request(url, method, headers, data=None): + def request(url, method, headers, data=None, timeout=None): ''' Make an HTTP request in Python 3.x This method will abstract the underlying organization and invocation of @@ -50,7 +50,7 @@ def request(url, method, headers, data=None): if data: request.add_header('Content-Type', 'application/json') - response = urlopen(request).read().decode('utf8') + response = urlopen(request, timeout=timeout).read().decode('utf8') try: return json.loads(response) @@ -64,7 +64,7 @@ def request(url, method, headers, data=None): from urllib2 import urlopen from urllib2 import HTTPError - def request(url, method, headers, data=None): + def request(url, method, headers, data=None, timeout=None): ''' Make an HTTP request in Python 2.x This method will abstract the underlying organization and invocation of @@ -96,7 +96,7 @@ def request(url, method, headers, data=None): request.add_header('Content-Type', 'application/json') request.add_data(json.dumps(data)) - response = urlopen(request).read() + response = urlopen(request, timeout=timeout).read() try: return json.loads(response) diff --git a/pybutton/resources/resource.py b/pybutton/resources/resource.py index a0b85d0..c038108 100644 --- a/pybutton/resources/resource.py +++ b/pybutton/resources/resource.py @@ -31,10 +31,9 @@ class Resource(object): ''' - API_BASE = 'https://api.usebutton.com' - - def __init__(self, api_key): + def __init__(self, api_key, config): self.api_key = api_key + self.config = config def api_get(self, path): '''Make an HTTP GET request @@ -91,7 +90,8 @@ def _api_request(self, path, method, data=None): ''' - url = '{0}{1}'.format(self.API_BASE, path) + url = self._request_url(path) + api_key_bytes = '{0}:'.format(self.api_key).encode() authorization = b64encode(api_key_bytes).decode() @@ -101,7 +101,7 @@ def _api_request(self, path, method, data=None): } try: - resp = request(url, method, headers, data).get('object', {}) + resp = request(url, method, headers, data, self.config['timeout']).get('object', {}) return Response(resp) except HTTPError as e: response = e.read() @@ -115,3 +115,7 @@ def _api_request(self, path, method, data=None): error = json.loads(data).get('error', {}) message = error.get('message', fallback) raise ButtonClientError(message) + + def _request_url(self, path): + protocol = 'https://' if self.config['secure'] else 'http://' + return '{0}{1}:{2}{3}'.format(protocol, self.config['hostname'], self.config['port'], path) diff --git a/test/client_test.py b/test/client_test.py index c8c152d..764e0ba 100644 --- a/test/client_test.py +++ b/test/client_test.py @@ -33,3 +33,42 @@ def test_requires_api_key(self): def test_orders(self): client = Client('sk-XXX') self.assertTrue(client.orders is not None) + + def test_config(self): + client = Client('sk-XXX') + + # Defaults + config = client._config_with_defaults({}) + + self.assertEqual(config, { + 'hostname': 'api.usebutton.com', + 'port': 443, + 'secure': True, + 'timeout': None + }) + + # Port and timeout overrides + config = client._config_with_defaults({ + 'port': 88, + 'timeout': 5 + }) + + self.assertEqual(config, { + 'hostname': 'api.usebutton.com', + 'port': 88, + 'secure': True, + 'timeout': 5 + }) + + # Hostname and secure overrides + config = client._config_with_defaults({ + 'hostname': 'localhost', + 'secure': False + }) + + self.assertEqual(config, { + 'hostname': 'localhost', + 'port': 80, + 'secure': False, + 'timeout': None + }) diff --git a/test/resources/orders_test.py b/test/resources/orders_test.py index 0d4ee03..e75d19c 100644 --- a/test/resources/orders_test.py +++ b/test/resources/orders_test.py @@ -9,16 +9,22 @@ from pybutton.resources import Orders +config = { + 'hostname': 'api.usebutton.com', + 'secure': True, + 'port': 443, + 'timeout': None +} class OrdersTestCase(TestCase): def test_path(self): - order = Orders('sk-XXX') + order = Orders('sk-XXX', config) self.assertEqual(order._path(), '/v1/order') self.assertEqual(order._path('btnorder-1'), '/v1/order/btnorder-1') def test_get(self): - order = Orders('sk-XXX') + order = Orders('sk-XXX', config) order_response = {'a': 1} api_get = Mock() @@ -31,7 +37,7 @@ def test_get(self): api_get.assert_called_with('/v1/order/btnorder-XXX') def test_create(self): - order = Orders('sk-XXX') + order = Orders('sk-XXX', config) order_payload = {'b': 2} order_response = {'a': 1} @@ -45,7 +51,7 @@ def test_create(self): api_post.assert_called_with('/v1/order', order_payload) def test_update(self): - order = Orders('sk-XXX') + order = Orders('sk-XXX', config) order_payload = {'b': 2} order_response = {'a': 1} @@ -62,7 +68,7 @@ def test_update(self): ) def test_delete(self): - order = Orders('sk-XXX') + order = Orders('sk-XXX', config) order_response = {'a': 1} api_delete = Mock() diff --git a/test/resources/resource_test.py b/test/resources/resource_test.py index 572a906..dfb98e7 100644 --- a/test/resources/resource_test.py +++ b/test/resources/resource_test.py @@ -11,6 +11,12 @@ from pybutton.resources.resource import Resource from pybutton.error import ButtonClientError +config = { + 'hostname': 'api.usebutton.com', + 'secure': True, + 'port': 443, + 'timeout': None +} class ResourceTestCase(TestCase): @@ -18,12 +24,12 @@ class ResourceTestCase(TestCase): def test_api_request(self, request): resource_response = {'a': 1} request.return_value = {'object': resource_response} - resource = Resource('sk-XXX') + resource = Resource('sk-XXX', config) response = resource._api_request('/v1/api', 'GET') args = request.call_args[0] self.assertEqual(response.to_dict(), resource_response) - self.assertEqual(args[0], 'https://api.usebutton.com/v1/api') + self.assertEqual(args[0], 'https://api.usebutton.com:443/v1/api') self.assertEqual(args[1], 'GET') self.assertTrue(len(args[2]['User-Agent']) != 0) self.assertEqual(args[2]['Authorization'], 'Basic c2stWFhYOg==') @@ -33,12 +39,12 @@ def test_api_request(self, request): def test_api_request_with_other_methods(self, request): resource_response = {'a': 1} request.return_value = {'object': resource_response} - resource = Resource('sk-XXX') + resource = Resource('sk-XXX', config) response = resource._api_request('/v1/api', 'POST') args = request.call_args[0] self.assertEqual(response.to_dict(), resource_response) - self.assertEqual(args[0], 'https://api.usebutton.com/v1/api') + self.assertEqual(args[0], 'https://api.usebutton.com:443/v1/api') self.assertEqual(args[1], 'POST') self.assertTrue(len(args[2]['User-Agent']) != 0) self.assertEqual(args[2]['Authorization'], 'Basic c2stWFhYOg==') @@ -48,12 +54,12 @@ def test_api_request_with_other_methods(self, request): def test_api_request_with_other_paths(self, request): resource_response = {'a': 1} request.return_value = {'object': resource_response} - resource = Resource('sk-XXX') + resource = Resource('sk-XXX', config) response = resource._api_request('/v2/api', 'GET') args = request.call_args[0] self.assertEqual(response.to_dict(), resource_response) - self.assertEqual(args[0], 'https://api.usebutton.com/v2/api') + self.assertEqual(args[0], 'https://api.usebutton.com:443/v2/api') self.assertEqual(args[1], 'GET') self.assertTrue(len(args[2]['User-Agent']) != 0) self.assertEqual(args[2]['Authorization'], 'Basic c2stWFhYOg==') @@ -64,12 +70,12 @@ def test_api_request_with_data(self, request): data = {'c': 3} resource_response = {'a': 1} request.return_value = {'object': resource_response} - resource = Resource('sk-XXX') + resource = Resource('sk-XXX', config) response = resource._api_request('/v2/api', 'GET', data) args = request.call_args[0] self.assertEqual(response.to_dict(), resource_response) - self.assertEqual(args[0], 'https://api.usebutton.com/v2/api') + self.assertEqual(args[0], 'https://api.usebutton.com:443/v2/api') self.assertEqual(args[1], 'GET') self.assertTrue(len(args[2]['User-Agent']) != 0) self.assertEqual(args[2]['Authorization'], 'Basic c2stWFhYOg==') @@ -86,7 +92,7 @@ def side_effect(*args): raise HTTPError('url', 404, 'bloop', {}, fp) request.side_effect = side_effect - resource = Resource('sk-XXX') + resource = Resource('sk-XXX', config) try: resource._api_request('/v2/api', 'GET', data) @@ -107,7 +113,7 @@ def side_effect(*args): raise HTTPError('url', 404, 'bloop', {}, fp) request.side_effect = side_effect - resource = Resource('sk-XXX') + resource = Resource('sk-XXX', config) try: resource._api_request('/v2/api', 'GET', data) @@ -119,12 +125,12 @@ def side_effect(*args): def test_api_get(self, request): resource_response = {'a': 1} request.return_value = {'object': resource_response} - resource = Resource('sk-XXX') + resource = Resource('sk-XXX', config) response = resource.api_get('/v1/api') args = request.call_args[0] self.assertEqual(response.to_dict(), resource_response) - self.assertEqual(args[0], 'https://api.usebutton.com/v1/api') + self.assertEqual(args[0], 'https://api.usebutton.com:443/v1/api') self.assertEqual(args[1], 'GET') self.assertTrue(len(args[2]['User-Agent']) != 0) self.assertEqual(args[2]['Authorization'], 'Basic c2stWFhYOg==') @@ -135,12 +141,12 @@ def test_api_post(self, request): request_payload = {'c': 3} resource_response = {'a': 1} request.return_value = {'object': resource_response} - resource = Resource('sk-XXX') + resource = Resource('sk-XXX', config) response = resource.api_post('/v1/api', request_payload) args = request.call_args[0] self.assertEqual(response.to_dict(), resource_response) - self.assertEqual(args[0], 'https://api.usebutton.com/v1/api') + self.assertEqual(args[0], 'https://api.usebutton.com:443/v1/api') self.assertEqual(args[1], 'POST') self.assertTrue(len(args[2]['User-Agent']) != 0) self.assertEqual(args[2]['Authorization'], 'Basic c2stWFhYOg==') @@ -150,13 +156,26 @@ def test_api_post(self, request): def test_api_delete(self, request): resource_response = {'a': 1} request.return_value = {'object': resource_response} - resource = Resource('sk-XXX') + resource = Resource('sk-XXX', config) response = resource.api_delete('/v1/api') args = request.call_args[0] self.assertEqual(response.to_dict(), resource_response) - self.assertEqual(args[0], 'https://api.usebutton.com/v1/api') + self.assertEqual(args[0], 'https://api.usebutton.com:443/v1/api') self.assertEqual(args[1], 'DELETE') self.assertTrue(len(args[2]['User-Agent']) != 0) self.assertEqual(args[2]['Authorization'], 'Basic c2stWFhYOg==') self.assertEqual(args[3], None) + + def test_request_url(self): + resource = Resource('sk-XXX', config) + path = resource._request_url('/v1/api/btnorder-XXX') + self.assertEqual(path, 'https://api.usebutton.com:443/v1/api/btnorder-XXX') + + resource = Resource('sk-XXX', { + 'hostname': 'localhost', + 'port': 80, + 'secure': False + }) + path = resource._request_url('/v1/api/btnorder-XXX') + self.assertEqual(path, 'http://localhost:80/v1/api/btnorder-XXX') From d43dedf8d1eaab615ebf1610e324e479b45cce8c Mon Sep 17 00:00:00 2001 From: gckwan Date: Tue, 27 Sep 2016 16:25:23 -0400 Subject: [PATCH 02/10] Update README --- README.rst | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.rst b/README.rst index e8c0613..17a7955 100644 --- a/README.rst +++ b/README.rst @@ -65,6 +65,27 @@ instance: print(response.to_dict()) # {'status': open, 'btn_ref': None, 'line_items': [], ...} +Configuration +--------- + +You may optionally supply a config argument with your API key: + +.. code:: python + + client = Client("sk-XXX", { + 'hostname': 'api.testsite.com', + 'port': 80, + 'secure': False, + 'timeout': 5 + }) + +The supported options are as follows: + +* ``hostname``: Defaults to ``api.usebutton.com``. +* ``port``: Defaults to ``443`` if ``config.secure``, else defaults to ``80``. +* ``secure``: Whether or not to use HTTPS. Defaults to ``True``. **N.B: Button's API is only exposed through HTTPS. This option is provided purely as a convenience for testing and development.** +* ``timeout``: The time in seconds that may elapse before network requests abort. Defaults to ``None``. + Resources --------- From d2fda7a3f57f06f5f00fc75826f7eaff3c05e1ae Mon Sep 17 00:00:00 2001 From: gckwan Date: Wed, 28 Sep 2016 10:56:32 -0400 Subject: [PATCH 03/10] Address comments --- CHANGELOG.md | 3 +++ README.rst | 6 ++++-- pybutton/client.py | 28 ++++++++++++++++------------ pybutton/resources/resource.py | 12 ++++++++++-- pybutton/version.py | 2 +- test/client_test.py | 9 ++++----- 6 files changed, 38 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b422f..074a78c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,5 @@ 1.0.2 August 11, 2016 - Initial Release +1.1.0 September 28, 2016 + - Added config options: hostname, port, secure, timeout + diff --git a/README.rst b/README.rst index 17a7955..ee165ae 100644 --- a/README.rst +++ b/README.rst @@ -66,17 +66,19 @@ instance: # {'status': open, 'btn_ref': None, 'line_items': [], ...} Configuration ---------- +------------- You may optionally supply a config argument with your API key: .. code:: python + from pybutton import Client + client = Client("sk-XXX", { 'hostname': 'api.testsite.com', 'port': 80, 'secure': False, - 'timeout': 5 + 'timeout': 5 # seconds }) The supported options are as follows: diff --git a/pybutton/client.py b/pybutton/client.py index 264b50b..79f4be3 100644 --- a/pybutton/client.py +++ b/pybutton/client.py @@ -18,10 +18,10 @@ class Client(object): https://app.usebutton.com/settings/organization. config (dict): Configuration options for the client. Options include: - timeout: The time in ms for network requests to abort. Defaults to None. hostname: Defaults to api.usebutton.com. port: Defaults to 443 if config.secure, else defaults to 80. secure: Whether or not to use HTTPS. Defaults to True. + timeout: The time in seconds for network requests to abort. Defaults to None. (N.B: Button's API is only exposed through HTTPS. This option is provided purely as a convenience for testing and development.) Attributes: @@ -32,7 +32,7 @@ class Client(object): ''' - def __init__(self, api_key, config={}): + def __init__(self, api_key, config=None): if not api_key: raise ButtonClientError(( @@ -40,17 +40,21 @@ def __init__(self, api_key, config={}): ' https://app.usebutton.com/settings/organization' )) - config = self._config_with_defaults(config) + if config is None: + config = {} + + config = _config_with_defaults(config) self.orders = Orders(api_key, config) - def _config_with_defaults(self, config): - secure = config.get('secure', True) - defaultPort = 443 if secure else 80 - return { - 'secure': secure, - 'timeout': config.get('timeout'), - 'hostname': config.get('hostname', 'api.usebutton.com'), - 'port': config.get('port', defaultPort) - } +def _config_with_defaults(config): + secure = config.get('secure', True) + defaultPort = 443 if secure else 80 + + return { + 'secure': secure, + 'timeout': config.get('timeout'), + 'hostname': config.get('hostname', 'api.usebutton.com'), + 'port': config.get('port', defaultPort) + } diff --git a/pybutton/resources/resource.py b/pybutton/resources/resource.py index c038108..428aa34 100644 --- a/pybutton/resources/resource.py +++ b/pybutton/resources/resource.py @@ -6,6 +6,7 @@ from base64 import b64encode from platform import python_version import json +import urlparse from ..response import Response from ..error import ButtonClientError @@ -26,6 +27,13 @@ class Resource(object): api_key (string): Your organization's API key. Do find yours at https://app.usebutton.com/settings/organization. + config (dict): Configuration options for the client. Options include: + hostname: Defaults to api.usebutton.com. + port: Defaults to 443 if config.secure, else defaults to 80. + secure: Whether or not to use HTTPS. Defaults to True. + timeout: The time in seconds for network requests to abort. Defaults to None. + (N.B: Button's API is only exposed through HTTPS. This option is provided purely as a convenience for testing and development.) + Raises: pybutton.ButtonClientError @@ -117,5 +125,5 @@ def _api_request(self, path, method, data=None): raise ButtonClientError(message) def _request_url(self, path): - protocol = 'https://' if self.config['secure'] else 'http://' - return '{0}{1}:{2}{3}'.format(protocol, self.config['hostname'], self.config['port'], path) + protocol = 'https' if self.config['secure'] else 'http' + return urlparse.urlunsplit((protocol, '{0}:{1}'.format(self.config['hostname'], self.config['port']), path, '', '')) diff --git a/pybutton/version.py b/pybutton/version.py index 6732d5a..9cff1bc 100644 --- a/pybutton/version.py +++ b/pybutton/version.py @@ -1 +1 @@ -VERSION = '1.0.2' +VERSION = '1.1.0' diff --git a/test/client_test.py b/test/client_test.py index 764e0ba..bc6fcad 100644 --- a/test/client_test.py +++ b/test/client_test.py @@ -6,6 +6,7 @@ from unittest import TestCase from pybutton.client import Client +from pybutton.client import _config_with_defaults from pybutton import ButtonClientError @@ -35,10 +36,8 @@ def test_orders(self): self.assertTrue(client.orders is not None) def test_config(self): - client = Client('sk-XXX') - # Defaults - config = client._config_with_defaults({}) + config = _config_with_defaults({}) self.assertEqual(config, { 'hostname': 'api.usebutton.com', @@ -48,7 +47,7 @@ def test_config(self): }) # Port and timeout overrides - config = client._config_with_defaults({ + config = _config_with_defaults({ 'port': 88, 'timeout': 5 }) @@ -61,7 +60,7 @@ def test_config(self): }) # Hostname and secure overrides - config = client._config_with_defaults({ + config = _config_with_defaults({ 'hostname': 'localhost', 'secure': False }) From 7f0052f8d74edbad510362977d04ae5551897731 Mon Sep 17 00:00:00 2001 From: gckwan Date: Fri, 30 Sep 2016 14:59:17 -0400 Subject: [PATCH 04/10] Accounts resource --- CHANGELOG.md | 3 +- pybutton/client.py | 2 + pybutton/request.py | 26 +++++++++- pybutton/resources/__init__.py | 3 +- pybutton/resources/accounts.py | 84 +++++++++++++++++++++++++++++++++ pybutton/resources/orders.py | 6 +++ pybutton/resources/resource.py | 21 ++++----- pybutton/response.py | 49 ++++++++++++------- pybutton/version.py | 2 +- test/request_test.py | 8 ++++ test/resources/accounts_test.py | 56 ++++++++++++++++++++++ test/resources/resource_test.py | 27 +++-------- test/response_test.py | 56 +++++++++++++++++----- 13 files changed, 278 insertions(+), 65 deletions(-) create mode 100644 pybutton/resources/accounts.py create mode 100644 test/resources/accounts_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 074a78c..afd332b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,4 +2,5 @@ - Initial Release 1.1.0 September 28, 2016 - Added config options: hostname, port, secure, timeout - +2.0.0 September 30, 2016 + - Added accounts resource diff --git a/pybutton/client.py b/pybutton/client.py index 79f4be3..538892a 100644 --- a/pybutton/client.py +++ b/pybutton/client.py @@ -3,6 +3,7 @@ from __future__ import print_function from __future__ import unicode_literals +from .resources import Accounts from .resources import Orders from .error import ButtonClientError @@ -46,6 +47,7 @@ def __init__(self, api_key, config=None): config = _config_with_defaults(config) self.orders = Orders(api_key, config) + self.accounts = Accounts(api_key, config) def _config_with_defaults(config): diff --git a/pybutton/request.py b/pybutton/request.py index 1d7d0da..55729f6 100644 --- a/pybutton/request.py +++ b/pybutton/request.py @@ -20,6 +20,10 @@ from urllib.request import Request from urllib.request import urlopen from urllib.error import HTTPError + from urllib.parse import urlencode + from urllib.parse import urlparse + from urllib.parse import urlunsplit + from urllib.parse import parse_qs def request(url, method, headers, data=None, timeout=None): ''' Make an HTTP request in Python 3.x @@ -57,12 +61,23 @@ def request(url, method, headers, data=None, timeout=None): except ValueError: raise ButtonClientError('Invalid response: {0}'.format(response)) - __all__ = [Request, urlopen, HTTPError, request] + def request_url(secure, hostname, port, path, query=None): + '''Combines url components into a url passable into the request function.''' + query = urlencode(query) if query else '' + scheme = 'https' if secure else 'http' + netloc = '{0}:{1}'.format(hostname, port) + return urlunsplit((scheme, netloc, path, query, '')) + + __all__ = [Request, urlopen, HTTPError, request, request_url, urlparse, parse_qs] else: from urllib2 import Request from urllib2 import urlopen from urllib2 import HTTPError + from urllib import urlencode + from urlparse import urlparse + from urlparse import urlunsplit + from urlparse import parse_qs def request(url, method, headers, data=None, timeout=None): ''' Make an HTTP request in Python 2.x @@ -103,4 +118,11 @@ def request(url, method, headers, data=None, timeout=None): except ValueError: raise ButtonClientError('Invalid response: {0}'.format(response)) - __all__ = [Request, urlopen, HTTPError, request] + def request_url(secure, hostname, port, path, query=None): + '''Combines url components into a url passable into the request function.''' + query = urlencode(query) if query else '' + scheme = 'https' if secure else 'http' + netloc = '{0}:{1}'.format(hostname, port) + return urlunsplit((scheme, netloc, path, query, '')) + + __all__ = [Request, urlopen, HTTPError, request, request_url, urlparse, parse_qs] diff --git a/pybutton/resources/__init__.py b/pybutton/resources/__init__.py index 1a1370a..fff6aa2 100644 --- a/pybutton/resources/__init__.py +++ b/pybutton/resources/__init__.py @@ -4,5 +4,6 @@ from __future__ import unicode_literals from .orders import Orders +from .accounts import Accounts -__all__ = [Orders] +__all__ = [Orders, Accounts] diff --git a/pybutton/resources/accounts.py b/pybutton/resources/accounts.py new file mode 100644 index 0000000..92c4df9 --- /dev/null +++ b/pybutton/resources/accounts.py @@ -0,0 +1,84 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +from .resource import Resource + + +class Accounts(Resource): + '''Manages interacting with Button Orders with the Button API + + Args: + api_key (string): Your organization's API key. Do find yours at + https://app.usebutton.com/settings/organization. + config (dict): Configuration options for the client. Options include: + hostname: Defaults to api.usebutton.com. + port: Defaults to 443 if config.secure, else defaults to 80. + secure: Whether or not to use HTTPS. Defaults to True. + timeout: The time in seconds for network requests to abort. Defaults to None. + (N.B: Button's API is only exposed through HTTPS. This option is provided purely as a convenience for testing and development.) + + Raises: + pybutton.ButtonClientError + + ''' + + def _path(self, account_id=None): + '''Format a url path + + Args: + account_id (str) optional: A Button account id ('acc-XXX') + query (dict) optional: A dictionary of key: value query parameters + + Returns: + (str): The formatted path + + ''' + + if account_id: + return '/v1/affiliation/accounts/{0}/transactions'.format(account_id) + else: + return '/v1/affiliation/accounts' + + def all(self): + '''Get a list of available accounts + + Raises: + pybutton.ButtonClientError + + Returns: + (pybutton.Response) The API response + + ''' + + return self.api_get(self._path()) + + def transactions(self, account_id, cursor=None, start=None, end=None): + '''Get a list of transactions. + To paginate transactions, use the meta.next URL as your next request URL until it is null. + + Args: + account_id (str) optional: A Button account id ('acc-XXX') + cursor (str) optional: An opaque string that lets you view a consistent list of transactions. + start (ISO-8601 datetime str) optional: Filter transactions created at or after this time. + end (ISO-8601 datetime str) optional: Filter transactions created before this time. + + Raises: + pybutton.ButtonClientError + + Returns: + (pybutton.Response) The API response + + ''' + + query = {} + + if cursor: + query['cursor'] = cursor + if start: + query['start'] = start + if end: + query['end'] = end + + return self.api_get(self._path(account_id), query=query) diff --git a/pybutton/resources/orders.py b/pybutton/resources/orders.py index 4f8c49f..c0ebb7f 100644 --- a/pybutton/resources/orders.py +++ b/pybutton/resources/orders.py @@ -12,6 +12,12 @@ class Orders(Resource): Args: api_key (string): Your organization's API key. Do find yours at https://app.usebutton.com/settings/organization. + config (dict): Configuration options for the client. Options include: + hostname: Defaults to api.usebutton.com. + port: Defaults to 443 if config.secure, else defaults to 80. + secure: Whether or not to use HTTPS. Defaults to True. + timeout: The time in seconds for network requests to abort. Defaults to None. + (N.B: Button's API is only exposed through HTTPS. This option is provided purely as a convenience for testing and development.) Raises: pybutton.ButtonClientError diff --git a/pybutton/resources/resource.py b/pybutton/resources/resource.py index 428aa34..151cf59 100644 --- a/pybutton/resources/resource.py +++ b/pybutton/resources/resource.py @@ -6,12 +6,13 @@ from base64 import b64encode from platform import python_version import json -import urlparse +import sys from ..response import Response from ..error import ButtonClientError from ..version import VERSION from ..request import request +from ..request import request_url from ..request import HTTPError USER_AGENT = 'pybutton/{0} python/{1}'.format(VERSION, python_version()) @@ -43,7 +44,7 @@ def __init__(self, api_key, config): self.api_key = api_key self.config = config - def api_get(self, path): + def api_get(self, path, query=None): '''Make an HTTP GET request Args: @@ -53,7 +54,7 @@ def api_get(self, path): (pybutton.Response): The API response ''' - return self._api_request(path, 'GET') + return self._api_request(path, 'GET', query=query) def api_post(self, path, data): '''Make an HTTP POST request @@ -80,7 +81,7 @@ def api_delete(self, path): ''' return self._api_request(path, 'DELETE') - def _api_request(self, path, method, data=None): + def _api_request(self, path, method, data=None, query=None): '''Make an HTTP request Any data provided will be JSON encoded an included as part of the @@ -98,7 +99,9 @@ def _api_request(self, path, method, data=None): ''' - url = self._request_url(path) + url = request_url(self.config['secure'], self.config['hostname'], self.config['port'], path, query) + + print(url) api_key_bytes = '{0}:'.format(self.api_key).encode() authorization = b64encode(api_key_bytes).decode() @@ -109,8 +112,8 @@ def _api_request(self, path, method, data=None): } try: - resp = request(url, method, headers, data, self.config['timeout']).get('object', {}) - return Response(resp) + resp = request(url, method, headers, data, self.config['timeout']) + return Response(resp.get('meta', {}), resp.get('object', resp.get('objects'))) except HTTPError as e: response = e.read() fallback = '{0} {1}'.format(e.code, e.msg) @@ -123,7 +126,3 @@ def _api_request(self, path, method, data=None): error = json.loads(data).get('error', {}) message = error.get('message', fallback) raise ButtonClientError(message) - - def _request_url(self, path): - protocol = 'https' if self.config['secure'] else 'http' - return urlparse.urlunsplit((protocol, '{0}:{1}'.format(self.config['hostname'], self.config['port']), path, '', '')) diff --git a/pybutton/response.py b/pybutton/response.py index a4214e9..0fa4ba3 100644 --- a/pybutton/response.py +++ b/pybutton/response.py @@ -3,39 +3,54 @@ from __future__ import print_function from __future__ import unicode_literals +from .request import urlparse +from .request import parse_qs class Response(object): - '''The Response class wraps a return value (dict) from an API call. + '''The Response class wraps the returned values from an API call. - It exposes all keys in the underlying response as attributes on the - instance. + It exposes the response data via the `data` method and cursors for pagination via the `next`/`prev` methods. Args: - attrs (dict): The underlying response value from an API call + meta (dict): The metadata from an API call + data (dict or array): The response elements from an API call Attributes: * (*): All keys in `attrs` will be exposed as attributes of an instance ''' - def __init__(self, attrs): - self.attrs = attrs + def __init__(self, meta, response_data): + self.meta = meta + self.response_data = response_data - def to_dict(self): - '''Return the raw response received from the server''' + def data(self): + '''Return the raw response element(s) received from the server. May be a single dict or an array of dicts.''' + return self.response_data - return self.attrs + def next(self): + '''For paginated responses, return the url used to fetch the next elements''' + return self._format_cursor(self.meta.get('next')) - def __getattr__(self, attr): - '''Proxy attribute lookups on an instance down to the response''' - - return self.attrs.get(attr) + def prev(self): + '''For paginated responses, return the url used to fetch the previous elements''' + return self._format_cursor(self.meta.get('prev')) def __repr__(self): values = [] - if self.attrs: - for k, v in self.attrs.items(): + if isinstance(self.response_data, dict): + for k, v in self.response_data.items(): values = values + ['{0}: {1}'.format(k, v)] - - return ''.format(', '.join(values)) + return ''.format(', '.join(values)) + elif isinstance(self.response_data, list): + return ''.format(len(self.response_data)) + else: + return '' + + def _format_cursor(self, cursor_url): + if cursor_url: + query_string = urlparse(cursor_url)[4] + query = parse_qs(query_string) + + return query['cursor'][0] diff --git a/pybutton/version.py b/pybutton/version.py index 9cff1bc..204a96f 100644 --- a/pybutton/version.py +++ b/pybutton/version.py @@ -1 +1 @@ -VERSION = '1.1.0' +VERSION = '2.0.0' diff --git a/test/request_test.py b/test/request_test.py index 0ac16ee..dca255f 100644 --- a/test/request_test.py +++ b/test/request_test.py @@ -10,6 +10,7 @@ from mock import patch from pybutton.request import request +from pybutton.request import request_url from pybutton import ButtonClientError @@ -197,3 +198,10 @@ def test_raises_with_invalid_response_data(self, MockRequest, self.assertTrue(False) except ButtonClientError: pass + + def test_request_url(self): + path = request_url(True, 'api.usebutton.com', 443, '/v1/api/btnorder-XXX') + self.assertEqual(path, 'https://api.usebutton.com:443/v1/api/btnorder-XXX') + + path = request_url(False, 'localhost', 80, '/v1/api/btnorder-XXX') + self.assertEqual(path, 'http://localhost:80/v1/api/btnorder-XXX') diff --git a/test/resources/accounts_test.py b/test/resources/accounts_test.py new file mode 100644 index 0000000..80d3b2e --- /dev/null +++ b/test/resources/accounts_test.py @@ -0,0 +1,56 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +from unittest import TestCase +from mock import Mock +from mock import patch + +from pybutton.resources import Accounts + +config = { + 'hostname': 'api.usebutton.com', + 'secure': True, + 'port': 443, + 'timeout': None +} + +class AccountsTestCase(TestCase): + + def test_path(self): + account = Accounts('sk-XXX', config) + self.assertEqual(account._path(), '/v1/affiliation/accounts') + self.assertEqual(account._path('acc-123'), '/v1/affiliation/accounts/acc-123/transactions') + + def test_all(self): + account = Accounts('sk-XXX', config) + account_response = [{'a': 1}, {'b': 2}] + + api_get = Mock() + api_get.return_value = account_response + + with patch.object(account, 'api_get', api_get): + response = account.all() + + self.assertEqual(response, account_response) + api_get.assert_called_with('/v1/affiliation/accounts') + + def test_transactions(self): + account = Accounts('sk-XXX', config) + account_response = [{'a': 1}, {'b': 2}] + + api_get = Mock() + api_get.return_value = account_response + + with patch.object(account, 'api_get', api_get): + response = account.transactions('acc-123') + self.assertEqual(response, account_response) + self.assertEqual(api_get.call_args[0][0], '/v1/affiliation/accounts/acc-123/transactions') + + response = account.transactions('acc-123', cursor='abc', start='2016-09-15T00:00:00.000Z', end='2016-09-30T00:00:00.000Z') + self.assertEqual(response, account_response) + query = api_get.call_args[1]['query'] + self.assertEqual(query['cursor'], 'abc') + self.assertEqual(query['start'], '2016-09-15T00:00:00.000Z') + self.assertEqual(query['end'], '2016-09-30T00:00:00.000Z') diff --git a/test/resources/resource_test.py b/test/resources/resource_test.py index dfb98e7..5b3b474 100644 --- a/test/resources/resource_test.py +++ b/test/resources/resource_test.py @@ -28,7 +28,7 @@ def test_api_request(self, request): response = resource._api_request('/v1/api', 'GET') args = request.call_args[0] - self.assertEqual(response.to_dict(), resource_response) + self.assertEqual(response.data(), resource_response) self.assertEqual(args[0], 'https://api.usebutton.com:443/v1/api') self.assertEqual(args[1], 'GET') self.assertTrue(len(args[2]['User-Agent']) != 0) @@ -43,7 +43,7 @@ def test_api_request_with_other_methods(self, request): response = resource._api_request('/v1/api', 'POST') args = request.call_args[0] - self.assertEqual(response.to_dict(), resource_response) + self.assertEqual(response.data(), resource_response) self.assertEqual(args[0], 'https://api.usebutton.com:443/v1/api') self.assertEqual(args[1], 'POST') self.assertTrue(len(args[2]['User-Agent']) != 0) @@ -58,7 +58,7 @@ def test_api_request_with_other_paths(self, request): response = resource._api_request('/v2/api', 'GET') args = request.call_args[0] - self.assertEqual(response.to_dict(), resource_response) + self.assertEqual(response.data(), resource_response) self.assertEqual(args[0], 'https://api.usebutton.com:443/v2/api') self.assertEqual(args[1], 'GET') self.assertTrue(len(args[2]['User-Agent']) != 0) @@ -74,7 +74,7 @@ def test_api_request_with_data(self, request): response = resource._api_request('/v2/api', 'GET', data) args = request.call_args[0] - self.assertEqual(response.to_dict(), resource_response) + self.assertEqual(response.data(), resource_response) self.assertEqual(args[0], 'https://api.usebutton.com:443/v2/api') self.assertEqual(args[1], 'GET') self.assertTrue(len(args[2]['User-Agent']) != 0) @@ -129,7 +129,7 @@ def test_api_get(self, request): response = resource.api_get('/v1/api') args = request.call_args[0] - self.assertEqual(response.to_dict(), resource_response) + self.assertEqual(response.data(), resource_response) self.assertEqual(args[0], 'https://api.usebutton.com:443/v1/api') self.assertEqual(args[1], 'GET') self.assertTrue(len(args[2]['User-Agent']) != 0) @@ -145,7 +145,7 @@ def test_api_post(self, request): response = resource.api_post('/v1/api', request_payload) args = request.call_args[0] - self.assertEqual(response.to_dict(), resource_response) + self.assertEqual(response.data(), resource_response) self.assertEqual(args[0], 'https://api.usebutton.com:443/v1/api') self.assertEqual(args[1], 'POST') self.assertTrue(len(args[2]['User-Agent']) != 0) @@ -160,22 +160,9 @@ def test_api_delete(self, request): response = resource.api_delete('/v1/api') args = request.call_args[0] - self.assertEqual(response.to_dict(), resource_response) + self.assertEqual(response.data(), resource_response) self.assertEqual(args[0], 'https://api.usebutton.com:443/v1/api') self.assertEqual(args[1], 'DELETE') self.assertTrue(len(args[2]['User-Agent']) != 0) self.assertEqual(args[2]['Authorization'], 'Basic c2stWFhYOg==') self.assertEqual(args[3], None) - - def test_request_url(self): - resource = Resource('sk-XXX', config) - path = resource._request_url('/v1/api/btnorder-XXX') - self.assertEqual(path, 'https://api.usebutton.com:443/v1/api/btnorder-XXX') - - resource = Resource('sk-XXX', { - 'hostname': 'localhost', - 'port': 80, - 'secure': False - }) - path = resource._request_url('/v1/api/btnorder-XXX') - self.assertEqual(path, 'http://localhost:80/v1/api/btnorder-XXX') diff --git a/test/response_test.py b/test/response_test.py index 3aad03a..b65b23f 100644 --- a/test/response_test.py +++ b/test/response_test.py @@ -10,15 +10,47 @@ class ResponseTestCase(TestCase): - def test_to_dict(self): - attrs = {'a': 1, 'b': 2} - response = Response(attrs) - - self.assertEqual(response.to_dict(), attrs) - - def test_access_attribute(self): - attrs = {'a': 1, 'b': 2} - response = Response(attrs) - - self.assertEqual(response.a, attrs['a']) - self.assertEqual(response.b, attrs['b']) + def test_data(self): + response_data = {'a': 1, 'b': 2} + response = Response({}, response_data) + self.assertEqual(response.data(), response_data) + + response_data = [{'a': 1, 'b': 2}, {'c': 3}, {'d': 4}] + response = Response({}, response_data) + self.assertEqual(response.data(), response_data) + + def test_next(self): + meta = { + 'status': 'ok', + 'next': 'https://api.usebutton.com:443/v1/affiliation/accounts/acc-123/transactions?cursor=abc', + 'prev': None + } + response_data = {'a': 1, 'b': 2} + response = Response(meta, response_data) + + self.assertEqual(response.next(), 'abc') + self.assertEqual(response.prev(), None) + + def test_prev(self): + meta = { + 'status': 'ok', + 'next': None, + 'prev': 'https://api.usebutton.com:443/v1/affiliation/accounts/acc-123/transactions?cursor=def' + } + response_data = {'a': 1, 'b': 2} + response = Response(meta, response_data) + + self.assertEqual(response.next(), None) + self.assertEqual(response.prev(), 'def') + + def test_repr(self): + response_data = {'a': 1} + response = Response({}, response_data) + self.assertEqual(response.__repr__(), '') + + response_data = [{'a': 1, 'b': 2}, {'c': 3}, {'d': 4}] + response = Response({}, response_data) + self.assertEqual(response.__repr__(), '') + + response = Response({}, None) + self.assertEqual(response.__repr__(), '') From 794015ef7d33ed5a8f1910861ed4e14d85179ec4 Mon Sep 17 00:00:00 2001 From: gckwan Date: Mon, 10 Oct 2016 14:58:09 -0400 Subject: [PATCH 05/10] Fix lint issues --- README.rst | 47 ++++++++++++++++++++++++++++++++- pybutton/request.py | 14 +++++++++- pybutton/resources/accounts.py | 22 ++++++++++----- pybutton/resources/orders.py | 6 +++-- pybutton/resources/resource.py | 3 +-- pybutton/response.py | 20 ++++++++++---- test/resources/accounts_test.py | 18 ++++++++++--- test/response_test.py | 11 +++++--- 8 files changed, 117 insertions(+), 24 deletions(-) diff --git a/README.rst b/README.rst index 2a78242..28ec8f1 100644 --- a/README.rst +++ b/README.rst @@ -91,7 +91,7 @@ The supported options are as follows: Resources --------- -We currently expose only one resource to manage, ``Orders``. +We currently expose two resources to manage, ``Orders`` and ``Accounts``. Orders ~~~~~~ @@ -163,6 +163,51 @@ Delete print(response) # +Accounts +~~~~~~~~ + +All +''' + +.. code:: python + + from pybutton import Client + + client = Client('sk-XXX') + + response = client.accounts.all() + + print(response) + # + +Transactions +'''''''''''' + +.. code:: python + + from pybutton import Client + + client = Client('sk-XXX') + + response = client.accounts.transactions('acc-123', + start='2016-07-15T00:00:00.000Z', + end='2016-09-30T00:00:00.000Z' + ) + + print(response) + # + + cursor = response.next() + + response = client.accounts.transactions('acc-123', + cursor=cursor, + start='2016-07-15T00:00:00.000Z', + end='2016-09-30T00:00:00.000Z' + ) + + print(response) + # + Contributing ------------ diff --git a/pybutton/request.py b/pybutton/request.py index 7870a2f..16d9229 100644 --- a/pybutton/request.py +++ b/pybutton/request.py @@ -22,6 +22,8 @@ from urllib.error import HTTPError from urllib.parse import urlencode from urllib.parse import urlunsplit + from urllib.parse import urlparse + from urllib.parse import parse_qs def request(url, method, headers, data=None, timeout=None): ''' Make an HTTP request in Python 3.x @@ -65,6 +67,8 @@ def request(url, method, headers, data=None, timeout=None): from urllib2 import HTTPError from urllib import urlencode from urlparse import urlunsplit + from urlparse import urlparse + from urlparse import parse_qs def request(url, method, headers, data=None, timeout=None): ''' Make an HTTP request in Python 2.x @@ -125,4 +129,12 @@ def request_url(secure, hostname, port, path, query=None): return urlunsplit((scheme, netloc, path, query, '')) -__all__ = [Request, urlopen, HTTPError, request, request_url] +__all__ = [ + Request, + urlopen, + HTTPError, + request, + request_url, + urlparse, + parse_qs +] diff --git a/pybutton/resources/accounts.py b/pybutton/resources/accounts.py index 92c4df9..958cfe7 100644 --- a/pybutton/resources/accounts.py +++ b/pybutton/resources/accounts.py @@ -16,8 +16,10 @@ class Accounts(Resource): hostname: Defaults to api.usebutton.com. port: Defaults to 443 if config.secure, else defaults to 80. secure: Whether or not to use HTTPS. Defaults to True. - timeout: The time in seconds for network requests to abort. Defaults to None. - (N.B: Button's API is only exposed through HTTPS. This option is provided purely as a convenience for testing and development.) + timeout: The time in seconds for network requests to abort. + Defaults to None. + (N.B: Button's API is only exposed through HTTPS. This option is + provided purely as a convenience for testing and development.) Raises: pybutton.ButtonClientError @@ -37,7 +39,9 @@ def _path(self, account_id=None): ''' if account_id: - return '/v1/affiliation/accounts/{0}/transactions'.format(account_id) + return '/v1/affiliation/accounts/{0}/transactions'.format( + account_id + ) else: return '/v1/affiliation/accounts' @@ -56,13 +60,17 @@ def all(self): def transactions(self, account_id, cursor=None, start=None, end=None): '''Get a list of transactions. - To paginate transactions, use the meta.next URL as your next request URL until it is null. + To paginate transactions, use the meta.next URL as your next request + URL until it is null. Args: account_id (str) optional: A Button account id ('acc-XXX') - cursor (str) optional: An opaque string that lets you view a consistent list of transactions. - start (ISO-8601 datetime str) optional: Filter transactions created at or after this time. - end (ISO-8601 datetime str) optional: Filter transactions created before this time. + cursor (str) optional: An opaque string that lets you view a + consistent list of transactions. + start (ISO-8601 datetime str) optional: Filter out transactions + created at or after this time. + end (ISO-8601 datetime str) optional: Filter out transactions + created before this time. Raises: pybutton.ButtonClientError diff --git a/pybutton/resources/orders.py b/pybutton/resources/orders.py index 2e88913..3082493 100644 --- a/pybutton/resources/orders.py +++ b/pybutton/resources/orders.py @@ -16,8 +16,10 @@ class Orders(Resource): hostname: Defaults to api.usebutton.com. port: Defaults to 443 if config.secure, else defaults to 80. secure: Whether or not to use HTTPS. Defaults to True. - timeout: The time in seconds for network requests to abort. Defaults to None. - (N.B: Button's API is only exposed through HTTPS. This option is provided purely as a convenience for testing and development.) + timeout: The time in seconds for network requests to abort. + Defaults to None. + (N.B: Button's API is only exposed through HTTPS. This option is + provided purely as a convenience for testing and development.) config (dict): Configuration options for the client. Options include: hostname: Defaults to api.usebutton.com. diff --git a/pybutton/resources/resource.py b/pybutton/resources/resource.py index 795e9b1..922c090 100644 --- a/pybutton/resources/resource.py +++ b/pybutton/resources/resource.py @@ -6,7 +6,6 @@ from base64 import b64encode from platform import python_version import json -import sys from ..response import Response from ..error import ButtonClientError @@ -123,7 +122,7 @@ def _api_request(self, path, method, data=None, query=None): headers, data, self.config['timeout'] - ).get('object', {}) + ) return Response( resp.get('meta', {}), diff --git a/pybutton/response.py b/pybutton/response.py index 0fa4ba3..ab52746 100644 --- a/pybutton/response.py +++ b/pybutton/response.py @@ -6,10 +6,12 @@ from .request import urlparse from .request import parse_qs + class Response(object): '''The Response class wraps the returned values from an API call. - It exposes the response data via the `data` method and cursors for pagination via the `next`/`prev` methods. + It exposes the response data via the `data` method and cursors for + pagination via the `next`/`prev` methods. Args: meta (dict): The metadata from an API call @@ -25,15 +27,21 @@ def __init__(self, meta, response_data): self.response_data = response_data def data(self): - '''Return the raw response element(s) received from the server. May be a single dict or an array of dicts.''' + '''Return the raw response element(s) received from the server. + May be a single dict or an array of dicts. + ''' return self.response_data def next(self): - '''For paginated responses, return the url used to fetch the next elements''' + '''For paginated responses, returns the url used to fetch + the next elements. + ''' return self._format_cursor(self.meta.get('next')) def prev(self): - '''For paginated responses, return the url used to fetch the previous elements''' + '''For paginated responses, returns the url used to fetch + the previous elements. + ''' return self._format_cursor(self.meta.get('prev')) def __repr__(self): @@ -44,7 +52,9 @@ def __repr__(self): values = values + ['{0}: {1}'.format(k, v)] return ''.format(', '.join(values)) elif isinstance(self.response_data, list): - return ''.format(len(self.response_data)) + return ''.format( + len(self.response_data) + ) else: return '' diff --git a/test/resources/accounts_test.py b/test/resources/accounts_test.py index 80d3b2e..738d2a8 100644 --- a/test/resources/accounts_test.py +++ b/test/resources/accounts_test.py @@ -16,12 +16,16 @@ 'timeout': None } + class AccountsTestCase(TestCase): def test_path(self): account = Accounts('sk-XXX', config) self.assertEqual(account._path(), '/v1/affiliation/accounts') - self.assertEqual(account._path('acc-123'), '/v1/affiliation/accounts/acc-123/transactions') + self.assertEqual( + account._path('acc-123'), + '/v1/affiliation/accounts/acc-123/transactions' + ) def test_all(self): account = Accounts('sk-XXX', config) @@ -46,9 +50,17 @@ def test_transactions(self): with patch.object(account, 'api_get', api_get): response = account.transactions('acc-123') self.assertEqual(response, account_response) - self.assertEqual(api_get.call_args[0][0], '/v1/affiliation/accounts/acc-123/transactions') + self.assertEqual( + api_get.call_args[0][0], + '/v1/affiliation/accounts/acc-123/transactions' + ) - response = account.transactions('acc-123', cursor='abc', start='2016-09-15T00:00:00.000Z', end='2016-09-30T00:00:00.000Z') + response = account.transactions( + 'acc-123', + cursor='abc', + start='2016-09-15T00:00:00.000Z', + end='2016-09-30T00:00:00.000Z' + ) self.assertEqual(response, account_response) query = api_get.call_args[1]['query'] self.assertEqual(query['cursor'], 'abc') diff --git a/test/response_test.py b/test/response_test.py index b65b23f..4aba92d 100644 --- a/test/response_test.py +++ b/test/response_test.py @@ -22,7 +22,8 @@ def test_data(self): def test_next(self): meta = { 'status': 'ok', - 'next': 'https://api.usebutton.com:443/v1/affiliation/accounts/acc-123/transactions?cursor=abc', + 'next': """https://api.usebutton.com:443/v1/affiliation/accounts/ + acc-123/transactions?cursor=abc""", 'prev': None } response_data = {'a': 1, 'b': 2} @@ -35,7 +36,8 @@ def test_prev(self): meta = { 'status': 'ok', 'next': None, - 'prev': 'https://api.usebutton.com:443/v1/affiliation/accounts/acc-123/transactions?cursor=def' + 'prev': """https://api.usebutton.com:443/v1/affiliation/accounts/ + acc-123/transactions?cursor=def""" } response_data = {'a': 1, 'b': 2} response = Response(meta, response_data) @@ -50,7 +52,10 @@ def test_repr(self): response_data = [{'a': 1, 'b': 2}, {'c': 3}, {'d': 4}] response = Response({}, response_data) - self.assertEqual(response.__repr__(), '') + self.assertEqual( + response.__repr__(), + '' + ) response = Response({}, None) self.assertEqual(response.__repr__(), '') From 4da26befd2658d876694a331330a122b217ee753 Mon Sep 17 00:00:00 2001 From: gckwan Date: Mon, 10 Oct 2016 15:51:20 -0400 Subject: [PATCH 06/10] Tweaks --- README.rst | 2 +- pybutton/resources/accounts.py | 32 +++++++++----------------------- test/resources/accounts_test.py | 8 -------- 3 files changed, 10 insertions(+), 32 deletions(-) diff --git a/README.rst b/README.rst index 28ec8f1..2536c41 100644 --- a/README.rst +++ b/README.rst @@ -206,7 +206,7 @@ Transactions ) print(response) - # + # Contributing ------------ diff --git a/pybutton/resources/accounts.py b/pybutton/resources/accounts.py index 958cfe7..73f5059 100644 --- a/pybutton/resources/accounts.py +++ b/pybutton/resources/accounts.py @@ -26,25 +26,6 @@ class Accounts(Resource): ''' - def _path(self, account_id=None): - '''Format a url path - - Args: - account_id (str) optional: A Button account id ('acc-XXX') - query (dict) optional: A dictionary of key: value query parameters - - Returns: - (str): The formatted path - - ''' - - if account_id: - return '/v1/affiliation/accounts/{0}/transactions'.format( - account_id - ) - else: - return '/v1/affiliation/accounts' - def all(self): '''Get a list of available accounts @@ -56,12 +37,13 @@ def all(self): ''' - return self.api_get(self._path()) + return self.api_get('/v1/affiliation/accounts') def transactions(self, account_id, cursor=None, start=None, end=None): '''Get a list of transactions. - To paginate transactions, use the meta.next URL as your next request - URL until it is null. + To paginate transactions, pass the result of response.next() as the + cursor argument. + Args: account_id (str) optional: A Button account id ('acc-XXX') @@ -89,4 +71,8 @@ def transactions(self, account_id, cursor=None, start=None, end=None): if end: query['end'] = end - return self.api_get(self._path(account_id), query=query) + path = '/v1/affiliation/accounts/{0}/transactions'.format( + account_id + ) + + return self.api_get(path, query=query) diff --git a/test/resources/accounts_test.py b/test/resources/accounts_test.py index 738d2a8..c9b5b11 100644 --- a/test/resources/accounts_test.py +++ b/test/resources/accounts_test.py @@ -19,14 +19,6 @@ class AccountsTestCase(TestCase): - def test_path(self): - account = Accounts('sk-XXX', config) - self.assertEqual(account._path(), '/v1/affiliation/accounts') - self.assertEqual( - account._path('acc-123'), - '/v1/affiliation/accounts/acc-123/transactions' - ) - def test_all(self): account = Accounts('sk-XXX', config) account_response = [{'a': 1}, {'b': 2}] From 0b3a18351b030bc7756a35365e209a1f32720ca6 Mon Sep 17 00:00:00 2001 From: gckwan Date: Mon, 10 Oct 2016 15:55:03 -0400 Subject: [PATCH 07/10] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ccc32b..d3c5b02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -2.0.0 September 30, 2016 +2.0.0 October 10, 2016 - Added accounts resource 1.1.0 October 4, 2016 - Added config options: hostname, port, secure, timeout From 6c3cbe593fb40e43164279257982c509a882defc Mon Sep 17 00:00:00 2001 From: gckwan Date: Wed, 12 Oct 2016 11:01:31 -0400 Subject: [PATCH 08/10] Address comments --- README.rst | 87 ++++++++++++++++++++++++++++++---- pybutton/request.py | 10 +++- pybutton/resources/accounts.py | 6 +-- pybutton/response.py | 25 ++++++---- test/request_test.py | 11 +++++ test/response_test.py | 31 ++++++++---- 6 files changed, 137 insertions(+), 33 deletions(-) diff --git a/README.rst b/README.rst index 2536c41..a915d2a 100644 --- a/README.rst +++ b/README.rst @@ -183,13 +183,21 @@ All Transactions '''''''''''' +Along with the required account ID, you may also +pass the following optional arguments: + +* ``cursor`` (string): An API cursor to fetch a specific set of results. +* ``start`` (ISO-8601 datetime string): Fetch transactions after this time. +* ``end`` (ISO-8601 datetime string): Fetch transactions before this time. + .. code:: python from pybutton import Client client = Client('sk-XXX') - response = client.accounts.transactions('acc-123', + response = client.accounts.transactions( + 'acc-123', start='2016-07-15T00:00:00.000Z', end='2016-09-30T00:00:00.000Z' ) @@ -197,16 +205,79 @@ Transactions print(response) # - cursor = response.next() +Response +-------- - response = client.accounts.transactions('acc-123', - cursor=cursor, - start='2016-07-15T00:00:00.000Z', - end='2016-09-30T00:00:00.000Z' - ) +The format of the ``pybutton.Response`` class varies depending on whether it contains +one or multiple elements. + +Methods +~~~~~~~ + +Data +'''' + +.. code:: python + + from pybutton import Client + + client = Client('sk-XXX') + + response = client.orders.get('btnorder-XXX') + + print(response.data()) + # {'total': 50, 'currency': 'USD', 'status': 'open' ... } + + response = client.accounts.all() + + print(response.data()) + # [{'id': 'acc-123', ... }, {'id': 'acc-234', ... }] + +NextCursor +'''''''''' + +For any paged resource, ``nextCursor()`` will return a cursor to +supply for the next page of results. + +.. code:: python + + from pybutton import Client + + client = Client('sk-XXX') + + response = client.accounts.transactions('acc-123') + cursor = response.nextCursor() + + # loop through and print all transactions + while cursor: + response = client.accounts.transactions('acc-123', cursor=cursor) + print(response.data()) + cursor = response.nextCursor() + +PrevCursor +'''''''''' + +For any paged resource, ``prevCursor()`` will return a cursor to +supply for the next page of results. + +.. code:: python + + from pybutton import Client + + client = Client('sk-XXX') + + response = client.accounts.transactions('acc-123', cursor='xyz') print(response) - # + # + + cursor = response.prevCursor() + + response = client.accounts.transactions('acc-123', cursor=cursor) + + print(response) + # + Contributing ------------ diff --git a/pybutton/request.py b/pybutton/request.py index 16d9229..17a6488 100644 --- a/pybutton/request.py +++ b/pybutton/request.py @@ -129,12 +129,18 @@ def request_url(secure, hostname, port, path, query=None): return urlunsplit((scheme, netloc, path, query, '')) +def query_dict(url): + url_components = urlparse(url) + + if (url_components): + query_string = url_components.query + return parse_qs(query_string) + __all__ = [ Request, urlopen, HTTPError, request, request_url, - urlparse, - parse_qs + query_dict, ] diff --git a/pybutton/resources/accounts.py b/pybutton/resources/accounts.py index 73f5059..131b4f4 100644 --- a/pybutton/resources/accounts.py +++ b/pybutton/resources/accounts.py @@ -7,7 +7,7 @@ class Accounts(Resource): - '''Manages interacting with Button Orders with the Button API + '''Manages interacting with Button Accounts with the Button API Args: api_key (string): Your organization's API key. Do find yours at @@ -41,8 +41,8 @@ def all(self): def transactions(self, account_id, cursor=None, start=None, end=None): '''Get a list of transactions. - To paginate transactions, pass the result of response.next() as the - cursor argument. + To paginate transactions, pass the result of response.nextCursor() as + the cursor argument. Args: diff --git a/pybutton/response.py b/pybutton/response.py index ab52746..eef7213 100644 --- a/pybutton/response.py +++ b/pybutton/response.py @@ -3,9 +3,7 @@ from __future__ import print_function from __future__ import unicode_literals -from .request import urlparse -from .request import parse_qs - +from .request import query_dict class Response(object): '''The Response class wraps the returned values from an API call. @@ -32,13 +30,13 @@ def data(self): ''' return self.response_data - def next(self): + def nextCursor(self): '''For paginated responses, returns the url used to fetch the next elements. ''' return self._format_cursor(self.meta.get('next')) - def prev(self): + def prevCursor(self): '''For paginated responses, returns the url used to fetch the previous elements. ''' @@ -47,12 +45,18 @@ def prev(self): def __repr__(self): values = [] + classPrefix = 'class pybutton.Response' + if isinstance(self.response_data, dict): for k, v in self.response_data.items(): values = values + ['{0}: {1}'.format(k, v)] - return ''.format(', '.join(values)) + return '<{0} {1}>'.format( + classPrefix, + ', '.join(values) + ) elif isinstance(self.response_data, list): - return ''.format( + return '<{0} [{1} elements]>'.format( + classPrefix, len(self.response_data) ) else: @@ -60,7 +64,8 @@ def __repr__(self): def _format_cursor(self, cursor_url): if cursor_url: - query_string = urlparse(cursor_url)[4] - query = parse_qs(query_string) + query = query_dict(cursor_url) + cursor_values = query.get('cursor') - return query['cursor'][0] + if cursor_values: + return cursor_values[0] diff --git a/test/request_test.py b/test/request_test.py index f7ec1cb..334986b 100644 --- a/test/request_test.py +++ b/test/request_test.py @@ -11,6 +11,7 @@ from pybutton.request import request from pybutton.request import request_url +from pybutton.request import query_dict from pybutton import ButtonClientError @@ -214,3 +215,13 @@ def test_request_url(self): path = request_url(False, 'localhost', 80, '/v1/api/btnorder-XXX') self.assertEqual(path, 'http://localhost:80/v1/api/btnorder-XXX') + + def test_query_dict(self): + url = 'https://api.usebutton.com:/test/url?cursor=test_cursor' + result = query_dict(url) + self.assertEqual(result.get('cursor'), ['test_cursor']) + self.assertEqual(result.get('random_key'), None) + + no_query_url = 'https://api.usebutton.com:/test/url' + result = query_dict(no_query_url) + self.assertEqual(result.get('cursor'), None) diff --git a/test/response_test.py b/test/response_test.py index 4aba92d..ce99b6e 100644 --- a/test/response_test.py +++ b/test/response_test.py @@ -19,31 +19,42 @@ def test_data(self): response = Response({}, response_data) self.assertEqual(response.data(), response_data) - def test_next(self): + def test_cursors(self): + response_data = {'a': 1, 'b': 2} + meta = { 'status': 'ok', 'next': """https://api.usebutton.com:443/v1/affiliation/accounts/ acc-123/transactions?cursor=abc""", - 'prev': None + 'prev': """https://api.usebutton.com:443/v1/affiliation/accounts/ + acc-123/transactions?cursor=def""", } - response_data = {'a': 1, 'b': 2} response = Response(meta, response_data) - self.assertEqual(response.next(), 'abc') - self.assertEqual(response.prev(), None) + self.assertEqual(response.nextCursor(), 'abc') + self.assertEqual(response.prevCursor(), 'def') - def test_prev(self): meta = { 'status': 'ok', 'next': None, + 'prev': 'https://', + } + response = Response(meta, response_data) + + self.assertEqual(response.nextCursor(), None) + self.assertEqual(response.prevCursor(), None) + + meta = { + 'status': 'ok', + 'next': '12345', 'prev': """https://api.usebutton.com:443/v1/affiliation/accounts/ - acc-123/transactions?cursor=def""" + acc-123/transactions?c=abc""" } - response_data = {'a': 1, 'b': 2} response = Response(meta, response_data) - self.assertEqual(response.next(), None) - self.assertEqual(response.prev(), 'def') + self.assertEqual(response.nextCursor(), None) + self.assertEqual(response.prevCursor(), None) + def test_repr(self): response_data = {'a': 1} From daec3a4dc09c7b7f752e38aacfe58a38460bd0d3 Mon Sep 17 00:00:00 2001 From: gckwan Date: Thu, 13 Oct 2016 09:49:26 -0400 Subject: [PATCH 09/10] Style nits --- CHANGELOG.md | 3 ++- README.rst | 25 ++++++++++++------------- pybutton/request.py | 15 +++++++++++++-- pybutton/resources/accounts.py | 2 +- pybutton/resources/resource.py | 6 +++--- pybutton/response.py | 21 +++++++++++---------- test/resources/accounts_test.py | 2 +- test/resources/orders_test.py | 4 ++-- test/response_test.py | 17 ++++++++--------- 9 files changed, 53 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c5b02..1416548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ -2.0.0 October 10, 2016 +2.0.0 October 13, 2016 - Added accounts resource + - Breaking changes to pybutton.Response class 1.1.0 October 4, 2016 - Added config options: hostname, port, secure, timeout 1.0.2 August 11, 2016 diff --git a/README.rst b/README.rst index a915d2a..b890c93 100644 --- a/README.rst +++ b/README.rst @@ -208,13 +208,10 @@ pass the following optional arguments: Response -------- -The format of the ``pybutton.Response`` class varies depending on whether it contains -one or multiple elements. - Methods ~~~~~~~ -Data +data '''' .. code:: python @@ -233,11 +230,12 @@ Data print(response.data()) # [{'id': 'acc-123', ... }, {'id': 'acc-234', ... }] -NextCursor +next_cursor '''''''''' -For any paged resource, ``nextCursor()`` will return a cursor to -supply for the next page of results. +For any paged resource, ``next_cursor()`` will return a cursor to +supply for the next page of results. If ``next_cursor()`` returns ``None``, +there are no more results. .. code:: python @@ -246,19 +244,20 @@ supply for the next page of results. client = Client('sk-XXX') response = client.accounts.transactions('acc-123') - cursor = response.nextCursor() + cursor = response.next_cursor() # loop through and print all transactions while cursor: response = client.accounts.transactions('acc-123', cursor=cursor) print(response.data()) - cursor = response.nextCursor() + cursor = response.next_cursor() -PrevCursor +prev_cursor '''''''''' -For any paged resource, ``prevCursor()`` will return a cursor to -supply for the next page of results. +For any paged resource, ``prev_cursor()`` will return a cursor to +supply for the next page of results. If ``prev_cursor()`` returns +``None``, there are no more previous results. .. code:: python @@ -271,7 +270,7 @@ supply for the next page of results. print(response) # - cursor = response.prevCursor() + cursor = response.prev_cursor() response = client.accounts.transactions('acc-123', cursor=cursor) diff --git a/pybutton/request.py b/pybutton/request.py index 17a6488..d1bf4f4 100644 --- a/pybutton/request.py +++ b/pybutton/request.py @@ -119,17 +119,28 @@ def request_url(secure, hostname, port, path, query=None): hostname (str): The host name for the url. port (int): The port number, as an integer. path (str): The hierarchical path. + query (dict): A dict of query parameters. Returns: (str) A complete url made up of the arguments. ''' - query = urlencode(query) if query else '' + encoded_query = urlencode(query) if query else '' scheme = 'https' if secure else 'http' netloc = '{0}:{1}'.format(hostname, port) - return urlunsplit((scheme, netloc, path, query, '')) + return urlunsplit((scheme, netloc, path, encoded_query, '')) + def query_dict(url): + ''' + Given a url, extracts the query parameters into a dictionary of the + following form: + + { + 'query_parameter': [ list of values ], + ... + } + ''' url_components = urlparse(url) if (url_components): diff --git a/pybutton/resources/accounts.py b/pybutton/resources/accounts.py index 131b4f4..18625f1 100644 --- a/pybutton/resources/accounts.py +++ b/pybutton/resources/accounts.py @@ -41,7 +41,7 @@ def all(self): def transactions(self, account_id, cursor=None, start=None, end=None): '''Get a list of transactions. - To paginate transactions, pass the result of response.nextCursor() as + To paginate transactions, pass the result of response.next_cursor() as the cursor argument. diff --git a/pybutton/resources/resource.py b/pybutton/resources/resource.py index 922c090..f074968 100644 --- a/pybutton/resources/resource.py +++ b/pybutton/resources/resource.py @@ -105,14 +105,14 @@ def _api_request(self, path, method, data=None, query=None): self.config['hostname'], self.config['port'], path, - query + query, ) api_key_bytes = '{0}:'.format(self.api_key).encode() authorization = b64encode(api_key_bytes).decode() headers = { 'Authorization': 'Basic {0}'.format(authorization), - 'User-Agent': USER_AGENT + 'User-Agent': USER_AGENT, } try: @@ -121,7 +121,7 @@ def _api_request(self, path, method, data=None, query=None): method, headers, data, - self.config['timeout'] + self.config['timeout'], ) return Response( diff --git a/pybutton/response.py b/pybutton/response.py index eef7213..5580cf6 100644 --- a/pybutton/response.py +++ b/pybutton/response.py @@ -5,21 +5,25 @@ from .request import query_dict + class Response(object): '''The Response class wraps the returned values from an API call. It exposes the response data via the `data` method and cursors for - pagination via the `next`/`prev` methods. + pagination via the `next_cursor`/`prev_cursor` methods. Args: meta (dict): The metadata from an API call - data (dict or array): The response elements from an API call + response_data (dict or array): The response elements from an + API call Attributes: * (*): All keys in `attrs` will be exposed as attributes of an instance ''' + classPrefix = 'class pybutton.Response' + def __init__(self, meta, response_data): self.meta = meta self.response_data = response_data @@ -30,33 +34,30 @@ def data(self): ''' return self.response_data - def nextCursor(self): + def next_cursor(self): '''For paginated responses, returns the url used to fetch the next elements. ''' return self._format_cursor(self.meta.get('next')) - def prevCursor(self): + def prev_cursor(self): '''For paginated responses, returns the url used to fetch the previous elements. ''' return self._format_cursor(self.meta.get('prev')) def __repr__(self): - values = [] - - classPrefix = 'class pybutton.Response' - if isinstance(self.response_data, dict): + values = [] for k, v in self.response_data.items(): values = values + ['{0}: {1}'.format(k, v)] return '<{0} {1}>'.format( - classPrefix, + Response.classPrefix, ', '.join(values) ) elif isinstance(self.response_data, list): return '<{0} [{1} elements]>'.format( - classPrefix, + Response.classPrefix, len(self.response_data) ) else: diff --git a/test/resources/accounts_test.py b/test/resources/accounts_test.py index c9b5b11..fb4f157 100644 --- a/test/resources/accounts_test.py +++ b/test/resources/accounts_test.py @@ -13,7 +13,7 @@ 'hostname': 'api.usebutton.com', 'secure': True, 'port': 443, - 'timeout': None + 'timeout': None, } diff --git a/test/resources/orders_test.py b/test/resources/orders_test.py index a988d76..1c4b57c 100644 --- a/test/resources/orders_test.py +++ b/test/resources/orders_test.py @@ -13,7 +13,7 @@ 'hostname': 'api.usebutton.com', 'secure': True, 'port': 443, - 'timeout': None + 'timeout': None, } @@ -65,7 +65,7 @@ def test_update(self): self.assertEqual(response, order_response) api_post.assert_called_with( '/v1/order/btnorder-XXX', - order_payload + order_payload, ) def test_delete(self): diff --git a/test/response_test.py b/test/response_test.py index ce99b6e..1865272 100644 --- a/test/response_test.py +++ b/test/response_test.py @@ -31,8 +31,8 @@ def test_cursors(self): } response = Response(meta, response_data) - self.assertEqual(response.nextCursor(), 'abc') - self.assertEqual(response.prevCursor(), 'def') + self.assertEqual(response.next_cursor(), 'abc') + self.assertEqual(response.prev_cursor(), 'def') meta = { 'status': 'ok', @@ -41,20 +41,19 @@ def test_cursors(self): } response = Response(meta, response_data) - self.assertEqual(response.nextCursor(), None) - self.assertEqual(response.prevCursor(), None) + self.assertEqual(response.next_cursor(), None) + self.assertEqual(response.prev_cursor(), None) meta = { 'status': 'ok', 'next': '12345', 'prev': """https://api.usebutton.com:443/v1/affiliation/accounts/ - acc-123/transactions?c=abc""" + acc-123/transactions?c=abc""", } response = Response(meta, response_data) - self.assertEqual(response.nextCursor(), None) - self.assertEqual(response.prevCursor(), None) - + self.assertEqual(response.next_cursor(), None) + self.assertEqual(response.prev_cursor(), None) def test_repr(self): response_data = {'a': 1} @@ -65,7 +64,7 @@ def test_repr(self): response = Response({}, response_data) self.assertEqual( response.__repr__(), - '' + '', ) response = Response({}, None) From 0ef6f3909c132ff380547bb8ca8fafc585b19841 Mon Sep 17 00:00:00 2001 From: gckwan Date: Thu, 13 Oct 2016 11:31:47 -0400 Subject: [PATCH 10/10] Better docstring --- pybutton/request.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pybutton/request.py b/pybutton/request.py index d1bf4f4..85262d2 100644 --- a/pybutton/request.py +++ b/pybutton/request.py @@ -133,13 +133,18 @@ def request_url(secure, hostname, port, path, query=None): def query_dict(url): ''' - Given a url, extracts the query parameters into a dictionary of the - following form: + Given a url, returns a dictionary of its query parameters. + + Args: + url (string): The url to extract query parameters from. + + Returns: + (dict) A dictionary of query parameters, formatted as follows: + { + query_name: [ list of values ], + ... + } - { - 'query_parameter': [ list of values ], - ... - } ''' url_components = urlparse(url)