Add a descriptor object for each service proxy

The end-goal here is to be able to do version discovery rather than
relying on config and in-code version defaults for what gets attached to
the connection as the service proxy for a service. However, since we're
currently constructing Adapter objects at instantation time we'd have to
authenticate AND do version discovery on every service when we create a
Connection to be able to do that.

That would be bad.

Add a Descriptor class that creates the Proxy object on-demand. That is,
when someone does "conn.compute", a Proxy will be created and returned.

To support doing that without a ton of duplicate copy-pasta for each
service, add a metaclass to Connection which reads os-service-types and
does the import lookup / BaseProxy fallback as part of Connection class
creation. One of the upsides to this is that we can add docstrings to
the service descriptor objects - meaning that the docs for Connection
actually list the proxy objects.

While we're in here, fix a NOTE in a connection doc string and add a
reference to BaseProxy in the docs so that it'll show up in the docs
too.

Change-Id: I3bef5de60b848146fc8563d853774769d0875c65
This commit is contained in:
Monty Taylor
2018-01-29 14:40:20 -06:00
parent 426a93cd82
commit ff5b6bc0c9
6 changed files with 193 additions and 99 deletions

View File

@@ -73,7 +73,12 @@ OpenStack services.
connection connection
Once you have a *Connection* instance, the following services may be exposed Once you have a *Connection* instance, the following services may be exposed
to you. The combination of your ``CloudRegion`` and the catalog of the cloud to you via the :class:`~openstack.proxy.BaseProxy` interface.
.. autoclass:: openstack.proxy.BaseProxy
:members:
The combination of your ``CloudRegion`` and the catalog of the cloud
in question control which services are exposed, but listed below are the ones in question control which services are exposed, but listed below are the ones
provided by the SDK. provided by the SDK.

126
openstack/_meta.py Normal file
View File

@@ -0,0 +1,126 @@
# 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.
import importlib
import warnings
import os_service_types
from openstack import _log
from openstack import proxy
from openstack import service_description
_logger = _log.setup_logging('openstack')
_service_type_manager = os_service_types.ServiceTypes()
_DOC_TEMPLATE = (
":class:`{class_name}` for {service_type} aka {project}")
_PROXY_TEMPLATE = """Proxy for {service_type} aka {project}
This proxy object could be an instance of
{class_doc_strings}
depending on client configuration and which version of the service is
found on remotely on the cloud.
"""
class ConnectionMeta(type):
def __new__(meta, name, bases, dct):
for service in _service_type_manager.services:
service_type = service['service_type']
if service_type == 'ec2-api':
# NOTE(mordred) It doesn't make any sense to use ec2-api
# from openstacksdk. The credentials API calls are all calls
# on identity endpoints.
continue
desc_class = service_description.ServiceDescription
service_filter_class = _find_service_filter_class(service_type)
descriptor_args = {'service_type': service_type}
if service_filter_class:
desc_class = service_description.OpenStackServiceDescription
descriptor_args['service_filter_class'] = service_filter_class
class_names = service_filter_class._get_proxy_class_names()
if len(class_names) == 1:
doc = _DOC_TEMPLATE.format(
class_name="{service_type} Proxy <{name}>".format(
service_type=service_type, name=class_names[0]),
**service)
else:
class_doc_strings = "\n".join([
":class:`{class_name}`".format(class_name=class_name)
for class_name in class_names])
doc = _PROXY_TEMPLATE.format(
class_doc_strings=class_doc_strings, **service)
else:
descriptor_args['proxy_class'] = proxy.BaseProxy
doc = _DOC_TEMPLATE.format(
class_name='~openstack.proxy.BaseProxy', **service)
descriptor = desc_class(**descriptor_args)
descriptor.__doc__ = doc
dct[service_type.replace('-', '_')] = descriptor
# Register the descriptor class with every known alias. Don't
# add doc strings though - although they are supported, we don't
# want to give anybody any bad ideas. Making a second descriptor
# does not introduce runtime cost as the descriptors all use
# the same _proxies dict on the instance.
for alias_name in _get_aliases(service_type):
if alias_name[-1].isdigit():
continue
alias_descriptor = desc_class(**descriptor_args)
dct[alias_name.replace('-', '_')] = alias_descriptor
return super(ConnectionMeta, meta).__new__(meta, name, bases, dct)
def _get_aliases(service_type, aliases=None):
# We make connection attributes for all official real type names
# and aliases. Three services have names they were called by in
# openstacksdk that are not covered by Service Types Authority aliases.
# Include them here - but take heed, no additional values should ever
# be added to this list.
# that were only used in openstacksdk resource naming.
LOCAL_ALIASES = {
'baremetal': 'bare_metal',
'block_storage': 'block_store',
'clustering': 'cluster',
}
all_types = set(_service_type_manager.get_aliases(service_type))
if aliases:
all_types.update(aliases)
if service_type in LOCAL_ALIASES:
all_types.add(LOCAL_ALIASES[service_type])
return all_types
def _find_service_filter_class(service_type):
package_name = 'openstack.{service_type}'.format(
service_type=service_type).replace('-', '_')
module_name = service_type.replace('-', '_') + '_service'
class_name = ''.join(
[part.capitalize() for part in module_name.split('_')])
try:
import_name = '.'.join([package_name, module_name])
service_filter_module = importlib.import_module(import_name)
except ImportError as e:
# ImportWarning is ignored by default. This warning is here
# as an opt-in for people trying to figure out why something
# didn't work.
warnings.warn(
"Could not import {service_type} service filter: {e}".format(
service_type=service_type, e=str(e)),
ImportWarning)
return None
# There are no cases in which we should have a module but not the class
# inside it.
service_filter_class = getattr(service_filter_module, class_name)
return service_filter_class

