diff --git a/.travis.yml b/.travis.yml index ecd56cf..9eb24d1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,10 @@ language: python python: - - "2.6" - "2.7" - - "3.2" - - "3.3" - "3.4" - "3.5" - "3.6" + - "3.7" install: - "pip install flake8==3.3.0" script: python setup.py test diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d224c..5c615d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ Current Version - +3.0.0 September 25, 2019 + - Update flake8 quote linting + - Add official support for Python 3.7 + - Drop official support for Python 2.6, 3.2, 3.3 + - Added `transactions` resource + +2.7.0 June 21, 2018 + - Added optional `time_field` argument to `client.accounts.transactions` + +2.6.1 June 5, 2018 + - Added `HTTPResponseError` to top-level import + - Remove `__all__` imports: they never worked + +2.6.0 May 17, 2018 + - Added `HTTPResponseError` + 2.5.0 December 5, 2017 - Add links resource diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..7a6479e --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @button/python-reviewers diff --git a/README.rst b/README.rst index 9363957..c4659d6 100644 --- a/README.rst +++ b/README.rst @@ -12,7 +12,7 @@ touch `__. Supported runtimes ^^^^^^^^^^^^^^^^^^ -- cPython ``2.6``, ``2.7``, ``3.2``, ``3.3``, ``3.4``, ``3.5``, ``3.6`` +- cPython ``2.7``, ``3.4``, ``3.5``, ``3.6``, ``3.7`` Dependencies ^^^^^^^^^^^^ @@ -37,7 +37,7 @@ key `__. client = Client('sk-XXX') The client will always attempt to raise a ``pybutton.ButtonClientError`` -in an error condition. +or a subclass in an error condition. All API requests will return a ``pybutton.response.Response`` instance, which supports accessing data via the `#data` method. For instance: @@ -45,20 +45,22 @@ which supports accessing data via the `#data` method. For instance: .. code:: python from pybutton import Client - from pybutton import ButtonClientError + from pybutton import ButtonClientError, HTTPResponseError client = Client("sk-XXX") try: response = client.orders.get("btnorder-XXX") + except HTTPResponseError as e: + print('API request failed: http status {}'.format(e.status_code)) except ButtonClientError as e: print(e) + else: + print(response) + # - print(response) - # - - print(response.data()) - # {'status': open, 'btn_ref': None, 'line_items': [], ...} + print(response.data()) + # {'status': open, 'btn_ref': None, 'line_items': [], ...} Configuration @@ -94,6 +96,7 @@ We currently expose the following resources to manage: * `Orders`_ * `Customers`_ * `Links`_ +* `Transactions`_ Accounts ~~~~~~~~ @@ -112,8 +115,8 @@ All print(response) # -Transactions -'''''''''''' +Transactions (per-account) +'''''''''''''''''''''''''' Along with the required account ID, you may also pass the following optional arguments: @@ -121,6 +124,7 @@ 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. +* ``time_field`` (string): Which time field ``start`` and ``end`` filter on. .. code:: python @@ -182,6 +186,7 @@ Create 'total': 50, 'currency': 'USD', 'order_id': '2007', + 'purchase_date': '2017-07-25T08:23:52Z', 'finalization_date': '2017-08-02T19:26:08Z', 'btn_ref': 'srctok-XXX', 'customer': { @@ -313,6 +318,34 @@ Get print(response) # +Transactions +~~~~~~~~~~~~ + +All +''' + +You may 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. +* ``time_field`` (string): Which time field ``start`` and ``end`` filter on. + +.. code:: python + + from pybutton import Client + + client = Client('sk-XXX') + + response = client.transactions( + start='2016-07-15T00:00:00.000Z', + end='2016-09-30T00:00:00.000Z', + time_field='modified_date', + ) + + print(response) + # + Response -------- @@ -419,7 +452,7 @@ Contributing * Installing locally: ``python setup.py install`` * Running tests: ``python setup.py test`` (you'll need to ``pip install flake8==3.3.0``) * Running lint directly: ``flake8 pybutton`` -* Running tests on all versions: ``tox`` (need to ``pip install tox`` and something like ``pyenv local 2.7.10 2.6.9 3.1.5 3.3.6 3.4.6 3.5.3 3.6.0`` if using ``pyenv``) +* Running tests on all versions: ``tox`` (need to ``pip install tox`` and something like ``pyenv local 2.7.10 3.4.6 3.5.3 3.6.0`` if using ``pyenv``) .. |Build Status| image:: https://travis-ci.org/button/button-client-python.svg?branch=master :target: https://travis-ci.org/button/button-client-python diff --git a/pybutton/__init__.py b/pybutton/__init__.py index c40568f..565262b 100644 --- a/pybutton/__init__.py +++ b/pybutton/__init__.py @@ -3,8 +3,7 @@ from __future__ import print_function from __future__ import unicode_literals -from pybutton.client import Client -from pybutton.error import ButtonClientError -from pybutton.version import VERSION - -__all__ = [Client, ButtonClientError, VERSION] +from pybutton.client import Client # noqa: 401 +from pybutton.error import ButtonClientError # noqa: 401 +from pybutton.error import HTTPResponseError # noqa: 401 +from pybutton.version import VERSION # noqa: 401 diff --git a/pybutton/client.py b/pybutton/client.py index 476df32..6387722 100644 --- a/pybutton/client.py +++ b/pybutton/client.py @@ -8,11 +8,12 @@ from pybutton.resources import Merchants from pybutton.resources import Orders from pybutton.resources import Links +from pybutton.resources import Transactions from pybutton.error import ButtonClientError class Client(object): - '''Top-level interface for making requests to the Button API. + """Top-level interface for making requests to the Button API. All resources implemented in this client will be exposed as attributes of a pybutton.Client instance. @@ -37,7 +38,7 @@ class Client(object): Raises: pybutton.ButtonClientError - ''' + """ def __init__(self, api_key, config=None): @@ -57,6 +58,7 @@ def __init__(self, api_key, config=None): self.merchants = Merchants(api_key, config) self.customers = Customers(api_key, config) self.links = Links(api_key, config) + self.transactions = Transactions(api_key, config) def config_with_defaults(config): diff --git a/pybutton/constants.py b/pybutton/constants.py new file mode 100644 index 0000000..c8e88eb --- /dev/null +++ b/pybutton/constants.py @@ -0,0 +1,2 @@ +TIME_FIELD_CREATED = 'created_date' +TIME_FIELD_MODIFIED = 'modified_date' diff --git a/pybutton/error.py b/pybutton/error.py index 7414ae3..053ec68 100644 --- a/pybutton/error.py +++ b/pybutton/error.py @@ -5,5 +5,20 @@ class ButtonClientError(Exception): - '''An Exception class for all pybutton understood errors. - ''' + """An Exception class for all pybutton understood errors. + """ + + +class HTTPResponseError(ButtonClientError): + """A non-success HTTP response was returned from the remote API. + + The HTTP response status code can be retrieved from the + `.status_code` property. + + The original error object can be retrieved from the + `.cause` property. + """ + def __init__(self, message, status_code, cause): + super(HTTPResponseError, self).__init__(message) + self.status_code = status_code + self.cause = cause diff --git a/pybutton/request.py b/pybutton/request.py index 0cc1cb8..3bf8a73 100644 --- a/pybutton/request.py +++ b/pybutton/request.py @@ -26,7 +26,7 @@ from urllib.parse import parse_qs def request(url, method, headers, data=None, timeout=None): - ''' Make an HTTP request in Python 3.x + """ Make an HTTP request in Python 3.x This method will abstract the underlying organization and invocation of the Python 3 HTTP standard lib implementation. @@ -45,7 +45,7 @@ def request(url, method, headers, data=None, timeout=None): Returns: (dict): The response from the server interpreted as JSON. - ''' + """ encoded_data = json.dumps(data).encode('utf8') if data else None request = Request(url, data=encoded_data, headers=headers) @@ -64,14 +64,14 @@ def request(url, method, headers, data=None, timeout=None): else: from urllib2 import Request from urllib2 import urlopen - from urllib2 import HTTPError + from urllib2 import HTTPError # noqa: 401 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 + """ Make an HTTP request in Python 2.x This method will abstract the underlying organization and invocation of the Python 2 HTTP standard lib implementation. @@ -90,7 +90,7 @@ def request(url, method, headers, data=None, timeout=None): Returns: (dict): The response from the server interpreted as JSON. - ''' + """ request = Request(url) request.get_method = lambda: method @@ -111,7 +111,7 @@ def request(url, method, headers, data=None, timeout=None): def request_url(secure, hostname, port, path, query=None): - ''' + """ Combines url components into a url passable into the request function. Args: @@ -123,7 +123,7 @@ def request_url(secure, hostname, port, path, query=None): 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) @@ -132,7 +132,7 @@ def request_url(secure, hostname, port, path, query=None): def query_dict(url): - ''' + """ Given a url, returns a dictionary of its query parameters. Args: @@ -145,19 +145,9 @@ 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, - query_dict, -] diff --git a/pybutton/resources/__init__.py b/pybutton/resources/__init__.py index 3d2b0e9..1b6fc99 100644 --- a/pybutton/resources/__init__.py +++ b/pybutton/resources/__init__.py @@ -3,16 +3,9 @@ from __future__ import print_function from __future__ import unicode_literals -from pybutton.resources.accounts import Accounts -from pybutton.resources.customers import Customers -from pybutton.resources.links import Links -from pybutton.resources.merchants import Merchants -from pybutton.resources.orders import Orders - -__all__ = [ - Accounts, - Customers, - Links, - Merchants, - Orders -] +from pybutton.resources.accounts import Accounts # noqa: 401 +from pybutton.resources.customers import Customers # noqa: 401 +from pybutton.resources.links import Links # noqa: 401 +from pybutton.resources.merchants import Merchants # noqa: 401 +from pybutton.resources.orders import Orders # noqa: 401 +from pybutton.resources.transactions import Transactions # noqa: 401 diff --git a/pybutton/resources/accounts.py b/pybutton/resources/accounts.py index 320434c..435d3a0 100644 --- a/pybutton/resources/accounts.py +++ b/pybutton/resources/accounts.py @@ -7,14 +7,14 @@ class Accounts(Resource): - '''Manages interacting with Button Accounts via the Button API + """Manages interacting with Button Accounts via the Button API See Resource for class docstring. - ''' + """ def all(self): - '''Get a list of available accounts + """Get a list of available accounts Raises: pybutton.ButtonClientError @@ -22,12 +22,13 @@ def all(self): 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. + def transactions(self, account_id, cursor=None, start=None, end=None, + time_field=None): + """Get a list of transactions. To paginate transactions, pass the result of response.next_cursor() as the cursor argument. @@ -40,6 +41,8 @@ def transactions(self, account_id, cursor=None, start=None, end=None): created at or after this time. end (ISO-8601 datetime str) optional: Filter out transactions created before this time. + time_field (str) optional: Which time field ``start`` and ``end`` + filter on Raises: pybutton.ButtonClientError @@ -47,7 +50,7 @@ def transactions(self, account_id, cursor=None, start=None, end=None): Returns: (pybutton.Response) The API response - ''' + """ query = {} @@ -57,6 +60,8 @@ def transactions(self, account_id, cursor=None, start=None, end=None): query['start'] = start if end: query['end'] = end + if time_field: + query['time_field'] = time_field path = '/v1/affiliation/accounts/{0}/transactions'.format( account_id diff --git a/pybutton/resources/customers.py b/pybutton/resources/customers.py index a59ca98..6bbd2ef 100644 --- a/pybutton/resources/customers.py +++ b/pybutton/resources/customers.py @@ -7,14 +7,14 @@ class Customers(Resource): - '''Manages interacting with Button Customers via the Button API + """Manages interacting with Button Customers via the Button API See Resource for class docstring. - ''' + """ def _path(self, customer_id=None): - '''Format a url path + """Format a url path Args: customer_id (str) optional: A Button customer id ('customer-XXX') @@ -22,7 +22,7 @@ def _path(self, customer_id=None): Returns: (str): The formatted path - ''' + """ if customer_id: return '/v1/customers/{0}'.format(customer_id) @@ -30,7 +30,7 @@ def _path(self, customer_id=None): return '/v1/customers' def get(self, customer_id): - '''Get a customer + """Get a customer Args: customer_id (str) : A Button customer id ('customer-XXX') @@ -41,12 +41,12 @@ def get(self, customer_id): Returns: (pybutton.Response) The API response - ''' + """ return self.api_get(self._path(customer_id)) def create(self, customer): - '''Create an customer + """Create an customer Args: customer (dict): A dict representing the attributes of an customer @@ -57,6 +57,6 @@ def create(self, customer): Returns: (pybutton.Response) The API response - ''' + """ return self.api_post(self._path(), customer) diff --git a/pybutton/resources/links.py b/pybutton/resources/links.py index 205addb..cd9d102 100644 --- a/pybutton/resources/links.py +++ b/pybutton/resources/links.py @@ -7,14 +7,14 @@ class Links(Resource): - '''Manages interacting with Button Links via the Button API + """Manages interacting with Button Links via the Button API See Resource for class docstring. - ''' + """ def _path(self): - '''Format a url path + """Format a url path Args: link (dict): A dict representing the attributes of a link @@ -22,12 +22,12 @@ def _path(self): Returns: (str): The formatted path - ''' + """ return '/v1/links' def create(self, link): - '''Create a link + """Create a link Args: link (dict): A dict representing the attributes of a link @@ -38,12 +38,12 @@ def create(self, link): Returns: (pybutton.Response) The API response - ''' + """ return self.api_post(self._path(), link) def get_info(self, link): - '''Get info on a link + """Get info on a link Args: link (dict): A dict representing the attributes of a link for info @@ -54,6 +54,6 @@ def get_info(self, link): Returns: (pybutton.Response) The API response - ''' + """ return self.api_post(self._path() + '/info', link) diff --git a/pybutton/resources/merchants.py b/pybutton/resources/merchants.py index 18e5250..1eceec8 100644 --- a/pybutton/resources/merchants.py +++ b/pybutton/resources/merchants.py @@ -7,14 +7,14 @@ class Merchants(Resource): - '''Manages interacting with Button Merchants via the Button API + """Manages interacting with Button Merchants via the Button API See Resource for class docstring. - ''' + """ def all(self, status=None, currency=None): - '''Get a list of merchants and their configured rates + """Get a list of merchants and their configured rates Args: status (str) optional: A status to filter by. One of ('approved', @@ -28,7 +28,7 @@ def all(self, status=None, currency=None): Returns: (pybutton.Response) The API response - ''' + """ query = {} diff --git a/pybutton/resources/orders.py b/pybutton/resources/orders.py index 65f6b24..32a379b 100644 --- a/pybutton/resources/orders.py +++ b/pybutton/resources/orders.py @@ -7,14 +7,14 @@ class Orders(Resource): - '''Manages interacting with Button Orders via the Button API + """Manages interacting with Button Orders via the Button API See Resource for class docstring. - ''' + """ def _path(self, order_id=None): - '''Format a url path + """Format a url path Args: order_id (str) optional: A Button order id ('btnorder-XXX') @@ -22,7 +22,7 @@ def _path(self, order_id=None): Returns: (str): The formatted path - ''' + """ if order_id: return '/v1/order/{0}'.format(order_id) @@ -30,7 +30,7 @@ def _path(self, order_id=None): return '/v1/order' def get(self, order_id): - '''Get an order + """Get an order Args: order_id (str) : A Button order id ('btnorder-XXX') @@ -41,12 +41,12 @@ def get(self, order_id): Returns: (pybutton.Response) The API response - ''' + """ return self.api_get(self._path(order_id)) def create(self, order): - '''Create an order + """Create an order Args: order (dict): A dict representing the attributes of an order @@ -57,12 +57,12 @@ def create(self, order): Returns: (pybutton.Response) The API response - ''' + """ return self.api_post(self._path(), order) def update(self, order_id, order): - '''Update an order + """Update an order Args: order_id (str) : A Button order id ('btnorder-XXX') @@ -74,12 +74,12 @@ def update(self, order_id, order): Returns: (pybutton.Response) The API response - ''' + """ return self.api_post(self._path(order_id), order) def delete(self, order_id): - '''Delete an order + """Delete an order Args: order_id (str) : A Button order id ('btnorder-XXX') @@ -90,6 +90,6 @@ def delete(self, order_id): Returns: (pybutton.Response) The API response - ''' + """ return self.api_delete(self._path(order_id)) diff --git a/pybutton/resources/resource.py b/pybutton/resources/resource.py index bf0eeca..68b8af7 100644 --- a/pybutton/resources/resource.py +++ b/pybutton/resources/resource.py @@ -8,7 +8,7 @@ import json from pybutton.response import Response -from pybutton.error import ButtonClientError +from pybutton.error import HTTPResponseError from pybutton.version import VERSION from pybutton.request import request from pybutton.request import request_url @@ -18,7 +18,7 @@ class Resource(object): - '''Abstract Base Class for managing a remote resource in our API. + """Abstract Base Class for managing a remote resource in our API. Includes handy methods for making HTTP calls against our API and returning payloads in a standardized format (namely, pybutton.Response objects). @@ -38,15 +38,16 @@ class Resource(object): Raises: pybutton.ButtonClientError + pybutton.HTTPResponseError - ''' + """ def __init__(self, api_key, config): self.api_key = api_key self.config = config def api_get(self, path, query=None): - '''Make an HTTP GET request + """Make an HTTP GET request Args: path (str): The path of the resource @@ -54,11 +55,11 @@ def api_get(self, path, query=None): Returns: (pybutton.Response): The API response - ''' + """ return self._api_request(path, 'GET', query=query) def api_post(self, path, data): - '''Make an HTTP POST request + """Make an HTTP POST request Args: path (str): The path of the resource @@ -67,11 +68,11 @@ def api_post(self, path, data): Returns: (pybutton.Response): The API response - ''' + """ return self._api_request(path, 'POST', data) def api_delete(self, path): - '''Make an HTTP DELETE request + """Make an HTTP DELETE request Args: path (str): The path of the resource @@ -79,12 +80,12 @@ def api_delete(self, path): Returns: (pybutton.Response): The API response - ''' + """ return self._api_request(path, 'DELETE') def _headers(self): - '''Generate the HTTP headers used for a request - ''' + """Generate the HTTP headers used for a request + """ api_key_bytes = '{0}:'.format(self.api_key).encode() authorization = b64encode(api_key_bytes).decode() @@ -100,7 +101,7 @@ def _headers(self): return headers def _api_request(self, path, method, data=None, query=None): - '''Make an HTTP request + """Make an HTTP request Any data provided will be JSON encoded an included as part of the request body. Additionally, an Authorization header will be set based @@ -115,7 +116,7 @@ def _api_request(self, path, method, data=None, query=None): Returns: (pybutton.Response): The API response - ''' + """ url = request_url( self.config['secure'], @@ -151,4 +152,4 @@ def _api_request(self, path, method, data=None, query=None): error = json.loads(data).get('error', {}) message = error.get('message', fallback) - raise ButtonClientError(message) + raise HTTPResponseError(message, status_code=e.code, cause=e) diff --git a/pybutton/resources/transactions.py b/pybutton/resources/transactions.py new file mode 100644 index 0000000..5230018 --- /dev/null +++ b/pybutton/resources/transactions.py @@ -0,0 +1,54 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +from pybutton.resources.resource import Resource + + +class Transactions(Resource): + """Manages interacting with Button Transactions via the Button API + + See Resource for class docstring. + + """ + + def all(self, cursor=None, start=None, end=None, time_field=None): + """Get a list of transactions. + To paginate transactions, pass the result of response.next_cursor() as + the cursor argument. + Unlike Accounts.transactions, which retrieves transactions only for a + single account, Transactions.all retrieves all of an organization's + transactions. + + + Args: + 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. + time_field (str) optional: Which time field ``start`` and ``end`` + filter on. Defaults to created_date. + + 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 + if time_field: + query['time_field'] = time_field + + return self.api_get('/v1/affiliation/transactions', query=query) diff --git a/pybutton/response.py b/pybutton/response.py index a21c0b6..432de33 100644 --- a/pybutton/response.py +++ b/pybutton/response.py @@ -7,7 +7,7 @@ class Response(object): - '''The Response class wraps the returned values from an API call. + """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_cursor`/`prev_cursor` methods. @@ -16,7 +16,7 @@ class Response(object): meta (dict): The metadata from an API call response_data (dict or array): The response elements from an API call - ''' + """ classPrefix = 'class pybutton.Response' @@ -25,26 +25,26 @@ 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 self.response_data 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 prev_cursor(self): - ''' + """ For paginated responses, returns the url used to fetch the previous elements. - ''' + """ return self._format_cursor(self.meta.get('prev')) diff --git a/pybutton/test/request_test.py b/pybutton/test/request_test.py index 334986b..1b0357e 100644 --- a/pybutton/test/request_test.py +++ b/pybutton/test/request_test.py @@ -36,7 +36,7 @@ def test_get_request(self, MockRequest, mock_url_open): MockRequest.assert_called_with(url) self.assertEqual(instance.get_method(), method) instance.add_header.assert_called_with('b', 2) - self.assertEqual(response, {"a": 1}) + self.assertEqual(response, {'a': 1}) @patch('pybutton.request.urlopen') @patch('pybutton.request.Request') @@ -56,7 +56,7 @@ def test_post_request(self, MockRequest, mock_url_open): MockRequest.assert_called_with(url) self.assertEqual(instance.get_method(), method) - self.assertEqual(response, {"a": 1}) + self.assertEqual(response, {'a': 1}) @patch('pybutton.request.urlopen') @patch('pybutton.request.Request') @@ -85,7 +85,7 @@ def test_post_request_with_data(self, MockRequest, mock_url_open): 'application/json' ) - self.assertEqual(response, {"a": 1}) + self.assertEqual(response, {'a': 1}) @patch('pybutton.request.urlopen') @patch('pybutton.request.Request') @@ -103,8 +103,10 @@ def test_raises_with_invalid_response_data(self, MockRequest, try: request(url, method, headers) self.assertTrue(False) - except ButtonClientError: - pass + except ButtonClientError as e: + # We expect the generic ButtonClientError, and not a subclass, + # in this condition. + assert type(e) is ButtonClientError class RequestTestCasePy3(TestCase): @@ -127,7 +129,7 @@ def test_get_request(self, MockRequest, mock_url_open): MockRequest.assert_called_with(url, data=None, headers=headers) self.assertEqual(instance.get_method(), method) - self.assertEqual(response, {"a": 1}) + self.assertEqual(response, {'a': 1}) @patch('pybutton.request.urlopen') @patch('pybutton.request.Request') @@ -147,7 +149,7 @@ def test_post_request(self, MockRequest, mock_url_open): MockRequest.assert_called_with(url, data=None, headers=headers) self.assertEqual(instance.get_method(), method) - self.assertEqual(response, {"a": 1}) + self.assertEqual(response, {'a': 1}) @patch('pybutton.request.urlopen') @patch('pybutton.request.Request') @@ -179,7 +181,7 @@ def test_post_request_with_data(self, MockRequest, mock_url_open): 'application/json' ) - self.assertEqual(response, {"a": 1}) + self.assertEqual(response, {'a': 1}) @patch('pybutton.request.urlopen') @patch('pybutton.request.Request') diff --git a/pybutton/test/resources/accounts_test.py b/pybutton/test/resources/accounts_test.py index fb4f157..90093dc 100644 --- a/pybutton/test/resources/accounts_test.py +++ b/pybutton/test/resources/accounts_test.py @@ -58,3 +58,14 @@ def test_transactions(self): 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') + + response = account.transactions( + 'acc-123', + cursor='abc', + start='2016-09-15T00:00:00.000Z', + end='2016-09-30T00:00:00.000Z', + time_field='created_date' + ) + self.assertEqual(response, account_response) + query = api_get.call_args[1]['query'] + self.assertEqual(query['time_field'], 'created_date') diff --git a/pybutton/test/resources/resource_test.py b/pybutton/test/resources/resource_test.py index a9eb8f4..c18bef4 100644 --- a/pybutton/test/resources/resource_test.py +++ b/pybutton/test/resources/resource_test.py @@ -9,7 +9,7 @@ from pybutton.request import HTTPError from pybutton.resources.resource import Resource -from pybutton.error import ButtonClientError +from pybutton.error import HTTPResponseError config = { 'hostname': 'api.usebutton.com', @@ -117,17 +117,19 @@ def side_effect(*args): try: resource._api_request('/v2/api', 'GET', data) self.assertTrue(False) - except ButtonClientError as e: + except HTTPResponseError as e: self.assertEqual(str(e), '404 bloop') + self.assertEqual(e.status_code, 404) + self.assertTrue(e.cause is not None) @patch('pybutton.resources.resource.request') def test_api_request_with_byte_response(self, request): data = {'c': 3} fp = Mock() - fp.read.return_value = ''' + fp.read.return_value = """ { "error": { "message": "bloop failed" } } - '''.encode() + """.encode() def side_effect(*args): raise HTTPError('url', 404, 'bloop', {}, fp) @@ -138,8 +140,10 @@ def side_effect(*args): try: resource._api_request('/v2/api', 'GET', data) self.assertTrue(False) - except ButtonClientError as e: + except HTTPResponseError as e: self.assertEqual(str(e), 'bloop failed') + self.assertEqual(e.status_code, 404) + self.assertTrue(e.cause is not None) @patch('pybutton.resources.resource.request') def test_api_get(self, request): diff --git a/pybutton/test/resources/transactions_test.py b/pybutton/test/resources/transactions_test.py new file mode 100644 index 0000000..3f26584 --- /dev/null +++ b/pybutton/test/resources/transactions_test.py @@ -0,0 +1,54 @@ +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 import constants +from pybutton.resources import Transactions + +config = { + 'hostname': 'api.usebutton.com', + 'secure': True, + 'port': 443, + 'timeout': None, +} + + +class TransactionsTestCase(TestCase): + + def test_all(self): + transactions = Transactions('sk-XXX', config) + transaction_response = [{'a': 1}, {'b': 2}] + + api_get = Mock() + api_get.return_value = transaction_response + + with patch.object(transactions, 'api_get', api_get): + response = transactions.all( + cursor='abc', + start='2016-09-15T00:00:00.000Z', + end='2016-09-30T00:00:00.000Z' + ) + self.assertEqual( + api_get.call_args[0][0], + '/v1/affiliation/transactions' + ) + self.assertEqual(response, transaction_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') + + response = transactions.all( + cursor='abc', + start='2016-09-15T00:00:00.000Z', + end='2016-09-30T00:00:00.000Z', + time_field=constants.TIME_FIELD_MODIFIED + ) + self.assertEqual(response, transaction_response) + query = api_get.call_args[1]['query'] + self.assertEqual(query['time_field'], 'modified_date') diff --git a/pybutton/utils.py b/pybutton/utils.py index 77cf42f..ff5eaeb 100644 --- a/pybutton/utils.py +++ b/pybutton/utils.py @@ -9,7 +9,7 @@ def is_webhook_authentic(webhook_secret, request_body, sent_signature): - '''Used to verify that requests sent to a webhook endpoint are from Button + """Used to verify that requests sent to a webhook endpoint are from Button and that their payload can be trusted. Returns True if a webhook request body matches the sent signature and False otherwise. @@ -24,7 +24,7 @@ def is_webhook_authentic(webhook_secret, request_body, sent_signature): Returns: (bool) Whether or not the request is authentic - ''' + """ computed_signature = hmac.new( as_bytes(webhook_secret), @@ -42,7 +42,7 @@ def is_webhook_authentic(webhook_secret, request_body, sent_signature): def as_bytes(v, only_py_2=False): - '''Converts v to a UTF-8 byte string if unicode, else returns identity. + """Converts v to a UTF-8 byte string if unicode, else returns identity. Args: v (str|unicode): the string to convert @@ -52,7 +52,7 @@ def as_bytes(v, only_py_2=False): Returns: (byte string): A byte string copy, UTF-8 enccoded - ''' + """ python_version = sys.version_info[0] diff --git a/pybutton/version.py b/pybutton/version.py index 8af1c58..aaa4264 100644 --- a/pybutton/version.py +++ b/pybutton/version.py @@ -1 +1 @@ -VERSION = '2.5.0' +VERSION = '3.0.0' diff --git a/setup.py b/setup.py index 7662c89..3acc97e 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ include_package_data=False, license='MIT', test_suite='nose.collector', - tests_require=['nose', 'mock'], + tests_require=['nose', 'mock', "flake8-quotes==2.1.0"], zip_safe=True, classifiers=[ "Intended Audience :: Developers", diff --git a/tox.ini b/tox.ini index c42ac1c..a7936c4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py26, py27, py33, py34, py35, py36 +envlist = py27, py34, py35, py36, py37 skip_missing_interpreters = True [testenv]