From 8deaed5fee4046f9bff2ba1f0e0c6791a9cb9471 Mon Sep 17 00:00:00 2001 From: Francesco Siddi Date: Thu, 26 Mar 2015 12:52:47 +0100 Subject: [PATCH] Initial commit --- .gitignore | 6 + LICENSE.md | 29 +++++ README.md | 2 + attractsdk/__init__.py | 3 + attractsdk/api.py | 188 +++++++++++++++++++++++++++++++ attractsdk/config.py | 5 + attractsdk/exceptions.py | 96 ++++++++++++++++ attractsdk/resource.py | 235 +++++++++++++++++++++++++++++++++++++++ attractsdk/utils.py | 46 ++++++++ setup.py | 40 +++++++ 10 files changed, 650 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 attractsdk/__init__.py create mode 100644 attractsdk/api.py create mode 100644 attractsdk/config.py create mode 100644 attractsdk/exceptions.py create mode 100644 attractsdk/resource.py create mode 100644 attractsdk/utils.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb82bc7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ + +*.pyc + +*.sublime-project + +*.sublime-workspace diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..6837cdc --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,29 @@ +The Attract Python SDK is released under the BSD License: + + Copyright (c) 2015, Blender Foundation + Copyright (c) 2013-2014, PayPal + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + The views and conclusions contained in the software and documentation are those + of the authors and should not be interpreted as representing official policies, + either expressed or implied, of the FreeBSD Project. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0d0507 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Attract Python REST SDK +Integrate this module in your Python app to communicate with an Attract server. diff --git a/attractsdk/__init__.py b/attractsdk/__init__.py new file mode 100644 index 0000000..c0fdb4b --- /dev/null +++ b/attractsdk/__init__.py @@ -0,0 +1,3 @@ +from attractsdk.api import Api +from attractsdk.exceptions import ResourceNotFound, UnauthorizedAccess, MissingConfig +from attractsdk.config import __version__, __pypi_packagename__ diff --git a/attractsdk/api.py b/attractsdk/api.py new file mode 100644 index 0000000..66af7f4 --- /dev/null +++ b/attractsdk/api.py @@ -0,0 +1,188 @@ +import base64 +import requests +import json +import logging +import platform +import sys + +import attractsdk.utils as utils +from attractsdk import exceptions +from attractsdk.config import __version__ + + +class Api(object): + + # User-Agent for HTTP request + library_details = "requests {0}; python {1}".format( + requests.__version__, platform.python_version()) + user_agent = "Attract-Python-SDK/{0} ({1})".format( + __version__, library_details) + _api_singleton = None + def __init__(self, options=None, **kwargs): + """Create API object + + Usage:: + + >>> from attractsdk import Api + >>> Api.Default( + endpoint="http://localhost:5000", + username='USERNAME', + password='PASSWORD' + ) + """ + kwargs = utils.merge_dict(options or {}, kwargs) + + self.endpoint = kwargs["endpoint"] + self.username = kwargs["username"] + self.password = kwargs["password"] + self.token = kwargs["token"] if kwargs.get("token") else None + self.options = kwargs + + @staticmethod + def Default(**kwargs): + """Initialize the API in a singleton style + """ + if Api._api_singleton is None: + try: + Api._api_singleton = Api( + endpoint=kwargs["endpoint"], + username=kwargs["username"], + password=kwargs["password"]) + except KeyError: + #raise exceptions.MissingConfig("Missing configuration value") + print("Missing configuration value. Initialize with Api.Default().") + sys.exit(0) + + return Api._api_singleton + + + def basic_auth(self): + """Returns base64 encoded token. Used to encode auth credentials + for retrieving the token. + """ + credentials = "{0}:{1}".format(self.username, self.password) + return base64.b64encode(credentials.encode('utf-8')).decode('utf-8').replace("\n", "") + + def get_token(self): + """Generate new token by making a POST request + """ + path = "/users" + payload = None + + if self.token: + return self.token + else: + # If token is not set we do initial request with username and password + self.token = self.http_call( + utils.join_url(self.endpoint, path), "POST", + data=payload, + headers={ + "Authorization": ("Basic {0}".format(self.basic_auth())), + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "User-Agent": self.user_agent + }) + + return self.token + + def request(self, url, method, body=None, headers=None): + """Make HTTP call, formats response and does error handling. + Uses http_call method in API class. + """ + + http_headers = utils.merge_dict(self.headers(), headers or {}) + + if http_headers.get('Attract-Request-Id'): + logging.info("Attract-Request-Id: {0}".format(http_headers['Attract-Request-Id'])) + + try: + return self.http_call(url, method, data=json.dumps(body), headers=http_headers) + + except exceptions.BadRequest as error: + return {"error": json.loads(error.content)} + + # Handle unauthorized token + except exceptions.UnauthorizedAccess as error: + raise error + + def http_call(self, url, method, **kwargs): + """Makes a http call. Logs response information. + """ + # logging.info("Request[{0}]: {1}".format(method, url)) + response = requests.request(method, url, **kwargs) + # logging.info("Response[{0}]: {1}".format(response.status_code, response.reason)) + return self.handle_response(response, response.content.decode('utf-8')) + + def handle_response(self, response, content): + """Check HTTP response codes + """ + status = response.status_code + if status in (301, 302, 303, 307): + raise exceptions.Redirection(response, content) + elif 200 <= status <= 299: + return json.loads(content) if content else {} + elif status == 400: + raise exceptions.BadRequest(response, content) + elif status == 401: + raise exceptions.UnauthorizedAccess(response, content) + elif status == 403: + raise exceptions.ForbiddenAccess(response, content) + elif status == 404: + raise exceptions.ResourceNotFound(response, content) + elif status == 405: + raise exceptions.MethodNotAllowed(response, content) + elif status == 409: + raise exceptions.ResourceConflict(response, content) + elif status == 410: + raise exceptions.ResourceGone(response, content) + elif status == 422: + raise exceptions.ResourceInvalid(response, content) + elif 401 <= status <= 499: + raise exceptions.ClientError(response, content) + elif 500 <= status <= 599: + raise exceptions.ServerError(response, content) + else: + raise exceptions.ConnectionError(response, + content, "Unknown response code: #{response.code}") + + def headers(self): + """Default HTTP headers + """ + token = self.get_token() + + return { + "Authorization": ("Basic %s" % self.basic_auth(token=token)), + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": self.user_agent + } + + def get(self, action, headers=None): + """Make GET request + """ + return self.request(utils.join_url(self.endpoint, action), 'GET', + headers=headers or {}) + + def post(self, action, params=None, headers=None): + """Make POST request + """ + return self.request(utils.join_url(self.endpoint, action), 'POST', + body=params or {}, headers=headers or {}, refresh_token=refresh_token) + + def put(self, action, params=None, headers=None): + """Make PUT request + """ + return self.request(utils.join_url(self.endpoint, action), 'PUT', + body=params or {}, headers=headers or {}, refresh_token=refresh_token) + + def patch(self, action, params=None, headers=None): + """Make PATCH request + """ + return self.request(utils.join_url(self.endpoint, action), 'PATCH', + body=params or {}, headers=headers or {}, refresh_token=refresh_token) + + def delete(self, action, headers=None): + """Make DELETE request + """ + return self.request(utils.join_url(self.endpoint, action), 'DELETE', + headers=headers or {}) diff --git a/attractsdk/config.py b/attractsdk/config.py new file mode 100644 index 0000000..b5f2998 --- /dev/null +++ b/attractsdk/config.py @@ -0,0 +1,5 @@ +__version__ = "0.0.1" +__pypi_username__ = "fsiddi" +__pypi_packagename__ = "attract-sdk" +__github_username__ = "fsiddi" +__github_reponame__ = "Attract-Python-SDK" diff --git a/attractsdk/exceptions.py b/attractsdk/exceptions.py new file mode 100644 index 0000000..29032b7 --- /dev/null +++ b/attractsdk/exceptions.py @@ -0,0 +1,96 @@ + +class ConnectionError(Exception): + def __init__(self, response, content=None, message=None): + self.response = response + self.content = content + self.message = message + + def __str__(self): + message = "Failed." + if hasattr(self.response, 'status_code'): + message += " Response status: %s." % (self.response.status_code) + if hasattr(self.response, 'reason'): + message += " Response message: %s." % (self.response.reason) + if self.content is not None: + message += " Error message: " + str(self.content) + return message + + +class Redirection(ConnectionError): + """3xx Redirection + """ + def __str__(self): + message = super(Redirection, self).__str__() + if self.response.get('Location'): + message = "%s => %s" % (message, self.response.get('Location')) + return message + + +class MissingParam(TypeError): + pass + + +class MissingConfig(Exception): + pass + + +class ClientError(ConnectionError): + """4xx Client Error + """ + pass + + +class BadRequest(ClientError): + """400 Bad Request + """ + pass + + +class UnauthorizedAccess(ClientError): + """401 Unauthorized + """ + pass + + +class ForbiddenAccess(ClientError): + """403 Forbidden + """ + pass + + +class ResourceNotFound(ClientError): + """404 Not Found + """ + pass + + +class ResourceConflict(ClientError): + """409 Conflict + """ + pass + + +class ResourceGone(ClientError): + """410 Gone + """ + pass + + +class ResourceInvalid(ClientError): + """422 Invalid + """ + pass + + +class ServerError(ConnectionError): + """5xx Server Error + """ + pass + + +class MethodNotAllowed(ClientError): + """405 Method Not Allowed + """ + + def allowed_methods(self): + return self.response['Allow'] diff --git a/attractsdk/resource.py b/attractsdk/resource.py new file mode 100644 index 0000000..f09a567 --- /dev/null +++ b/attractsdk/resource.py @@ -0,0 +1,235 @@ +import uuid + +import attractsdk.utils as utils +from attractsdk.api import Api + + +class Resource(object): + """Base class for all REST services + """ + convert_resources = {} + + def __init__(self, attributes=None): + attributes = attributes or {} + self.__dict__['api'] = Api.Default() + + super(Resource, self).__setattr__('__data__', {}) + super(Resource, self).__setattr__('error', None) + super(Resource, self).__setattr__('headers', {}) + super(Resource, self).__setattr__('header', {}) + super(Resource, self).__setattr__('request_id', None) + self.merge(attributes) + + def generate_request_id(self): + """Generate unique request id + """ + if self.request_id is None: + self.request_id = str(uuid.uuid4()) + return self.request_id + + def http_headers(self): + """Generate HTTP header + """ + return utils.merge_dict(self.header, self.headers, + {'Attract-Request-Id': self.generate_request_id()}) + + def __str__(self): + return self.__data__.__str__() + + def __repr__(self): + return self.__data__.__str__() + + def __getattr__(self, name): + return self.__data__.get(name) + + def __setattr__(self, name, value): + try: + # Handle attributes(error, header, request_id) + super(Resource, self).__getattribute__(name) + super(Resource, self).__setattr__(name, value) + except AttributeError: + self.__data__[name] = self.convert(name, value) + + def success(self): + return self.error is None + + def merge(self, new_attributes): + """Merge new attributes e.g. response from a post to Resource + """ + for key, val in new_attributes.items(): + setattr(self, key, val) + + def convert(self, name, value): + """Convert the attribute values to configured class + """ + if isinstance(value, dict): + cls = self.convert_resources.get(name, Resource) + return cls(value, api=self.api) + elif isinstance(value, list): + new_list = [] + for obj in value: + new_list.append(self.convert(name, obj)) + return new_list + else: + return value + + def __getitem__(self, key): + return self.__data__[key] + + def __setitem__(self, key, value): + self.__data__[key] = self.convert(key, value) + + def to_dict(self): + + def parse_object(value): + if isinstance(value, Resource): + return value.to_dict() + elif isinstance(value, list): + new_list = [] + for obj in value: + new_list.append(parse_object(obj)) + return new_list + else: + return value + + data = {} + for key in self.__data__: + data[key] = parse_object(self.__data__[key]) + return data + + +class Find(Resource): + + @classmethod + def find(cls, resource_id): + """Locate resource, usually using ObjectID + + Usage:: + + >>> Node.find("507f1f77bcf86cd799439011") + """ + + api = Api.Default() + + url = utils.join_url(cls.path, str(resource_id)) + return cls(api.get(url)) + + +class List(Resource): + + list_class = Resource + + @classmethod + def all(cls, params=None): + """Get list of resources, allowing some parameters such as: + - count + - start_time + - sort_by + - sort_order + + Usage:: + + >>> shots = Nodes.all({'count': 2, 'type': 'shot'}) + """ + api = Api.Default() + + if params is None: + url = cls.path + else: + url = utils.join_url_params(cls.path, params) + + try: + response = api.get(url) + return cls.list_class(response) + except AttributeError: + # To handle the case when response is JSON Array + if isinstance(response, list): + new_resp = [cls.list_class(elem) for elem in response] + return new_resp + + +class Create(Resource): + + def create(self): + """Create a resource + + Usage:: + + >>> node = Node({}) + >>> node.create() + """ + + headers = self.http_headers() + + new_attributes = self.api.post(self.path, self.to_dict(), headers) + self.error = None + self.merge(new_attributes) + return self.success() + + +class Update(Resource): + """Update a resource + """ + + def update(self, attributes=None): + attributes = attributes or self.to_dict() + url = utils.join_url(self.path, str(self['id'])) + new_attributes = self.api.put(url, attributes, self.http_headers()) + self.error = None + self.merge(new_attributes) + return self.success() + + +class Replace(Resource): + """Partial update or modify resource + see http://williamdurand.fr/2014/02/14/please-do-not-patch-like-an-idiot/ + + Usage:: + + >>> node = Node.find("507f1f77bcf86cd799439011") + >>> node.replace([{'op': 'replace', 'path': '/name', 'value': 'Renamed Shot 2' }]) + """ + + def replace(self, attributes=None): + attributes = attributes or self.to_dict() + url = utils.join_url(self.path, str(self['id'])) + new_attributes = self.api.patch(url, attributes, self.http_headers()) + self.error = None + self.merge(new_attributes) + return self.success() + + +class Delete(Resource): + + def delete(self): + """Delete a resource + + Usage:: + + >>> node = Node.find("507f1f77bcf86cd799439011") + >>> node.delete() + """ + url = utils.join_url(self.path, str(self['id'])) + new_attributes = self.api.delete(url) + self.error = None + self.merge(new_attributes) + return self.success() + + +class Post(Resource): + + def post(self, name, attributes=None, cls=Resource, fieldname='id'): + """Constructs url with passed in headers and makes post request via + post method in api class. + """ + attributes = attributes or {} + url = utils.join_url(self.path, str(self[fieldname]), name) + if not isinstance(attributes, Resource): + attributes = Resource(attributes, api=self.api) + new_attributes = self.api.post(url, attributes.to_dict(), attributes.http_headers()) + if isinstance(cls, Resource): + cls.error = None + cls.merge(new_attributes) + return self.success() + else: + return cls(new_attributes, api=self.api) diff --git a/attractsdk/utils.py b/attractsdk/utils.py new file mode 100644 index 0000000..a948807 --- /dev/null +++ b/attractsdk/utils.py @@ -0,0 +1,46 @@ +import re + +try: + from urllib.parse import urlencode +except ImportError: + from urllib import urlencode + + +def join_url(url, *paths): + """ + Joins individual URL strings together, and returns a single string. + + Usage:: + + >>> utils.join_url("attract:5000", "shots") + attract:5000/shots + """ + for path in paths: + url = re.sub(r'/?$', re.sub(r'^/?', '/', path), url) + return url + + +def join_url_params(url, params): + """Constructs a query string from a dictionary and appends it to a url. + + Usage:: + + >>> utils.join_url_params("attract:5000/shots", {"page-id": 2, "NodeType": "Shot Group"}) + attract:5000/shots?page-id=2&NodeType=Shot+Group + """ + return url + "?" + urlencode(params) + + +def merge_dict(data, *override): + """ + Merges any number of dictionaries together, and returns a single dictionary. + + Usage:: + + >>> utils.merge_dict({"foo": "bar"}, {1: 2}, {"foo1": "bar2"}) + {1: 2, 'foo': 'bar', 'foo1': 'bar2'} + """ + result = {} + for current_dict in (data,) + override: + result.update(current_dict) + return result diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..bae5e89 --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +from distutils.core import setup + +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'attractsdk')) +from config import __version__, __pypi_packagename__, __github_username__, __github_reponame__ + +long_description=""" + The Attract REST SDK provides Python APIs to communicate to the Attract webservices. + """ + +# license='Free BSD' +# if os.path.exists('LICENSE.md'): +# license = open('LICENSE.md').read() + +url='https://github.com/' + __github_username__ + '/' + __github_reponame__ + +setup( + name=__pypi_packagename__, + version= __version__, + author='Francesco Siddi, PayPal', + author_email='francesco@blender.org', + packages=['attractsdk'], + scripts=[], + url=url, + license='BSD License', + description='The Attract REST SDK provides Python APIs to communicate to the Attract webservices.', + long_description=long_description, + install_requires=['requests>=1.0.0', 'six>=1.0.0', 'pyopenssl>=0.14'], + classifiers=[ + 'Intended Audience :: Developers', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Topic :: Software Development :: Libraries :: Python Modules' + ], + keywords=['attract', 'rest', 'sdk', 'tracking', 'film', 'production'] +)