diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..a07d3ed --- /dev/null +++ b/.coveragerc @@ -0,0 +1,17 @@ +[paths] +source = + yamlconf + +[run] +source = + yamlconf +omit = + *tox* + setup.py + *test* + +[report] +;sort = Cover +sort = Name +skip_covered = True +show_missing = True \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9bdc2db..94553eb 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,4 @@ ENV/ # ignore backup files etc *.bak *.old +.pytest_cache/ diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..e3c918e --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,16 @@ +[settings] +line_length=79 +multi_line_output=3 +include_trailing_comma=1 +known_standard_library=typing +known_django=django +known_thirdparty=peewee +import_heading_stdlib=Imports from Standard Library +import_heading_thirdparty=Imports from Third Party Modules +import_heading_django=Imports from Django +import_heading_firstparty=Local Imports +sections=FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +not_skip = __init__.py + +# for additional settings see: +# https://github.com/timothycrosley/isort/wiki/isort-Settings diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..30a9e49 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,6 @@ +Changelog +========= + +0.1.0 [2018-02-16] +------------------ +* OpenSource release \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 040f846..0000000 --- a/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Py-SEED: ap API client for the SEED Platform - -This provides two Python clients for interacting with the SEED Platform Api. -(One is read only). - -SEED (Standard Energy Efficiency Data Platform™) is an open source -"web-based application that helps organizations easily manage data on the -energy performance of large groups of buildings" funded by the United States -Department of Energy. - -More information can be found here: -* https://energy.gov/eere/buildings/standard-energy-efficiency-data-platform -* http://seedinfo.lbl.gov/ -* https://github.com/SEED-platform - - -Note the clients do not provide per api-call methods, but does provide -the standard CRUD methods: get, list, put, post, patch, delete - -The intended use of these clients is to be futher subclassed or wrapped in -functions to provide the desired functionality. The CRUD methods are provided -via mixins so its possible to create a client for example without the ability -to delete by subclassing SEEDBaseClient and adding only the mixins -that provided the Create, Read and Update capabilities. - -Basic usage for the provided clients is below. - -Usage: -```python -from pyseed import SEEDReadWriteClient - -seed_client = SEEDReadWriteClient( - your_org_id, - username=your_username, - password=your_password, - base_url=url_of_your_seed_host, - ) - -# list all properties -seed_client.list(endpoint='properties') - -# get a single property -seed_client.get(property_pk, endpoint='properties') - -N.B. this client is undergoing development and should be considered -experimental. -``` diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..8107c34 --- /dev/null +++ b/README.rst @@ -0,0 +1,67 @@ +Py-SEED +======= + +A python API client for the SEED Platform + + +Documentation +------------- +This provides two user authentication based Python clients and two OAuth2 authentication based Python clients for interacting with the SEED Platform Api:: + + + SEEDOAuthReadOnlyClient + SEEDOAuthReadWriteClient + SEEDReadOnlyClient + SEEDReadWriteClient + + +(The OAuthMixin is constructed around the the JWTGrantClient found in jwt-oauth2lib. see https://github.com/GreenBuildingRegistry/jwt_oauth2) + +SEED (Standard Energy Efficiency Data Platform™) is an open source "web-based application that helps organizations easily manage data on the energy performance of large groups of buildings" funded by the United States Department of Energy. + +More information can be found here: +* https://energy.gov/eere/buildings/standard-energy-efficiency-data-platform +* http://seedinfo.lbl.gov/ +* https://github.com/SEED-platform + + +Note the clients do not provide per api-call methods, but does provide the standard CRUD methods: get, list, put, post, patch, delete + +The intended use of these clients is to be futher subclassed or wrapped in functions to provide the desired functionality. The CRUD methods are provided via mixins so its possible to create a client for example without the ability to delete by subclassing SEEDUserAuthBaseClient, or SEEDOAuthBaseClient, and adding only the mixins that provided the Create, Read and Update capabilities. + +Basic usage for the provided clients is below. + +Usage: + + +.. code-block:: python + + from pyseed import SEEDReadWriteClient + + seed_client = SEEDReadWriteClient( + your_org_id, + username=your_username, + password=your_password, + base_url=url_of_your_seed_host, + ) + + # list all properties + seed_client.list(endpoint='properties') + + # get a single property + seed_client.get(property_pk, endpoint='properties') + + +Contributing +------------ + +License +------- +py-SEED is released under the terms of the MIT license. Full details in LICENSE file. + +Changelog +--------- +py-SEED was developed for use in the greenbuildingregistry project. +For a full changelog see `CHANGELOG.rst `_. + +N.B. this client is undergoing development and should be considered experimental. diff --git a/pyseed/__init__.py b/pyseed/__init__.py new file mode 100644 index 0000000..dd86008 --- /dev/null +++ b/pyseed/__init__.py @@ -0,0 +1,7 @@ +# Local Imports +from pyseed.seedclient import ( + SEEDOAuthReadOnlyClient, + SEEDOAuthReadWriteClient, + SEEDReadOnlyClient, + SEEDReadWriteClient, +) diff --git a/pyseed/apibase.py b/pyseed/apibase.py index 5a278d2..a239f76 100755 --- a/pyseed/apibase.py +++ b/pyseed/apibase.py @@ -8,12 +8,16 @@ # Imports from Standard Library import re +# Imports from Third Party Modules # Imports from External Modules import requests - +# Local Imports # Public Functions and Classes # Helper functions for use by BaseAPI subclasses +from pyseed.exceptions import APIClientError + + def add_pk(url, pk, required=True, slash=False): """Add id/primary key to url""" if required and not pk: @@ -32,10 +36,6 @@ def add_pk(url, pk, required=True, slash=False): return url -class APIClientError(Exception): - pass - - class BaseAPI(object): """ Base class for API Calls @@ -281,3 +281,44 @@ def _construct_payload(self, params): if getattr(self, 'use_auth', None) and not getattr(self, 'auth', None): self.auth = self._get_auth() return super(UserAuthMixin, self)._construct_payload(params) + + +class OAuthMixin(object): + """ + Mixin to provide api client authentication via OAuth access tokens based + on the JWTGrantClient found in jwt-oauth2lib. + + see https://github.com/GreenBuildingRegistry/jwt_oauth2 + """ + + _token_type = "Bearer" + oauth_client = None + + def _get_access_token(self): + """Generate OAuth access token""" + config = getattr(self, 'config') + private_key_file = config.get('private_key_location', default=None) + client_id = config.get('client_id', default=None) + username = getattr(self, 'username', None) + with open(private_key_file, 'r') as pk_file: + sig = pk_file.read() + oauth_client = self.oauth_client( + sig, username, client_id, + pvt_key_password=getattr(self, 'pvt_key_password', None) + ) + return oauth_client.get_access_token() + + def _construct_payload(self, params): + """Construct parameters for an api call. +. + :param params: An dictionary of key-value pairs to include + in the request. + :return: A dictionary of k-v pairs to send to the server + in the request. + """ + params = super(OAuthMixin, self)._construct_payload(params) + token = getattr(self, 'token', None) or self._get_access_token() + params['headers'] = { + 'Authorization': '{} {}'.format(self._token_type, token) + } + return params diff --git a/pyseed/exceptions.py b/pyseed/exceptions.py new file mode 100644 index 0000000..f73c3b7 --- /dev/null +++ b/pyseed/exceptions.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python +# encoding: utf-8 +""" +copyright (c) 2016-2017 Earth Advantage. +All rights reserved + +..codeauthor::Paul Munday +""" + + +# Setup + +# Constants + +# Data Structure Definitions + +# Private Functions + +# Public Classes and Functions + + +class APIClientError(Exception): + """Indicates errors when calling an API""" + + def __init__(self, error, service=None, url=None, caller=None, + verb=None, status_code=None, **kwargs): + self.error = error + self.service = service + self.url = url + self.caller = caller + self.verb = verb + self.status_code = status_code + args = ( + error, service, url, caller, verb.upper() if verb else None, + status_code + ) + self.kwargs = kwargs + super(APIClientError, self).__init__(*args) + + def __str__(self): + msg = "{}: {}".format(self.__class__.__name__, self.error) + if self.service: + msg = "{}, calling service {}".format(msg, self.service) + if self.caller: + msg = "{} as {}".format(msg, self.caller) + if self.url: + msg = "{} with url {}".format(msg, self.url) + if self.verb: + msg = "{}, http method: {}".format(msg, self.verb.upper()) + if self.kwargs: + arguments = ", ".join([ + "{}={}".format(str(key), str(val)) + for key, val in self.kwargs.items() + ]) + msg = "{} supplied with {}".format(msg, arguments) + if self.status_code: + msg = "{} http status code: {}".format(msg, self.status_code) + return msg + + +class SEEDError(APIClientError): + """Indicates Error interacting with SEED API""" + + def __init__(self, error, url=None, caller=None, verb=None, + status_code=None, **kwargs): + super(SEEDError, self).__init__( + error, service='SEED', url=url, caller=caller, verb=verb, + status_code=status_code, **kwargs + ) diff --git a/pyseed/seedclient.py b/pyseed/seedclient.py index 5f47172..58475a9 100644 --- a/pyseed/seedclient.py +++ b/pyseed/seedclient.py @@ -15,7 +15,7 @@ This is a deliberate design decision. There is no general purpose client that can write to the db, this ensures caching is transparent and always valid. -You *must* always use the class corresponsing to the relevant model, i.e. +You *must* always use the class corresponding to the relevant model, i.e. one that inherits from SEEDRecord to be able to write to the db. You *should* generally this for reading too, in order to get the benefits of caching. @@ -28,11 +28,9 @@ # Imports from Third Party Modules import requests -# Imports from Django - # Local Imports -from pyseed.apibase import (add_pk, JSONAPI, UserAuthMixin, APIClientError) - +from pyseed.apibase import JSONAPI, OAuthMixin, UserAuthMixin, add_pk +from pyseed.exceptions import SEEDError # Constants URLS = { @@ -82,12 +80,9 @@ def _set_default(obj, key, val, required=True): # Public Classes and Functions -class SEEDError(APIClientError): - # pylint:disable=too-few-public-methods - pass -class SEEDBaseClient(UserAuthMixin, JSONAPI): +class SEEDBaseClient(JSONAPI): """Interact with SEED API. Raises a SEEDError on an API Error. No further logging or error @@ -134,18 +129,17 @@ class SEEDBaseClient(UserAuthMixin, JSONAPI): """ # pylint:disable=too-few-public-methods,too-many-arguments # pylint:disable=too-many-instance-attributes + def __init__(self, org_id, username=None, password=None, access_token=None, endpoint=None, data_name=None, use_ssl=None, base_url=None, port=None, url_map=None, **kwargs): - use_ssl = use_ssl if use_ssl else True + use_ssl = use_ssl if use_ssl is not None else True super(SEEDBaseClient, self).__init__( username=username, password=password, use_ssl=use_ssl, use_auth=True, access_token=access_token, **kwargs ) self.org_id = org_id self.token = access_token - if not url_map: - url_map = URLS # prevent overriding if set in sublcass as class attr if not getattr(self, 'endpoint', None): self.endpoint = endpoint @@ -402,13 +396,49 @@ def delete(self, pk, endpoint=None, data_name=None, **kwargs): self._check_response(response, **kwargs) -class SEEDReadOnlyClient(ReadMixin, SEEDBaseClient): +class SEEDUserAuthBaseClient(UserAuthMixin, SEEDBaseClient): + """ + SEED base client using username and password(or api key) authentication + """ + pass + + +class SEEDOAuthBaseClient(OAuthMixin, SEEDBaseClient): + """SEED base client using JWT OAuth2 based authentication""" + + def __init__(self, oauth_client, org_id, username=None, password=None, + access_token=None, endpoint=None, data_name=None, + use_ssl=None, base_url=None, port=None, url_map=None, + **kwargs): + + self.oauth_client = oauth_client + super(SEEDOAuthBaseClient, self).__init__( + org_id, username=username, password=password, + access_token=access_token, endpoint=endpoint, data_name=data_name, + use_ssl=use_ssl, base_url=base_url, port=port, url_map=url_map, + **kwargs + ) + + +class SEEDReadOnlyClient(ReadMixin, SEEDUserAuthBaseClient): """Read Ony Client""" pass class SEEDReadWriteClient(CreateMixin, ReadMixin, UpdateMixin, DeleteMixin, - SEEDBaseClient): + SEEDUserAuthBaseClient): + """Client with full CRUD Methods""" + # pylint:disable=too-many-ancestors + pass + + +class SEEDOAuthReadOnlyClient(ReadMixin, SEEDOAuthBaseClient): + """Read Ony Client""" + pass + + +class SEEDOAuthReadWriteClient(CreateMixin, ReadMixin, UpdateMixin, + DeleteMixin, SEEDOAuthBaseClient): """Client with full CRUD Methods""" # pylint:disable=too-many-ancestors pass diff --git a/pyseed/tests/__init__.py b/pyseed/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyseed/tests/test_apibase.py b/pyseed/tests/test_apibase.py new file mode 100755 index 0000000..308d3dd --- /dev/null +++ b/pyseed/tests/test_apibase.py @@ -0,0 +1,527 @@ +#!/usr/bin/env python +# encoding: utf-8 +""" +copyright (c) 2016-2016 Earth Advantage. All rights reserved. +..codeauthor::Paul Munday + +Unit tests for pyseed/apibase +""" +# Imports from Standard Library +import sys +import unittest + +# Local Imports +from pyseed.apibase import JSONAPI, BaseAPI, add_pk +from pyseed.exceptions import APIClientError +from pyseed.seedclient import _get_urls, _set_default + +NO_URL_ERROR = "APIClientError: No url set" +SSL_ERROR = "APIClientError: use_ssl is true but url does not starts with https" +SSL_ERROR2 = "APIClientError: use_ssl is false but url starts with https" + +# Constants +SERVICES_DICT = { + 'urls': {'test1': 'test1', 'test2': '/test2'} +} + + +PY3 = sys.version_info[0] == 3 +if PY3: + from unittest import mock +else: + import mock + + +class MockConfig(object): + """Mock config object""" + # pylint:disable=too-few-public-methods, no-self-use + + def __init__(self, conf): + self.conf = conf + + def get(self, var, section=None, default=None): + if section: # pragma: no cover + cdict = self.conf.get(section, {}) + else: + cdict = self.conf + return cdict.get(var, default) + + +SERVICES = MockConfig(SERVICES_DICT) + + +@mock.patch('pyseed.apibase.requests') +class APITests(unittest.TestCase): + """Tests for API base classes""" + # pylint: disable=protected-access, no-self-use, unused-argument + + def setUp(self): + self.url = 'example.org' + self.api = JSONAPI(self.url) + + def test_ssl_verification(self, mock_requests): + """Test ssl usage""" + # ensure error is raised if http is supplied and use_ssl is true + with self.assertRaises(APIClientError) as conm: + JSONAPI('http://example.org') + exception = conm.exception + expected = SSL_ERROR + self.assertEqual(expected, str(exception)) + + # ensure error is raised if https is supplied and use_ssl is false + with self.assertRaises(APIClientError) as conm: + JSONAPI('https://example.org', use_ssl=False) + exception = conm.exception + expected = SSL_ERROR2 + self.assertEqual(expected, str(exception)) + + # test defaults to https + api = JSONAPI('example.org') + api._get() + mock_requests.get.assert_called_with( + 'https://example.org', timeout=None, headers=None + ) + + # use_ssl is False + api = JSONAPI('example.org', use_ssl=False) + api._get() + mock_requests.get.assert_called_with( + 'http://example.org', timeout=None, headers=None + ) + + def test_get(self, mock_requests): + """Test _get method.""" + self.api._get(id=1, foo='bar') + mock_requests.get.assert_called_with( + 'https://example.org', params={'id': 1, 'foo': 'bar'}, + timeout=None, headers=None + ) + + def test_post(self, mock_requests): + """Test _get_post.""" + params = {'id': 1} + files = {'file': 'mock_file'} + data = {'foo': 'bar', 'test': 'test'} + self.api._post(params=params, files=files, foo='bar', test='test') + mock_requests.post.assert_called_with( + 'https://example.org', params=params, files=files, + json=data, timeout=None, headers=None + ) + + # Not json + api = BaseAPI('example.org') + api._post(params=params, files=files, foo='bar', test='test') + mock_requests.post.assert_called_with( + 'https://example.org', params=params, files=files, + data=data, timeout=None, headers=None + ) + + def test_patch(self, mock_requests): + """Test _get_patch.""" + params = {'id': 1} + files = {'file': 'mock_file'} + data = {'foo': 'bar', 'test': 'test'} + self.api._patch(params=params, files=files, foo='bar', test='test') + mock_requests.patch.assert_called_with( + 'https://example.org', params=params, files=files, + json=data, timeout=None, headers=None + ) + + # Not json + api = BaseAPI('example.org') + api._patch(params=params, files=files, foo='bar', test='test') + mock_requests.patch.assert_called_with( + 'https://example.org', params=params, files=files, + data=data, timeout=None, headers=None + ) + + def test_delete(self, mock_requests): + """Test _delete method.""" + self.api._delete(id=1, foo='bar') + mock_requests.delete.assert_called_with( + 'https://example.org', params={'id': 1, 'foo': 'bar'}, + timeout=None, headers=None + ) + + def test_construct_payload(self, mock_requests): + """Test construct_payload method.""" + with self.assertRaises(APIClientError): + api = BaseAPI('example.org', compulsory_params=['id']) + api._get(foo='bar') + api._get(id=1) + self.assertTrue(mock_requests.get.called) + + url = self.url + + class TestAPI(BaseAPI): + """test class""" + # pylint: disable=too-few-public-methods + + def __init__(self): + self.compulsory_params = ['id', 'comp'] + super(TestAPI, self).__init__(url) + self.comp = 1 + with self.assertRaises(APIClientError) as conm: + api = TestAPI() + api._get() + exception = conm.exception + self.assertEqual( + 'APIClientError: id is a compulsory field', str(exception) + ) + api._get(id=1) + self.assertTrue(mock_requests.get.called) + + def test_check_call_success(self, mock_requests): + """Test check_call_success method.""" + mock_response = mock.MagicMock() + mock_response.status_code = 200 + mock_requests.get.return_value = mock_response + mock_requests.codes.ok = 200 + response = self.api._get(id=1) + self.assertTrue(self.api.check_call_success(response)) + + def test_construct_url(self, mock_requests): + """Test _construct_url method.""" + api = BaseAPI(use_ssl=False) + + # ensure error is raised if no url is supplied + with self.assertRaises(APIClientError) as conm: + api._construct_url(None) + exception = conm.exception + expected = NO_URL_ERROR + self.assertEqual(expected, str(exception)) + + # ensure error is raised if https is supplied and use_ssl is false + with self.assertRaises(APIClientError) as conm: + api._construct_url('https://www.example.org', use_ssl=False) + exception = conm.exception + expected = SSL_ERROR2 + self.assertEqual(expected, str(exception)) + + def test_construct_url_ssl_explicit(self, mock_requests): + """Test _construct_url method.""" + api = BaseAPI(use_ssl=True) + + # ensure error is raised if http is supplied and use_ssl is true + with self.assertRaises(APIClientError) as conm: + api._construct_url('http://example.org', use_ssl=True) + exception = conm.exception + expected = SSL_ERROR + self.assertEqual(expected, str(exception)) + + # ensure error is raised if http is supplied and use_ssl is default + with self.assertRaises(APIClientError) as conm: + api._construct_url('http://example.org') + exception = conm.exception + expected = SSL_ERROR + self.assertEqual(expected, str(exception)) + + def test_construct_url_ssl_implicit(self, mock_requests): + """Test _construct_url method.""" + api = BaseAPI() + + # ensure error is raised if http is supplied and use_ssl is true + with self.assertRaises(APIClientError) as conm: + api._construct_url('http://example.org', use_ssl=True) + exception = conm.exception + expected = SSL_ERROR + self.assertEqual(expected, str(exception)) + + # ensure error is raised if http is supplied and use_ssl is default + with self.assertRaises(APIClientError) as conm: + api._construct_url('http://example.org') + exception = conm.exception + expected = SSL_ERROR + self.assertEqual(expected, str(exception)) + + +@mock.patch('pyseed.apibase.requests') +class APITestsNoURL(unittest.TestCase): + """Tests for API base classes with no self.url set""" + # pylint: disable=protected-access, no-self-use, unused-argument + + def setUp(self): + self.url = 'example.org' + self.api = JSONAPI() + + def test_no_url(self, mock_requests): + """Test ssl usage""" + + def test_get(self, mock_requests): + """Test _get method.""" + self.api._get(self.url, id=1, foo='bar') + mock_requests.get.assert_called_with( + 'https://example.org', params={'id': 1, 'foo': 'bar'}, + timeout=None, headers=None + ) + + # ensure error is raised if https is supplied and use_ssl is false + with self.assertRaises(APIClientError) as conm: + api = BaseAPI('example.org', use_ssl=False) + api._get(url='https://www.example.org', use_ssl=False) + exception = conm.exception + expected = SSL_ERROR2 + self.assertEqual(expected, str(exception)) + + # ensure error is raised if http is supplied and use_ssl is true + with self.assertRaises(APIClientError) as conm: + self.api._get(url='http://example.org') + exception = conm.exception + expected = SSL_ERROR + self.assertEqual(expected, str(exception)) + + # ensure error is raised if no url is supplied + with self.assertRaises(APIClientError) as conm: + self.api._get() + exception = conm.exception + expected = NO_URL_ERROR + self.assertEqual(expected, str(exception)) + + # test defaults to http + self.api._get(url=self.url) + mock_requests.get.assert_called_with( + 'https://example.org', timeout=None, headers=None + ) + + # use_ssl is False + api = BaseAPI('example.org', use_ssl=False) + api._get(url=self.url, use_ssl=False) + mock_requests.get.assert_called_with( + 'http://example.org', timeout=None, headers=None + ) + + def test_post(self, mock_requests): + """Test _get_post.""" + params = {'id': 1} + files = {'file': 'mock_file'} + data = {'foo': 'bar', 'test': 'test'} + self.api._post( + url=self.url, params=params, files=files, foo='bar', test='test' + ) + mock_requests.post.assert_called_with( + 'https://example.org', params=params, files=files, + json=data, timeout=None, headers=None + ) + + # Not json + api = BaseAPI() + api._post(url=self.url, params=params, files=files, + foo='bar', test='test') + mock_requests.post.assert_called_with( + 'https://example.org', params=params, files=files, + data=data, timeout=None, headers=None + ) + + # ensure error is raised if no url is supplied + with self.assertRaises(APIClientError) as conm: + self.api._post( + params=params, files=files, foo='bar', test='test' + ) + exception = conm.exception + expected = NO_URL_ERROR + self.assertEqual(expected, str(exception)) + + # ensure error is raised if https is supplied and use_ssl is false + with self.assertRaises(APIClientError) as conm: + api = BaseAPI('example.org', use_ssl=False) + api._post( + url='https://example.org', use_ssl=False, + params=params, files=files, foo='bar', test='test' + ) + exception = conm.exception + expected = SSL_ERROR2 + self.assertEqual(expected, str(exception)) + + # ensure error is raised if http is supplied and use_ssl is true + with self.assertRaises(APIClientError) as conm: + self.api._post( + url='http://example.org', use_ssl=True, + params=params, files=files, foo='bar', test='test' + ) + exception = conm.exception + expected = SSL_ERROR + self.assertEqual(expected, str(exception)) + + def test_patch(self, mock_requests): + """Test _get_patch.""" + params = {'id': 1} + files = {'file': 'mock_file'} + data = {'foo': 'bar', 'test': 'test'} + self.api._patch( + url=self.url, params=params, files=files, foo='bar', test='test' + ) + mock_requests.patch.assert_called_with( + 'https://example.org', params=params, files=files, + json=data, timeout=None, headers=None + ) + + # Not json + api = BaseAPI('example.org') + api._patch( + url=self.url, params=params, files=files, foo='bar', test='test' + ) + mock_requests.patch.assert_called_with( + 'https://example.org', params=params, files=files, + data=data, timeout=None, headers=None + ) + + # ensure error is raised if no url is supplied + with self.assertRaises(APIClientError) as conm: + self.api._patch( + params=params, files=files, foo='bar', test='test' + ) + exception = conm.exception + expected = NO_URL_ERROR + self.assertEqual(expected, str(exception)) + + # ensure error is raised if https is supplied and use_ssl is false + with self.assertRaises(APIClientError) as conm: + api = BaseAPI('example.org', use_ssl=False) + api._patch( + url='https://example.org', use_ssl=False, + params=params, files=files, foo='bar', test='test' + ) + exception = conm.exception + expected = SSL_ERROR2 + self.assertEqual(expected, str(exception)) + + # ensure error is raised if http is supplied and use_ssl is true + with self.assertRaises(APIClientError) as conm: + self.api._patch( + url='http://example.org', use_ssl=True, + params=params, files=files, foo='bar', test='test' + ) + exception = conm.exception + expected = SSL_ERROR + self.assertEqual(expected, str(exception)) + + def test_delete(self, mock_requests): + """Test _delete method.""" + self.api._delete(url=self.url, id=1, foo='bar') + mock_requests.delete.assert_called_with( + 'https://example.org', params={'id': 1, 'foo': 'bar'}, + timeout=None, headers=None + ) + + # ensure error is raised if https is supplied and use_ssl is false + with self.assertRaises(APIClientError) as conm: + api = BaseAPI('example.org', use_ssl=False) + api._delete(url='https://www.example.org') + exception = conm.exception + expected = SSL_ERROR2 + self.assertEqual(expected, str(exception)) + + # ensure error is raised if http is supplied and use_ssl is true + with self.assertRaises(APIClientError) as conm: + self.api._delete(url='http://example.org') + exception = conm.exception + expected = SSL_ERROR + self.assertEqual(expected, str(exception)) + + # ensure error is raised if no url is supplied + with self.assertRaises(APIClientError) as conm: + self.api._delete() + exception = conm.exception + expected = NO_URL_ERROR + self.assertEqual(expected, str(exception)) + + # test defaults to http + self.api._delete(url=self.url) + mock_requests.delete.assert_called_with( + 'https://example.org', timeout=None, headers=None + ) + + # use_ssl is False + api = BaseAPI('example.org', use_ssl=False) + api._delete(url=self.url, use_ssl=False) + mock_requests.delete.assert_called_with( + 'http://example.org', timeout=None, headers=None + ) + + +class APIFunctionTest(unittest.TestCase): + + def testadd_pk(self): + """Test add_pk helper function.""" + # Error checks + with self.assertRaises(APIClientError) as conm: + add_pk('url', None) + self.assertEqual( + 'APIClientError: id/pk must be supplied', str(conm.exception) + ) + + with self.assertRaises(TypeError) as conm: + add_pk('url', 'a') + self.assertEqual( + 'id/pk must be a positive integer', str(conm.exception) + ) + + with self.assertRaises(TypeError) as conm: + add_pk('url', 1.2) + self.assertEqual( + 'id/pk must be a positive integer', str(conm.exception) + ) + + with self.assertRaises(TypeError) as conm: + add_pk('url', -1) + self.assertEqual( + 'id/pk must be a positive integer', str(conm.exception) + ) + + # adds ints + result = add_pk('url', 1) + self.assertEqual('url/1', result) + + # converts strings if digit + result = add_pk('url', '1') + self.assertEqual('url/1', result) + + # id not required + result = add_pk('url', None, required=False) + self.assertEqual('url', result) + + # adds_slash + result = add_pk('url', 1, slash=True) + self.assertEqual('url/1/', result) + + # does not repeat / + result = add_pk('url/', 1) + self.assertEqual('url/1', result) + + def test_set_default(self): + """Test _set_default helper method""" + obj = mock.MagicMock() + obj.key = 'val' + # make sure nokey is not set on mock + del obj.nokey + + # raises error if attribute not set and val is none + with self.assertRaises(AttributeError) as conm: + _set_default(obj, 'nokey', None) + self.assertEqual('nokey is not set', str(conm.exception)) + + result = _set_default(obj, 'key', None) + self.assertNotEqual(result, None) + self.assertEqual(result, 'val') + + # returns obj.key if not value is supplied + result = _set_default(obj, 'key', None) + self.assertNotEqual(result, None) + self.assertEqual(result, 'val') + + # return value if supplied + result = _set_default(obj, 'key', 'other') + self.assertNotEqual(result, None) + self.assertEqual(result, 'other') + + # Return None if val and attr not set and required = False + result = _set_default(obj, 'nokey', None, required=False) + self.assertEqual(result, None) + + def test_get_urls(self): + """test _get_urls correctly formats urls""" + expected = {'test1': 'base_url/test1', 'test2': 'base_url/test2'} + result = _get_urls( + 'base_url/', {'test1': 'test1', 'test2': 'test2'} + ) + self.assertDictEqual(expected, result) diff --git a/pyseed/tests/test_seedclient.py b/pyseed/tests/test_seedclient.py new file mode 100644 index 0000000..8a5aca2 --- /dev/null +++ b/pyseed/tests/test_seedclient.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python +# encoding: utf-8 +""" +copyright (c) 2016 Earth Advantage. +All rights reserved + +Tests for SEEDClient +""" + +# Imports from Standard Library +import json +import sys +import unittest + +# Imports from Third Party Modules +import requests + +# Local Imports +from pyseed.exceptions import SEEDError +from pyseed.seedclient import ( # SEEDReadWriteClient, + ReadMixin, + SEEDBaseClient, + SEEDOAuthReadWriteClient, +) + +PY3 = sys.version_info[0] == 3 +if PY3: + from unittest import mock +else: + import mock + +# Constants +URLS = { + 'test1': 'api/v2/test', + 'test2': 'api/v2/test2', + 'test3': 'api/v2/test3', +} + +CONFIG_DICT = { + 'port': 1337, + 'urls_key': 'urls', + 'base_url': 'example.org' +} + +SERVICES_DICT = { + 'seed': { + 'urls': URLS, + + } +} + + +class MockConfig(object): + """Mock config object""" + # pylint:disable=too-few-public-methods, no-self-use + + def __init__(self, conf): + self.conf = conf + + def get(self, var, section=None, default=None): + if section: + cdict = self.conf.get(section, {}) + else: + cdict = self.conf + return cdict.get(var, default) + + +CONFIG = MockConfig(CONFIG_DICT) +SERVICES = MockConfig(SERVICES_DICT) + + +# Helper Functions & Classes +class MySeedClient(ReadMixin, SEEDBaseClient): + # pylint:disable=too-few-public-methods + endpoint = 'test1' + + +class MockOAuthClient(object): + + def __init__(self, sig, username, client_id): + pass + + def get_access_token(self): + return 'dfghjk' + + +def get_mock_response(data=None, data_name='data', error=False, + status_code=200, method='get', + base_url=CONFIG_DICT['base_url'], + endpoint='test1', extra=None, https=False, + content=True): + """Create mock response in the style of SEED""" + # pylint:disable=too-many-arguments + status = 'error' if error else 'success' + mock_request = mock.MagicMock() + url = "{}://{}/{}/".format( + 'https' if https else 'http', + base_url, + URLS[endpoint] + ) + if extra: # pragma: no cover + url = url + extra + mock_request.url = url + mock_request.method = method + mock_response = mock.MagicMock() + mock_response.status_code = status_code + mock_response.request = mock_request + # SEED old style + if content: + if error: + data_name = 'message' + content_dict = {'status': status, data_name: data} + mock_response.content = json.dumps(content_dict) + mock_response.json.return_value = content_dict + else: + mock_response.content = None + return mock_response + + +# Tests +@mock.patch('pyseed.apibase.requests') +class SEEDClientErrorHandlingTests(unittest.TestCase): + """ + The error handling uses the inspect module to examine the stack + to get the calling function for the error message. + + Since SEEDBaseClient is only intended for inheritance the stack + inspections counts up to get the right function name + """ + + def setUp(self): + self.port = 1137 + self.urls_map = URLS + self.base_url = 'example.org' + print(self.urls_map) + self.client = MySeedClient( + 1, username='test@example.org', access_token='dfghj', + base_url=self.base_url, port=self.port, url_map=self.urls_map + ) + + def test_check_response_inheritance(self, mock_requests): + """ + Ensure errors are correctly reported. + + SEEDError should show the calling method where the error occured. + It uses the inspect module to get the calling method from the stack. + + Error called in _check_response(), this also tests that method + as well as _raise_error(). + """ + url = 'http://example.org/api/v2/test/' + # Old SEED Style 200 (sic) with error message + mock_requests.get.return_value = get_mock_response( + data="No llama!", error=True + ) + with self.assertRaises(SEEDError) as conm: + self.client.get(1) + + self.assertEqual(conm.exception.error, 'No llama!') + self.assertEqual(conm.exception.service, 'SEED') + self.assertEqual(conm.exception.url, url) + self.assertEqual(conm.exception.caller, 'MySeedClient.get') + self.assertEqual(conm.exception.verb.upper(), 'GET') + self.assertEqual(conm.exception.status_code, 200) + + # newer/correct using status codes (no message) + mock_requests.get.return_value = get_mock_response( + status_code=404, data="No llama!", error=True, content=False + ) + with self.assertRaises(SEEDError) as conm: + self.client.get(1) + + self.assertEqual( + conm.exception.error, 'SEED returned status code: 404' + ) + self.assertEqual(conm.exception.service, 'SEED') + self.assertEqual(conm.exception.url, url) + self.assertEqual(conm.exception.caller, 'MySeedClient.get') + self.assertEqual(conm.exception.verb.upper(), 'GET') + self.assertEqual(conm.exception.status_code, 404) + + # newer/correct using status codes (with message) + mock_requests.get.return_value = get_mock_response( + status_code=404, data="No llama!", error=True, content=True + ) + with self.assertRaises(SEEDError) as conm: + self.client.get(1) + + self.assertEqual( + conm.exception.error, 'No llama!' + ) + self.assertEqual(conm.exception.service, 'SEED') + self.assertEqual(conm.exception.url, url) + self.assertEqual(conm.exception.caller, 'MySeedClient.get') + self.assertEqual(conm.exception.verb.upper(), 'GET') + self.assertEqual(conm.exception.status_code, 404) + + def test_get_result(self, mock_requests): + """Test errors raised in _get_result""" + url = 'http://example.org/api/v2/test/' + mock_requests.get.return_value = get_mock_response( + data="No llama!", data_name='bar', error=False, + ) + with self.assertRaises(SEEDError) as conm: + self.client.get(1) + + self.assertEqual( + conm.exception.error, 'Could not find result using data_name test.' + ) + self.assertEqual(conm.exception.service, 'SEED') + self.assertEqual(conm.exception.url, url) + self.assertEqual(conm.exception.verb.upper(), 'GET') + self.assertEqual(conm.exception.status_code, 200) + + +class SEEDClientMethodTests(unittest.TestCase): + + def setUp(self): + self.port = 1137 + self.urls_map = URLS + self.base_url = 'example.org' + self.client = MySeedClient( + 1, username='test@example.org', access_token='dfghj', + base_url=self.base_url, port=self.port, url_map=self.urls_map + ) + + def test_init(self): + """Test init sets params correctly""" + urls = { + key: "{}:{}/{}".format( + self.base_url, self.port, val + ) for key, val in URLS.items() + } + self.assertTrue(self.client.use_ssl) + self.assertTrue(self.client.use_json) + self.assertEqual(1, self.client.org_id) + self.assertEqual( + "{}:{}/".format( + self.base_url, self.port + ), + self.client.base_url + ) + self.assertEqual('test1', self.client.endpoint) + self.assertEqual(None, self.client.data_name) + self.assertEqual(urls, self.client.urls) + self.assertEqual(URLS.keys(), self.client.endpoints) + self.assertEqual('test1', self.client.endpoint) + + def test_get_result(self): + """Test _get_result method.""" + response = get_mock_response(data='test') + result = self.client._get_result(response) + self.assertEqual('test', result) + + +@mock.patch('pyseed.apibase.requests') +class MixinTests(unittest.TestCase): + """Test Mixins via SEEDReadWriteClient""" + + def setUp(self): + self.port = 1337 + self.urls_map = URLS + self.base_url = 'example.org' + self.client = SEEDOAuthReadWriteClient( + MockOAuthClient, 1, username='test@example.org', + access_token='dfghjk', base_url=self.base_url, + port=self.port, url_map=self.urls_map + ) + self.call_dict = { + 'headers': {'Authorization': 'Bearer dfghjk'}, + 'params': { + 'org_id': 1, + 'headers': {'Authorization': 'Bearer dfghjk'} + }, + 'timeout': None + } + + def test_delete(self, mock_requests): + # pylint:disable=no-member + url = 'https://example.org:1337/api/v2/test/1/' + mock_requests.delete.return_value = get_mock_response( + status_code=requests.codes.no_content + ) + result = self.client.delete(1, endpoint='test1') + self.assertEqual(None, result) + mock_requests.delete.assert_called_with(url, **self.call_dict) + + def test_get(self, mock_requests): + url = 'https://example.org:1337/api/v2/test/1/' + mock_requests.get.return_value = get_mock_response(data="Llama!") + result = self.client.get(1, endpoint='test1') + self.assertEqual('Llama!', result) + mock_requests.get.assert_called_with(url, **self.call_dict) + + def test_list(self, mock_requests): + url = 'https://example.org:1337/api/v2/test/' + mock_requests.get.return_value = get_mock_response(data=["Llama!"]) + result = self.client.list(endpoint='test1') + self.assertEqual(['Llama!'], result) + mock_requests.get.assert_called_with(url, **self.call_dict) + + def test_patch(self, mock_requests): + url = 'https://example.org:1337/api/v2/test/1/' + mock_requests.patch.return_value = get_mock_response(data="Llama!") + result = self.client.patch(1, endpoint='test1', foo='bar') + self.assertEqual('Llama!', result) + + call_dict = self.call_dict.copy() + call_dict['json'] = {'org_id': 1, 'foo': 'bar'} + del call_dict['params']['org_id'] + mock_requests.patch.assert_called_with(url, **call_dict) + + def test_put(self, mock_requests): + url = 'https://example.org:1337/api/v2/test/1/' + mock_requests.put.return_value = get_mock_response(data="Llama!") + result = self.client.put(1, endpoint='test1', foo='bar') + self.assertEqual('Llama!', result) + + call_dict = self.call_dict.copy() + call_dict['json'] = {'org_id': 1, 'foo': 'bar'} + del call_dict['params']['org_id'] + mock_requests.put.assert_called_with(url, **call_dict) + + def test_post(self, mock_requests): + url = 'https://example.org:1337/api/v2/test/' + mock_requests.post.return_value = get_mock_response(data="Llama!") + result = self.client.post(endpoint='test1', foo='bar') + self.assertEqual('Llama!', result) + + call_dict = self.call_dict.copy() + call_dict['json'] = {'org_id': 1, 'foo': 'bar'} + del call_dict['params']['org_id'] + mock_requests.post.assert_called_with(url, **call_dict) diff --git a/requirements.txt b/requirements.txt index 271baf7..6daa048 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ requests==2.18.4 +typing==3.6.1 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..98c8a06 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,27 @@ +[metadata] +name=py-seed +version=0.1.0 +description=A Python API client for the SEED Platform +author=Paul Munday +author_email=paul@paulmunday.net +maintainer=GreenBuildingRegistry +maintainer_email=admin@greenbuildingregistry.com +keywords= seed, api +url=https://github.com/GreenBuildingRegistry/py-seed +classifiers = + Development Status :: 4 - Beta + Intended Audience :: Developers + Operating System :: OS Independent + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3.4 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 +[options] +packages = find: +include_package_data = True +zip_safe = False +install_requires = + requests==2.18.4 + typing==3.6.1 +[bdist_wheel] +universal = 1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..dd4e63e --- /dev/null +++ b/setup.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python + +from setuptools import setup + + +setup() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..d42e195 --- /dev/null +++ b/tox.ini @@ -0,0 +1,13 @@ +[tox] +envlist = py27, py34,py35,py36 + +[testenv] +deps= + -rrequirements.txt + mock + pytest + pytest-cov + pytest-xdist + testfixtures>=5.1.1 + +commands = pytest --cov=. --cov-report= --cov-append -s