Implements 'microversions' api type - Part 2

New decorator "novaclient.api_versions.wraps" replaces original method with
substitution. This substitution searches for methods which desire specified
api version.

Also, this patch updates novaclient shell to discover versioned methods and
arguments.

Related to bp api-microversion-support
Co-Authored-By: Alex Xu <hejie.xu@intel.com>
Change-Id: I1939c19664e58e2def684380d64c465dc1cfc132
This commit is contained in:
Andrey Kurilin 2015-04-02 19:28:02 +03:00 committed by He Jie Xu
parent 169b8a08ce
commit 936cf572df
9 changed files with 367 additions and 10 deletions

View File

@ -11,6 +11,7 @@
# License for the specific language governing permissions and limitations
# under the License.
import functools
import logging
import os
import pkgutil
@ -20,6 +21,7 @@ from oslo_utils import strutils
from novaclient import exceptions
from novaclient.i18n import _, _LW
from novaclient import utils
LOG = logging.getLogger(__name__)
if not LOG.handlers:
@ -29,6 +31,7 @@ if not LOG.handlers:
# key is a deprecated version and value is an alternative version.
DEPRECATED_VERSIONS = {"1.1": "2"}
_SUBSTITUTIONS = {}
_type_error_msg = _("'%(other)s' should be an instance of '%(cls)s'")
@ -150,6 +153,31 @@ class APIVersion(object):
return "%s.%s" % (self.ver_major, self.ver_minor)
class VersionedMethod(object):
def __init__(self, name, start_version, end_version, func):
"""Versioning information for a single method
:param name: Name of the method
:param start_version: Minimum acceptable version
:param end_version: Maximum acceptable_version
:param func: Method to call
Minimum and maximums are inclusive
"""
self.name = name
self.start_version = start_version
self.end_version = end_version
self.func = func
def __str__(self):
return ("Version Method %s: min: %s, max: %s"
% (self.name, self.start_version, self.end_version))
def __repr__(self):
return "<VersionedMethod %s>" % self.name
def get_available_major_versions():
# NOTE(andreykurilin): available clients version should not be
# hardcoded, so let's discover them.
@ -206,3 +234,44 @@ def update_headers(headers, api_version):
if not api_version.is_null() and api_version.ver_minor != 0:
headers["X-OpenStack-Nova-API-Version"] = api_version.get_string()
def add_substitution(versioned_method):
_SUBSTITUTIONS.setdefault(versioned_method.name, [])
_SUBSTITUTIONS[versioned_method.name].append(versioned_method)
def get_substitutions(func_name, api_version=None):
substitutions = _SUBSTITUTIONS.get(func_name, [])
if api_version and not api_version.is_null():
return [m for m in substitutions
if api_version.matches(m.start_version, m.end_version)]
return substitutions
def wraps(start_version, end_version=None):
start_version = APIVersion(start_version)
if end_version:
end_version = APIVersion(end_version)
else:
end_version = APIVersion("%s.latest" % start_version.ver_major)
def decor(func):
func.versioned = True
name = utils.get_function_name(func)
versioned_method = VersionedMethod(name, start_version,
end_version, func)
add_substitution(versioned_method)
@functools.wraps(func)
def substitution(obj, *args, **kwargs):
methods = get_substitutions(name, obj.api_version)
if not methods:
raise exceptions.VersionNotFoundForAPIMethod(
obj.api_version.get_string(), name)
else:
return max(methods, key=lambda f: f.start_version).func(
obj, *args, **kwargs)
return substitution
return decor

View File

@ -61,6 +61,10 @@ class Manager(base.HookableMixin):
def client(self):
return self.api.client
@property
def api_version(self):
return self.api.api_version
def _list(self, url, response_key, obj_class=None, body=None):
if body:
_resp, body = self.api.client.post(url, body=body)

View File

