From 76907eb5e96a6df7a690512905f3929010166663 Mon Sep 17 00:00:00 2001 From: david Karchmer Date: Fri, 2 Sep 2016 15:14:15 -0700 Subject: [PATCH] first commit --- .gitignore | 13 +++ CHANGELOG.md | 3 + LICENSE | 21 ++++ MANIFEST.in | 3 + README.md | 20 ++++ pystrato/__init__.py | 0 pystrato/connection.py | 231 +++++++++++++++++++++++++++++++++++++++++ pystrato/exceptions.py | 61 +++++++++++ requests.txt | 1 + setup.cfg | 2 + setup.py | 14 +++ 11 files changed, 369 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 pystrato/__init__.py create mode 100644 pystrato/connection.py create mode 100644 pystrato/exceptions.py create mode 100644 requests.txt create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c10f96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.idea/ +.DS_Store + +*.pyc +.env +*.log + +# Setup/Build +build/ +dist/ +*.egg-info/ +*.egg + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..be170b7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +### v0.1.0 (2016-09-02) + + * First release \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5aa45e3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Arch Systems Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..1a59b85 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE +include CHANGELOG.md +include README.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..210bd2f --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Strato Python API Package + +A python library for interacting with [Strato](https://strato.arch-iot.com) Rest API + +## Installation + +``` +pip install pystrato +``` + +Package is based on https://github.com/samgiles/slumber + +## Requirements + +pystrato requires the following modules. + +Python 2.7+ or 3.4+ +requests + +## Copyright and license \ No newline at end of file diff --git a/pystrato/__init__.py b/pystrato/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pystrato/connection.py b/pystrato/connection.py new file mode 100644 index 0000000..9310f7f --- /dev/null +++ b/pystrato/connection.py @@ -0,0 +1,231 @@ +__author__ = 'dkarchmer' + +""" +See https://gist.github.com/dkarchmer/d85e55f9ed5450ba58cb +This API generically supports DjangoRestFramework based APIs +It is based on https://github.com/samgiles/slumber, but customized for +Django Rest Frameworks, and the use of TokenAuthentication. +Usage: + # Assuming + # v1_api_router.register(r'some_model', SomeModelViewSet) + api = Api('http://127.0.0.1:8000') + api.login(email='user1@test.com', password='user1') + obj_list = api.some_model.get() + logger.debug('Found {0} groups'.format(obj_list['count'])) + obj_one = api.some_model(1).get() + api.logout() +""" +import json +import requests +import logging +import os +from .exceptions import * + +DOMAIN_NAME = 'https://strato.arch-iot.com' +API_PREFIX = 'api/v1' +DEFAULT_HEADERS = {'Content-Type': 'application/json'} + +logger = logging.getLogger(__name__) + + +class RestResource(object): + """ + Resource provides the main functionality behind a Django Rest Framework based API. It handles the + attribute -> url, kwarg -> query param, and other related behind the scenes + python to HTTP transformations. It's goal is to represent a single resource + which may or may not have children. + """ + + def __init__(self, *args, **kwargs): + self._store = kwargs + if 'use_token' not in self._store: + self._store['use_token'] = False + + def __call__(self, id=None, action=None): + """ + Returns a new instance of self modified by one or more of the available + parameters. These allows us to do things like override format for a + specific request, and enables the api.resource(ID).get() syntax to get + a specific resource by it's ID. + """ + + kwargs = { + 'token': self._store['token'], + 'use_token': True, + 'base_url': self._store['base_url'] + } + + new_url = self._store['base_url'] + if id is not None: + new_url = '{0}/{1}/'.format(new_url, id) + + if action is not None: + new_url = os.path.join(new_url, action ) + '/' + + kwargs['base_url'] = new_url + + return self.__class__(**kwargs) + + def _check_for_errors(self, resp, url): + + if 400 <= resp.status_code <= 499: + exception_class = HttpNotFoundError if resp.status_code == 404 else HttpClientError + raise exception_class("Client Error %s: %s" % (resp.status_code, url), response=resp, content=resp.content) + elif 500 <= resp.status_code <= 599: + raise HttpServerError("Server Error %s: %s" % (resp.status_code, url), response=resp, content=resp.content) + + def _handle_redirect(self, resp, **kwargs): + # @@@ Hacky, see description in __call__ + resource_obj = self(url_override=resp.headers["location"]) + return resource_obj.get(**kwargs) + + def _try_to_serialize_response(self, resp): + if resp.status_code in [204, 205]: + return + + if resp.content: + if type(resp.content) == bytes: + try: + encoding = requests.utils.guess_json_utf(resp.content) + return json.loads(resp.content.decode(encoding)) + except Exception: + return resp.content + return json.loads(resp.content) + else: + return resp.content + + def _process_response(self, resp): + + self._check_for_errors(resp, self.url()) + + if 200 <= resp.status_code <= 299: + return self._try_to_serialize_response(resp) + else: + return # @@@ We should probably do some sort of error here? (Is this even possible?) + + def url(self, args=None): + url = self._store["base_url"] + if args: + url += '?{0}'.format(args) + return url + + def _get_header(self): + headers = DEFAULT_HEADERS + if self._store['use_token']: + if not "token" in self._store: + raise RestBaseException('No Token') + authorization_str = 'token %s' % self._store["token"] + headers['Authorization'] = authorization_str + + return headers + + def get(self, **kwargs): + args = None + if 'extra' in kwargs: + args = kwargs['extra'] + resp = requests.get(self.url(args), headers=self._get_header()) + return self._process_response(resp) + + def post(self, data=None, **kwargs): + if data: + payload = json.dumps(data) + else: + payload = None + + resp = requests.post(self.url(), data=payload, headers=self._get_header()) + return self._process_response(resp) + + def patch(self, data=None, **kwargs): + if data: + payload = json.dumps(data) + else: + payload = None + + resp = requests.patch(self.url(), data=payload, headers=self._get_header()) + return self._process_response(resp) + + def put(self, data=None, **kwargs): + if data: + payload = json.dumps(data) + else: + payload = None + + resp = requests.put(self.url(), data=payload, headers=self._get_header()) + return self._process_response(resp) + + def delete(self, **kwargs): + resp = requests.delete(self.url(), headers=self._get_header()) + if 200 <= resp.status_code <= 299: + if resp.status_code == 204: + return True + else: + return True # @@@ Should this really be True? + else: + return False + + +class Api(object): + token = None + domain = DOMAIN_NAME + resource_class = RestResource + + def __init__(self, domain=None): + if domain: + self.domain = domain + self.base_url = '{0}/{1}'.format(self.domain, API_PREFIX) + self.use_token = True + + def set_token(self, token): + self.token = token + + def login(self, password, email): + data = {'email': email, 'password': password} + url = '{0}/{1}'.format(self.base_url, 'auth/login/') + + payload = json.dumps(data) + r = requests.post(url, data=payload, headers=DEFAULT_HEADERS) + if r.status_code == 200: + content = json.loads(r.content.decode()) + self.token = content['token'] + self.username = content['username'] + logger.info('Welcome @{0} (token: {1})'.format(self.username, self.token)) + return True + else: + logger.error('Login failed: ' + str(r.status_code) + ' ' + r.content.decode()) + return False + + def logout(self): + url = '{0}/{1}'.format(self.base_url, 'auth/logout/') + headers = DEFAULT_HEADERS + headers['Authorization'] = 'token {0}'.format(self.token) + + r = requests.post(url, headers=headers) + if r.status_code == 204: + logger.info('Goodbye @{0}'.format(self.username)) + self.username = None + self.token = None + else: + logger.error('Logout failed: ' + str(r.status_code) + ' ' + r.content.decode()) + + def __getattr__(self, item): + """ + Instead of raising an attribute error, the undefined attribute will + return a Resource Instance which can be used to make calls to the + resource identified by the attribute. + """ + + # Don't allow access to 'private' by convention attributes. + if item.startswith("_"): + raise AttributeError(item) + + kwargs = { + 'token': self.token, + 'base_url': self.base_url, + 'use_token': self.use_token + } + kwargs.update({'base_url': '{0}/{1}/'.format(kwargs['base_url'], item)}) + + return self._get_resource(**kwargs) + + def _get_resource(self, **kwargs): + return self.resource_class(**kwargs) \ No newline at end of file diff --git a/pystrato/exceptions.py b/pystrato/exceptions.py new file mode 100644 index 0000000..e0a2cb8 --- /dev/null +++ b/pystrato/exceptions.py @@ -0,0 +1,61 @@ + + +class RestBaseException(Exception): + """ + All Rest exceptions inherit from this exception. + """ + + +class RestHttpBaseException(RestBaseException): + """ + All Rest HTTP Exceptions inherit from this exception. + """ + + def __init__(self, *args, **kwargs): + """ + Helper to get and a proper dict iterator with Py2k and Py3k + """ + try: + iter = kwargs.iteritems() + except AttributeError: + iter = kwargs.items() + + for key, value in iter: + setattr(self, key, value) + super(RestHttpBaseException, self).__init__(*args) + + +class HttpClientError(RestHttpBaseException): + """ + Called when the server tells us there was a client error (4xx). + """ + + +class HttpNotFoundError(HttpClientError): + """ + Called when the server sends a 404 error. + """ + + +class HttpServerError(RestHttpBaseException): + """ + Called when the server tells us there was a server error (5xx). + """ + + +class SerializerNoRestailable(RestBaseException): + """ + There are no Restailable Serializers. + """ + + +class SerializerNotRestailable(RestBaseException): + """ + The chosen Serializer is not Restailable. + """ + + +class ImproperlyConfigured(RestBaseException): + """ + Rest is somehow improperly configured. + """ \ No newline at end of file diff --git a/requests.txt b/requests.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/requests.txt @@ -0,0 +1 @@ +requests diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..224a779 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[metadata] +description-file = README.md \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..9e174cc --- /dev/null +++ b/setup.py @@ -0,0 +1,14 @@ +from setuptools import setup + +setup(name='pystrato', + version='0.1.0', + description='Python client for strato.arch-iot.com', + url='https://github.com/iotile/strato_python_api', + author='David Karchmer', + author_email='david@arch-iot.com', + license='MIT', + packages=['pystrato'], + install_requires=[ + 'requests', + ], + zip_safe=False) \ No newline at end of file