Generate proxy methods from resource objects
Each service has a set of Resources which define the fundamental qualities of the remote resources. Because of this, a large portion of the methods on Proxy classes are (or can be) boilerplate. Add a metaclass (two in a row!) that reads the definition of the Proxy class and looks for Resource classes attached to it. It then checks them to see which operations are allowed by looking at the Resource's allow_ flags. Based on that, it generates the standard methods and doc strings from a template and adds them to the class. If a method exists on the class when it is read, the generated method does not overwrite the existing method. Instead, it is attached as ``_generated_{method_name}``. This allows people to either write specific proxy methods and completely ignore the generated method, or to write specialized methods that then delegate action to the generated method. Since this is done as a metaclass at class object creation time, things like sphinx continue to work. One of the results of this is the addition of a reference to each resource class on the proxy object. I've wanted one of those before (I don't remember right now why I wanted it) This makes a change to just a few methods/resources in Server as an example of impact. If we like it we can go through and remove all of the boilerplate methods and leave only methods that are special. openstack.compute.v2._proxy.Proxy.servers is left in place largely because it has a special doc string. I think we could (and should) update the generation to look at the query parameters to find and document in the docstring for list methods what the supported parameters are. This stems from some thinking we had in shade about being able to generate most of the methods that fit the pattern. It's likely we'll want to do that for shade methods as well - but we should actually be able to piggyback shade methods on top of the proxy methods, or at least use a similar approach to reduce most of the boilerplate in the shade layer. Change-Id: I9bee095d90cad25acadbf311d4dd8af2e76ba00a
This commit is contained in:
parent
a99d6f1912
commit
a61b5d25de
|
@ -0,0 +1,150 @@
|
|||
# Copyright 2018 Red Hat, Inc.
|
||||
#
|
||||
# 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.
|
||||
"""Doc and Code templates to be used by the Proxy Metaclass.
|
||||
|
||||
The doc templates and code templates are stored separately because having
|
||||
either of them templated is weird in the first place, but having a doc
|
||||
string inside of a function definition that's inside of a triple-quoted
|
||||
string is just hard on the eyeballs.
|
||||
"""
|
||||
|
||||
_FIND_TEMPLATE = """Find a single {resource_name}
|
||||
|
||||
:param name_or_id: The name or ID of an {resource_name}.
|
||||
:param bool ignore_missing: When set to ``False``
|
||||
:class:`~openstack.exceptions.ResourceNotFound` will be
|
||||
raised when the resource does not exist.
|
||||
When set to ``True``, None will be returned when
|
||||
attempting to find a nonexistent resource.
|
||||
:returns: One :class:`~{resource_class}` or None
|
||||
"""
|
||||
|
||||
_LIST_TEMPLATE = """Retrieve a generator of all {resource_name}
|
||||
|
||||
:param bool details: When ``True``, returns
|
||||
:class:`~{detail_class}` objects,
|
||||
otherwise :class:`~{resource_class}`.
|
||||
*Default: ``True``*
|
||||
:param kwargs \*\*query: Optional query parameters to be sent to limit
|
||||
the flavors being returned.
|
||||
|
||||
:returns: A generator of {resource_name} instances.
|
||||
:rtype: :class:`~{resource_class}`
|
||||
"""
|
||||
|
||||
_DELETE_TEMPLATE = """Delete a {resource_name}
|
||||
|
||||
:param {name}:
|
||||
The value can be either the ID of a {name} or a
|
||||
:class:`~{resource_class}` instance.
|
||||
:param bool ignore_missing:
|
||||
When set to ``False`` :class:`~openstack.exceptions.ResourceNotFound`
|
||||
will be raised when the {name} does not exist.
|
||||
When set to ``True``, no exception will be set when
|
||||
attempting to delete a nonexistent {name}.
|
||||
|
||||
:returns: ``None``
|
||||
"""
|
||||
|
||||
_GET_TEMPLATE = """Get a single {resource_name}
|
||||
|
||||
:param {name}:
|
||||
The value can be the ID of a {name} or a
|
||||
:class:`~{resource_class}` instance.
|
||||
|
||||
:returns: One :class:`~{resource_class}`
|
||||
:raises: :class:`~openstack.exceptions.ResourceNotFound`
|
||||
when no resource can be found.
|
||||
"""
|
||||
|
||||
_CREATE_TEMPLATE = """Create a new {resource_name} from attributes
|
||||
|
||||
:param dict attrs:
|
||||
Keyword arguments which will be used to create a
|
||||
:class:`~{resource_class}`.
|
||||
|
||||
:returns: The results of {resource_name} creation
|
||||
:rtype: :class:`~{resource_class}`
|
||||
"""
|
||||
|
||||
_UPDATE_TEMPLATE = """Update a {resource_name}
|
||||
|
||||
:param {name}:
|
||||
Either the ID of a {resource_name} or a :class:`~{resource_class}`
|
||||
instance.
|
||||
:attrs kwargs:
|
||||
The attributes to update on the {resource_name} represented by
|
||||
``{name}``.
|
||||
|
||||
:returns: The updated server
|
||||
:rtype: :class:`~{resource_class}`
|
||||
"""
|
||||
|
||||
_DOC_TEMPLATES = {
|
||||
'create': _CREATE_TEMPLATE,
|
||||
'delete': _DELETE_TEMPLATE,
|
||||
'find': _FIND_TEMPLATE,
|
||||
'list': _LIST_TEMPLATE,
|
||||
'get': _GET_TEMPLATE,
|
||||
'update': _UPDATE_TEMPLATE,
|
||||
}
|
||||
|
||||
_FIND_SOURCE = """
|
||||
def find(self, name_or_id, ignore_missing=True):
|
||||
return self._find(
|
||||
self.{resource_name}, name_or_id, ignore_missing=ignore_missing)
|
||||
"""
|
||||
|
||||
_CREATE_SOURCE = """
|
||||
def create(self, **attrs):
|
||||
return self._create(self.{resource_name}, **attrs)
|
||||
"""
|
||||
|
||||
_DELETE_SOURCE = """
|
||||
def delete(self, {name}, ignore_missing=True):
|
||||
self._delete(self.{resource_name}, {name}, ignore_missing=ignore_missing)
|
||||
"""
|
||||
|
||||
_GET_SOURCE = """
|
||||
def get(self, {name}):
|
||||
return self._get(self.{resource_name}, {name})
|
||||
"""
|
||||
|
||||
_LIST_SOURCE = """
|
||||
def list(self, details=True, **query):
|
||||
res_cls = self.{detail_name} if details else self.{resource_name}
|
||||
return self._list(res_cls, paginated=True, **query)
|
||||
"""
|
||||
|
||||
_UPDATE_SOURCE = """
|
||||
def update(self, {name}, **attrs):
|
||||
return self._update(self.{resource_name}, {name}, **attrs)
|
||||
"""
|
||||
|
||||
_SOURCE_TEMPLATES = {
|
||||
'create': _CREATE_SOURCE,
|
||||
'delete': _DELETE_SOURCE,
|
||||
'find': _FIND_SOURCE,
|
||||
'list': _LIST_SOURCE,
|
||||
'get': _GET_SOURCE,
|
||||
'update': _UPDATE_SOURCE,
|
||||
}
|
||||
|
||||
|
||||
def get_source_template(action, **kwargs):
|
||||
return _SOURCE_TEMPLATES[action].format(**kwargs)
|
||||
|
||||
|
||||
def get_doc_template(action, **kwargs):
|
||||
return _DOC_TEMPLATES[action].format(**kwargs)
|
|
@ -0,0 +1,124 @@
|
|||
# Copyright 2018 Red Hat, Inc.
|
||||
#
|
||||
# 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.
|
||||
|
||||
# Inspired by code from
|
||||
# https://github.com/micheles/decorator/blob/master/src/decorator.py
|
||||
# which is MIT licensed.
|
||||
|
||||
from openstack._meta import _proxy_templates
|
||||
from openstack import resource
|
||||
|
||||
|
||||
def compile_function(evaldict, action, module, **kwargs):
|
||||
"Make a new functions"
|
||||
|
||||
src = _proxy_templates.get_source_template(action, **kwargs)
|
||||
|
||||
# Ensure each generated block of code has a unique filename for profilers
|
||||
# (such as cProfile) that depend on the tuple of (<filename>,
|
||||
# <definition line>, <function name>) being unique.
|
||||
filename = '<generated-{module}>'.format(module=module)
|
||||
code = compile(src, filename, 'exec')
|
||||
exec(code, evaldict)
|
||||
func = evaldict[action]
|
||||
func.__source__ = src
|
||||
return func
|
||||
|
||||
|
||||
def add_function(dct, func, action, args, name_template='{action}_{name}'):
|
||||
func_name = name_template.format(action=action, **args)
|
||||
# If the class has the function already, don't override it
|
||||
if func_name in dct:
|
||||
func_name = '_generated_' + func_name
|
||||
func.__name__ = func_name
|
||||
func.__qualname__ = func_name
|
||||
func.__doc__ = _proxy_templates.get_doc_template(action, **args)
|
||||
func.__module__ = args['module']
|
||||
dct[func_name] = func
|
||||
|
||||
|
||||
def expand_classname(res):
|
||||
return '{module}.{name}'.format(module=res.__module__, name=res.__name__)
|
||||
|
||||
|
||||
class ProxyMeta(type):
|
||||
"""Metaclass that generates standard methods based on Resources.
|
||||
|
||||
Each service has a set of Resources which define the fundamental
|
||||
qualities of the remote resources. A large portion of the methods
|
||||
on Proxy classes are boilerplate.
|
||||
|
||||
This metaclass reads the definition of the Proxy class and looks for
|
||||
Resource classes attached to it. It then checks them to see which
|
||||
operations are allowed by looking at the ``allow_`` flags. Based on that,
|
||||
it generates the standard methods and adds them to the class.
|
||||
|
||||
If a method exists on the class when it is read, the generated method
|
||||
does not overwrite the existing method. Instead, it is attached as
|
||||
``_generated_{method_name}``. This allows people to either write
|
||||
specific proxy methods and completely ignore the generated method,
|
||||
or to write specialized methods that then delegate action to the generated
|
||||
method.
|
||||
|
||||
Since this is done as a metaclass at class object creation time,
|
||||
things like sphinx continue to work.
|
||||
"""
|
||||
def __new__(meta, name, bases, dct):
|
||||
# Build up a list of resource classes attached to the Proxy
|
||||
resources = {}
|
||||
details = {}
|
||||
for k, v in dct.items():
|
||||
if isinstance(v, type) and issubclass(v, resource.Resource):
|
||||
if v.detail_for:
|
||||
details[v.detail_for.__name__] = v
|
||||
else:
|
||||
resources[v.__name__] = v
|
||||
|
||||
for resource_name, res in resources.items():
|
||||
resource_class = expand_classname(res)
|
||||
detail = details.get(resource_name, res)
|
||||
detail_name = detail.__name__
|
||||
detail_class = expand_classname(detail)
|
||||
|
||||
lower_name = resource_name.lower()
|
||||
plural_name = getattr(res, 'plural_name', lower_name + 's')
|
||||
args = dict(
|
||||
resource_name=resource_name,
|
||||
resource_class=resource_class,
|
||||
name=lower_name,
|
||||
module=res.__module__,
|
||||
detail_name=detail_name,
|
||||
detail_class=detail_class,
|
||||
plural_name=plural_name,
|
||||
)
|
||||
# Generate unbound methods from the template strings.
|
||||
# We have to do a compile step rather than using somthing
|
||||
# like existing function objects wrapped with closures
|
||||
# because of the argument naming pattern for delete and update.
|
||||
# You can't really change an argument name programmatically,
|
||||
# at least not that I've been able to find.
|
||||
# We pass in a copy of the dct dict so that the exec step can
|
||||
# be done in the context of the class the methods will be attached
|
||||
# to. This allows name resolution to work properly.
|
||||
for action in ('create', 'get', 'update', 'delete'):
|
||||
if getattr(res, 'allow_{action}'.format(action=action)):
|
||||
func = compile_function(dct.copy(), action, **args)
|
||||
add_function(dct, func, action, args)
|
||||
if res.allow_list:
|
||||
func = compile_function(dct.copy(), 'find', **args)
|
||||
add_function(dct, func, 'find', args)
|
||||
func = compile_function(dct.copy(), 'list', **args)
|
||||
add_function(dct, func, 'list', args, plural_name)
|
||||
|
||||
return super(ProxyMeta, meta).__new__(meta, name, bases, dct)
|
|
@ -29,96 +29,11 @@ from openstack import resource
|
|||
|
||||
class Proxy(proxy.Proxy):
|
||||
|
||||
def find_extension(self, name_or_id, ignore_missing=True):
|
||||
"""Find a single extension
|
||||
|
||||
:param name_or_id: The name or ID of an extension.
|
||||
:param bool ignore_missing: When set to ``False``
|
||||
:class:`~openstack.exceptions.ResourceNotFound` will be
|
||||
raised when the resource does not exist.
|
||||
When set to ``True``, None will be returned when
|
||||
attempting to find a nonexistent resource.
|
||||
:returns: One :class:`~openstack.compute.v2.extension.Extension` or
|
||||
None
|
||||
"""
|
||||
return self._find(extension.Extension, name_or_id,
|
||||
ignore_missing=ignore_missing)
|
||||
|
||||
def extensions(self):
|
||||
"""Retrieve a generator of extensions
|
||||
|
||||
:returns: A generator of extension instances.
|
||||
:rtype: :class:`~openstack.compute.v2.extension.Extension`
|
||||
"""
|
||||
return self._list(extension.Extension, paginated=False)
|
||||
|
||||
def find_flavor(self, name_or_id, ignore_missing=True):
|
||||
"""Find a single flavor
|
||||
|
||||
:param name_or_id: The name or ID of a flavor.
|
||||
:param bool ignore_missing: When set to ``False``
|
||||
:class:`~openstack.exceptions.ResourceNotFound` will be
|
||||
raised when the resource does not exist.
|
||||
When set to ``True``, None will be returned when
|
||||
attempting to find a nonexistent resource.
|
||||
:returns: One :class:`~openstack.compute.v2.flavor.Flavor` or None
|
||||
"""
|
||||
return self._find(_flavor.Flavor, name_or_id,
|
||||
ignore_missing=ignore_missing)
|
||||
|
||||
def create_flavor(self, **attrs):
|
||||
"""Create a new flavor from attributes
|
||||
|
||||
:param dict attrs: Keyword arguments which will be used to create
|
||||
a :class:`~openstack.compute.v2.flavor.Flavor`,
|
||||
comprised of the properties on the Flavor class.
|
||||
|
||||
:returns: The results of flavor creation
|
||||
:rtype: :class:`~openstack.compute.v2.flavor.Flavor`
|
||||
"""
|
||||
return self._create(_flavor.Flavor, **attrs)
|
||||
|
||||
def delete_flavor(self, flavor, ignore_missing=True):
|
||||
"""Delete a flavor
|
||||
|
||||
:param flavor: The value can be either the ID of a flavor or a
|
||||
:class:`~openstack.compute.v2.flavor.Flavor` instance.
|
||||
:param bool ignore_missing: When set to ``False``
|
||||
:class:`~openstack.exceptions.ResourceNotFound` will be
|
||||
raised when the flavor does not exist.
|
||||
When set to ``True``, no exception will be set when
|
||||
attempting to delete a nonexistent flavor.
|
||||
|
||||
:returns: ``None``
|
||||
"""
|
||||
self._delete(_flavor.Flavor, flavor, ignore_missing=ignore_missing)
|
||||
|
||||
def get_flavor(self, flavor):
|
||||
"""Get a single flavor
|
||||
|
||||
:param flavor: The value can be the ID of a flavor or a
|
||||
:class:`~openstack.compute.v2.flavor.Flavor` instance.
|
||||
|
||||
:returns: One :class:`~openstack.compute.v2.flavor.Flavor`
|
||||
:raises: :class:`~openstack.exceptions.ResourceNotFound`
|
||||
when no resource can be found.
|
||||
"""
|
||||
return self._get(_flavor.Flavor, flavor)
|
||||
|
||||
def flavors(self, details=True, **query):
|
||||
"""Return a generator of flavors
|
||||
|
||||
:param bool details: When ``True``, returns
|
||||
:class:`~openstack.compute.v2.flavor.FlavorDetail` objects,
|
||||
otherwise :class:`~openstack.compute.v2.flavor.Flavor`.
|
||||
*Default: ``True``*
|
||||
:param kwargs \*\*query: Optional query parameters to be sent to limit
|
||||
the flavors being returned.
|
||||
|
||||
:returns: A generator of flavor objects
|
||||
"""
|
||||
flv = _flavor.FlavorDetail if details else _flavor.Flavor
|
||||
return self._list(flv, paginated=True, **query)
|
||||
Extension = extension.Extension
|
||||
Flavor = _flavor.Flavor
|
||||
FlavorDetail = _flavor.FlavorDetail
|
||||
Server = _server.Server
|
||||
ServerDetail = _server.ServerDetail
|
||||
|
||||
def delete_image(self, image, ignore_missing=True):
|
||||
"""Delete an image
|
||||
|
@ -312,18 +227,6 @@ class Proxy(proxy.Proxy):
|
|||
"""
|
||||
return self._get(limits.Limits)
|
||||
|
||||
def create_server(self, **attrs):
|
||||
"""Create a new server from attributes
|
||||
|
||||
:param dict attrs: Keyword arguments which will be used to create
|
||||
a :class:`~openstack.compute.v2.server.Server`,
|
||||
comprised of the properties on the Server class.
|
||||
|
||||
:returns: The results of server creation
|
||||
:rtype: :class:`~openstack.compute.v2.server.Server`
|
||||
"""
|
||||
return self._create(_server.Server, **attrs)
|
||||
|
||||
def delete_server(self, server, ignore_missing=True, force=False):
|
||||
"""Delete a server
|
||||
|
||||
|
@ -343,33 +246,8 @@ class Proxy(proxy.Proxy):
|
|||
server = self._get_resource(_server.Server, server)
|
||||
server.force_delete(self)
|
||||
else:
|
||||
self._delete(_server.Server, server, ignore_missing=ignore_missing)
|
||||
|
||||
def find_server(self, name_or_id, ignore_missing=True):
|
||||
"""Find a single server
|
||||
|
||||
:param name_or_id: The name or ID of a server.
|
||||
:param bool ignore_missing: When set to ``False``
|
||||
:class:`~openstack.exceptions.ResourceNotFound` will be
|
||||
raised when the resource does not exist.
|
||||
When set to ``True``, None will be returned when
|
||||
attempting to find a nonexistent resource.
|
||||
:returns: One :class:`~openstack.compute.v2.server.Server` or None
|
||||
"""
|
||||
return self._find(_server.Server, name_or_id,
|
||||
ignore_missing=ignore_missing)
|
||||
|
||||
def get_server(self, server):
|
||||
"""Get a single server
|
||||
|
||||
:param server: The value can be the ID of a server or a
|
||||
:class:`~openstack.compute.v2.server.Server` instance.
|
||||
|
||||
:returns: One :class:`~openstack.compute.v2.server.Server`
|
||||
:raises: :class:`~openstack.exceptions.ResourceNotFound`
|
||||
when no resource can be found.
|
||||
"""
|
||||
return self._get(_server.Server, server)
|
||||
self._generated_delete_server(
|
||||
server, ignore_missing=ignore_missing)
|
||||
|
||||
def servers(self, details=True, **query):
|
||||
"""Retrieve a generator of servers
|
||||
|
@ -408,8 +286,7 @@ class Proxy(proxy.Proxy):
|
|||
|
||||
:returns: A generator of server instances.
|
||||
"""
|
||||
srv = _server.ServerDetail if details else _server.Server
|
||||
return self._list(srv, paginated=True, **query)
|
||||
return self._generated_servers(details=details, **query)
|
||||
|
||||
def update_server(self, server, **attrs):
|
||||
"""Update a server
|
||||
|
|
|
@ -64,3 +64,5 @@ class FlavorDetail(Flavor):
|
|||
allow_update = False
|
||||
allow_delete = False
|
||||
allow_list = True
|
||||
|
||||
detail_for = Flavor
|
||||
|
|
|
@ -383,3 +383,5 @@ class ServerDetail(Server):
|
|||
allow_update = False
|
||||
allow_delete = False
|
||||
allow_list = True
|
||||
|
||||
detail_for = Server
|
||||
|
|
|
@ -167,7 +167,7 @@ import requestsexceptions
|
|||
import six
|
||||
|
||||
from openstack import _log
|
||||
from openstack import _meta
|
||||
from openstack._meta import connection as _meta
|
||||
from openstack import cloud as _cloud
|
||||
from openstack import config as _config
|
||||
from openstack.config import cloud_region
|
||||
|
|
|
@ -10,7 +10,10 @@
|
|||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import six
|
||||
|
||||
from openstack import _adapter
|
||||
from openstack._meta import proxy as _meta
|
||||
from openstack import exceptions
|
||||
from openstack import resource
|
||||
from openstack import utils
|
||||
|
@ -40,7 +43,7 @@ def _check_resource(strict=False):
|
|||
return wrap
|
||||
|
||||
|
||||
class Proxy(_adapter.OpenStackSDKAdapter):
|
||||
class Proxy(six.with_metaclass(_meta.ProxyMeta, _adapter.OpenStackSDKAdapter)):
|
||||
"""Represents a service."""
|
||||
|
||||
def _get_resource(self, resource_type, value, **attrs):
|
||||
|
|
|
@ -317,6 +317,8 @@ class Resource(object):
|
|||
requires_id = True
|
||||
#: Do responses for this resource have bodies
|
||||
has_body = True
|
||||
#: Is this a detailed version of another Resource
|
||||
detail_for = None
|
||||
|
||||
def __init__(self, _synchronized=False, **attrs):
|
||||
"""The base resource
|
||||
|
|
|
@ -36,7 +36,7 @@ class TestComputeProxy(test_proxy_base.TestProxyBase):
|
|||
|
||||
def test_extensions(self):
|
||||
self.verify_list_no_kwargs(self.proxy.extensions, extension.Extension,
|
||||
paginated=False)
|
||||
paginated=True)
|
||||
|
||||
def test_flavor_create(self):
|
||||
self.verify_create(self.proxy.create_flavor, flavor.Flavor)
|
||||
|
|
Loading…
Reference in New Issue