Initial commit

This commit is contained in:
2015-03-26 12:52:47 +01:00
commit 8deaed5fee
10 changed files with 650 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
*.pyc
*.sublime-project
*.sublime-workspace

29
LICENSE.md Normal file
View File

@@ -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.

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
# Attract Python REST SDK
Integrate this module in your Python app to communicate with an Attract server.

3
attractsdk/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from attractsdk.api import Api
from attractsdk.exceptions import ResourceNotFound, UnauthorizedAccess, MissingConfig
from attractsdk.config import __version__, __pypi_packagename__

188
attractsdk/api.py Normal file
View File

@@ -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 {})

5
attractsdk/config.py Normal file
View File

@@ -0,0 +1,5 @@
__version__ = "0.0.1"
__pypi_username__ = "fsiddi"
__pypi_packagename__ = "attract-sdk"
__github_username__ = "fsiddi"
__github_reponame__ = "Attract-Python-SDK"

96
attractsdk/exceptions.py Normal file
View File

@@ -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']

235
attractsdk/resource.py Normal file
View File

@@ -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)

46
attractsdk/utils.py Normal file
View File

@@ -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

40
setup.py Normal file
View File

@@ -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']
)