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:
Monty Taylor 2018-02-01 12:35:56 -06:00
parent a99d6f1912
commit a61b5d25de
No known key found for this signature in database
GPG Key ID: 7BAE94BC7141A594
11 changed files with 294 additions and 134 deletions

View File

View File

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

124
openstack/_meta/proxy.py Normal file
View File

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

View File

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

View File

@ -64,3 +64,5 @@ class FlavorDetail(Flavor):
allow_update = False
allow_delete = False
allow_list = True
detail_for = Flavor

View File

@ -383,3 +383,5 @@ class ServerDetail(Server):
allow_update = False
allow_delete = False
allow_list = True
detail_for = Server

View File

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

View File

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

View File

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

View File

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