diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec8d19..1416548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +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 2a78242..b890c93 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,121 @@ Delete print(response) # +Accounts +~~~~~~~~ + +All +''' + +.. code:: python + + from pybutton import Client + + client = Client('sk-XXX') + + response = client.accounts.all() + + print(response) + # + +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', + start='2016-07-15T00:00:00.000Z', + end='2016-09-30T00:00:00.000Z' + ) + + print(response) + # + +Response +-------- + +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', ... }] + +next_cursor +'''''''''' + +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 + + from pybutton import Client + + client = Client('sk-XXX') + + response = client.accounts.transactions('acc-123') + 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.next_cursor() + +prev_cursor +'''''''''' + +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 + + from pybutton import Client + + client = Client('sk-XXX') + + response = client.accounts.transactions('acc-123', cursor='xyz') + + print(response) + # + + cursor = response.prev_cursor() + + response = client.accounts.transactions('acc-123', cursor=cursor) + + print(response) + # + + Contributing ------------ diff --git a/pybutton/client.py b/pybutton/client.py index 5e32313..e5be8c8 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 @@ -48,6 +49,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 34f4847..85262d2 100644 --- a/pybutton/request.py +++ b/pybutton/request.py @@ -20,7 +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 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 @@ -62,7 +65,10 @@ def request(url, method, headers, data=None, timeout=None): from urllib2 import Request from urllib2 import urlopen 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 @@ -104,7 +110,7 @@ def request(url, method, headers, data=None, timeout=None): raise ButtonClientError('Invalid response: {0}'.format(response)) -def request_url(secure, hostname, port, path): +def request_url(secure, hostname, port, path, query=None): ''' Combines url components into a url passable into the request function. @@ -113,13 +119,44 @@ def request_url(secure, hostname, port, path): 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. ''' + encoded_query = urlencode(query) if query else '' scheme = 'https' if secure else 'http' netloc = '{0}:{1}'.format(hostname, port) - return urlunsplit((scheme, netloc, path, '', '')) + return urlunsplit((scheme, netloc, path, encoded_query, '')) -__all__ = [Request, urlopen, HTTPError, request, request_url] + +def query_dict(url): + ''' + 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 ], + ... + } + + ''' + url_components = urlparse(url) + + if (url_components): + query_string = url_components.query + return parse_qs(query_string) + +__all__ = [ + Request, + urlopen, + HTTPError, + request, + request_url, + query_dict, +] 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..18625f1 --- /dev/null +++ b/pybutton/resources/accounts.py @@ -0,0 +1,78 @@ +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 Accounts 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 all(self): + '''Get a list of available accounts + + Raises: + pybutton.ButtonClientError + + Returns: + (pybutton.Response) The API response + + ''' + + 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, pass the result of response.next_cursor() as + the cursor argument. + + + 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 out transactions + created at or after this time. + end (ISO-8601 datetime str) optional: Filter out 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 + + path = '/v1/affiliation/accounts/{0}/transactions'.format( + account_id + ) + + return self.api_get(path, query=query) diff --git a/pybutton/resources/orders.py b/pybutton/resources/orders.py index 011d1c5..3082493 100644 --- a/pybutton/resources/orders.py +++ b/pybutton/resources/orders.py @@ -12,6 +12,14 @@ 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.) 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 26831aa..f074968 100644 --- a/pybutton/resources/resource.py +++ b/pybutton/resources/resource.py @@ -45,7 +45,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: @@ -55,7 +55,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 @@ -82,7 +82,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 @@ -104,14 +104,15 @@ def _api_request(self, path, method, data=None): self.config['secure'], self.config['hostname'], self.config['port'], - path + path, + 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: @@ -120,10 +121,15 @@ def _api_request(self, path, method, data=None): method, headers, data, - self.config['timeout'] - ).get('object', {}) - - return Response(resp) + self.config['timeout'], + ) + + return Response( + resp.get('meta', {}), + # Response info may have 'object' or 'objects' key, depending + # on whether there are 1 or multiple results. + resp.get('object', resp.get('objects')) + ) except HTTPError as e: response = e.read() fallback = '{0} {1}'.format(e.code, e.msg) diff --git a/pybutton/response.py b/pybutton/response.py index a4214e9..5580cf6 100644 --- a/pybutton/response.py +++ b/pybutton/response.py @@ -3,39 +3,70 @@ from __future__ import print_function from __future__ import unicode_literals +from .request import query_dict + 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_cursor`/`prev_cursor` methods. Args: - attrs (dict): The underlying response value from an API call + meta (dict): The metadata 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 ''' - def __init__(self, attrs): - self.attrs = attrs + classPrefix = 'class pybutton.Response' - def to_dict(self): - '''Return the raw response received from the server''' + def __init__(self, meta, response_data): + self.meta = meta + self.response_data = response_data - return self.attrs + 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 - def __getattr__(self, attr): - '''Proxy attribute lookups on an instance down to the response''' + def next_cursor(self): + '''For paginated responses, returns the url used to fetch + the next elements. + ''' + return self._format_cursor(self.meta.get('next')) - return self.attrs.get(attr) + 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 = [] - - if self.attrs: - for k, v in self.attrs.items(): + 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( + Response.classPrefix, + ', '.join(values) + ) + elif isinstance(self.response_data, list): + return '<{0} [{1} elements]>'.format( + Response.classPrefix, + len(self.response_data) + ) + else: + return '' + + def _format_cursor(self, cursor_url): + if cursor_url: + query = query_dict(cursor_url) + cursor_values = query.get('cursor') - return ''.format(', '.join(values)) + if cursor_values: + return cursor_values[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 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/resources/accounts_test.py b/test/resources/accounts_test.py new file mode 100644 index 0000000..fb4f157 --- /dev/null +++ b/test/resources/accounts_test.py @@ -0,0 +1,60 @@ +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_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/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/resources/resource_test.py b/test/resources/resource_test.py index e6fbe23..12a33b2 100644 --- a/test/resources/resource_test.py +++ b/test/resources/resource_test.py @@ -29,7 +29,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) @@ -44,7 +44,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) @@ -59,7 +59,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) @@ -75,7 +75,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) @@ -130,7 +130,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) @@ -146,7 +146,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) @@ -161,7 +161,7 @@ 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) diff --git a/test/response_test.py b/test/response_test.py index 3aad03a..1865272 100644 --- a/test/response_test.py +++ b/test/response_test.py @@ -10,15 +10,62 @@ class ResponseTestCase(TestCase): - def test_to_dict(self): - attrs = {'a': 1, 'b': 2} - response = Response(attrs) + def test_data(self): + response_data = {'a': 1, 'b': 2} + response = Response({}, response_data) + self.assertEqual(response.data(), response_data) - self.assertEqual(response.to_dict(), attrs) + response_data = [{'a': 1, 'b': 2}, {'c': 3}, {'d': 4}] + response = Response({}, response_data) + self.assertEqual(response.data(), response_data) - def test_access_attribute(self): - attrs = {'a': 1, 'b': 2} - response = Response(attrs) + def test_cursors(self): + response_data = {'a': 1, 'b': 2} - self.assertEqual(response.a, attrs['a']) - self.assertEqual(response.b, attrs['b']) + meta = { + 'status': 'ok', + 'next': """https://api.usebutton.com:443/v1/affiliation/accounts/ + acc-123/transactions?cursor=abc""", + 'prev': """https://api.usebutton.com:443/v1/affiliation/accounts/ + acc-123/transactions?cursor=def""", + } + response = Response(meta, response_data) + + self.assertEqual(response.next_cursor(), 'abc') + self.assertEqual(response.prev_cursor(), 'def') + + meta = { + 'status': 'ok', + 'next': None, + 'prev': 'https://', + } + response = Response(meta, response_data) + + 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""", + } + response = Response(meta, response_data) + + self.assertEqual(response.next_cursor(), None) + self.assertEqual(response.prev_cursor(), None) + + 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__(), '')