From 936cf572dff92036db8966204e01a59fe67ffb83 Mon Sep 17 00:00:00 2001 From: Andrey Kurilin Date: Thu, 2 Apr 2015 19:28:02 +0300 Subject: [PATCH] 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 Change-Id: I1939c19664e58e2def684380d64c465dc1cfc132 --- novaclient/api_versions.py | 69 ++++++++++++ novaclient/base.py | 4 + novaclient/exceptions.py | 11 ++ novaclient/shell.py | 51 +++++++-- novaclient/tests/unit/fake_actions_module.py | 39 +++++++ novaclient/tests/unit/test_api_versions.py | 79 ++++++++++++++ novaclient/tests/unit/test_shell.py | 109 +++++++++++++++++++ novaclient/tests/unit/v2/fakes.py | 5 +- novaclient/utils.py | 10 ++ 9 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 novaclient/tests/unit/fake_actions_module.py diff --git a/novaclient/api_versions.py b/novaclient/api_versions.py index 6d1c0672b..e50edea2a 100644 --- a/novaclient/api_versions.py +++ b/novaclient/api_versions.py @@ -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 "" % 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 diff --git a/novaclient/base.py b/novaclient/base.py index e541c8e30..74c7e2fe2 100644 --- a/novaclient/base.py +++ b/novaclient/base.py @@ -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) diff --git a/novaclient/exceptions.py b/novaclient/exceptions.py index c95925416..371197581 100644 --- a/novaclient/exceptions.py +++ b/novaclient/exceptions.py @@ -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. diff --git a/novaclient/shell.py b/novaclient/shell.py index f91d1fa80..acda8bf04 100644 --- a/novaclient/shell.py +++ b/novaclient/shell.py @@ -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: diff --git a/novaclient/tests/unit/fake_actions_module.py b/novaclient/tests/unit/fake_actions_module.py new file mode 100644 index 000000000..2bd0e2f04 --- /dev/null +++ b/novaclient/tests/unit/fake_actions_module.py @@ -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 diff --git a/novaclient/tests/unit/test_api_versions.py b/novaclient/tests/unit/test_api_versions.py index 13f557cfd..2db3a8859 100644 --- a/novaclient/tests/unit/test_api_versions.py +++ b/novaclient/tests/unit/test_api_versions.py @@ -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) diff --git a/novaclient/tests/unit/test_shell.py b/novaclient/tests/unit/test_shell.py index 3d6fc63c0..1caa56cbd 100644 --- a/novaclient/tests/unit/test_shell.py +++ b/novaclient/tests/unit/test_shell.py @@ -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='') + 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='') + 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='') + 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='') + 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='') + 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='') + 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='') + 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: diff --git a/novaclient/tests/unit/v2/fakes.py b/novaclient/tests/unit/v2/fakes.py index 5b5971e4d..5819e2707 100644 --- a/novaclient/tests/unit/v2/fakes.py +++ b/novaclient/tests/unit/v2/fakes.py @@ -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) diff --git a/novaclient/utils.py b/novaclient/utils.py index 1f4df7a57..be513bc78 100644 --- a/novaclient/utils.py +++ b/novaclient/utils.py @@ -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__)