diff --git a/setup.py b/setup.py
index 7b5d540..15774cd 100644
--- a/setup.py
+++ b/setup.py
@@ -21,6 +21,7 @@
'testtools',
'iso8601',
'python-ntlm',
+ 'mock'
],
test_suite = "v1pysdk.tests",
diff --git a/v1pysdk/client.py b/v1pysdk/client.py
index f579d2f..6863597 100644
--- a/v1pysdk/client.py
+++ b/v1pysdk/client.py
@@ -1,7 +1,6 @@
-
-import logging, time, base64
+import logging
import urllib2
-from urllib2 import Request, urlopen, HTTPError, HTTPBasicAuthHandler, HTTPCookieProcessor
+from urllib2 import Request, HTTPError, HTTPBasicAuthHandler, HTTPCookieProcessor
from urllib import urlencode
from urlparse import urlunparse, urlparse
@@ -27,197 +26,181 @@ class CustomHTTPNtlmAuthHandler(HTTPNtlmAuthHandler):
call chain, most importantly `HTTPErrorProcessor.http_response`,
which normally raises an error for 'bad' http status codes..
"""
+
def http_error_401(self, req, fp, code, msg, hdrs):
response = HTTPNtlmAuthHandler.http_error_401(self, req, fp, code, msg, hdrs)
if not (200 <= response.code < 300):
response = self.parent.error(
- 'http', req, response, response.code, response.msg,
- response.info)
+ 'http', req, response, response.code, response.msg,
+ response.info)
return response
- AUTH_HANDLERS.append(CustomHTTPNtlmAuthHandler)
-
+ AUTH_HANDLERS.append(CustomHTTPNtlmAuthHandler)
class V1Error(Exception): pass
+
class V1AssetNotFoundError(V1Error): pass
-class V1Server(object):
- "Accesses a V1 HTTP server as a client of the XML API protocol"
-
- def __init__(self, address="localhost", instance="VersionOne.Web", username='', password='', scheme="http", instance_url=None, logparent=None, loglevel=logging.ERROR):
- if instance_url:
- self.instance_url = instance_url
- parsed = urlparse(instance_url)
- self.address = parsed.netloc
- self.instance = parsed.path.strip('/')
- self.scheme = parsed.scheme
- else:
- self.address = address
- self.instance = instance.strip('/')
- self.scheme = scheme
- self.instance_url = self.build_url('')
-
- modulelogname='v1pysdk.client'
- logname = "%s.%s" % (logparent, modulelogname) if logparent else None
- self.logger = logging.getLogger(logname)
- self.logger.setLevel(loglevel)
- self.username = username
- self.password = password
- self._install_opener()
-
- def _install_opener(self):
- base_url = self.build_url('')
- password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm()
- password_manager.add_password(None, base_url, self.username, self.password)
- handlers = [HandlerClass(password_manager) for HandlerClass in AUTH_HANDLERS]
- self.opener = urllib2.build_opener(*handlers)
- self.opener.add_handler(HTTPCookieProcessor())
-
- def http_get(self, url):
- request = Request(url)
- request.add_header("Content-Type", "text/xml;charset=UTF-8")
- response = self.opener.open(request)
- return response
-
- def http_post(self, url, data=''):
- request = Request(url, data)
- request.add_header("Content-Type", "text/xml;charset=UTF-8")
- response = self.opener.open(request)
- return response
-
- def build_url(self, path, query='', fragment='', params=''):
- "So we dont have to interpolate urls ad-hoc"
- path = self.instance + '/' + path.strip('/')
- if isinstance(query, dict):
- query = urlencode(query)
- url = urlunparse( (self.scheme, self.address, path, params, query, fragment) )
- return url
-
- def _debug_headers(self, headers):
- self.logger.debug("Headers:")
- for hdr in str(headers).split('\n'):
- self.logger.debug(" %s" % hdr)
-
- def _debug_body(self, body, headers):
- try:
- ctype = headers['content-type']
- except AttributeError:
- ctype = None
- if ctype is not None and ctype[:5] == 'text/':
- self.logger.debug("Body:")
- for line in str(body).split('\n'):
- self.logger.debug(" %s" % line)
- else:
- self.logger.debug("Body: non-textual content (Content-Type: %s). Not logged." % ctype)
-
- def fetch(self, path, query='', postdata=None):
- "Perform an HTTP GET or POST depending on whether postdata is present"
- url = self.build_url(path, query=query)
- self.logger.debug("URL: %s" % url)
- try:
- if postdata is not None:
- if isinstance(postdata, dict):
- postdata = urlencode(postdata)
- self.logger.debug("postdata: %s" % postdata)
- response = self.http_post(url, postdata)
- else:
- response = self.http_get(url)
- body = response.read()
- self._debug_headers(response.headers)
- self._debug_body(body, response.headers)
- return (None, body)
- except HTTPError, e:
- if e.code == 401:
- raise
- body = e.fp.read()
- self._debug_headers(e.headers)
- self._debug_body(body, e.headers)
- return (e, body)
-
- def handle_non_xml_response(self, body, exception, msg, postdata):
- if exception.code >= 500:
- # 5XX error codes mean we won't have an XML response to parse
- self.logger.error("{0} during {1}".format(exception, msg))
- if postdata is not None:
- self.logger.error(postdata)
- raise exception
-
- def get_xml(self, path, query='', postdata=None):
- verb = "HTTP POST to " if postdata else "HTTP GET from "
- msg = verb + path
- self.logger.info(msg)
- exception, body = self.fetch(path, query=query, postdata=postdata)
- if exception:
- self.handle_non_xml_response(body, exception, msg, postdata)
-
- self.logger.warn("{0} during {1}".format(exception, msg))
- if postdata is not None:
- self.logger.warn(postdata)
- document = ElementTree.fromstring(body)
- if exception:
- exception.xmldoc = document
- if exception.code == 404:
- raise V1AssetNotFoundError(exception)
- elif exception.code == 400:
- raise V1Error('\n'+body)
- else:
- raise V1Error(exception)
- return document
-
- def get_asset_xml(self, asset_type_name, oid, moment=None):
- path = '/rest-1.v1/Data/{0}/{1}/{2}'.format(asset_type_name, oid, moment) if moment else '/rest-1.v1/Data/{0}/{1}'.format(asset_type_name, oid)
- return self.get_xml(path)
-
- def get_query_xml(self, asset_type_name, where=None, sel=None):
- path = '/rest-1.v1/Data/{0}'.format(asset_type_name)
- query = {}
- if where is not None:
- query['Where'] = where
- if sel is not None:
- query['sel'] = sel
- return self.get_xml(path, query=query)
-
- def get_meta_xml(self, asset_type_name):
- path = '/meta.v1/{0}'.format(asset_type_name)
- return self.get_xml(path)
-
- def execute_operation(self, asset_type_name, oid, opname):
- path = '/rest-1.v1/Data/{0}/{1}'.format(asset_type_name, oid)
- query = {'op': opname}
- return self.get_xml(path, query=query, postdata={})
-
- def get_attr(self, asset_type_name, oid, attrname, moment=None):
- path = '/rest-1.v1/Data/{0}/{1}/{3}/{2}'.format(asset_type_name, oid, attrname, moment) if moment else '/rest-1.v1/Data/{0}/{1}/{2}'.format(asset_type_name, oid, attrname)
- return self.get_xml(path)
-
- def create_asset(self, asset_type_name, xmldata, context_oid=''):
- body = ElementTree.tostring(xmldata, encoding="utf-8")
- query = {}
- if context_oid:
- query = {'ctx': context_oid}
- path = '/rest-1.v1/Data/{0}'.format(asset_type_name)
- return self.get_xml(path, query=query, postdata=body)
-
- def update_asset(self, asset_type_name, oid, update_doc):
- newdata = ElementTree.tostring(update_doc, encoding='utf-8')
- path = '/rest-1.v1/Data/{0}/{1}'.format(asset_type_name, oid)
- return self.get_xml(path, postdata=newdata)
-
-
- def get_attachment_blob(self, attachment_id, blobdata=None):
- path = '/attachment.v1/{0}'.format(attachment_id)
- exception, body = self.fetch(path, postdata=blobdata)
- if exception:
- raise exception
- return body
-
- set_attachment_blob = get_attachment_blob
-
-
-
-
-
+class V1Server(object):
+ "Accesses a V1 HTTP server as a client of the XML API protocol"
+ def __init__(self, address="localhost", instance="VersionOne.Web", username='', password='', scheme="http",
+ instance_url=None, logparent=None, loglevel=logging.ERROR):
+ if instance_url:
+ self.instance_url = instance_url
+ parsed = urlparse(instance_url)
+ self.address = parsed.netloc
+ self.instance = parsed.path.strip('/')
+ self.scheme = parsed.scheme
+ else:
+ self.address = address
+ self.instance = instance.strip('/')
+ self.scheme = scheme
+ self.instance_url = self.build_url('')
+
+ modulelogname = 'v1pysdk.client'
+ logname = "%s.%s" % (logparent, modulelogname) if logparent else None
+ self.logger = logging.getLogger(logname)
+ self.logger.setLevel(loglevel)
+ base_url = self.build_url('')
+ self.http_client = HttpClient(base_url, username, password)
+
+ def build_url(self, path, query='', fragment='', params=''):
+ "So we dont have to interpolate urls ad-hoc"
+ path = self.instance + '/' + path.strip('/')
+ if isinstance(query, dict):
+ query = urlencode(query)
+ url = urlunparse((self.scheme, self.address, path, params, query, fragment))
+ return url
+
+ def __handle_non_xml_response(self, body, exception, msg, postdata):
+ if exception.code >= 500:
+ # 5XX error codes mean we won't have an XML response to parse
+ self.logger.error("{0} during {1}".format(exception, msg))
+ if postdata is not None:
+ self.logger.error(postdata)
+ raise exception
+
+ def __handle_http_exception(self, exception, document, body):
+ if exception:
+ exception.xmldoc = document
+ if exception.code == 404:
+ raise V1AssetNotFoundError(exception)
+ elif exception.code == 400:
+ raise V1Error('\n' + body)
+ else:
+ raise V1Error(exception)
+
+ def __parse_xml(self, body, exception):
+ if exception:
+ self.__handle_non_xml_response(body, exception, '', postdata=None)
+ document = ElementTree.fromstring(body)
+ self.__handle_http_exception(exception, document, body)
+ return document
+
+ def get_asset_xml(self, asset_type_name, oid, moment=None):
+ path = '/rest-1.v1/Data/{0}/{1}/{2}'.format(asset_type_name, oid,
+ moment) if moment else '/rest-1.v1/Data/{0}/{1}'.format(
+ asset_type_name, oid)
+ url = self.build_url(path)
+ exception, body = self.http_client.get(url)
+ return self.__parse_xml(body, exception)
+
+ def get_query_xml(self, api, asset_type_name, where=None, sel=None):
+ path = '/rest-1.v1/{0}/{1}'.format(api, asset_type_name)
+ query = {}
+ if where is not None:
+ query['Where'] = where
+ if sel is not None:
+ query['sel'] = sel
+ url = self.build_url(path, query)
+ exception, body = self.http_client.get(url)
+ return self.__parse_xml(body, exception)
+
+ def get_meta_xml(self, asset_type_name):
+ path = '/meta.v1/{0}'.format(asset_type_name)
+ url = self.build_url(path)
+ exception, body = self.http_client.get(url)
+ return self.__parse_xml(body, exception)
+
+ def execute_operation(self, asset_type_name, oid, opname):
+ path = '/rest-1.v1/Data/{0}/{1}'.format(asset_type_name, oid)
+ query = {'op': opname}
+ url = self.build_url(path, query)
+ exception, body = self.http_client.post(url, postdata={})
+ return self.__parse_xml(body, exception)
+
+ def get_attr(self, asset_type_name, oid, attrname, moment=None):
+ path = '/rest-1.v1/Data/{0}/{1}/{3}/{2}'.format(asset_type_name, oid, attrname,
+ moment) if moment else '/rest-1.v1/Data/{0}/{1}/{2}'.format(
+ asset_type_name, oid, attrname)
+ url = self.build_url(path)
+ exception, body = self.http_client.get(url)
+ return self.__parse_xml(body, exception)
+
+ def create_asset(self, asset_type_name, xmldata, context_oid=''):
+ body = ElementTree.tostring(xmldata, encoding="utf-8")
+ query = {}
+ if context_oid:
+ query = {'ctx': context_oid}
+ path = '/rest-1.v1/Data/{0}'.format(asset_type_name)
+ url = self.build_url(path, query)
+ exception, body = self.http_client.post(url, body)
+ return self.__parse_xml(body, exception)
+
+ def update_asset(self, asset_type_name, oid, update_doc):
+ newdata = ElementTree.tostring(update_doc, encoding='utf-8')
+ path = '/rest-1.v1/Data/{0}/{1}'.format(asset_type_name, oid)
+ url = self.build_url(path)
+ exception, body = self.http_client.post(url, newdata)
+ return self.__parse_xml(body, exception)
+
+ def get_attachment_blob(self, attachment_id, blobdata=None):
+ path = '/attachment.v1/{0}'.format(attachment_id)
+ #TODO: post or get?
+ exception, body = self.__http_get(path)
+ if exception:
+ raise exception
+ return body
+
+ set_attachment_blob = get_attachment_blob
+
+
+class HttpClient(object):
+ def __init__(self, base_url, username, password):
+ password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm()
+ password_manager.add_password(None, base_url, username, password)
+ handlers = [HandlerClass(password_manager) for HandlerClass in AUTH_HANDLERS]
+ self.opener = urllib2.build_opener(*handlers)
+ self.opener.add_handler(HTTPCookieProcessor())
+
+ def get(self, url):
+ try:
+ request = Request(url)
+ request.add_header("Content-Type", "text/xml;charset=UTF-8")
+ response = self.opener.open(request)
+ body = response.read()
+ return (None, body)
+ except HTTPError, e:
+ if e.code == 401:
+ raise
+ body = e.fp.read()
+ return (e, body)
+
+ def post(self, url, postdata):
+ try:
+ if isinstance(postdata, dict):
+ postdata = urlencode(postdata)
+ request = Request(url, postdata)
+ request.add_header("Content-Type", "text/xml;charset=UTF-8")
+ response = self.opener.open(request)
+ body = response.read()
+ return (None, body)
+ except HTTPError, e:
+ if e.code == 401:
+ raise
+ body = e.fp.read()
+ return (e, body)
diff --git a/v1pysdk/query.py b/v1pysdk/query.py
index 9471626..2fdb9a5 100644
--- a/v1pysdk/query.py
+++ b/v1pysdk/query.py
@@ -5,7 +5,7 @@ class V1Query(object):
"""A fluent query object. Use .select() and .where() to add items to the
select list and the query criteria, then iterate over the object to execute
and use the query results."""
-
+
def __init__(self, asset_class, sel_string=None, filterexpr=None):
"Takes the asset class we will be querying"
self.asset_class = asset_class
@@ -17,7 +17,7 @@ def __init__(self, asset_class, sel_string=None, filterexpr=None):
self.sel_string = sel_string
self.empty_sel = sel_string is None
self.where_string = filterexpr
-
+
def __iter__(self):
"Iterate over the results."
if not self.query_has_run:
@@ -25,7 +25,7 @@ def __iter__(self):
for (result, asof) in self.query_results:
for found_asset in result.findall('Asset'):
yield self.asset_class.from_query_select(found_asset, asof)
-
+
def get_sel_string(self):
if self.sel_string:
return self.sel_string
@@ -36,14 +36,17 @@ def get_where_string(self):
if self.where_string:
terms.append(self.where_string)
return ';'.join(terms)
-
+
def run_single_query(self, url_params={}, api="Data"):
- urlquery = urlencode(url_params)
- urlpath = '/rest-1.v1/{1}/{0}'.format(self.asset_class._v1_asset_type_name, api)
- # warning: tight coupling ahead
- xml = self.asset_class._v1_v1meta.server.get_xml(urlpath, query=urlquery)
+ where = None
+ sel = None
+ if 'where' in url_params:
+ where = url_params['where']
+ if 'sel' in url_params:
+ sel = url_params['sel']
+ xml = self.asset_class._v1_v1meta.server.get_query_xml(api, self.asset_class._v1_asset_type_name, where, sel)
return xml
-
+
def run_query(self):
"Actually hit the server to perform the query"
url_params = {}
@@ -65,12 +68,12 @@ def run_query(self):
xml = self.run_single_query(url_params)
self.query_results.append((xml, None))
self.query_has_run = True
-
+
def select(self, *args, **kw):
"""Add attribute names to the select list for this query. The attributes
in the select list will be returned in the query results, and can be used
without further network traffic"""
-
+
for sel in args:
parts = split_attribute(sel)
for i in range(1, len(parts)):
@@ -79,18 +82,18 @@ def select(self, *args, **kw):
self.sel_list.append(pname)
self.sel_list.append(sel)
return self
-
+
def where(self, terms={}, **kw):
"""Add where terms to the criteria for this query. Right now this method
only allows Equals comparisons."""
self.where_terms.update(terms)
self.where_terms.update(kw)
return self
-
+
def filter(self, filterexpr):
self.where_string = filterexpr
return self
-
+
def asof(self, *asofs):
for asof_list in asofs:
if isinstance(asof_list, str):
@@ -98,14 +101,14 @@ def asof(self, *asofs):
for asof in asof_list:
self.asof_list.append(asof)
return self
-
+
def first(self):
return list(self)[0]
-
+
def set(self, **updatelist):
for found_asset in self:
found_asset.pending(updatelist)
-
+
def __getattr__(self, attrname):
""" Return a sequence of the attribute from all matched results
diff --git a/v1pysdk/tests/__init__.py b/v1pysdk/tests/__init__.py
index e494681..c1fce79 100644
--- a/v1pysdk/tests/__init__.py
+++ b/v1pysdk/tests/__init__.py
@@ -1,3 +1,3 @@
import connect_tests
-import meta_tests
+import string_utils_tests
diff --git a/v1pysdk/tests/client_tests.py b/v1pysdk/tests/client_tests.py
new file mode 100644
index 0000000..1d7b81e
--- /dev/null
+++ b/v1pysdk/tests/client_tests.py
@@ -0,0 +1,116 @@
+from v1pysdk.client import V1Server, HttpClient
+from mock import *
+import unittest
+
+
+class TestV1Server(unittest.TestCase):
+ address = 'local'
+ instance = 'V1'
+ username = 'usr'
+ password = 'pass'
+ scheme = 'https'
+
+ @patch('v1pysdk.client.HttpClient')
+ def setUp(self, http_client):
+ self.http_client = http_client
+ http_client.return_value = self.client = Mock()
+ self.client.get.return_value = (None, 'response')
+ self.client.post.return_value = (None, 'response')
+
+ self.server = V1Server(self.address, self.instance, self.username, self.password, self.scheme)
+
+ def test_constructor(self):
+ self.assertEqual(1, self.http_client.call_count)
+
+ def test_build_url(self):
+ # {'sel': 'Name', 'Where': "AssetState='64','Name='No importa'"}
+ url = self.server.build_url('mypath', {'a': 'b'}, 'myfragment')
+ self.assertEqual('https://local/V1/mypath?a=b#myfragment', url)
+
+ def test_get_asset_xml(self):
+ xml = self.server.get_asset_xml('Story', '123')
+
+ self.client.get.assert_called_once_with('https://local/V1/rest-1.v1/Data/Story/123')
+ self.assertEqual('root', xml.tag)
+
+ def test_get_query_xml(self):
+ xml = self.server.get_query_xml('Data', 'Story', "Name='My Story';AssetState='64'", 'Name,Number')
+
+ self.client.get \
+ .assert_called_once_with(
+ 'https://local/V1/rest-1.v1/Data/Story?sel=Name%2CNumber&Where=Name%3D%27My+Story%27%3BAssetState%3D%2764%27')
+ self.assertEqual('root', xml.tag)
+
+ def test_get_meta_xml(self):
+ xml = self.server.get_meta_xml('Story')
+
+ self.client.get \
+ .assert_called_once_with('https://local/V1/meta.v1/Story')
+ self.assertEqual('root', xml.tag)
+
+ def test_execute_operation(self):
+ xml = self.server.execute_operation('Story', '1234', 'Inactivate')
+
+ self.client.post.assert_called_once_with('https://local/V1/rest-1.v1/Data/Story/1234?op=Inactivate',
+ postdata={})
+ self.assertEqual('root', xml.tag)
+
+ def test_get_attr_without_moment(self):
+ xml = self.server.get_attr(asset_type_name='Story', oid='1234', attrname='AssetState')
+
+ self.client.get.assert_called_once_with('https://local/V1/rest-1.v1/Data/Story/1234/AssetState')
+ self.assertEqual('root', xml.tag)
+
+ def test_get_attr_with_moment(self):
+ xml = self.server.get_attr(asset_type_name='Story', oid='1234', attrname='AssetState', moment='1016')
+
+ self.client.get.assert_called_once_with('https://local/V1/rest-1.v1/Data/Story/1234/1016/AssetState')
+ self.assertEqual('root', xml.tag)
+
+ if __name__ == '__main__':
+ unittest.main()
+
+
+class TestHttpClient(unittest.TestCase):
+ base_url = 'https://localhost/VersionOne/'
+ username = 'usr'
+ password = 'pass'
+
+ @patch('v1pysdk.client.urllib2.HTTPPasswordMgrWithDefaultRealm')
+ @patch('v1pysdk.client.urllib2.build_opener')
+ def setUp(self, build_opener, HTTPPasswordMgr):
+ self.manager = Mock()
+ self.opener = Mock()
+ build_opener.return_value = self.opener
+ HTTPPasswordMgr.return_value = self.manager
+ self.http_client = HttpClient(self.base_url, self.username, self.password)
+
+ def test_constructor(self):
+ self.assertEqual(1, self.opener.add_handler.call_count)
+ self.assertEqual(1, self.manager.add_password.call_count)
+
+ @patch('v1pysdk.client.Request')
+ def test_get(self, request):
+ r = Mock()
+ request.return_value = r
+
+ self.http_client.get('http://get.com')
+
+ request.assert_called_with('http://get.com')
+ r.add_header.assert_called_with('Content-Type', 'text/xml;charset=UTF-8')
+ self.opener.open.assert_called_with(r)
+
+ @patch('v1pysdk.client.Request')
+ def test_post(self, request):
+ r = Mock()
+ request.return_value = r
+ data = Mock()
+
+ self.http_client.post('http://post.com', data)
+
+ request.assert_called_with('http://post.com', data)
+ r.add_header.assert_called_with('Content-Type', 'text/xml;charset=UTF-8')
+ self.opener.open.assert_called_with(r)
+
+ if __name__ == '__main__':
+ unittest.main()
diff --git a/v1pysdk/tests/connect_tests.py b/v1pysdk/tests/connect_tests.py
index a5eb7e9..b76bc71 100644
--- a/v1pysdk/tests/connect_tests.py
+++ b/v1pysdk/tests/connect_tests.py
@@ -1,14 +1,14 @@
from testtools import TestCase
from testtools.matchers import Equals
-from elementtree.ElementTree import parse, fromstring, ElementTree
+from elementtree.ElementTree import fromstring
from v1pysdk.client import *
class TestV1Connection(TestCase):
def test_connect(self, username='admin', password='admin'):
- server = V1Server(address='www14.v1host.com', username=username, password=password,instance='v1sdktesting')
- code, body = server.fetch('/rest-1.v1/Data/Story?sel=Name')
+ client = HttpClient('http://www14.v1host.com/v1sdktesting', username=username, password=password)
+ code, body = client.get('http://www14.v1host.com/v1sdktesting/rest-1.v1/Data/Story?sel=Name')
print "\n\nCode: ", code
print "Body: ", body
elem = fromstring(body)
diff --git a/v1pysdk/v1meta.py b/v1pysdk/v1meta.py
index b75db82..dae8e45 100644
--- a/v1pysdk/v1meta.py
+++ b/v1pysdk/v1meta.py
@@ -10,7 +10,8 @@
from none_deref import NoneDeref
from string_utils import split_attribute
-class V1Meta(object):
+
+class V1Meta(object):
def __init__(self, *args, **kw):
self.server = V1Server(*args, **kw)
self.global_cache = {}
@@ -142,8 +143,8 @@ def get_attr(self, asset_type_name, oid, attrname, moment=None):
dummy_asset.append(xml)
return self.unpack_asset(dummy_asset)[attrname]
- def query(self, asset_type_name, wherestring, selstring):
- return self.server.get_query_xml(asset_type_name, wherestring, selstring)
+ def query(self, asset_type_name, where, sel):
+ return self.server.get_query_xml('Data', asset_type_name, where, sel)
def read_asset(self, asset_type_name, asset_oid, moment=None):
xml = self.server.get_asset_xml(asset_type_name, asset_oid, moment)