summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJason Kölker <jason@koelker.net>2011-11-01 13:26:41 -0500
committerJason Kölker <jason@koelker.net>2011-11-01 13:26:41 -0500
commit997c2e8eb0ade364a8920dd085ec0e24f56182fb (patch)
tree5641d99a31163258d25be0af7da3f34b395359fa
parent1a8fa72410cacaf2589544ecc161306de9d13766 (diff)
parent02c95aeb2ffe112f7b60a1d3c53cdde22bc5db4d (diff)
downloadoslo-997c2e8eb0ade364a8920dd085ec0e24f56182fb.tar.gz
oslo-997c2e8eb0ade364a8920dd085ec0e24f56182fb.tar.xz
oslo-997c2e8eb0ade364a8920dd085ec0e24f56182fb.zip
merge in upstream
-rw-r--r--.gitignore8
-rw-r--r--etc/openstack-common.conf.test28
-rw-r--r--openstack/common/__init__.py2
-rw-r--r--openstack/common/exception.py4
-rw-r--r--openstack/common/extensions.py530
-rw-r--r--openstack/common/wsgi.py502
-rw-r--r--run_tests.py293
-rwxr-xr-xrun_tests.sh103
-rw-r--r--tests/unit/extension_stubs.py53
-rw-r--r--tests/unit/extensions/__init__.py15
-rw-r--r--tests/unit/extensions/foxinsocks.py102
-rw-r--r--tests/unit/test_extensions.py504
-rw-r--r--tests/unit/test_wsgi.py413
-rw-r--r--tools/install_venv.py147
-rw-r--r--tools/pip-requires13
-rwxr-xr-xtools/with_venv.sh4
16 files changed, 2651 insertions, 70 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b6415a9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+*.swp
+*.pyc
+*.log
+.openstack-common-venv/
+skeleton.egg-info/
+build/
+dist/
+
diff --git a/etc/openstack-common.conf.test b/etc/openstack-common.conf.test
new file mode 100644
index 0000000..89bdf2d
--- /dev/null
+++ b/etc/openstack-common.conf.test
@@ -0,0 +1,28 @@
+[DEFAULT]
+# Show more verbose log output (sets INFO log level output)
+verbose = True
+
+# Show debugging output in logs (sets DEBUG log level output)
+debug = False
+
+# Address to bind the server to
+bind_host = 0.0.0.0
+
+# Port the bind the server to
+bind_port = 80
+
+# Log to this file. Make sure the user running skeleton-api has
+# permissions to write to this file!
+log_file = /tmp/openstack-common.log
+
+# Send logs to syslog (/dev/log) instead of to file specified by `log_file`
+use_syslog = False
+
+[pipeline:extensions_app_with_filter]
+pipeline = extensions extensions_test_app
+
+[filter:extensions]
+paste.filter_factory = openstack.common.extensions:ExtensionMiddleware.factory
+
+[app:extensions_test_app]
+paste.app_factory = tests.unit.test_extensions:app_factory
diff --git a/openstack/common/__init__.py b/openstack/common/__init__.py
index b22496f..64da808 100644
--- a/openstack/common/__init__.py
+++ b/openstack/common/__init__.py
@@ -15,5 +15,5 @@
# License for the specific language governing permissions and limitations
# under the License.
-# TODO(jaypipes) Code in this module is intended to be ported to the eventual
+# TODO(jaypipes) Code in this module is intended to be ported to the eventual
# openstack-common library
diff --git a/openstack/common/exception.py b/openstack/common/exception.py
index a81355e..ba32da5 100644
--- a/openstack/common/exception.py
+++ b/openstack/common/exception.py
@@ -139,5 +139,9 @@ class OpenstackException(Exception):
return self._error_string
+class MalformedRequestBody(OpenstackException):
+ message = "Malformed message body: %(reason)s"
+
+
class InvalidContentType(OpenstackException):
message = "Invalid content type %(content_type)s"
diff --git a/openstack/common/extensions.py b/openstack/common/extensions.py
new file mode 100644
index 0000000..d2fab36
--- /dev/null
+++ b/openstack/common/extensions.py
@@ -0,0 +1,530 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2011 OpenStack LLC.
+# Copyright 2011 Justin Santa Barbara
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import imp
+import os
+import routes
+import webob.dec
+import webob.exc
+import logging
+from lxml import etree
+
+from openstack.common import exception
+from openstack.common import wsgi
+
+LOG = logging.getLogger('extensions')
+DEFAULT_XMLNS = "http://docs.openstack.org/"
+XMLNS_ATOM = "http://www.w3.org/2005/Atom"
+
+
+class ExtensionDescriptor(object):
+ """Base class that defines the contract for extensions.
+
+ Note that you don't have to derive from this class to have a valid
+ extension; it is purely a convenience.
+
+ """
+
+ def get_name(self):
+ """The name of the extension.
+
+ e.g. 'Fox In Socks'
+
+ """
+ raise NotImplementedError()
+
+ def get_alias(self):
+ """The alias for the extension.
+
+ e.g. 'FOXNSOX'
+
+ """
+ raise NotImplementedError()
+
+ def get_description(self):
+ """Friendly description for the extension.
+
+ e.g. 'The Fox In Socks Extension'
+
+ """
+ raise NotImplementedError()
+
+ def get_namespace(self):
+ """The XML namespace for the extension.
+
+ e.g. 'http://www.fox.in.socks/api/ext/pie/v1.0'
+
+ """
+ raise NotImplementedError()
+
+ def get_updated(self):
+ """The timestamp when the extension was last updated.
+
+ e.g. '2011-01-22T13:25:27-06:00'
+
+ """
+ # NOTE(justinsb): Not sure of the purpose of this is, vs the XML NS
+ raise NotImplementedError()
+
+ def get_resources(self):
+ """List of extensions.ResourceExtension extension objects.
+
+ Resources define new nouns, and are accessible through URLs.
+
+ """
+ resources = []
+ return resources
+
+ def get_actions(self):
+ """List of extensions.ActionExtension extension objects.
+
+ Actions are verbs callable from the API.
+
+ """
+ actions = []
+ return actions
+
+ def get_request_extensions(self):
+ """List of extensions.RequestException extension objects.
+
+ Request extensions are used to handle custom request data.
+
+ """
+ request_exts = []
+ return request_exts
+
+
+class ActionExtensionController(object):
+ def __init__(self, application):
+ self.application = application
+ self.action_handlers = {}
+
+ def add_action(self, action_name, handler):
+ self.action_handlers[action_name] = handler
+
+ def action(self, req, id, body):
+ for action_name, handler in self.action_handlers.iteritems():
+ if action_name in body:
+ return handler(body, req, id)
+ # no action handler found (bump to downstream application)
+ res = self.application
+ return res
+
+
+class ActionExtensionResource(wsgi.Resource):
+
+ def __init__(self, application):
+ controller = ActionExtensionController(application)
+ wsgi.Resource.__init__(self, controller)
+
+ def add_action(self, action_name, handler):
+ self.controller.add_action(action_name, handler)
+
+
+class RequestExtensionController(object):
+
+ def __init__(self, application):
+ self.application = application
+ self.handlers = []
+
+ def add_handler(self, handler):
+ self.handlers.append(handler)
+
+ def process(self, req, *args, **kwargs):
+ res = req.get_response(self.application)
+ # currently request handlers are un-ordered
+ for handler in self.handlers:
+ res = handler(req, res)
+ return res
+
+
+class RequestExtensionResource(wsgi.Resource):
+
+ def __init__(self, application):
+ controller = RequestExtensionController(application)
+ wsgi.Resource.__init__(self, controller)
+
+ def add_handler(self, handler):
+ self.controller.add_handler(handler)
+
+
+class ExtensionsResource(wsgi.Resource):
+
+ def __init__(self, extension_manager):
+ self.extension_manager = extension_manager
+ body_serializers = {'application/xml': ExtensionsXMLSerializer()}
+ serializer = wsgi.ResponseSerializer(body_serializers=body_serializers)
+ super(ExtensionsResource, self).__init__(self, None, serializer)
+
+ def _translate(self, ext):
+ ext_data = {}
+ ext_data['name'] = ext.get_name()
+ ext_data['alias'] = ext.get_alias()
+ ext_data['description'] = ext.get_description()
+ ext_data['namespace'] = ext.get_namespace()
+ ext_data['updated'] = ext.get_updated()
+ ext_data['links'] = [] # TODO(dprince): implement extension links
+ return ext_data
+
+ def index(self, req):
+ extensions = []
+ for _alias, ext in self.extension_manager.extensions.iteritems():
+ extensions.append(self._translate(ext))
+ return dict(extensions=extensions)
+
+ def show(self, req, id):
+ # NOTE(dprince): the extensions alias is used as the 'id' for show
+ ext = self.extension_manager.extensions.get(id, None)
+ if not ext:
+ raise webob.exc.HTTPNotFound(
+ _("Extension with alias %s does not exist") % id)
+
+ return dict(extension=self._translate(ext))
+
+ def delete(self, req, id):
+ raise webob.exc.HTTPNotFound()
+
+ def create(self, req):
+ raise webob.exc.HTTPNotFound()
+
+
+class ExtensionMiddleware(wsgi.Middleware):
+ """Extensions middleware for WSGI."""
+
+ def _action_ext_resources(self, application, ext_mgr, mapper):
+ """Return a dict of ActionExtensionResource-s by collection."""
+ action_resources = {}
+ for action in ext_mgr.get_actions():
+ if not action.collection in action_resources.keys():
+ resource = ActionExtensionResource(application)
+ mapper.connect("/%s/:(id)/action.:(format)" %
+ action.collection,
+ action='action',
+ controller=resource,
+ conditions=dict(method=['POST']))
+ mapper.connect("/%s/:(id)/action" %
+ action.collection,
+ action='action',
+ controller=resource,
+ conditions=dict(method=['POST']))
+ action_resources[action.collection] = resource
+
+ return action_resources
+
+ def _request_ext_resources(self, application, ext_mgr, mapper):
+ """Returns a dict of RequestExtensionResource-s by collection."""
+ request_ext_resources = {}
+ for req_ext in ext_mgr.get_request_extensions():
+ if not req_ext.key in request_ext_resources.keys():
+ resource = RequestExtensionResource(application)
+ mapper.connect(req_ext.url_route + '.:(format)',
+ action='process',
+ controller=resource,
+ conditions=req_ext.conditions)
+
+ mapper.connect(req_ext.url_route,
+ action='process',
+ controller=resource,
+ conditions=req_ext.conditions)
+ request_ext_resources[req_ext.key] = resource
+
+ return request_ext_resources
+
+ def __init__(self, application, ext_mgr):
+ self.ext_mgr = ext_mgr
+
+ mapper = routes.Mapper()
+
+ # extended resources
+ for resource_ext in ext_mgr.get_resources():
+ LOG.debug(_('Extended resource: %s'), resource_ext.collection)
+ controller_resource = wsgi.Resource(resource_ext.controller,
+ resource_ext.deserializer,
+ resource_ext.serializer)
+ self._map_custom_collection_actions(resource_ext, mapper,
+ controller_resource)
+ kargs = dict(controller=controller_resource,
+ collection=resource_ext.collection_actions,
+ member=resource_ext.member_actions)
+ if resource_ext.parent:
+ kargs['parent_resource'] = resource_ext.parent
+ mapper.resource(resource_ext.collection,
+ resource_ext.collection, **kargs)
+
+ # extended actions
+ action_resources = self._action_ext_resources(application, ext_mgr,
+ mapper)
+ for action in ext_mgr.get_actions():
+ LOG.debug(_('Extended action: %s'), action.action_name)
+ resource = action_resources[action.collection]
+ resource.add_action(action.action_name, action.handler)
+
+ # extended requests
+ req_controllers = self._request_ext_resources(application, ext_mgr,
+ mapper)
+ for request_ext in ext_mgr.get_request_extensions():
+ LOG.debug(_('Extended request: %s'), request_ext.key)
+ controller = req_controllers[request_ext.key]
+ controller.add_handler(request_ext.handler)
+
+ self._router = routes.middleware.RoutesMiddleware(self._dispatch,
+ mapper)
+
+ super(ExtensionMiddleware, self).__init__(application)
+
+ def _map_custom_collection_actions(self, resource_ext, mapper,
+ controller_resource):
+ for action, method in resource_ext.collection_actions.iteritems():
+ parent = resource_ext.parent
+ conditions = dict(method=[method])
+ path = "/%s/%s" % (resource_ext.collection, action)
+
+ path_prefix = ""
+ if parent:
+ path_prefix = "/%s/{%s_id}" % (parent["collection_name"],
+ parent["member_name"])
+
+ with mapper.submapper(controller=controller_resource,
+ action=action,
+ path_prefix=path_prefix,
+ conditions=conditions) as submap:
+ submap.connect(path)
+ submap.connect("%s.:(format)" % path)
+
+ @webob.dec.wsgify(RequestClass=wsgi.Request)
+ def __call__(self, req):
+ """Route the incoming request with router."""
+ req.environ['extended.app'] = self.application
+ return self._router
+
+ @staticmethod
+ @webob.dec.wsgify(RequestClass=wsgi.Request)
+ def _dispatch(req):
+ """Dispatch the request.
+
+ Returns the routed WSGI app's response or defers to the extended
+ application.
+
+ """
+ match = req.environ['wsgiorg.routing_args'][1]
+ if not match:
+ return req.environ['extended.app']
+ app = match['controller']
+ return app
+
+
+class ExtensionManager(object):
+ """Load extensions from the configured extension path.
+
+ See nova/tests/api/openstack/extensions/foxinsocks/extension.py for an
+ example extension implementation.
+
+ """
+
+ def __init__(self, path):
+ LOG.debug(_('Initializing extension manager.'))
+
+ self.path = path
+ self.extensions = {}
+ self._load_all_extensions()
+
+ def get_resources(self):
+ """Returns a list of ResourceExtension objects."""
+ resources = []
+ extension_resource = ExtensionsResource(self)
+ res_ext = ResourceExtension('extensions',
+ extension_resource,
+ serializer=extension_resource.serializer)
+ resources.append(res_ext)
+ for alias, ext in self.extensions.iteritems():
+ try:
+ resources.extend(ext.get_resources())
+ except AttributeError:
+ # NOTE(dprince): Extension aren't required to have resource
+ # extensions
+ pass
+ return resources
+
+ def get_actions(self):
+ """Returns a list of ActionExtension objects."""
+ actions = []
+ for alias, ext in self.extensions.iteritems():
+ try:
+ actions.extend(ext.get_actions())
+ except AttributeError:
+ # NOTE(dprince): Extension aren't required to have action
+ # extensions
+ pass
+ return actions
+
+ def get_request_extensions(self):
+ """Returns a list of RequestExtension objects."""
+ request_exts = []
+ for alias, ext in self.extensions.iteritems():
+ try:
+ request_exts.extend(ext.get_request_extensions())
+ except AttributeError:
+ # NOTE(dprince): Extension aren't required to have request
+ # extensions
+ pass
+ return request_exts
+
+ def _check_extension(self, extension):
+ """Checks for required methods in extension objects."""
+ try:
+ LOG.debug(_('Ext name: %s'), extension.get_name())
+ LOG.debug(_('Ext alias: %s'), extension.get_alias())
+ LOG.debug(_('Ext description: %s'), extension.get_description())
+ LOG.debug(_('Ext namespace: %s'), extension.get_namespace())
+ LOG.debug(_('Ext updated: %s'), extension.get_updated())
+ except AttributeError as ex:
+ LOG.exception(_("Exception loading extension: %s"), unicode(ex))
+ return False
+ return True
+
+ def _load_all_extensions(self):
+ """Load extensions from the configured path.
+
+ Load extensions from the configured path. The extension name is
+ constructed from the module_name. If your extension module was named
+ widgets.py the extension class within that module should be
+ 'Widgets'.
+
+ In addition, extensions are loaded from the 'contrib' directory.
+
+ See nova/tests/api/openstack/extensions/foxinsocks.py for an example
+ extension implementation.
+
+ """
+ if os.path.exists(self.path):
+ self._load_all_extensions_from_path(self.path)
+
+ contrib_path = os.path.join(os.path.dirname(__file__), "contrib")
+ if os.path.exists(contrib_path):
+ self._load_all_extensions_from_path(contrib_path)
+
+ def _load_all_extensions_from_path(self, path):
+ for f in os.listdir(path):
+ LOG.debug(_('Loading extension file: %s'), f)
+ mod_name, file_ext = os.path.splitext(os.path.split(f)[-1])
+ ext_path = os.path.join(path, f)
+ if file_ext.lower() == '.py' and not mod_name.startswith('_'):
+ mod = imp.load_source(mod_name, ext_path)
+ ext_name = mod_name[0].upper() + mod_name[1:]
+ new_ext_class = getattr(mod, ext_name, None)
+ if not new_ext_class:
+ LOG.warn(_('Did not find expected name '
+ '"%(ext_name)s" in %(file)s'),
+ {'ext_name': ext_name,
+ 'file': ext_path})
+ continue
+ new_ext = new_ext_class()
+ self.add_extension(new_ext)
+
+ def add_extension(self, ext):
+ # Do nothing if the extension doesn't check out
+ if not self._check_extension(ext):
+ return
+
+ alias = ext.get_alias()
+ LOG.debug(_('Loaded extension: %s'), alias)
+
+ if alias in self.extensions:
+ raise exception.Error("Found duplicate extension: %s" % alias)
+ self.extensions[alias] = ext
+
+
+class RequestExtension(object):
+ """Extend requests and responses of core nova OpenStack API resources.
+
+ Provide a way to add data to responses and handle custom request data
+ that is sent to core nova OpenStack API controllers.
+
+ """
+ def __init__(self, method, url_route, handler):
+ self.url_route = url_route
+ self.handler = handler
+ self.conditions = dict(method=[method])
+ self.key = "%s-%s" % (method, url_route)
+
+
+class ActionExtension(object):
+ """Add custom actions to core nova OpenStack API resources."""
+
+ def __init__(self, collection, action_name, handler):
+ self.collection = collection
+ self.action_name = action_name
+ self.handler = handler
+
+
+class ResourceExtension(object):
+ """Add top level resources to the OpenStack API in nova."""
+
+ def __init__(self, collection, controller, parent=None,
+ collection_actions=None, member_actions=None,
+ deserializer=None, serializer=None):
+ if not collection_actions:
+ collection_actions = {}
+ if not member_actions:
+ member_actions = {}
+ self.collection = collection
+ self.controller = controller
+ self.parent = parent
+ self.collection_actions = collection_actions
+ self.member_actions = member_actions
+ self.deserializer = deserializer
+ self.serializer = serializer
+
+
+class ExtensionsXMLSerializer(wsgi.XMLDictSerializer):
+
+ NSMAP = {None: DEFAULT_XMLNS, 'atom': XMLNS_ATOM}
+
+ def show(self, ext_dict):
+ ext = etree.Element('extension', nsmap=self.NSMAP)
+ self._populate_ext(ext, ext_dict['extension'])
+ return self._to_xml(ext)
+
+ def index(self, exts_dict):
+ exts = etree.Element('extensions', nsmap=self.NSMAP)
+ for ext_dict in exts_dict['extensions']:
+ ext = etree.SubElement(exts, 'extension')
+ self._populate_ext(ext, ext_dict)
+ return self._to_xml(exts)
+
+ def _populate_ext(self, ext_elem, ext_dict):
+ """Populate an extension xml element from a dict."""
+
+ ext_elem.set('name', ext_dict['name'])
+ ext_elem.set('namespace', ext_dict['namespace'])
+ ext_elem.set('alias', ext_dict['alias'])
+ ext_elem.set('updated', ext_dict['updated'])
+ desc = etree.Element('description')
+ desc.text = ext_dict['description']
+ ext_elem.append(desc)
+ for link in ext_dict.get('links', []):
+ elem = etree.SubElement(ext_elem, '{%s}link' % XMLNS_ATOM)
+ elem.set('rel', link['rel'])
+ elem.set('href', link['href'])
+ elem.set('type', link['type'])
+ return ext_elem
+
+ def _to_xml(self, root):
+ """Convert the xml object to an xml string."""
+
+ return etree.tostring(root, encoding='UTF-8')
diff --git a/openstack/common/wsgi.py b/openstack/common/wsgi.py
index 8faa6dc..128ae8c 100644
--- a/openstack/common/wsgi.py
+++ b/openstack/common/wsgi.py
@@ -15,26 +15,30 @@
# License for the specific language governing permissions and limitations
# under the License.
-"""
-Utility methods for working with WSGI servers
-"""
+"""Utility methods for working with WSGI servers."""
-import json
-import logging
-import sys
import datetime
-
import eventlet
import eventlet.wsgi
+
eventlet.patcher.monkey_patch(all=False, socket=True)
+
+import json
+import logging
+import sys
import routes
import routes.middleware
import webob.dec
import webob.exc
+from xml.dom import minidom
+from xml.parsers import expat
from openstack.common import exception
+LOG = logging.getLogger('wsgi')
+
+
class WritableLogger(object):
"""A thin wrapper that responds to `write` and logs."""
@@ -206,65 +210,47 @@ class Router(object):
class Request(webob.Request):
-
"""Add some Openstack API-specific logic to the base webob.Request."""
- def best_match_content_type(self):
- """Determine the requested response content-type."""
- supported = ('application/json',)
- bm = self.accept.best_match(supported)
- return bm or 'application/json'
-
- def get_content_type(self, allowed_content_types):
- """Determine content type of the request body."""
- if not "Content-Type" in self.headers:
- raise exception.InvalidContentType(content_type=None)
-
- content_type = self.content_type
-
- if content_type not in allowed_content_types:
- raise exception.InvalidContentType(content_type=content_type)
- else:
- return content_type
+ default_request_content_types = ('application/json', 'application/xml')
+ default_accept_types = ('application/json', 'application/xml')
+ default_accept_type = 'application/json'
+ def best_match_content_type(self, supported_content_types=None):
+ """Determine the requested response content-type.
-class JSONRequestDeserializer(object):
- def has_body(self, request):
- """
- Returns whether a Webob.Request object will possess an entity body.
+ Based on the query extension then the Accept header.
+ Defaults to default_accept_type if we don't find a preference
- :param request: Webob.Request object
"""
- if 'transfer-encoding' in request.headers:
- return True
- elif request.content_length > 0:
- return True
-
- return False
+ supported_content_types = (supported_content_types or
+ self.default_accept_types)
- def from_json(self, datastring):
- return json.loads(datastring)
+ parts = self.path.rsplit('.', 1)
+ if len(parts) > 1:
+ ctype = 'application/{0}'.format(parts[1])
+ if ctype in supported_content_types:
+ return ctype
- def default(self, request):
- if self.has_body(request):
- return {'body': self.from_json(request.body)}
- else:
- return {}
+ bm = self.accept.best_match(supported_content_types)
+ return bm or self.default_accept_type
+ def get_content_type(self, allowed_content_types=None):
+ """Determine content type of the request body.
-class JSONResponseSerializer(object):
+ Does not do any body introspection, only checks header
- def to_json(self, data):
- def sanitizer(obj):
- if isinstance(obj, datetime.datetime):
- return obj.isoformat()
- return obj
+ """
+ if not "Content-Type" in self.headers:
+ return None
- return json.dumps(data, default=sanitizer)
+ content_type = self.content_type
+ allowed_content_types = (allowed_content_types or
+ self.default_request_content_types)
- def default(self, response, result):
- response.headers.add('Content-Type', 'application/json')
- response.body = self.to_json(result)
+ if content_type not in allowed_content_types:
+ raise exception.InvalidContentType(content_type=content_type)
+ return content_type
class Resource(object):
@@ -284,7 +270,7 @@ class Resource(object):
may raise a webob.exc exception or return a dict, which will be
serialized by requested content type.
"""
- def __init__(self, controller, deserializer, serializer):
+ def __init__(self, controller, deserializer=None, serializer=None):
"""
:param controller: object that implement methods created by routes lib
:param deserializer: object that supports webob request deserialization
@@ -293,33 +279,34 @@ class Resource(object):
through controller-like actions
"""
self.controller = controller
- self.serializer = serializer
- self.deserializer = deserializer
+ self.serializer = serializer or ResponseSerializer()
+ self.deserializer = deserializer or RequestDeserializer()
@webob.dec.wsgify(RequestClass=Request)
def __call__(self, request):
"""WSGI method that controls (de)serialization and method dispatch."""
- action_args = self.get_action_args(request.environ)
- action = action_args.pop('action', None)
- deserialized_params = self.deserialize_request(action, request)
- action_args.update(deserialized_params)
- action_result = self.execute_action(action, request, **action_args)
try:
- return self.serialize_response(action, action_result, request)
+ action, action_args, accept = self.deserialize_request(request)
+ except exception.InvalidContentType:
+ msg = _("Unsupported Content-Type")
+ return webob.exc.HTTPUnsupportedMediaType(explanation=msg)
+ except exception.MalformedRequestBody:
+ msg = _("Malformed request body")
+ return webob.exc.HTTPBadRequest(explanation=msg)
+ action_result = self.execute_action(action, request, **action_args)
+ try:
+ return self.serialize_response(action, action_result, accept)
# return unserializable result (typically a webob exc)
except Exception:
return action_result
- def deserialize_request(self, action, request):
- return self.dispatch(self.deserializer, action, request)
+ def deserialize_request(self, request):
+ return self.deserializer.deserialize(request)
- def serialize_response(self, action, action_result, request):
- response = webob.Response()
- self.dispatch(self.serializer, action, response,
- action_result, request)
- return response
+ def serialize_response(self, action, action_result, accept):
+ return self.serializer.serialize(action_result, accept, action)
def execute_action(self, action, request, **action_args):
return self.dispatch(self.controller, action, request, **action_args)
@@ -351,3 +338,380 @@ class Resource(object):
pass
return args
+
+
+class ActionDispatcher(object):
+ """Maps method name to local methods through action name."""
+
+ def dispatch(self, *args, **kwargs):
+ """Find and call local method."""
+ action = kwargs.pop('action', 'default')
+ action_method = getattr(self, str(action), self.default)
+ return action_method(*args, **kwargs)
+
+ def default(self, data):
+ raise NotImplementedError()
+
+
+class DictSerializer(ActionDispatcher):
+ """Default request body serialization"""
+
+ def serialize(self, data, action='default'):
+ return self.dispatch(data, action=action)
+
+ def default(self, data):
+ return ""
+
+
+class JSONDictSerializer(DictSerializer):
+ """Default JSON request body serialization"""
+
+ def default(self, data):
+ def sanitizer(obj):
+ if isinstance(obj, datetime.datetime):
+ _dtime = obj - datetime.timedelta(microseconds=obj.microsecond)
+ return _dtime.isoformat()
+ return obj
+ return json.dumps(data, default=sanitizer)
+
+
+class XMLDictSerializer(DictSerializer):
+
+ def __init__(self, metadata=None, xmlns=None):
+ """
+ :param metadata: information needed to deserialize xml into
+ a dictionary.
+ :param xmlns: XML namespace to include with serialized xml
+ """
+ super(XMLDictSerializer, self).__init__()
+ self.metadata = metadata or {}
+ self.xmlns = xmlns
+
+ def default(self, data):
+ # We expect data to contain a single key which is the XML root.
+ root_key = data.keys()[0]
+ doc = minidom.Document()
+ node = self._to_xml_node(doc, self.metadata, root_key, data[root_key])
+
+ return self.to_xml_string(node)
+
+ def to_xml_string(self, node, has_atom=False):
+ self._add_xmlns(node, has_atom)
+ return node.toprettyxml(indent=' ', encoding='UTF-8')
+
+ #NOTE (ameade): the has_atom should be removed after all of the
+ # xml serializers and view builders have been updated to the current
+ # spec that required all responses include the xmlns:atom, the has_atom
+ # flag is to prevent current tests from breaking
+ def _add_xmlns(self, node, has_atom=False):
+ if self.xmlns is not None:
+ node.setAttribute('xmlns', self.xmlns)
+ if has_atom:
+ node.setAttribute('xmlns:atom', "http://www.w3.org/2005/Atom")
+
+ def _to_xml_node(self, doc, metadata, nodename, data):
+ """Recursive method to convert data members to XML nodes."""
+ result = doc.createElement(nodename)
+
+ # Set the xml namespace if one is specified
+ # TODO(justinsb): We could also use prefixes on the keys
+ xmlns = metadata.get('xmlns', None)
+ if xmlns:
+ result.setAttribute('xmlns', xmlns)
+
+ #TODO(bcwaldon): accomplish this without a type-check
+ if type(data) is list:
+ collections = metadata.get('list_collections', {})
+ if nodename in collections:
+ metadata = collections[nodename]
+ for item in data:
+ node = doc.createElement(metadata['item_name'])
+ node.setAttribute(metadata['item_key'], str(item))
+ result.appendChild(node)
+ return result
+ singular = metadata.get('plurals', {}).get(nodename, None)
+ if singular is None:
+ if nodename.endswith('s'):
+ singular = nodename[:-1]
+ else:
+ singular = 'item'
+ for item in data:
+ node = self._to_xml_node(doc, metadata, singular, item)
+ result.appendChild(node)
+ #TODO(bcwaldon): accomplish this without a type-check
+ elif type(data) is dict:
+ collections = metadata.get('dict_collections', {})
+ if nodename in collections:
+ metadata = collections[nodename]
+ for k, v in data.items():
+ node = doc.createElement(metadata['item_name'])
+ node.setAttribute(metadata['item_key'], str(k))
+ text = doc.createTextNode(str(v))
+ node.appendChild(text)
+ result.appendChild(node)
+ return result
+ attrs = metadata.get('attributes', {}).get(nodename, {})
+ for k, v in data.items():
+ if k in attrs:
+ result.setAttribute(k, str(v))
+ else:
+ node = self._to_xml_node(doc, metadata, k, v)
+ result.appendChild(node)
+ else:
+ # Type is atom
+ node = doc.createTextNode(str(data))
+ result.appendChild(node)
+ return result
+
+ def _create_link_nodes(self, xml_doc, links):
+ link_nodes = []
+ for link in links:
+ link_node = xml_doc.createElement('atom:link')
+ link_node.setAttribute('rel', link['rel'])
+ link_node.setAttribute('href', link['href'])
+ if 'type' in link:
+ link_node.setAttribute('type', link['type'])
+ link_nodes.append(link_node)
+ return link_nodes
+
+
+class ResponseHeadersSerializer(ActionDispatcher):
+ """Default response headers serialization"""
+
+ def serialize(self, response, data, action):
+ self.dispatch(response, data, action=action)
+
+ def default(self, response, data):
+ response.status_int = 200
+
+
+class ResponseSerializer(object):
+ """Encode the necessary pieces into a response object"""
+
+ def __init__(self, body_serializers=None, headers_serializer=None):
+ self.body_serializers = {
+ 'application/xml': XMLDictSerializer(),
+ 'application/json': JSONDictSerializer(),
+ }
+ self.body_serializers.update(body_serializers or {})
+
+ self.headers_serializer = headers_serializer or \
+ ResponseHeadersSerializer()
+
+ def serialize(self, response_data, content_type, action='default'):
+ """Serialize a dict into a string and wrap in a wsgi.Request object.
+
+ :param response_data: dict produced by the Controller
+ :param content_type: expected mimetype of serialized response body
+
+ """
+ response = webob.Response()
+ self.serialize_headers(response, response_data, action)
+ self.serialize_body(response, response_data, content_type, action)
+ return response
+
+ def serialize_headers(self, response, data, action):
+ self.headers_serializer.serialize(response, data, action)
+
+ def serialize_body(self, response, data, content_type, action):
+ response.headers['Content-Type'] = content_type
+ if data is not None:
+ serializer = self.get_body_serializer(content_type)
+ response.body = serializer.serialize(data, action)
+
+ def get_body_serializer(self, content_type):
+ try:
+ return self.body_serializers[content_type]
+ except (KeyError, TypeError):
+ raise exception.InvalidContentType(content_type=content_type)
+
+
+class RequestHeadersDeserializer(ActionDispatcher):
+ """Default request headers deserializer"""
+
+ def deserialize(self, request, action):
+ return self.dispatch(request, action=action)
+
+ def default(self, request):
+ return {}
+
+
+class RequestDeserializer(object):
+ """Break up a Request object into more useful pieces."""
+
+ def __init__(self, body_deserializers=None, headers_deserializer=None,
+ supported_content_types=None):
+
+ self.supported_content_types = supported_content_types
+
+ self.body_deserializers = {
+ 'application/xml': XMLDeserializer(),
+ 'application/json': JSONDeserializer(),
+ }
+ self.body_deserializers.update(body_deserializers or {})
+
+ self.headers_deserializer = headers_deserializer or \
+ RequestHeadersDeserializer()
+
+ def deserialize(self, request):
+ """Extract necessary pieces of the request.
+
+ :param request: Request object
+ :returns tuple of expected controller action name, dictionary of
+ keyword arguments to pass to the controller, the expected
+ content type of the response
+
+ """
+ action_args = self.get_action_args(request.environ)
+ action = action_args.pop('action', None)
+
+ action_args.update(self.deserialize_headers(request, action))
+ action_args.update(self.deserialize_body(request, action))
+
+ accept = self.get_expected_content_type(request)
+
+ return (action, action_args, accept)
+
+ def deserialize_headers(self, request, action):
+ return self.headers_deserializer.deserialize(request, action)
+
+ def deserialize_body(self, request, action):
+ if not len(request.body) > 0:
+ LOG.debug(_("Empty body provided in request"))
+ return {}
+
+ try:
+ content_type = request.get_content_type()
+ except exception.InvalidContentType:
+ LOG.debug(_("Unrecognized Content-Type provided in request"))
+ raise
+
+ if content_type is None:
+ LOG.debug(_("No Content-Type provided in request"))
+ return {}
+
+ try:
+ deserializer = self.get_body_deserializer(content_type)
+ except exception.InvalidContentType:
+ LOG.debug(_("Unable to deserialize body as provided Content-Type"))
+ raise
+
+ return deserializer.deserialize(request.body, action)
+
+ def get_body_deserializer(self, content_type):
+ try:
+ return self.body_deserializers[content_type]
+ except (KeyError, TypeError):
+ raise exception.InvalidContentType(content_type=content_type)
+
+ def get_expected_content_type(self, request):
+ return request.best_match_content_type(self.supported_content_types)
+
+ def get_action_args(self, request_environment):
+ """Parse dictionary created by routes library."""
+ try:
+ args = request_environment['wsgiorg.routing_args'][1].copy()
+ except Exception:
+ return {}
+
+ try:
+ del args['controller']
+ except KeyError:
+ pass
+
+ try:
+ del args['format']
+ except KeyError:
+ pass
+
+ return args
+
+
+class TextDeserializer(ActionDispatcher):
+ """Default request body deserialization"""
+
+ def deserialize(self, datastring, action='default'):
+ return self.dispatch(datastring, action=action)
+
+ def default(self, datastring):
+ return {}
+
+
+class JSONDeserializer(TextDeserializer):
+
+ def _from_json(self, datastring):
+ try:
+ return json.loads(datastring)
+ except ValueError:
+ msg = _("cannot understand JSON")
+ raise exception.MalformedRequestBody(reason=msg)
+
+ def default(self, datastring):
+ return {'body': self._from_json(datastring)}
+
+
+class XMLDeserializer(TextDeserializer):
+
+ def __init__(self, metadata=None):
+ """
+ :param metadata: information needed to deserialize xml into
+ a dictionary.
+ """
+ super(XMLDeserializer, self).__init__()
+ self.metadata = metadata or {}
+
+ def _from_xml(self, datastring):
+ plurals = set(self.metadata.get('plurals', {}))
+
+ try:
+ node = minidom.parseString(datastring).childNodes[0]
+ return {node.nodeName: self._from_xml_node(node, plurals)}
+ except expat.ExpatError:
+ msg = _("cannot understand XML")
+ raise exception.MalformedRequestBody(reason=msg)
+
+ def _from_xml_node(self, node, listnames):
+ """Convert a minidom node to a simple Python type.
+
+ :param listnames: list of XML node names whose subnodes should
+ be considered list items.
+
+ """
+
+ if len(node.childNodes) == 1 and node.childNodes[0].nodeType == 3:
+ return node.childNodes[0].nodeValue
+ elif node.nodeName in listnames:
+ return [self._from_xml_node(n, listnames) for n in node.childNodes]
+ else:
+ result = dict()
+ for attr in node.attributes.keys():
+ result[attr] = node.attributes[attr].nodeValue
+ for child in node.childNodes:
+ if child.nodeType != node.TEXT_NODE:
+ result[child.nodeName] = self._from_xml_node(child,
+ listnames)
+ return result
+
+ def find_first_child_named(self, parent, name):
+ """Search a nodes children for the first child with a given name"""
+ for node in parent.childNodes:
+ if node.nodeName == name:
+ return node
+ return None
+
+ def find_children_named(self, parent, name):
+ """Return all of a nodes children who have the given name"""
+ for node in parent.childNodes:
+ if node.nodeName == name:
+ yield node
+
+ def extract_text(self, node):
+ """Get the text field contained by the given node"""
+ if len(node.childNodes) == 1:
+ child = node.childNodes[0]
+ if child.nodeType == child.TEXT_NODE:
+ return child.nodeValue
+ return ""
+
+ def default(self, datastring):
+ return {'body': self._from_xml(datastring)}
diff --git a/run_tests.py b/run_tests.py
new file mode 100644
index 0000000..0632a2e
--- /dev/null
+++ b/run_tests.py
@@ -0,0 +1,293 @@
+#!/usr/bin/env python
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2010 OpenStack, LLC
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Colorizer Code is borrowed from Twisted:
+# Copyright (c) 2001-2010 Twisted Matrix Laboratories.
+#
+# 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.
+
+"""
+Unittest runner for openstack
+
+To run all test::
+ python run_tests.py
+
+To run a single test::
+ python run_tests.py test_stores:TestSwiftBackend.test_get
+
+To run a single test module::
+ python run_tests.py test_stores
+"""
+
+import gettext
+import logging
+import os
+import unittest
+import sys
+
+gettext.install('openstack', unicode=1)
+
+from nose import config
+from nose import result
+from nose import core
+
+
+class _AnsiColorizer(object):
+ """
+ A colorizer is an object that loosely wraps around a stream, allowing
+ callers to write text to the stream in a particular color.
+
+ Colorizer classes must implement C{supported()} and C{write(text, color)}.
+ """
+ _colors = dict(black=30, red=31, green=32, yellow=33,
+ blue=34, magenta=35, cyan=36, white=37)
+
+ def __init__(self, stream):
+ self.stream = stream
+
+ def supported(cls, stream=sys.stdout):
+ """
+ A class method that returns True if the current platform supports
+ coloring terminal output using this method. Returns False otherwise.
+ """
+ if not stream.isatty():
+ return False # auto color only on TTYs
+ try:
+ import curses
+ except ImportError:
+ return False
+ else:
+ try:
+ try:
+ return curses.tigetnum("colors") > 2
+ except curses.error:
+ curses.setupterm()
+ return curses.tigetnum("colors") > 2
+ except:
+ raise
+ # guess false in case of error
+ return False
+ supported = classmethod(supported)
+
+ def write(self, text, color):
+ """
+ Write the given text to the stream in the given color.
+
+ @param text: Text to be written to the stream.
+
+ @param color: A string label for a color. e.g. 'red', 'white'.
+ """
+ color = self._colors[color]
+ self.stream.write('\x1b[%s;1m%s\x1b[0m' % (color, text))
+
+
+class _Win32Colorizer(object):
+ """
+ See _AnsiColorizer docstring.
+ """
+ def __init__(self, stream):
+ from win32console import GetStdHandle, STD_OUT_HANDLE, \
+ FOREGROUND_RED, FOREGROUND_BLUE, FOREGROUND_GREEN, \
+ FOREGROUND_INTENSITY
+ red, green, blue, bold = (FOREGROUND_RED, FOREGROUND_GREEN,
+ FOREGROUND_BLUE, FOREGROUND_INTENSITY)
+ self.stream = stream
+ self.screenBuffer = GetStdHandle(STD_OUT_HANDLE)
+ self._colors = {
+ 'normal': red | green | blue,
+ 'red': red | bold,
+ 'green': green | bold,
+ 'blue': blue | bold,
+ 'yellow': red | green | bold,
+ 'magenta': red | blue | bold,
+ 'cyan': green | blue | bold,
+ 'white': red | green | blue | bold}
+
+ def supported(cls, stream=sys.stdout):
+ try:
+ import win32console
+ screenBuffer = win32console.GetStdHandle(
+ win32console.STD_OUT_HANDLE)
+ except ImportError:
+ return False
+ import pywintypes
+ try:
+ screenBuffer.SetConsoleTextAttribute(
+ win32console.FOREGROUND_RED |
+ win32console.FOREGROUND_GREEN |
+ win32console.FOREGROUND_BLUE)
+ except pywintypes.error:
+ return False
+ else:
+ return True
+ supported = classmethod(supported)
+
+ def write(self, text, color):
+ color = self._colors[color]
+ self.screenBuffer.SetConsoleTextAttribute(color)
+ self.stream.write(text)
+ self.screenBuffer.SetConsoleTextAttribute(self._colors['normal'])
+
+
+class _NullColorizer(object):
+ """
+ See _AnsiColorizer docstring.
+ """
+ def __init__(self, stream):
+ self.stream = stream
+
+ def supported(cls, stream=sys.stdout):
+ return True
+ supported = classmethod(supported)
+
+ def write(self, text, color):
+ self.stream.write(text)
+
+
+class OpenstackTestResult(result.TextTestResult):
+ def __init__(self, *args, **kw):
+ result.TextTestResult.__init__(self, *args, **kw)
+ self._last_case = None
+ self.colorizer = None
+ # NOTE(vish, tfukushima): reset stdout for the terminal check
+ stdout = sys.stdout
+ sys.stdout = sys.__stdout__
+ for colorizer in [_Win32Colorizer, _AnsiColorizer, _NullColorizer]:
+ if colorizer.supported():
+ self.colorizer = colorizer(self.stream)
+ break
+ sys.stdout = stdout
+
+ def getDescription(self, test):
+ return str(test)
+
+ # NOTE(vish, tfukushima): copied from unittest with edit to add color
+ def addSuccess(self, test):
+ unittest.TestResult.addSuccess(self, test)
+ if self.showAll:
+ self.colorizer.write("OK", 'green')
+ self.stream.writeln()
+ elif self.dots:
+ self.stream.write('.')
+ self.stream.flush()
+
+ # NOTE(vish, tfukushima): copied from unittest with edit to add color
+ def addFailure(self, test, err):
+ unittest.TestResult.addFailure(self, test, err)
+ if self.showAll:
+ self.colorizer.write("FAIL", 'red')
+ self.stream.writeln()
+ elif self.dots:
+ self.stream.write('F')
+ self.stream.flush()
+
+ # NOTE(vish, tfukushima): copied from unittest with edit to add color
+ def addError(self, test, err):
+ """
+ Overrides normal addError to add support for errorClasses.
+ If the exception is a registered class, the error will be added
+ to the list for that class, not errors.
+ """
+ stream = getattr(self, 'stream', None)
+ ec, ev, tb = err
+ try:
+ exc_info = self._exc_info_to_string(err, test)
+ except TypeError:
+ # This is for compatibility with Python 2.3.
+ exc_info = self._exc_info_to_string(err)
+ for cls, (storage, label, isfail) in self.errorClasses.items():
+ if result.isclass(ec) and issubclass(ec, cls):
+ if isfail:
+ test.passwd = False
+ storage.append((test, exc_info))
+ # Might get patched into a streamless result
+ if stream is not None:
+ if self.showAll:
+ message = [label]
+ detail = result._exception_detail(err[1])
+ if detail:
+ message.append(detail)
+ stream.writeln(": ".join(message))
+ elif self.dots:
+ stream.write(label[:1])
+ return
+ self.errors.append((test, exc_info))
+ test.passed = False
+ if stream is not None:
+ if self.showAll:
+ self.colorizer.write("ERROR", 'red')
+ self.stream.writeln()
+ elif self.dots:
+ stream.write('E')
+
+ def startTest(self, test):
+ unittest.TestResult.startTest(self, test)
+ current_case = test.test.__class__.__name__
+
+ if self.showAll:
+ if current_case != self._last_case:
+ self.stream.writeln(current_case)
+ self._last_case = current_case
+
+ self.stream.write(
+ ' %s' % str(test.test._testMethodName).ljust(60))
+ self.stream.flush()
+
+
+class OpenstackTestRunner(core.TextTestRunner):
+ def _makeResult(self):
+ return OpenstackTestResult(self.stream,
+ self.descriptions,
+ self.verbosity,
+ self.config)
+
+
+if __name__ == '__main__':
+ logger = logging.getLogger()
+ hdlr = logging.StreamHandler()
+ formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
+ hdlr.setFormatter(formatter)
+ logger.addHandler(hdlr)
+ logger.setLevel(logging.DEBUG)
+
+ c = config.Config(stream=sys.stdout,
+ env=os.environ,
+ verbosity=3,
+ plugins=core.DefaultPluginManager())
+
+ runner = OpenstackTestRunner(stream=c.stream,
+ verbosity=c.verbosity,
+ config=c)
+ sys.exit(not core.run(config=c, testRunner=runner))
diff --git a/run_tests.sh b/run_tests.sh
new file mode 100755
index 0000000..2ea7de3
--- /dev/null
+++ b/run_tests.sh
@@ -0,0 +1,103 @@
+#!/bin/bash
+
+function usage {
+ echo "Usage: $0 [OPTION]..."
+ echo "Run Openstack-Common's test suite(s)"
+ echo ""
+ echo " -V, --virtual-env Always use virtualenv. Install automatically if not present"
+ echo " -N, --no-virtual-env Don't use virtualenv. Run tests in local environment"
+ echo " -f, --force Force a clean re-build of the virtual environment. Useful when dependencies have been added."
+ echo " --unittests-only Run unit tests only, exclude functional tests."
+ echo " -p, --pep8 Just run pep8"
+ echo " -h, --help Print this usage message"
+ echo ""
+ echo "Note: with no options specified, the script will try to run the tests in a virtual environment,"
+ echo " If no virtualenv is found, the script will ask if you would like to create one. If you "
+ echo " prefer to run tests NOT in a virtual environment, simply pass the -N option."
+ exit
+}
+
+function process_option {
+ case "$1" in
+ -h|--help) usage;;
+ -V|--virtual-env) let always_venv=1; let never_venv=0;;
+ -N|--no-virtual-env) let always_venv=0; let never_venv=1;;
+ -p|--pep8) let just_pep8=1;;
+ -f|--force) let force=1;;
+ --unittests-only) noseargs="$noseargs --exclude-dir=tests/functional";;
+ *) noseargs="$noseargs $1"
+ esac
+}
+
+venv=.openstack-common-venv
+with_venv=tools/with_venv.sh
+always_venv=0
+never_venv=0
+force=0
+noseargs=
+wrapper=""
+just_pep8=0
+
+for arg in "$@"; do
+ process_option $arg
+done
+
+function run_tests {
+ # Just run the test suites in current environment
+ ${wrapper} rm -f tests.sqlite
+ ${wrapper} $NOSETESTS 2> run_tests.err.log
+}
+
+function run_pep8 {
+ echo "Running pep8 ..."
+ # FIXME(sirp): bzr version-info is not currently pep-8. This was fixed with
+ # lp701898 [1], however, until that version of bzr becomes standard, I'm just
+ # excluding the vcsversion.py file
+ #
+ # [1] https://bugs.launchpad.net/bzr/+bug/701898
+ #
+ PEP8_EXCLUDE=vcsversion.py
+ PEP8_OPTIONS="--exclude=$PEP8_EXCLUDE --repeat --show-pep8 --show-source"
+ PEP8_INCLUDE="bin/* openstack skeleton tools setup.py run_tests.py"
+ ${wrapper} pep8 $PEP8_OPTIONS $PEP8_INCLUDE
+}
+
+
+NOSETESTS="python run_tests.py $noseargs"
+
+if [ $never_venv -eq 0 ]
+then
+ # Remove the virtual environment if --force used
+ if [ $force -eq 1 ]; then
+ echo "Cleaning virtualenv..."
+ rm -rf ${venv}
+ fi
+ if [ -e ${venv} ]; then
+ wrapper="${with_venv}"
+ else
+ if [ $always_venv -eq 1 ]; then
+ # Automatically install the virtualenv
+ python tools/install_venv.py
+ wrapper="${with_venv}"
+ else
+ echo -e "No virtual environment found...create one? (Y/n) \c"
+ read use_ve
+ if [ "x$use_ve" = "xY" -o "x$use_ve" = "x" -o "x$use_ve" = "xy" ]; then
+ # Install the virtualenv and run the test suite in it
+ python tools/install_venv.py
+ wrapper=${with_venv}
+ fi
+ fi
+ fi
+fi
+
+if [ $just_pep8 -eq 1 ]; then
+ run_pep8
+ exit
+fi
+
+run_tests || exit
+
+if [ -z "$noseargs" ]; then
+ run_pep8
+fi
diff --git a/tests/unit/extension_stubs.py b/tests/unit/extension_stubs.py
new file mode 100644
index 0000000..c25f285
--- /dev/null
+++ b/tests/unit/extension_stubs.py
@@ -0,0 +1,53 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+# Copyright 2011 OpenStack LLC.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from openstack.common import wsgi
+
+
+class StubExtension(object):
+
+ def __init__(self, alias="stub_extension"):
+ self.alias = alias
+
+ def get_name(self):
+ return "Stub Extension"
+
+ def get_alias(self):
+ return self.alias
+
+ def get_description(self):
+ return ""
+
+ def get_namespace(self):
+ return ""
+
+ def get_updated(self):
+ return ""
+
+
+class StubBaseAppController(object):
+
+ def index(self, request):
+ return "base app index"
+
+ def show(self, request, id):
+ return {'fort': 'knox'}
+
+ def update(self, request, id, body=None):
+ return {'uneditable': 'original_value'}
+
+ def create_resource(self):
+ return wsgi.Resource(self)
diff --git a/tests/unit/extensions/__init__.py b/tests/unit/extensions/__init__.py
new file mode 100644
index 0000000..848908a
--- /dev/null
+++ b/tests/unit/extensions/__init__.py
@@ -0,0 +1,15 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2011 OpenStack LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
diff --git a/tests/unit/extensions/foxinsocks.py b/tests/unit/extensions/foxinsocks.py
new file mode 100644
index 0000000..a0efd7e
--- /dev/null
+++ b/tests/unit/extensions/foxinsocks.py
@@ -0,0 +1,102 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2011 OpenStack LLC.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import json
+
+from openstack.common import extensions
+from openstack.common import wsgi
+
+
+class FoxInSocksController(object):
+
+ def index(self, request):
+ return "Try to say this Mr. Knox, sir..."
+
+ def create_resource(self):
+ return wsgi.Resource(self)
+
+
+class Foxinsocks(object):
+
+ def __init__(self):
+ pass
+
+ def get_name(self):
+ return "Fox In Socks"
+
+ def get_alias(self):
+ return "FOXNSOX"
+
+ def get_description(self):
+ return "The Fox In Socks Extension"
+
+ def get_namespace(self):
+ return "http://www.fox.in.socks/api/ext/pie/v1.0"
+
+ def get_updated(self):
+ return "2011-01-22T13:25:27-06:00"
+
+ def get_resources(self):
+ resources = []
+ resource = extensions.ResourceExtension('foxnsocks',
+ FoxInSocksController())
+ resources.append(resource)
+ return resources
+
+ def get_actions(self):
+ return [extensions.ActionExtension('dummy_resources',
+ 'FOXNSOX:add_tweedle',
+ self._add_tweedle_handler),
+ extensions.ActionExtension('dummy_resources',
+ 'FOXNSOX:delete_tweedle',
+ self._delete_tweedle_handler)]
+
+ def get_request_extensions(self):
+ request_exts = []
+
+ def _goose_handler(req, res):
+ #NOTE: This only handles JSON responses.
+ # You can use content type header to test for XML.
+ data = json.loads(res.body)
+ data['FOXNSOX:googoose'] = req.GET.get('chewing')
+ res.body = json.dumps(data)
+ return res
+
+ req_ext1 = extensions.RequestExtension('GET', '/dummy_resources/:(id)',
+ _goose_handler)
+ request_exts.append(req_ext1)
+
+ def _bands_handler(req, res):
+ #NOTE: This only handles JSON responses.
+ # You can use content type header to test for XML.
+ data = json.loads(res.body)
+ data['FOXNSOX:big_bands'] = 'Pig Bands!'
+ res.body = json.dumps(data)
+ return res
+
+ req_ext2 = extensions.RequestExtension('GET', '/dummy_resources/:(id)',
+ _bands_handler)
+ request_exts.append(req_ext2)
+ return request_exts
+
+ def _add_tweedle_handler(self, input_dict, req, id):
+ return "Tweedle {0} Added.".format(
+ input_dict['FOXNSOX:add_tweedle']['name'])
+
+ def _delete_tweedle_handler(self, input_dict, req, id):
+ return "Tweedle {0} Deleted.".format(
+ input_dict['FOXNSOX:delete_tweedle']['name'])
diff --git a/tests/unit/test_extensions.py b/tests/unit/test_extensions.py
new file mode 100644
index 0000000..841bf4d
--- /dev/null
+++ b/tests/unit/test_extensions.py
@@ -0,0 +1,504 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+# Copyright 2011 OpenStack LLC.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import json
+from lxml import etree
+import os.path
+import routes
+import unittest
+
+from webtest import TestApp
+
+
+from openstack.common import wsgi
+from openstack.common import config
+from openstack.common import extensions
+from tests.unit.extension_stubs import (StubExtension,
+ StubBaseAppController)
+from openstack.common.extensions import (ExtensionManager,
+ ExtensionMiddleware)
+
+
+test_conf_file = os.path.join(os.path.dirname(__file__), os.pardir,
+ os.pardir, 'etc', 'openstack-common.conf.test')
+extensions_path = os.path.join(os.path.dirname(__file__), "extensions")
+
+NS = "{http://docs.openstack.org/}"
+ATOMNS = "{http://www.w3.org/2005/Atom}"
+
+
+class ExtensionsTestApp(wsgi.Router):
+
+ def __init__(self, options={}):
+ mapper = routes.Mapper()
+ controller = StubBaseAppController()
+ mapper.resource("dummy_resource", "/dummy_resources",
+ controller=controller.create_resource())
+ super(ExtensionsTestApp, self).__init__(mapper)
+
+
+class ResourceExtensionTest(unittest.TestCase):
+
+ class ResourceExtensionController(object):
+
+ def index(self, request):
+ return "resource index"
+
+ def show(self, request, id):
+ return {'data': {'id': id}}
+
+ def custom_member_action(self, request, id):
+ return {'member_action': 'value'}
+
+ def custom_collection_action(self, request, **kwargs):
+ return {'collection': 'value'}
+
+ def test_resource_can_be_added_as_extension(self):
+ res_ext = extensions.ResourceExtension('tweedles',
+ self.ResourceExtensionController())
+ test_app = setup_extensions_test_app(SimpleExtensionManager(res_ext))
+
+ index_response = test_app.get("/tweedles")
+ self.assertEqual(200, index_response.status_int)
+ self.assertEqual("resource index", index_response.json)
+
+ show_response = test_app.get("/tweedles/25266")
+ self.assertEqual({'data': {'id': "25266"}}, show_response.json)
+
+ def test_resource_extension_with_custom_member_action(self):
+ controller = self.ResourceExtensionController()
+ member = {'custom_member_action': "GET"}
+ res_ext = extensions.ResourceExtension('tweedles', controller,
+ member_actions=member)
+ test_app = setup_extensions_test_app(SimpleExtensionManager(res_ext))
+
+ response = test_app.get("/tweedles/some_id/custom_member_action")
+ self.assertEqual(200, response.status_int)
+ self.assertEqual(json.loads(response.body)['member_action'], "value")
+
+ def test_resource_extension_for_get_custom_collection_action(self):
+ controller = self.ResourceExtensionController()
+ collections = {'custom_collection_action': "PUT"}
+ res_ext = extensions.ResourceExtension('tweedles', controller,
+ collection_actions=collections)
+ test_app = setup_extensions_test_app(SimpleExtensionManager(res_ext))
+
+ response = test_app.put("/tweedles/custom_collection_action")
+ self.assertEqual(200, response.status_int)
+ self.assertEqual(json.loads(response.body)['collection'], "value")
+
+ def test_resource_extension_for_put_custom_collection_action(self):
+ controller = self.ResourceExtensionController()
+ collections = {'custom_collection_action': "PUT"}
+ res_ext = extensions.ResourceExtension('tweedles', controller,
+ collection_actions=collections)
+ test_app = setup_extensions_test_app(SimpleExtensionManager(res_ext))
+
+ response = test_app.put("/tweedles/custom_collection_action")
+
+ self.assertEqual(200, response.status_int)
+ self.assertEqual(json.loads(response.body)['collection'], 'value')
+
+ def test_resource_extension_for_post_custom_collection_action(self):
+ controller = self.ResourceExtensionController()
+ collections = {'custom_collection_action': "POST"}
+ res_ext = extensions.ResourceExtension('tweedles', controller,
+ collection_actions=collections)
+ test_app = setup_extensions_test_app(SimpleExtensionManager(res_ext))
+
+ response = test_app.post("/tweedles/custom_collection_action")
+
+ self.assertEqual(200, response.status_int)
+ self.assertEqual(json.loads(response.body)['collection'], 'value')
+
+ def test_resource_extension_for_delete_custom_collection_action(self):
+ controller = self.ResourceExtensionController()
+ collections = {'custom_collection_action': "DELETE"}
+ res_ext = extensions.ResourceExtension('tweedles', controller,
+ collection_actions=collections)
+ test_app = setup_extensions_test_app(SimpleExtensionManager(res_ext))
+
+ response = test_app.delete("/tweedles/custom_collection_action")
+
+ self.assertEqual(200, response.status_int)
+ self.assertEqual(json.loads(response.body)['collection'], 'value')
+
+ def test_resource_ext_for_formatted_req_on_custom_collection_action(self):
+ controller = self.ResourceExtensionController()
+ collections = {'custom_collection_action': "GET"}
+ res_ext = extensions.ResourceExtension('tweedles', controller,
+ collection_actions=collections)
+ test_app = setup_extensions_test_app(SimpleExtensionManager(res_ext))
+
+ response = test_app.get("/tweedles/custom_collection_action.json")
+
+ self.assertEqual(200, response.status_int)
+ self.assertEqual(json.loads(response.body)['collection'], "value")
+
+ def test_resource_ext_for_nested_resource_custom_collection_action(self):
+ controller = self.ResourceExtensionController()
+ collections = {'custom_collection_action': "GET"}
+ parent = dict(collection_name='beetles', member_name='beetle')
+ res_ext = extensions.ResourceExtension('tweedles', controller,
+ collection_actions=collections,
+ parent=parent)
+ test_app = setup_extensions_test_app(SimpleExtensionManager(res_ext))
+
+ response = test_app.get("/beetles/beetle_id"
+ "/tweedles/custom_collection_action")
+
+ self.assertEqual(200, response.status_int)
+ self.assertEqual(json.loads(response.body)['collection'], "value")
+
+ def test_returns_404_for_non_existant_extension(self):
+ test_app = setup_extensions_test_app(SimpleExtensionManager(None))
+
+ response = test_app.get("/non_extistant_extension", status='*')
+
+ self.assertEqual(404, response.status_int)
+
+
+class ActionExtensionTest(unittest.TestCase):
+
+ def setUp(self):
+ super(ActionExtensionTest, self).setUp()
+ self.extension_app = setup_extensions_test_app()
+
+ def test_extended_action_for_adding_extra_data(self):
+ action_name = 'FOXNSOX:add_tweedle'
+ action_params = dict(name='Beetle')
+ req_body = json.dumps({action_name: action_params})
+ response = self.extension_app.post('/dummy_resources/1/action',
+ req_body, content_type='application/json')
+
+ self.assertEqual("Tweedle Beetle Added.", response.json)
+
+ def test_extended_action_for_deleting_extra_data(self):
+ action_name = 'FOXNSOX:delete_tweedle'
+ action_params = dict(name='Bailey')
+ req_body = json.dumps({action_name: action_params})
+ response = self.extension_app.post("/dummy_resources/1/action",
+ req_body, content_type='application/json')
+ self.assertEqual("Tweedle Bailey Deleted.", response.json)
+
+ def test_returns_404_for_non_existant_action(self):
+ non_existant_action = 'blah_action'
+ action_params = dict(name="test")
+ req_body = json.dumps({non_existant_action: action_params})
+
+ response = self.extension_app.post("/dummy_resources/1/action",
+ req_body, content_type='application/json',
+ status='*')
+
+ self.assertEqual(404, response.status_int)
+
+ def test_returns_404_for_non_existant_resource(self):
+ action_name = 'add_tweedle'
+ action_params = dict(name='Beetle')
+ req_body = json.dumps({action_name: action_params})
+
+ response = self.extension_app.post("/asdf/1/action", req_body,
+ content_type='application/json', status='*')
+ self.assertEqual(404, response.status_int)
+
+
+class RequestExtensionTest(unittest.TestCase):
+
+ def test_headers_can_be_extended(self):
+ def extend_headers(req, res):
+ assert req.headers['X-NEW-REQUEST-HEADER'] == "sox"
+ res.headers['X-NEW-RESPONSE-HEADER'] = "response_header_data"
+ return res
+
+ app = self._setup_app_with_request_handler(extend_headers, 'GET')
+ response = app.get("/dummy_resources/1",
+ headers={'X-NEW-REQUEST-HEADER': "sox"})
+
+ self.assertEqual(response.headers['X-NEW-RESPONSE-HEADER'],
+ "response_header_data")
+
+ def test_extend_get_resource_response(self):
+ def extend_response_data(req, res):
+ data = json.loads(res.body)
+ data['FOXNSOX:extended_key'] = req.GET.get('extended_key')
+ res.body = json.dumps(data)
+ return res
+
+ app = self._setup_app_with_request_handler(extend_response_data, 'GET')
+ response = app.get("/dummy_resources/1?extended_key=extended_data")
+
+ self.assertEqual(200, response.status_int)
+
+ response_data = json.loads(response.body)
+ self.assertEqual('extended_data',
+ response_data['FOXNSOX:extended_key'])
+ self.assertEqual('knox', response_data['fort'])
+
+ def test_get_resources(self):
+ app = setup_extensions_test_app()
+
+ response = app.get("/dummy_resources/1?chewing=newblue")
+
+ response_data = json.loads(response.body)
+ self.assertEqual('newblue', response_data['FOXNSOX:googoose'])
+ self.assertEqual("Pig Bands!", response_data['FOXNSOX:big_bands'])
+
+ def test_edit_previously_uneditable_field(self):
+
+ def _update_handler(req, res):
+ data = json.loads(res.body)
+ data['uneditable'] = json.loads(req.body)['uneditable']
+ res.body = json.dumps(data)
+ return res
+
+ base_app = TestApp(setup_base_app())
+ response = base_app.put("/dummy_resources/1",
+ json.dumps({'uneditable': "new_value"}),
+ headers={'Content-Type': "application/json"})
+ self.assertEqual(response.json['uneditable'], "original_value")
+
+ ext_app = self._setup_app_with_request_handler(_update_handler,
+ 'PUT')
+ ext_response = ext_app.put("/dummy_resources/1",
+ json.dumps({'uneditable': "new_value"}),
+ headers={'Content-Type': "application/json"})
+ self.assertEqual(ext_response.json['uneditable'], "new_value")
+
+ def _setup_app_with_request_handler(self, handler, verb):
+ req_ext = extensions.RequestExtension(verb,
+ '/dummy_resources/:(id)', handler)
+ manager = SimpleExtensionManager(None, None, req_ext)
+ return setup_extensions_test_app(manager)
+
+
+class ExtensionManagerTest(unittest.TestCase):
+
+ def test_invalid_extensions_are_not_registered(self):
+
+ class InvalidExtension(object):
+ """
+ This Extension doesn't implement extension methods :
+ get_name, get_description, get_namespace and get_updated
+ """
+ def get_alias(self):
+ return "invalid_extension"
+
+ ext_mgr = ExtensionManager('')
+ ext_mgr.add_extension(InvalidExtension())
+ ext_mgr.add_extension(StubExtension("valid_extension"))
+
+ self.assertTrue('valid_extension' in ext_mgr.extensions)
+ self.assertFalse('invalid_extension' in ext_mgr.extensions)
+
+
+class ExtensionControllerTest(unittest.TestCase):
+
+ def setUp(self):
+ super(ExtensionControllerTest, self).setUp()
+ self.test_app = setup_extensions_test_app()
+
+ def test_index_gets_all_registerd_extensions(self):
+ response = self.test_app.get("/extensions")
+ foxnsox = response.json["extensions"][0]
+
+ self.assertEqual(foxnsox, {
+ 'namespace': 'http://www.fox.in.socks/api/ext/pie/v1.0',
+ 'name': 'Fox In Socks',
+ 'updated': '2011-01-22T13:25:27-06:00',
+ 'description': 'The Fox In Socks Extension',
+ 'alias': 'FOXNSOX',
+ 'links': []
+ }
+ )
+
+ def test_extension_can_be_accessed_by_alias(self):
+ json_response = self.test_app.get("/extensions/FOXNSOX").json
+ foxnsox = json_response['extension']
+
+ self.assertEqual(foxnsox, {
+ 'namespace': 'http://www.fox.in.socks/api/ext/pie/v1.0',
+ 'name': 'Fox In Socks',
+ 'updated': '2011-01-22T13:25:27-06:00',
+ 'description': 'The Fox In Socks Extension',
+ 'alias': 'FOXNSOX',
+ 'links': []
+ }
+ )
+
+ def test_show_returns_not_found_for_non_existant_extension(self):
+ response = self.test_app.get("/extensions/non_existant", status="*")
+
+ self.assertEqual(response.status_int, 404)
+
+ def test_list_extensions_xml(self):
+ response = self.test_app.get("/extensions.xml")
+
+ self.assertEqual(200, response.status_int)
+ root = etree.XML(response.body)
+ self.assertEqual(root.tag.split('extensions')[0], NS)
+
+ # Make sure that Fox in Sox extension is correct.
+ exts = root.findall('{0}extension'.format(NS))
+ fox_ext = exts[0]
+ self.assertEqual(fox_ext.get('name'), 'Fox In Socks')
+ self.assertEqual(fox_ext.get('namespace'),
+ 'http://www.fox.in.socks/api/ext/pie/v1.0')
+ self.assertEqual(fox_ext.get('updated'), '2011-01-22T13:25:27-06:00')
+ self.assertEqual(fox_ext.findtext('{0}description'.format(NS)),
+ 'The Fox In Socks Extension')
+
+ def test_get_extension_xml(self):
+ response = self.test_app.get("/extensions/FOXNSOX.xml")
+ self.assertEqual(200, response.status_int)
+ xml = response.body
+
+ root = etree.XML(xml)
+ self.assertEqual(root.tag.split('extension')[0], NS)
+ self.assertEqual(root.get('alias'), 'FOXNSOX')
+ self.assertEqual(root.get('name'), 'Fox In Socks')
+ self.assertEqual(root.get('namespace'),
+ 'http://www.fox.in.socks/api/ext/pie/v1.0')
+ self.assertEqual(root.get('updated'), '2011-01-22T13:25:27-06:00')
+ self.assertEqual(root.findtext('{0}description'.format(NS)),
+ 'The Fox In Socks Extension')
+
+
+class ExtensionsXMLSerializerTest(unittest.TestCase):
+
+ def test_serialize_extenstion(self):
+ serializer = extensions.ExtensionsXMLSerializer()
+ data = {'extension': {
+ 'name': 'ext1',
+ 'namespace': 'http://docs.rack.com/servers/api/ext/pie/v1.0',
+ 'alias': 'RS-PIE',
+ 'updated': '2011-01-22T13:25:27-06:00',
+ 'description': 'Adds the capability to share an image.',
+ 'links': [{'rel': 'describedby',
+ 'type': 'application/pdf',
+ 'href': 'http://docs.rack.com/servers/api/ext/cs.pdf'},
+ {'rel': 'describedby',
+ 'type': 'application/vnd.sun.wadl+xml',
+ 'href': 'http://docs.rack.com/servers/api/ext/cs.wadl'}]}}
+
+ xml = serializer.serialize(data, 'show')
+ root = etree.XML(xml)
+ ext_dict = data['extension']
+ self.assertEqual(root.findtext('{0}description'.format(NS)),
+ ext_dict['description'])
+
+ for key in ['name', 'namespace', 'alias', 'updated']:
+ self.assertEqual(root.get(key), ext_dict[key])
+
+ link_nodes = root.findall('{0}link'.format(ATOMNS))
+ self.assertEqual(len(link_nodes), 2)
+ for i, link in enumerate(ext_dict['links']):
+ for key, value in link.items():
+ self.assertEqual(link_nodes[i].get(key), value)
+
+ def test_serialize_extensions(self):
+ serializer = extensions.ExtensionsXMLSerializer()
+ data = {"extensions": [{
+ "name": "Public Image Extension",
+ "namespace": "http://foo.com/api/ext/pie/v1.0",
+ "alias": "RS-PIE",
+ "updated": "2011-01-22T13:25:27-06:00",
+ "description": "Adds the capability to share an image.",
+ "links": [{"rel": "describedby",
+ "type": "application/pdf",
+ "type": "application/vnd.sun.wadl+xml",
+ "href": "http://foo.com/api/ext/cs-pie.pdf"},
+ {"rel": "describedby",
+ "type": "application/vnd.sun.wadl+xml",
+ "href": "http://foo.com/api/ext/cs-pie.wadl"}]},
+ {"name": "Cloud Block Storage",
+ "namespace": "http://foo.com/api/ext/cbs/v1.0",
+ "alias": "RS-CBS",
+ "updated": "2011-01-12T11:22:33-06:00",
+ "description": "Allows mounting cloud block storage.",
+ "links": [{"rel": "describedby",
+ "type": "application/pdf",
+ "href": "http://foo.com/api/ext/cs-cbs.pdf"},
+ {"rel": "describedby",
+ "type": "application/vnd.sun.wadl+xml",
+ "href": "http://foo.com/api/ext/cs-cbs.wadl"}]}]}
+
+ xml = serializer.serialize(data, 'index')
+ root = etree.XML(xml)
+ ext_elems = root.findall('{0}extension'.format(NS))
+ self.assertEqual(len(ext_elems), 2)
+ for i, ext_elem in enumerate(ext_elems):
+ ext_dict = data['extensions'][i]
+ self.assertEqual(ext_elem.findtext('{0}description'.format(NS)),
+ ext_dict['description'])
+
+ for key in ['name', 'namespace', 'alias', 'updated']:
+ self.assertEqual(ext_elem.get(key), ext_dict[key])
+
+ link_nodes = ext_elem.findall('{0}link'.format(ATOMNS))
+ self.assertEqual(len(link_nodes), 2)
+ for i, link in enumerate(ext_dict['links']):
+ for key, value in link.items():
+ self.assertEqual(link_nodes[i].get(key), value)
+
+
+def app_factory(global_conf, **local_conf):
+ conf = global_conf.copy()
+ conf.update(local_conf)
+ return ExtensionsTestApp(conf)
+
+
+def setup_base_app():
+ options = {'config_file': test_conf_file}
+ conf, app = config.load_paste_app('extensions_test_app', options, None)
+ return app
+
+
+def setup_extensions_middleware(extension_manager=None):
+ extension_manager = (extension_manager or
+ ExtensionManager(extensions_path))
+ options = {'config_file': test_conf_file}
+ conf, app = config.load_paste_app('extensions_test_app', options, None)
+ return ExtensionMiddleware(app, extension_manager)
+
+
+def setup_extensions_test_app(extension_manager=None):
+ return TestApp(setup_extensions_middleware(extension_manager))
+
+
+class SimpleExtensionManager(object):
+
+ def __init__(self, resource_ext=None, action_ext=None, request_ext=None):
+ self.resource_ext = resource_ext
+ self.action_ext = action_ext
+ self.request_ext = request_ext
+
+ def get_resources(self):
+ resource_exts = []
+ if self.resource_ext:
+ resource_exts.append(self.resource_ext)
+ return resource_exts
+
+ def get_actions(self):
+ action_exts = []
+ if self.action_ext:
+ action_exts.append(self.action_ext)
+ return action_exts
+
+ def get_request_extensions(self):
+ request_extensions = []
+ if self.request_ext:
+ request_extensions.append(self.request_ext)
+ return request_extensions
diff --git a/tests/unit/test_wsgi.py b/tests/unit/test_wsgi.py
new file mode 100644
index 0000000..7a5eaa4
--- /dev/null
+++ b/tests/unit/test_wsgi.py
@@ -0,0 +1,413 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2011 OpenStack LLC.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import unittest
+import webob
+
+from openstack.common import exception
+from openstack.common import wsgi
+
+
+class RequestTest(unittest.TestCase):
+
+ def test_content_type_missing(self):
+ request = wsgi.Request.blank('/tests/123', method='POST')
+ request.body = "<body />"
+ self.assertEqual(None, request.get_content_type())
+
+ def test_content_type_unsupported(self):
+ request = wsgi.Request.blank('/tests/123', method='POST')
+ request.headers["Content-Type"] = "text/html"
+ request.body = "asdf<br />"
+ self.assertRaises(exception.InvalidContentType,
+ request.get_content_type)
+
+ def test_content_type_with_charset(self):
+ request = wsgi.Request.blank('/tests/123')
+ request.headers["Content-Type"] = "application/json; charset=UTF-8"
+ result = request.get_content_type()
+ self.assertEqual(result, "application/json")
+
+ def test_content_type_with_given_content_types(self):
+ request = wsgi.Request.blank('/tests/123')
+ request.headers["Content-Type"] = "application/new-type;"
+ result = request.get_content_type(["application/json",
+ "application/new-type"])
+ self.assertEqual(result, "application/new-type")
+
+ def test_content_type_from_accept_xml(self):
+ request = wsgi.Request.blank('/tests/123')
+ request.headers["Accept"] = "application/xml"
+ result = request.best_match_content_type()
+ self.assertEqual(result, "application/xml")
+
+ request = wsgi.Request.blank('/tests/123')
+ request.headers["Accept"] = "application/json"
+ result = request.best_match_content_type()
+ self.assertEqual(result, "application/json")
+
+ request = wsgi.Request.blank('/tests/123')
+ request.headers["Accept"] = "application/xml, application/json"
+ result = request.best_match_content_type()
+ self.assertEqual(result, "application/json")
+
+ request = wsgi.Request.blank('/tests/123')
+ request.headers["Accept"] = \
+ "application/json; q=0.3, application/xml; q=0.9"
+ result = request.best_match_content_type()
+ self.assertEqual(result, "application/xml")
+
+ def test_content_type_from_query_extension(self):
+ request = wsgi.Request.blank('/tests/123.xml')
+ result = request.best_match_content_type()
+ self.assertEqual(result, "application/xml")
+
+ request = wsgi.Request.blank('/tests/123.json')
+ result = request.best_match_content_type()
+ self.assertEqual(result, "application/json")
+
+ request = wsgi.Request.blank('/tests/123.invalid')
+ result = request.best_match_content_type()
+ self.assertEqual(result, "application/json")
+
+ def test_content_type_accept_and_query_extension(self):
+ request = wsgi.Request.blank('/tests/123.xml')
+ request.headers["Accept"] = "application/json"
+ result = request.best_match_content_type()
+ self.assertEqual(result, "application/xml")
+
+ def test_content_type_accept_default(self):
+ request = wsgi.Request.blank('/tests/123.unsupported')
+ request.headers["Accept"] = "application/unsupported1"
+ result = request.best_match_content_type()
+ self.assertEqual(result, "application/json")
+
+ def test_content_type_accept_with_given_content_types(self):
+ request = wsgi.Request.blank('/tests/123')
+ request.headers["Accept"] = "application/new_type"
+ result = request.best_match_content_type(["application/new_type"])
+ self.assertEqual(result, "application/new_type")
+
+
+class ActionDispatcherTest(unittest.TestCase):
+
+ def test_dispatch(self):
+ serializer = wsgi.ActionDispatcher()
+ serializer.create = lambda x: x
+ self.assertEqual(serializer.dispatch('pants', action='create'),
+ 'pants')
+
+ def test_dispatch_action_None(self):
+ serializer = wsgi.ActionDispatcher()
+ serializer.create = lambda x: x + ' pants'
+ serializer.default = lambda x: x + ' trousers'
+ self.assertEqual(serializer.dispatch('Two', action=None),
+ 'Two trousers')
+
+ def test_dispatch_default(self):
+ serializer = wsgi.ActionDispatcher()
+ serializer.create = lambda x: x + ' pants'
+ serializer.default = lambda x: x + ' trousers'
+ self.assertEqual(serializer.dispatch('Two', action='update'),
+ 'Two trousers')
+
+
+class ResponseHeadersSerializerTest(unittest.TestCase):
+
+ def test_default(self):
+ serializer = wsgi.ResponseHeadersSerializer()
+ response = webob.Response()
+ serializer.serialize(response, {'v': '123'}, 'asdf')
+ self.assertEqual(response.status_int, 200)
+
+ def test_custom(self):
+ class Serializer(wsgi.ResponseHeadersSerializer):
+ def update(self, response, data):
+ response.status_int = 404
+ response.headers['X-Custom-Header'] = data['v']
+ serializer = Serializer()
+ response = webob.Response()
+ serializer.serialize(response, {'v': '123'}, 'update')
+ self.assertEqual(response.status_int, 404)
+ self.assertEqual(response.headers['X-Custom-Header'], '123')
+
+
+class DictSerializerTest(unittest.TestCase):
+
+ def test_dispatch_default(self):
+ serializer = wsgi.DictSerializer()
+ self.assertEqual(serializer.serialize({}, 'NonExistantAction'), '')
+
+
+class XMLDictSerializerTest(unittest.TestCase):
+
+ def test_xml(self):
+ input_dict = dict(servers=dict(a=(2, 3)))
+ expected_xml = """<servers xmlns="asdf">
+ <a>(2,3)</a>
+ </servers>"""
+ serializer = wsgi.XMLDictSerializer(xmlns="asdf")
+ result = serializer.serialize(input_dict)
+ result = result.replace('\n', '').replace(' ', '')
+ expected_xml = expected_xml.replace('\n', '').replace(' ', '')
+ self.assertEqual(result, expected_xml)
+
+
+class JSONDictSerializerTest(unittest.TestCase):
+
+ def test_json(self):
+ input_dict = dict(servers=dict(a=(2, 3)))
+ expected_json = '{"servers":{"a":[2,3]}}'
+ serializer = wsgi.JSONDictSerializer()
+ result = serializer.serialize(input_dict)
+ result = result.replace('\n', '').replace(' ', '')
+ self.assertEqual(result, expected_json)
+
+
+class TextDeserializerTest(unittest.TestCase):
+
+ def test_dispatch_default(self):
+ deserializer = wsgi.TextDeserializer()
+ self.assertEqual(deserializer.deserialize({}, 'update'), {})
+
+
+class JSONDeserializerTest(unittest.TestCase):
+
+ def test_json(self):
+ data = """{"a": {
+ "a1": "1",
+ "a2": "2",
+ "bs": ["1", "2", "3", {"c": {"c1": "1"}}],
+ "d": {"e": "1"},
+ "f": "1"}}"""
+ as_dict = {
+ 'body': {
+ 'a': {
+ 'a1': '1',
+ 'a2': '2',
+ 'bs': ['1', '2', '3', {'c': {'c1': '1'}}],
+ 'd': {'e': '1'},
+ 'f': '1',
+ },
+ },
+ }
+ deserializer = wsgi.JSONDeserializer()
+ self.assertEqual(deserializer.deserialize(data), as_dict)
+
+
+class XMLDeserializerTest(unittest.TestCase):
+
+ def test_xml(self):
+ xml = """
+ <a a1="1" a2="2">
+ <bs><b>1</b><b>2</b><b>3</b><b><c c1="1"/></b></bs>
+ <d><e>1</e></d>
+ <f>1</f>
+ </a>
+ """.strip()
+ as_dict = {
+ 'body': {
+ 'a': {
+ 'a1': '1',
+ 'a2': '2',
+ 'bs': ['1', '2', '3', {'c': {'c1': '1'}}],
+ 'd': {'e': '1'},
+ 'f': '1',
+ },
+ },
+ }
+ metadata = {'plurals': {'bs': 'b', 'ts': 't'}}
+ deserializer = wsgi.XMLDeserializer(metadata=metadata)
+ self.assertEqual(deserializer.deserialize(xml), as_dict)
+
+ def test_xml_empty(self):
+ xml = '<a></a>'
+ as_dict = {"body": {"a": {}}}
+ deserializer = wsgi.XMLDeserializer()
+ self.assertEqual(deserializer.deserialize(xml), as_dict)
+
+
+class RequestHeadersDeserializerTest(unittest.TestCase):
+
+ def test_default(self):
+ deserializer = wsgi.RequestHeadersDeserializer()
+ req = wsgi.Request.blank('/')
+ self.assertEqual(deserializer.deserialize(req, 'nonExistant'), {})
+
+ def test_custom(self):
+ class Deserializer(wsgi.RequestHeadersDeserializer):
+ def update(self, request):
+ return {'a': request.headers['X-Custom-Header']}
+ deserializer = Deserializer()
+ req = wsgi.Request.blank('/')
+ req.headers['X-Custom-Header'] = 'b'
+ self.assertEqual(deserializer.deserialize(req, 'update'), {'a': 'b'})
+
+
+class ResponseSerializerTest(unittest.TestCase):
+
+ def setUp(self):
+ class JSONSerializer(object):
+ def serialize(self, data, action='default'):
+ return 'pew_json'
+
+ class XMLSerializer(object):
+ def serialize(self, data, action='default'):
+ return 'pew_xml'
+
+ class HeadersSerializer(object):
+ def serialize(self, response, data, action):
+ response.status_int = 404
+
+ self.body_serializers = {
+ 'application/json': JSONSerializer(),
+ 'application/XML': XMLSerializer(),
+ }
+
+ self.serializer = wsgi.ResponseSerializer(self.body_serializers,
+ HeadersSerializer())
+
+ def tearDown(self):
+ pass
+
+ def test_get_serializer(self):
+ ctype = 'application/json'
+ self.assertEqual(self.serializer.get_body_serializer(ctype),
+ self.body_serializers[ctype])
+
+ def test_get_serializer_unknown_content_type(self):
+ self.assertRaises(exception.InvalidContentType,
+ self.serializer.get_body_serializer,
+ 'application/unknown')
+
+ def test_serialize_response(self):
+ response = self.serializer.serialize({}, 'application/json')
+ self.assertEqual(response.headers['Content-Type'], 'application/json')
+ self.assertEqual(response.body, 'pew_json')
+ self.assertEqual(response.status_int, 404)
+
+ def test_serialize_response_None(self):
+ response = self.serializer.serialize(None, 'application/json')
+
+ self.assertEqual(response.headers['Content-Type'], 'application/json')
+ self.assertEqual(response.body, '')
+ self.assertEqual(response.status_int, 404)
+
+ def test_serialize_response_dict_to_unknown_content_type(self):
+ self.assertRaises(exception.InvalidContentType,
+ self.serializer.serialize,
+ {}, 'application/unknown')
+
+
+class RequestDeserializerTest(unittest.TestCase):
+
+ def setUp(self):
+ class JSONDeserializer(object):
+ def deserialize(self, data, action='default'):
+ return 'pew_json'
+
+ class XMLDeserializer(object):
+ def deserialize(self, data, action='default'):
+ return 'pew_xml'
+
+ self.body_deserializers = {
+ 'application/json': JSONDeserializer(),
+ 'application/XML': XMLDeserializer(),
+ }
+
+ self.deserializer = wsgi.RequestDeserializer(self.body_deserializers)
+
+ def test_get_deserializer(self):
+ expected = self.deserializer.get_body_deserializer('application/json')
+ self.assertEqual(expected, self.body_deserializers['application/json'])
+
+ def test_get_deserializer_unknown_content_type(self):
+ self.assertRaises(exception.InvalidContentType,
+ self.deserializer.get_body_deserializer,
+ 'application/unknown')
+
+ def test_get_expected_content_type(self):
+ request = wsgi.Request.blank('/')
+ request.headers['Accept'] = 'application/json'
+ self.assertEqual(self.deserializer.get_expected_content_type(request),
+ 'application/json')
+
+ def test_get_action_args(self):
+ env = {
+ 'wsgiorg.routing_args': [None, {
+ 'controller': None,
+ 'format': None,
+ 'action': 'update',
+ 'id': 12,
+ }],
+ }
+
+ expected = {'action': 'update', 'id': 12}
+
+ self.assertEqual(self.deserializer.get_action_args(env), expected)
+
+ def test_deserialize(self):
+ def fake_get_routing_args(request):
+ return {'action': 'create'}
+ self.deserializer.get_action_args = fake_get_routing_args
+
+ request = wsgi.Request.blank('/')
+ request.headers['Accept'] = 'application/xml'
+
+ deserialized = self.deserializer.deserialize(request)
+ expected = ('create', {}, 'application/xml')
+
+ self.assertEqual(expected, deserialized)
+
+
+class ResourceTest(unittest.TestCase):
+
+ def test_dispatch(self):
+ class Controller(object):
+ def index(self, req, pants=None):
+ return pants
+
+ resource = wsgi.Resource(Controller())
+ actual = resource.dispatch(resource.controller,
+ 'index', None, pants='off')
+ expected = 'off'
+ self.assertEqual(actual, expected)
+
+ def test_dispatch_unknown_controller_action(self):
+ class Controller(object):
+ def index(self, req, pants=None):
+ return pants
+
+ resource = wsgi.Resource(Controller())
+ self.assertRaises(AttributeError, resource.dispatch,
+ resource.controller, 'create', None, {})
+
+ def test_malformed_request_body_throws_bad_request(self):
+ resource = wsgi.Resource(None)
+ request = wsgi.Request.blank("/", body="{mal:formed", method='POST',
+ headers={'Content-Type': "application/json"})
+
+ response = resource(request)
+ self.assertEqual(response.status, '400 Bad Request')
+
+ def test_wrong_content_type_throws_unsupported_media_type_error(self):
+ resource = wsgi.Resource(None)
+ request = wsgi.Request.blank("/", body="{some:json}", method='POST',
+ headers={'Content-Type': "xxx"})
+
+ response = resource(request)
+ self.assertEqual(response.status, '415 Unsupported Media Type')
diff --git a/tools/install_venv.py b/tools/install_venv.py
new file mode 100644
index 0000000..6e92378
--- /dev/null
+++ b/tools/install_venv.py
@@ -0,0 +1,147 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2010 United States Government as represented by the
+# Administrator of the National Aeronautics and Space Administration.
+# All Rights Reserved.
+#
+# Copyright 2010 OpenStack LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+"""
+Installation script for openstack common's development virtualenv
+"""
+
+import os
+import subprocess
+import sys
+
+
+ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
+VENV = os.path.join(ROOT, '.openstack-common-venv')
+PIP_REQUIRES = os.path.join(ROOT, 'tools', 'pip-requires')
+
+
+def die(message, *args):
+ print >> sys.stderr, message % args
+ sys.exit(1)
+
+
+def run_command(cmd, redirect_output=True, check_exit_code=True):
+ """
+ Runs a command in an out-of-process shell, returning the
+ output of that command. Working directory is ROOT.
+ """
+ if redirect_output:
+ stdout = subprocess.PIPE
+ else:
+ stdout = None
+
+ proc = subprocess.Popen(cmd, cwd=ROOT, stdout=stdout)
+ output = proc.communicate()[0]
+ if check_exit_code and proc.returncode != 0:
+ die('Command "%s" failed.\n%s', ' '.join(cmd), output)
+ return output
+
+
+HAS_EASY_INSTALL = bool(run_command(['which', 'easy_install'],
+ check_exit_code=False).strip())
+HAS_VIRTUALENV = bool(run_command(['which', 'virtualenv'],
+ check_exit_code=False).strip())
+
+
+def check_dependencies():
+ """Make sure virtualenv is in the path."""
+
+ if not HAS_VIRTUALENV:
+ print 'not found.'
+ # Try installing it via easy_install...
+ if HAS_EASY_INSTALL:
+ print 'Installing virtualenv via easy_install...',
+ if not run_command(['which', 'easy_install']):
+ die('ERROR: virtualenv not found.\n\n'
+ 'Openstack development requires virtualenv, please install'
+ ' it using your favorite package management tool')
+ print 'done.'
+ print 'done.'
+
+
+def create_virtualenv(venv=VENV):
+ """
+ Creates the virtual environment and installs PIP only into the
+ virtual environment
+ """
+ print 'Creating venv...',
+ run_command(['virtualenv', '-q', '--no-site-packages', VENV])
+ print 'done.'
+ print 'Installing pip in virtualenv...',
+ if not run_command(['tools/with_venv.sh', 'easy_install', 'pip']).strip():
+ die("Failed to install pip.")
+ print 'done.'
+
+
+def install_dependencies(venv=VENV):
+ print 'Installing dependencies with pip (this can take a while)...'
+
+ # Install greenlet by hand - just listing it in the requires file does not
+ # get it in stalled in the right order
+ venv_tool = 'tools/with_venv.sh'
+ run_command([venv_tool, 'pip', 'install', '-E', venv, '-r', PIP_REQUIRES],
+ redirect_output=False)
+
+ # Tell the virtual env how to "import openstack-common"
+ py_ver = _detect_python_version(venv)
+ pthfile = os.path.join(venv, "lib", py_ver, "site-packages",
+ "openstack-common.pth")
+ f = open(pthfile, 'w')
+ f.write("%s\n" % ROOT)
+
+
+def _detect_python_version(venv):
+ lib_dir = os.path.join(venv, "lib")
+ for pathname in os.listdir(lib_dir):
+ if pathname.startswith('python'):
+ return pathname
+ raise Exception('Unable to detect Python version')
+
+
+def print_help():
+ help = """
+ Openstack-Common development environment setup is complete.
+
+ Openstack-Common development uses virtualenv to track and manage Python
+ dependencies while in development and testing.
+
+ To activate the Openstack-Common virtualenv for the extent of your
+ current shell session you can run:
+
+ $ source .openstack-common-venv/bin/activate
+
+ Or, if you prefer, you can run commands in the virtualenv on a case by case
+ basis by running:
+
+ $ tools/with_venv.sh <your command>
+
+ Also, make test will automatically use the virtualenv.
+ """
+ print help
+
+
+def main(argv):
+ check_dependencies()
+ create_virtualenv()
+ install_dependencies()
+ print_help()
+
+if __name__ == '__main__':
+ main(sys.argv)
diff --git a/tools/pip-requires b/tools/pip-requires
new file mode 100644
index 0000000..c3262a4
--- /dev/null
+++ b/tools/pip-requires
@@ -0,0 +1,13 @@
+greenlet>=0.3.1
+pep8==0.6.1
+pylint==0.19
+eventlet>=0.9.12
+PasteDeploy
+routes
+webob==1.0.8
+nose
+nose-exclude
+mox==0.5.0
+bzr
+webtest
+lxml==2.3
diff --git a/tools/with_venv.sh b/tools/with_venv.sh
new file mode 100755
index 0000000..f42b577
--- /dev/null
+++ b/tools/with_venv.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+TOOLS=`dirname $0`
+VENV=$TOOLS/../.openstack-common-venv
+source $VENV/bin/activate && $@