View File

@@ -82,11 +82,11 @@ __all__ = [
import warnings import warnings
import keystoneauth1.exceptions import keystoneauth1.exceptions
import os_service_types
import requestsexceptions import requestsexceptions
import six import six
from openstack import _log from openstack import _log
from openstack import _meta
from openstack import config as _config from openstack import config as _config
from openstack import exceptions from openstack import exceptions
from openstack import service_description from openstack import service_description
@@ -128,7 +128,7 @@ def from_config(cloud=None, config=None, options=None, **kwargs):
return Connection(config=config) return Connection(config=config)
class Connection(object): class Connection(six.with_metaclass(_meta.ConnectionMeta)):
def __init__(self, cloud=None, config=None, session=None, def __init__(self, cloud=None, config=None, session=None,
app_name=None, app_version=None, app_name=None, app_version=None,
@@ -219,11 +219,7 @@ class Connection(object):
# to a Resource method's session argument. # to a Resource method's session argument.
self.session._sdk_connection = self self.session._sdk_connection = self
service_type_manager = os_service_types.ServiceTypes() self._proxies = {}
for service in service_type_manager.services:
self.add_service(
service_description.OpenStackServiceDescription(
service, self.config))
def add_service(self, service): def add_service(self, service):
"""Add a service to the Connection. """Add a service to the Connection.
@@ -245,29 +241,16 @@ class Connection(object):
# If we don't have a proxy, just instantiate BaseProxy so that # If we don't have a proxy, just instantiate BaseProxy so that
# we get an adapter. # we get an adapter.
if isinstance(service, six.string_types): if isinstance(service, six.string_types):
service_type = service service = service_description.ServiceDescription(service)
service = service_description.ServiceDescription(service_type)
else:
service_type = service.service_type
proxy_object = service.proxy_class(
session=self.config.get_session(),
task_manager=self.task_manager,
allow_version_hack=True,
service_type=self.config.get_service_type(service_type),
service_name=self.config.get_service_name(service_type),
interface=self.config.get_interface(service_type),
region_name=self.config.region_name,
version=self.config.get_api_version(service_type),
)
# Register the proxy class with every known alias # Register the proxy class with every known alias
for attr_name in service.all_types: for attr_name in service.all_types:
setattr(self, attr_name.replace('-', '_'), proxy_object) setattr(self, attr_name.replace('-', '_'), service)
def authorize(self): def authorize(self):
"""Authorize this Connection """Authorize this Connection
**NOTE**: This method is optional. When an application makes a call .. note:: This method is optional. When an application makes a call
to any OpenStack service, this method allows you to request to any OpenStack service, this method allows you to request
a token manually before attempting to do anything else. a token manually before attempting to do anything else.

View File

@@ -41,6 +41,7 @@ def _check_resource(strict=False):
class BaseProxy(_adapter.OpenStackSDKAdapter): class BaseProxy(_adapter.OpenStackSDKAdapter):
"""Represents a service."""
def _get_resource(self, resource_type, value, **attrs): def _get_resource(self, resource_type, value, **attrs):
"""Get a resource object to work on """Get a resource object to work on

View File

@@ -17,7 +17,6 @@ __all__ = [
] ]
import importlib import importlib
import warnings
import os_service_types import os_service_types
@@ -28,26 +27,6 @@ _logger = _log.setup_logging('openstack')
_service_type_manager = os_service_types.ServiceTypes() _service_type_manager = os_service_types.ServiceTypes()
def _get_all_types(service_type, aliases=None):
# We make connection attributes for all official real type names
# and aliases. Three services have names they were called by in
# openstacksdk that are not covered by Service Types Authority aliases.
# Include them here - but take heed, no additional values should ever
# be added to this list.
# that were only used in openstacksdk resource naming.
LOCAL_ALIASES = {
'baremetal': 'bare_metal',
'block_storage': 'block_store',
'clustering': 'cluster',
}
all_types = set(_service_type_manager.get_all_types(service_type))
if aliases:
all_types.update(aliases)
if service_type in LOCAL_ALIASES:
all_types.add(LOCAL_ALIASES[service_type])
return all_types
class ServiceDescription(object): class ServiceDescription(object):
#: Proxy class for this service #: Proxy class for this service
@@ -85,11 +64,13 @@ class ServiceDescription(object):
Optional list of aliases, if there is more than one name that might Optional list of aliases, if there is more than one name that might
be used to register the service in the catalog. be used to register the service in the catalog.
""" """
self.service_type = service_type self.service_type = service_type or self.service_type
self.proxy_class = proxy_class or self.proxy_class self.proxy_class = proxy_class or self.proxy_class
self.all_types = _get_all_types(service_type, aliases) if self.proxy_class:
self._validate_proxy_class()
self._validate_proxy_class() self.aliases = aliases or self.aliases
self.all_types = [service_type] + self.aliases
self._proxy = None
def _validate_proxy_class(self): def _validate_proxy_class(self):
if not issubclass(self.proxy_class, proxy.BaseProxy): if not issubclass(self.proxy_class, proxy.BaseProxy):
@@ -98,10 +79,36 @@ class ServiceDescription(object):
module=self.proxy_class.__module__, module=self.proxy_class.__module__,
proxy_class=self.proxy_class.__name__)) proxy_class=self.proxy_class.__name__))
def get_proxy_class(self, config):
return self.proxy_class
def __get__(self, instance, owner):
if instance is None:
return self
if self.service_type not in instance._proxies:
config = instance.config
proxy_class = self.get_proxy_class(config)
instance._proxies[self.service_type] = proxy_class(
session=instance.config.get_session(),
task_manager=instance.task_manager,
allow_version_hack=True,
service_type=config.get_service_type(self.service_type),
service_name=config.get_service_name(self.service_type),
interface=config.get_interface(self.service_type),
region_name=config.region_name,
version=config.get_api_version(self.service_type)
)
return instance._proxies[self.service_type]
def __set__(self, instance, value):
raise AttributeError('Service Descriptors cannot be set')
def __delete__(self, instance):
raise AttributeError('Service Descriptors cannot be deleted')
class OpenStackServiceDescription(ServiceDescription): class OpenStackServiceDescription(ServiceDescription):
def __init__(self, service_filter_class, *args, **kwargs):
def __init__(self, service, config):
"""Official OpenStack ServiceDescription. """Official OpenStack ServiceDescription.
The OpenStackServiceDescription class is a helper class for The OpenStackServiceDescription class is a helper class for
@@ -111,57 +118,19 @@ class OpenStackServiceDescription(ServiceDescription):
It finds the proxy_class by looking in the openstacksdk tree for It finds the proxy_class by looking in the openstacksdk tree for
appropriately named modules. appropriately named modules.
:param dict service: :param service_filter_class:
A service dict as found in `os_service_types.ServiceTypes.services` A subclass of :class:`~openstack.service_filter.ServiceFilter`
:param openstack.config.cloud_region.CloudRegion config:
ConfigRegion for the connection.
""" """
super(OpenStackServiceDescription, self).__init__( super(OpenStackServiceDescription, self).__init__(*args, **kwargs)
service['service_type']) self._service_filter_class = service_filter_class
self.config = config
service_filter = self._get_service_filter()
if service_filter:
module_name = service_filter.get_module() + "._proxy"
module = importlib.import_module(module_name)
self.proxy_class = getattr(module, "Proxy")
def _get_service_filter(self): def get_proxy_class(self, config):
service_filter_class = None
for service_type in self.all_types:
service_filter_class = self._find_service_filter_class()
if service_filter_class:
break
if not service_filter_class:
return None
# TODO(mordred) Replace this with proper discovery # TODO(mordred) Replace this with proper discovery
version_string = self.config.get_api_version(self.service_type) version_string = config.get_api_version(self.service_type)
version = None version = None
if version_string: if version_string:
version = 'v{version}'.format(version=version_string[0]) version = 'v{version}'.format(version=version_string[0])
return service_filter_class(version=version) service_filter = self._service_filter_class(version=version)
module_name = service_filter.get_module() + "._proxy"
def _find_service_filter_class(self): module = importlib.import_module(module_name)
package_name = 'openstack.{service_type}'.format( return getattr(module, "Proxy")
service_type=self.service_type).replace('-', '_')
module_name = self.service_type.replace('-', '_') + '_service'
class_name = ''.join(
[part.capitalize() for part in module_name.split('_')])
try:
import_name = '.'.join([package_name, module_name])
service_filter_module = importlib.import_module(import_name)
except ImportError as e:
# ImportWarning is ignored by default. This warning is here
# as an opt-in for people trying to figure out why something
# didn't work.
warnings.warn(
"Could not import {service_type} service filter: {e}".format(
service_type=self.service_type, e=str(e)),
ImportWarning)
return None
service_filter_class = getattr(service_filter_module, class_name, None)
if not service_filter_class:
_logger.warn(
'Unable to find class %s in module for service %s',
class_name, self.service_type)
return None
return service_filter_class

View File

@@ -92,6 +92,16 @@ class ServiceFilter(dict):
self['api_version'] = api_version self['api_version'] = api_version
self['requires_project_id'] = requires_project_id self['requires_project_id'] = requires_project_id
@classmethod
def _get_proxy_class_names(cls):
names = []
module_name = ".".join(cls.__module__.split('.')[:-1])
for version in cls.valid_versions:
names.append("{module}.{version}._proxy.Proxy".format(
module=module_name,
version=version.module))
return names
@property @property
def service_type(self): def service_type(self):
return self['service_type'] return self['service_type']