@ -82,6 +82,17 @@ class InstanceInErrorState(Exception):
pass
class VersionNotFoundForAPIMethod(Exception):
msg_fmt = "API version '%(vers)s' is not supported on '%(method)s' method."
def __init__(self, version, method):
self.version = version
self.method = method
def __str__(self):
return self.msg_fmt % {"vers": self.version, "method": self.method}
class ClientException(Exception):
"""
The base exception class for all exceptions this library raises.

View File

@ -426,7 +426,7 @@ class OpenStackComputeShell(object):
return parser
def get_subcommand_parser(self, version):
def get_subcommand_parser(self, version, do_help=False):
parser = self.get_base_parser()
self.subcommands = {}
@ -435,12 +435,11 @@ class OpenStackComputeShell(object):
actions_module = importutils.import_module(
"novaclient.v%s.shell" % version.ver_major)
# TODO(andreykurilin): discover actions based on microversions
self._find_actions(subparsers, actions_module)
self._find_actions(subparsers, self)
self._find_actions(subparsers, actions_module, version, do_help)
self._find_actions(subparsers, self, version, do_help)
for extension in self.extensions:
self._find_actions(subparsers, extension.module)
self._find_actions(subparsers, extension.module, version, do_help)
self._add_bash_completion_subparser(subparsers)
@ -460,12 +459,28 @@ class OpenStackComputeShell(object):
self.subcommands['bash_completion'] = subparser
subparser.set_defaults(func=self.do_bash_completion)
def _find_actions(self, subparsers, actions_module):
def _find_actions(self, subparsers, actions_module, version, do_help):
msg = _(" (Supported by API versions '%(start)s' - '%(end)s')")
for attr in (a for a in dir(actions_module) if a.startswith('do_')):
# I prefer to be hyphen-separated instead of underscores.
command = attr[3:].replace('_', '-')
callback = getattr(actions_module, attr)
desc = callback.__doc__ or ''
if hasattr(callback, "versioned"):
subs = api_versions.get_substitutions(
utils.get_function_name(callback))
if do_help:
desc += msg % {'start': subs[0].start_version.get_string(),
'end': subs[-1].end_version.get_string()}
else:
for versioned_method in subs:
if version.matches(versioned_method.start_version,
versioned_method.end_version):
callback = versioned_method.func
break
else:
continue
action_help = desc.strip()
arguments = getattr(callback, 'arguments', [])
@ -482,7 +497,26 @@ class OpenStackComputeShell(object):
)
self.subcommands[command] = subparser
for (args, kwargs) in arguments:
subparser.add_argument(*args, **kwargs)
start_version = kwargs.get("start_version", None)
if start_version:
start_version = api_versions.APIVersion(start_version)
end_version = kwargs.get("end_version", None)
if end_version:
end_version = api_versions.APIVersion(end_version)
else:
end_version = api_versions.APIVersion(
"%s.latest" % start_version.ver_major)
if do_help:
kwargs["help"] = kwargs.get("help", "") + (msg % {
"start": start_version.get_string(),
"end": end_version.get_string()})
else:
if not version.matches(start_version, end_version):
continue
kw = kwargs.copy()
kw.pop("start_version", None)
kw.pop("end_version", None)
subparser.add_argument(*args, **kw)
subparser.set_defaults(func=callback)
def setup_debugging(self, debug):
@ -534,7 +568,8 @@ class OpenStackComputeShell(object):
spot = argv.index('--endpoint_type')
argv[spot] = '--endpoint-type'
subcommand_parser = self.get_subcommand_parser(api_version)
subcommand_parser = self.get_subcommand_parser(
api_version, do_help=("help" in args))
self.parser = subcommand_parser
if options.help or not argv:

View File

@ -0,0 +1,39 @@
# Copyright 2011 OpenStack Foundation
# 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 novaclient import api_versions
from novaclient.openstack.common import cliutils
@api_versions.wraps("2.10", "2.20")
def do_fake_action():
return 1
@api_versions.wraps("2.21", "2.30")
def do_fake_action():
return 2
@cliutils.arg(
'--foo',
start_version='2.1',
end_version='2.2')
@cliutils.arg(
'--bar',
start_version='2.3',
end_version='2.4')
def do_fake_action2():
return 3

View File

@ -168,3 +168,82 @@ class GetAPIVersionTestCase(utils.TestCase):
self.assertEqual(mock_apiversion.return_value,
api_versions.get_api_version(version))
mock_apiversion.assert_called_once_with(version)
class WrapsTestCase(utils.TestCase):
def _get_obj_with_vers(self, vers):
return mock.MagicMock(api_version=api_versions.APIVersion(vers))
def _side_effect_of_vers_method(self, *args, **kwargs):
m = mock.MagicMock(start_version=args[1], end_version=args[2])
m.name = args[0]
return m
@mock.patch("novaclient.utils.get_function_name")
@mock.patch("novaclient.api_versions.VersionedMethod")
def test_end_version_is_none(self, mock_versioned_method, mock_name):
func_name = "foo"
mock_name.return_value = func_name
mock_versioned_method.side_effect = self._side_effect_of_vers_method
@api_versions.wraps("2.2")
def foo(*args, **kwargs):
pass
foo(self._get_obj_with_vers("2.4"))
mock_versioned_method.assert_called_once_with(
func_name, api_versions.APIVersion("2.2"),
api_versions.APIVersion("2.latest"), mock.ANY)
@mock.patch("novaclient.utils.get_function_name")
@mock.patch("novaclient.api_versions.VersionedMethod")
def test_start_and_end_version_are_presented(self, mock_versioned_method,
mock_name):
func_name = "foo"
mock_name.return_value = func_name
mock_versioned_method.side_effect = self._side_effect_of_vers_method
@api_versions.wraps("2.2", "2.6")
def foo(*args, **kwargs):
pass
foo(self._get_obj_with_vers("2.4"))
mock_versioned_method.assert_called_once_with(
func_name, api_versions.APIVersion("2.2"),
api_versions.APIVersion("2.6"), mock.ANY)
@mock.patch("novaclient.utils.get_function_name")
@mock.patch("novaclient.api_versions.VersionedMethod")
def test_api_version_doesnt_match(self, mock_versioned_method, mock_name):
func_name = "foo"
mock_name.return_value = func_name
mock_versioned_method.side_effect = self._side_effect_of_vers_method
@api_versions.wraps("2.2", "2.6")
def foo(*args, **kwargs):
pass
self.assertRaises(exceptions.VersionNotFoundForAPIMethod,
foo, self._get_obj_with_vers("2.1"))
mock_versioned_method.assert_called_once_with(
func_name, api_versions.APIVersion("2.2"),
api_versions.APIVersion("2.6"), mock.ANY)
def test_define_method_is_actually_called(self):
checker = mock.MagicMock()
@api_versions.wraps("2.2", "2.6")
def some_func(*args, **kwargs):
checker(*args, **kwargs)
obj = self._get_obj_with_vers("2.4")
some_args = ("arg_1", "arg_2")
some_kwargs = {"key1": "value1", "key2": "value2"}
some_func(obj, *some_args, **some_kwargs)
checker.assert_called_once_with(*((obj,) + some_args), **some_kwargs)

View File

@ -23,9 +23,11 @@ import requests_mock
import six
from testtools import matchers
from novaclient import api_versions
import novaclient.client
from novaclient import exceptions
import novaclient.shell
from novaclient.tests.unit import fake_actions_module
from novaclient.tests.unit import utils
FAKE_ENV = {'OS_USERNAME': 'username',
@ -402,6 +404,113 @@ class ShellTest(utils.TestCase):
self.assertIsInstance(keyring_saver, novaclient.shell.SecretsHelper)
class TestLoadVersionedActions(utils.TestCase):
def test_load_versioned_actions(self):
parser = novaclient.shell.NovaClientArgumentParser()
subparsers = parser.add_subparsers(metavar='<subcommand>')
shell = novaclient.shell.OpenStackComputeShell()
shell.subcommands = {}
shell._find_actions(subparsers, fake_actions_module,
api_versions.APIVersion("2.15"), False)
self.assertIn('fake-action', shell.subcommands.keys())
self.assertEqual(
1, shell.subcommands['fake-action'].get_default('func')())
shell.subcommands = {}
shell._find_actions(subparsers, fake_actions_module,
api_versions.APIVersion("2.25"), False)
self.assertIn('fake-action', shell.subcommands.keys())
self.assertEqual(
2, shell.subcommands['fake-action'].get_default('func')())
self.assertIn('fake-action2', shell.subcommands.keys())
self.assertEqual(
3, shell.subcommands['fake-action2'].get_default('func')())
def test_load_versioned_actions_not_in_version_range(self):
parser = novaclient.shell.NovaClientArgumentParser()
subparsers = parser.add_subparsers(metavar='<subcommand>')
shell = novaclient.shell.OpenStackComputeShell()
shell.subcommands = {}
shell._find_actions(subparsers, fake_actions_module,
api_versions.APIVersion("2.10000"), False)
self.assertNotIn('fake-action', shell.subcommands.keys())
self.assertIn('fake-action2', shell.subcommands.keys())
def test_load_versioned_actions_with_help(self):
parser = novaclient.shell.NovaClientArgumentParser()
subparsers = parser.add_subparsers(metavar='<subcommand>')
shell = novaclient.shell.OpenStackComputeShell()
shell.subcommands = {}
shell._find_actions(subparsers, fake_actions_module,
api_versions.APIVersion("2.10000"), True)
self.assertIn('fake-action', shell.subcommands.keys())
expected_desc = ("(Supported by API versions '%(start)s' - "
"'%(end)s')") % {'start': '2.10', 'end': '2.30'}
self.assertIn(expected_desc,
shell.subcommands['fake-action'].description)
@mock.patch.object(novaclient.shell.NovaClientArgumentParser,
'add_argument')
def test_load_versioned_actions_with_args(self, mock_add_arg):
parser = novaclient.shell.NovaClientArgumentParser(add_help=False)
subparsers = parser.add_subparsers(metavar='<subcommand>')
shell = novaclient.shell.OpenStackComputeShell()
shell.subcommands = {}
shell._find_actions(subparsers, fake_actions_module,
api_versions.APIVersion("2.1"), False)
self.assertIn('fake-action2', shell.subcommands.keys())
mock_add_arg.assert_has_calls([
mock.call('-h', '--help', action='help', help='==SUPPRESS=='),
mock.call('--foo')])
@mock.patch.object(novaclient.shell.NovaClientArgumentParser,
'add_argument')
def test_load_versioned_actions_with_args2(self, mock_add_arg):
parser = novaclient.shell.NovaClientArgumentParser(add_help=False)
subparsers = parser.add_subparsers(metavar='<subcommand>')
shell = novaclient.shell.OpenStackComputeShell()
shell.subcommands = {}
shell._find_actions(subparsers, fake_actions_module,
api_versions.APIVersion("2.4"), False)
self.assertIn('fake-action2', shell.subcommands.keys())
mock_add_arg.assert_has_calls([
mock.call('-h', '--help', action='help', help='==SUPPRESS=='),
mock.call('--bar')])
@mock.patch.object(novaclient.shell.NovaClientArgumentParser,
'add_argument')
def test_load_versioned_actions_with_args_not_in_version_range(
self, mock_add_arg):
parser = novaclient.shell.NovaClientArgumentParser(add_help=False)
subparsers = parser.add_subparsers(metavar='<subcommand>')
shell = novaclient.shell.OpenStackComputeShell()
shell.subcommands = {}
shell._find_actions(subparsers, fake_actions_module,
api_versions.APIVersion("2.10000"), False)
self.assertIn('fake-action2', shell.subcommands.keys())
mock_add_arg.assert_has_calls([
mock.call('-h', '--help', action='help', help='==SUPPRESS==')])
@mock.patch.object(novaclient.shell.NovaClientArgumentParser,
'add_argument')
def test_load_versioned_actions_with_args_and_help(self, mock_add_arg):
parser = novaclient.shell.NovaClientArgumentParser(add_help=False)
subparsers = parser.add_subparsers(metavar='<subcommand>')
shell = novaclient.shell.OpenStackComputeShell()
shell.subcommands = {}
shell._find_actions(subparsers, fake_actions_module,
api_versions.APIVersion("2.4"), True)
mock_add_arg.assert_has_calls([
mock.call('-h', '--help', action='help', help='==SUPPRESS=='),
mock.call('-h', '--help', action='help', help='==SUPPRESS=='),
mock.call('--foo',
help=" (Supported by API versions '2.1' - '2.2')"),
mock.call('--bar',
help=" (Supported by API versions '2.3' - '2.4')")])
class ShellTestKeystoneV3(ShellTest):
def make_env(self, exclude=None, fake_env=FAKE_ENV):
if 'OS_AUTH_URL' in fake_env:

View File

@ -2232,10 +2232,11 @@ class FakeHTTPClient(base_client.HTTPClient):
class FakeSessionClient(fakes.FakeClient, client.Client):
def __init__(self, *args, **kwargs):
def __init__(self, api_version, *args, **kwargs):
client.Client.__init__(self, 'username', 'password',
'project_id', 'auth_url',
extensions=kwargs.get('extensions'))
extensions=kwargs.get('extensions'),
api_version=api_version)
self.client = FakeSessionMockClient(**kwargs)

View File

@ -368,3 +368,13 @@ def record_time(times, enabled, *args):
yield
end = time.time()
times.append((' '.join(args), start, end))
def get_function_name(func):
if six.PY2:
if hasattr(func, "im_class"):
return "%s.%s" % (func.im_class, func.__name__)
else:
return "%s.%s" % (func.__module__, func.__name__)
else:
return "%s.%s" % (func.__module__, func.__qualname__)