diff --git a/zunclient/api_versions.py b/zunclient/api_versions.py new file mode 100644 index 00000000..c4dd58f7 --- /dev/null +++ b/zunclient/api_versions.py @@ -0,0 +1,318 @@ +# +# 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 functools +import logging +import os +import pkgutil +import re +import traceback + +from oslo_utils import strutils + +from zunclient import exceptions +from zunclient.i18n import _ + +LOG = logging.getLogger(__name__) +if not LOG.handlers: + LOG.addHandler(logging.StreamHandler()) + + +HEADER_NAME = "OpenStack-API-Version" +SERVICE_TYPE = "container" + +_SUBSTITUTIONS = {} + + +_type_error_msg = _("'%(other)s' should be an instance of '%(cls)s'") + + +class APIVersion(object): + """This class represents an API Version Request. + + This class provides convenience methods for manipulation + and comparison of version numbers that we need to do to + implement microversions. + """ + + def __init__(self, version_str=None): + """Create an API version object. + + :param version_str: String representation of APIVersionRequest. + Correct format is 'X.Y', where 'X' and 'Y' + are int values. None value should be used + to create Null APIVersionRequest, which is + equal to 0.0 + """ + self.ver_major = 0 + self.ver_minor = 0 + + if version_str is not None: + match = re.match(r"^([1-9]\d*)\.([1-9]\d*|0|latest)$", version_str) + if match: + self.ver_major = int(match.group(1)) + if match.group(2) == "latest": + # NOTE(andreykurilin): Infinity allows to easily determine + # latest version and doesn't require any additional checks + # in comparison methods. + self.ver_minor = float("inf") + else: + self.ver_minor = int(match.group(2)) + else: + msg = _("Invalid format of client version '%s'. " + "Expected format 'X.Y', where X is a major part and Y " + "is a minor part of version.") % version_str + raise exceptions.UnsupportedVersion(msg) + + def __str__(self): + """Debug/Logging representation of object.""" + if self.is_latest(): + return "Latest API Version Major: %s" % self.ver_major + return ("API Version Major: %s, Minor: %s" + % (self.ver_major, self.ver_minor)) + + def __repr__(self): + if self.is_null(): + return "" + else: + return "" % self.get_string() + + def is_null(self): + return self.ver_major == 0 and self.ver_minor == 0 + + def is_latest(self): + return self.ver_minor == float("inf") + + def __lt__(self, other): + if not isinstance(other, APIVersion): + raise TypeError(_type_error_msg % {"other": other, + "cls": self.__class__}) + + return ((self.ver_major, self.ver_minor) < + (other.ver_major, other.ver_minor)) + + def __eq__(self, other): + if not isinstance(other, APIVersion): + raise TypeError(_type_error_msg % {"other": other, + "cls": self.__class__}) + + return ((self.ver_major, self.ver_minor) == + (other.ver_major, other.ver_minor)) + + def __gt__(self, other): + if not isinstance(other, APIVersion): + raise TypeError(_type_error_msg % {"other": other, + "cls": self.__class__}) + + return ((self.ver_major, self.ver_minor) > + (other.ver_major, other.ver_minor)) + + def __le__(self, other): + return self < other or self == other + + def __ne__(self, other): + return not self.__eq__(other) + + def __ge__(self, other): + return self > other or self == other + + def matches(self, min_version, max_version): + """Matches the version object. + + Returns whether the version object represents a version + greater than or equal to the minimum version and less than + or equal to the maximum version. + + :param min_version: Minimum acceptable version. + :param max_version: Maximum acceptable version. + :returns: boolean + + If min_version is null then there is no minimum limit. + If max_version is null then there is no maximum limit. + If self is null then raise ValueError + """ + + if self.is_null(): + raise ValueError(_("Null APIVersion doesn't support 'matches'.")) + if max_version.is_null() and min_version.is_null(): + return True + elif max_version.is_null(): + return min_version <= self + elif min_version.is_null(): + return self <= max_version + else: + return min_version <= self <= max_version + + def get_string(self): + """Version string representation. + + Converts object to string representation which if used to create + an APIVersion object results in the same version. + """ + if self.is_null(): + raise ValueError( + _("Null APIVersion cannot be converted to string.")) + elif self.is_latest(): + return "%s.%s" % (self.ver_major, "latest") + 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. + matcher = re.compile(r"v[0-9]*$") + submodules = pkgutil.iter_modules([os.path.dirname(__file__)]) + available_versions = [name[1:] for loader, name, ispkg in submodules + if matcher.search(name)] + + return available_versions + + +def check_major_version(api_version): + """Checks major part of ``APIVersion`` obj is supported. + + :raises exceptions.UnsupportedVersion: if major part is not supported + """ + available_versions = get_available_major_versions() + if (not api_version.is_null() and + str(api_version.ver_major) not in available_versions): + if len(available_versions) == 1: + msg = _("Invalid client version '%(version)s'. " + "Major part should be '%(major)s'") % { + "version": api_version.get_string(), + "major": available_versions[0]} + else: + msg = _("Invalid client version '%(version)s'. " + "Major part must be one of: '%(major)s'") % { + "version": api_version.get_string(), + "major": ", ".join(available_versions)} + raise exceptions.UnsupportedVersion(msg) + + +def get_api_version(version_string): + """Returns checked APIVersion object""" + version_string = str(version_string) + if strutils.is_int_like(version_string): + version_string = "%s.0" % version_string + + api_version = APIVersion(version_string) + check_major_version(api_version) + return api_version + + +def update_headers(headers, api_version): + """Set microversion headers if api_version is not null""" + + if not api_version.is_null() and api_version.ver_minor != 0: + version_string = api_version.get_string() + headers[HEADER_NAME] = '%s %s' % (SERVICE_TYPE, version_string) + + +def _add_substitution(versioned_method): + _SUBSTITUTIONS.setdefault(versioned_method.name, []) + _SUBSTITUTIONS[versioned_method.name].append(versioned_method) + + +def _get_function_name(func): + # NOTE(andreykurilin): Based on the facts: + # - Python 2 does not have __qualname__ property as Python 3 has; + # - we cannot use im_class here, since we need to obtain name of + # function in `wraps` decorator during class initialization + # ("im_class" property does not exist at that moment) + # we need to write own logic to obtain the full function name which + # include module name, owner name(optional) and just function name. + filename, _lineno, _name, line = traceback.extract_stack()[-4] + module, _file_extension = os.path.splitext(filename) + module = module.replace("/", ".") + if module.endswith(func.__module__): + return "%s.[%s].%s" % (func.__module__, line, func.__name__) + else: + return "%s.%s" % (func.__module__, func.__name__) + + +def get_substitutions(func_name, api_version=None): + if hasattr(func_name, "__id__"): + func_name = func_name.__id__ + + 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 sorted(substitutions, key=lambda m: m.start_version) + + +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 = _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) + return methods[-1].func(obj, *args, **kwargs) + + # Let's share "arguments" with original method and substitution to + # allow put cliutils.arg and wraps decorators in any order + if not hasattr(func, 'arguments'): + func.arguments = [] + substitution.arguments = func.arguments + + # NOTE(andreykurilin): The way to obtain function's name in Python 2 + # bases on traceback(see _get_function_name for details). Since the + # right versioned method method is used in several places, one object + # can have different names. Let's generate name of function one time + # and use __id__ property in all other places. + substitution.__id__ = name + + return substitution + + return decor diff --git a/zunclient/common/apiclient/exceptions.py b/zunclient/common/apiclient/exceptions.py index 8bd317bb..9254e8ef 100644 --- a/zunclient/common/apiclient/exceptions.py +++ b/zunclient/common/apiclient/exceptions.py @@ -28,6 +28,17 @@ import six from zunclient.i18n import _ +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.""" pass diff --git a/zunclient/common/base.py b/zunclient/common/base.py index 52c9d325..f6856792 100644 --- a/zunclient/common/base.py +++ b/zunclient/common/base.py @@ -44,6 +44,10 @@ class Manager(object): def __init__(self, api): self.api = api + @property + def api_version(self): + return self.api.api_version + def _create(self, url, body): resp, body = self.api.json_request('POST', url, body=body) if body: diff --git a/zunclient/common/httpclient.py b/zunclient/common/httpclient.py index 2f5bbe61..d1d5bda2 100644 --- a/zunclient/common/httpclient.py +++ b/zunclient/common/httpclient.py @@ -26,6 +26,7 @@ from oslo_utils import importutils import six import six.moves.urllib.parse as urlparse +from zunclient import api_versions from zunclient import exceptions osprofiler_web = importutils.try_import("osprofiler.web") @@ -35,7 +36,7 @@ USER_AGENT = 'python-zunclient' CHUNKSIZE = 1024 * 64 # 64kB API_VERSION = '/v1' -DEFAULT_API_VERSION = 'latest' +DEFAULT_API_VERSION = '1.latest' def _extract_error_json(body): @@ -70,7 +71,7 @@ class HTTPClient(object): self.endpoint = endpoint self.auth_token = kwargs.get('token') self.auth_ref = kwargs.get('auth_ref') - self.api_version = api_version + self.api_version = api_version or api_versions.APIVersion() self.connection_params = self.get_connection_params(endpoint, **kwargs) @staticmethod @@ -157,10 +158,8 @@ class HTTPClient(object): # Copy the kwargs so we can reuse the original in case of redirects kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {})) kwargs['headers'].setdefault('User-Agent', USER_AGENT) - if self.api_version: - version_string = 'container %s' % self.api_version - kwargs['headers'].setdefault( - 'OpenStack-API-Version', version_string) + api_versions.update_headers(kwargs["headers"], self.api_version) + if self.auth_token: kwargs['headers'].setdefault('X-Auth-Token', self.auth_token) @@ -316,7 +315,7 @@ class SessionClient(adapter.LegacyJsonAdapter): def __init__(self, user_agent=USER_AGENT, logger=LOG, api_version=DEFAULT_API_VERSION, *args, **kwargs): self.user_agent = USER_AGENT - self.api_version = api_version + self.api_version = api_version or api_versions.APIVersion() super(SessionClient, self).__init__(*args, **kwargs) def _http_request(self, url, method, **kwargs): @@ -330,10 +329,7 @@ class SessionClient(adapter.LegacyJsonAdapter): # Copy the kwargs so we can reuse the original in case of redirects kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {})) kwargs['headers'].setdefault('User-Agent', self.user_agent) - if self.api_version: - version_string = 'container %s' % self.api_version - kwargs['headers'].setdefault( - 'OpenStack-API-Version', version_string) + api_versions.update_headers(kwargs["headers"], self.api_version) # NOTE(kevinz): osprofiler_web.get_trace_id_headers does not add any # headers in case if osprofiler is not initialized. diff --git a/zunclient/shell.py b/zunclient/shell.py index 3bb20e83..eb60fb96 100644 --- a/zunclient/shell.py +++ b/zunclient/shell.py @@ -52,6 +52,7 @@ try: except ImportError: pass +from zunclient import api_versions from zunclient.common.apiclient import auth from zunclient.common import cliutils from zunclient import exceptions as exc @@ -60,7 +61,7 @@ from zunclient.v1 import client as client_v1 from zunclient.v1 import shell as shell_v1 from zunclient import version -LATEST_API_VERSION = ('1', 'latest') +DEFAULT_API_VERSION = '1.2' DEFAULT_ENDPOINT_TYPE = 'publicURL' DEFAULT_SERVICE_TYPE = 'container' @@ -332,9 +333,9 @@ class OpenStackZunShell(object): metavar='', default=cliutils.env( 'ZUN_API_VERSION', - default='latest'), - help='Accepts "api", ' - 'defaults to env[ZUN_API_VERSION].') + default=DEFAULT_API_VERSION), + help='Accepts X, X.Y (where X is major, Y is minor' + ' part), defaults to env[ZUN_API_VERSION].') parser.add_argument('--zun_api_version', help=argparse.SUPPRESS) @@ -381,7 +382,7 @@ class OpenStackZunShell(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 = {} @@ -390,13 +391,13 @@ class OpenStackZunShell(object): try: actions_modules = { '1': shell_v1.COMMAND_MODULES - }[version] + }[version.ver_major] except KeyError: actions_modules = shell_v1.COMMAND_MODULES for actions_module in actions_modules: - 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) self._add_bash_completion_subparser(subparsers) @@ -411,12 +412,27 @@ class OpenStackZunShell(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(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', []) @@ -433,6 +449,25 @@ class OpenStackZunShell(object): self.subcommands[command] = subparser for (args, kwargs) in arguments: + 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, **kwargs) subparser.set_defaults(func=callback) @@ -448,34 +483,6 @@ class OpenStackZunShell(object): logging.basicConfig(level=logging.CRITICAL, format=streamformat) - def _check_version(self, api_version): - if api_version == 'latest': - return LATEST_API_VERSION - else: - try: - versions = tuple(int(i) for i in api_version.split('.')) - except ValueError: - versions = () - if len(versions) == 1: - # Default value of zun_api_version is '1'. - # If user not specify the value of api version, not passing - # headers at all. - zun_api_version = None - elif len(versions) == 2: - zun_api_version = api_version - # In the case of '1.0' - if versions[1] == 0: - zun_api_version = None - else: - msg = _("The requested API version %(ver)s is an unexpected " - "format. Acceptable formats are 'X', 'X.Y', or the " - "literal string '%(latest)s'." - ) % {'ver': api_version, 'latest': 'latest'} - raise exc.CommandError(msg) - - api_major_version = versions[0] - return (api_major_version, zun_api_version) - def main(self, argv): # NOTE(Christoph Jansen): With Python 3.4 argv somehow becomes a Map. @@ -487,6 +494,8 @@ class OpenStackZunShell(object): (options, args) = parser.parse_known_args(argv) self.setup_debugging(options.debug) + api_version = api_versions.get_api_version(options.zun_api_version) + # NOTE(dtroyer): Hackery to handle --endpoint_type due to argparse # thinking usage-list --end is ambiguous; but it # works fine with only --endpoint-type present @@ -495,13 +504,9 @@ class OpenStackZunShell(object): spot = argv.index('--endpoint_type') argv[spot] = '--endpoint-type' - # build available subcommands based on version - (api_major_version, zun_api_version) = ( - self._check_version(options.zun_api_version)) + subcommand_parser = self.get_subcommand_parser( + api_version, do_help=("help" in args)) - subcommand_parser = ( - self.get_subcommand_parser(api_major_version) - ) self.parser = subcommand_parser if options.help or not argv: @@ -523,13 +528,12 @@ class OpenStackZunShell(object): os_user_domain_id, os_user_domain_name, os_project_domain_id, os_project_domain_name, os_auth_url, os_auth_system, endpoint_type, - service_type, bypass_url, insecure, zun_api_version) = ( + service_type, bypass_url, insecure) = ( (args.os_username, args.os_project_name, args.os_project_id, args.os_user_domain_id, args.os_user_domain_name, args.os_project_domain_id, args.os_project_domain_name, args.os_auth_url, args.os_auth_system, args.endpoint_type, - args.service_type, args.bypass_url, args.insecure, - args.zun_api_version) + args.service_type, args.bypass_url, args.insecure) ) if os_auth_system and os_auth_system != "keystone": @@ -607,7 +611,7 @@ class OpenStackZunShell(object): try: client = { '1': client_v1, - }[api_major_version] + }[api_version.ver_major] except KeyError: client = client_v1 @@ -629,7 +633,7 @@ class OpenStackZunShell(object): zun_url=bypass_url, endpoint_type=endpoint_type, insecure=insecure, - api_version=zun_api_version, + api_version=api_version, **kwargs) args.func(self.cs, args) diff --git a/zunclient/tests/unit/common/test_httpclient.py b/zunclient/tests/unit/common/test_httpclient.py index 24dfb171..c245c764 100644 --- a/zunclient/tests/unit/common/test_httpclient.py +++ b/zunclient/tests/unit/common/test_httpclient.py @@ -18,6 +18,8 @@ import json import mock import six + +from zunclient import api_versions from zunclient.common.apiclient import exceptions from zunclient.common import httpclient as http from zunclient import exceptions as exc @@ -68,7 +70,9 @@ class HttpClientTest(utils.BaseTestCase): six.StringIO(error_body), version=1, status=500) - client = http.HTTPClient('http://localhost/') + client = http.HTTPClient( + 'http://localhost/', + api_version=api_versions.APIVersion('1.latest')) client.get_connection = ( lambda *a, **kw: utils.FakeConnection(fake_resp)) @@ -84,7 +88,9 @@ class HttpClientTest(utils.BaseTestCase): six.StringIO(error_body), version=1, status=500) - client = http.HTTPClient('http://localhost/') + client = http.HTTPClient( + 'http://localhost/', + api_version=api_versions.APIVersion('1.latest')) client.get_connection = ( lambda *a, **kw: utils.FakeConnection(fake_resp)) @@ -102,7 +108,9 @@ class HttpClientTest(utils.BaseTestCase): six.StringIO(error_body), version=1, status=500) - client = http.HTTPClient('http://localhost/') + client = http.HTTPClient( + 'http://localhost/', + api_version=api_versions.APIVersion('1.latest')) client.get_connection = ( lambda *a, **kw: utils.FakeConnection(fake_resp)) @@ -225,7 +233,10 @@ class HttpClientTest(utils.BaseTestCase): six.StringIO(error_body), version=1, status=401) - client = http.HTTPClient('http://localhost/') + client = http.HTTPClient( + 'http://localhost/', + api_version=api_versions.APIVersion('1.latest')) + client.get_connection = (lambda *a, **kw: utils.FakeConnection(fake_resp)) @@ -245,7 +256,9 @@ class SessionClientTest(utils.BaseTestCase): error_body, 500) - client = http.SessionClient(session=fake_session) + client = http.SessionClient( + api_version=api_versions.APIVersion('1.latest'), + session=fake_session) error = self.assertRaises(exc.InternalServerError, client.json_request, @@ -264,7 +277,9 @@ class SessionClientTest(utils.BaseTestCase): error_body, 500) - client = http.SessionClient(session=fake_session) + client = http.SessionClient( + api_version=api_versions.APIVersion('1.latest'), + session=fake_session) error = self.assertRaises(exc.InternalServerError, client.json_request, @@ -279,6 +294,7 @@ class SessionClientTest(utils.BaseTestCase): fake_session.request.side_effect = [fake_response] client = http.SessionClient( + api_version=api_versions.APIVersion('1.latest'), session=fake_session, endpoint_override='http://zun') client.json_request('GET', '/v1/services') @@ -293,6 +309,7 @@ class SessionClientTest(utils.BaseTestCase): fake_session = mock.MagicMock() fake_session.request.side_effect = [fake_response] client = http.SessionClient( + api_version=api_versions.APIVersion('1.latest'), session=fake_session, endpoint_override='http://zun') self.assertRaises(exceptions.GatewayTimeout, client.json_request, diff --git a/zunclient/tests/unit/test_api_versions.py b/zunclient/tests/unit/test_api_versions.py new file mode 100644 index 00000000..44588e46 --- /dev/null +++ b/zunclient/tests/unit/test_api_versions.py @@ -0,0 +1,246 @@ +# Copyright 2015 Mirantis +# 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. + +import mock + +from zunclient import api_versions +from zunclient import exceptions +from zunclient.tests.unit import utils + + +class APIVersionTestCase(utils.TestCase): + def test_valid_version_strings(self): + def _test_string(version, exp_major, exp_minor): + v = api_versions.APIVersion(version) + self.assertEqual(v.ver_major, exp_major) + self.assertEqual(v.ver_minor, exp_minor) + + _test_string("1.1", 1, 1) + _test_string("2.10", 2, 10) + _test_string("5.234", 5, 234) + _test_string("12.5", 12, 5) + _test_string("2.0", 2, 0) + _test_string("2.200", 2, 200) + + def test_null_version(self): + v = api_versions.APIVersion() + self.assertTrue(v.is_null()) + + def test_invalid_version_strings(self): + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, "2") + + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, "200") + + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, "2.1.4") + + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, "200.23.66.3") + + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, "5 .3") + + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, "5. 3") + + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, "5.03") + + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, "02.1") + + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, "2.001") + + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, "") + + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, " 2.1") + + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, "2.1 ") + + def test_version_comparisons(self): + v1 = api_versions.APIVersion("2.0") + v2 = api_versions.APIVersion("2.5") + v3 = api_versions.APIVersion("5.23") + v4 = api_versions.APIVersion("2.0") + v_null = api_versions.APIVersion() + + self.assertTrue(v1 < v2) + self.assertTrue(v3 > v2) + self.assertTrue(v1 != v2) + self.assertTrue(v1 == v4) + self.assertTrue(v1 != v_null) + self.assertTrue(v_null == v_null) + self.assertRaises(TypeError, v1.__le__, "2.1") + + def test_version_matches(self): + v1 = api_versions.APIVersion("2.0") + v2 = api_versions.APIVersion("2.5") + v3 = api_versions.APIVersion("2.45") + v4 = api_versions.APIVersion("3.3") + v5 = api_versions.APIVersion("3.23") + v6 = api_versions.APIVersion("2.0") + v7 = api_versions.APIVersion("3.3") + v8 = api_versions.APIVersion("4.0") + v_null = api_versions.APIVersion() + + self.assertTrue(v2.matches(v1, v3)) + self.assertTrue(v2.matches(v1, v_null)) + self.assertTrue(v1.matches(v6, v2)) + self.assertTrue(v4.matches(v2, v7)) + self.assertTrue(v4.matches(v_null, v7)) + self.assertTrue(v4.matches(v_null, v8)) + self.assertFalse(v1.matches(v2, v3)) + self.assertFalse(v5.matches(v2, v4)) + self.assertFalse(v2.matches(v3, v1)) + + self.assertRaises(ValueError, v_null.matches, v1, v3) + + def test_get_string(self): + v1_string = "3.23" + v1 = api_versions.APIVersion(v1_string) + self.assertEqual(v1_string, v1.get_string()) + + self.assertRaises(ValueError, + api_versions.APIVersion().get_string) + + +class UpdateHeadersTestCase(utils.TestCase): + def test_api_version_is_null(self): + headers = {} + api_versions.update_headers(headers, api_versions.APIVersion()) + self.assertEqual({}, headers) + + def test_api_version_is_major(self): + headers = {} + api_versions.update_headers(headers, api_versions.APIVersion("7.0")) + self.assertEqual({}, headers) + + def test_api_version_is_not_null(self): + api_version = api_versions.APIVersion("2.3") + headers = {} + api_versions.update_headers(headers, api_version) + self.assertEqual( + {"OpenStack-API-Version": + "container %s" % api_version.get_string()}, + headers) + + +class GetAPIVersionTestCase(utils.TestCase): + def test_get_available_client_versions(self): + output = api_versions.get_available_major_versions() + self.assertNotEqual([], output) + + def test_wrong_format(self): + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.get_api_version, "something_wrong") + + @mock.patch("zunclient.api_versions.APIVersion") + def test_only_major_part_is_presented(self, mock_apiversion): + version = 7 + self.assertEqual(mock_apiversion.return_value, + api_versions.get_api_version(version)) + mock_apiversion.assert_called_once_with("%s.0" % str(version)) + + @mock.patch("zunclient.api_versions.APIVersion") + def test_major_and_minor_parts_is_presented(self, mock_apiversion): + version = "2.7" + 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("zunclient.api_versions._get_function_name") + @mock.patch("zunclient.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("zunclient.api_versions._get_function_name") + @mock.patch("zunclient.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("zunclient.api_versions._get_function_name") + @mock.patch("zunclient.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/zunclient/tests/unit/test_shell.py b/zunclient/tests/unit/test_shell.py index dd4d3aa7..adf2ec7e 100644 --- a/zunclient/tests/unit/test_shell.py +++ b/zunclient/tests/unit/test_shell.py @@ -21,6 +21,7 @@ import mock import six from testtools import matchers +from zunclient import api_versions from zunclient import exceptions import zunclient.shell from zunclient.tests.unit import utils @@ -202,13 +203,12 @@ class ShellTest(utils.TestCase): _, create_args = mock_client.return_value.containers.create.call_args self.assertEqual({'key': 'value'}, create_args['environment']) - @mock.patch('zunclient.v1.services_shell.do_service_list') - @mock.patch('zunclient.v1.client.ksa_session') - def test_insecure(self, mock_session, mock_services_list): + @mock.patch('zunclient.v1.client.Client') + def test_insecure(self, mock_client): self.make_env() self.shell('--insecure service-list') - _, session_kwargs = mock_session.Session.call_args_list[0] - self.assertEqual(False, session_kwargs['verify']) + _, session_kwargs = mock_client.call_args_list[0] + self.assertEqual(True, session_kwargs['insecure']) @mock.patch('sys.stdin', side_effect=mock.MagicMock) @mock.patch('getpass.getpass', side_effect=EOFError) @@ -248,7 +248,8 @@ class ShellTest(utils.TestCase): service_type='container', region_name=expected_region_name, project_domain_id='', project_domain_name='', user_domain_id='', user_domain_name='', profile=None, - zun_url=None, insecure=False, api_version='latest') + zun_url=None, insecure=False, + api_version=api_versions.APIVersion('1.2')) def test_main_option_region(self): self.make_env() @@ -275,7 +276,8 @@ class ShellTest(utils.TestCase): service_type='container', region_name=None, project_domain_id='', project_domain_name='', user_domain_id='', user_domain_name='', profile=None, - zun_url=None, insecure=False, api_version='latest') + zun_url=None, insecure=False, + api_version=api_versions.APIVersion('1.2')) @mock.patch('zunclient.v1.client.Client') def test_main_endpoint_internal(self, mock_client): @@ -288,7 +290,8 @@ class ShellTest(utils.TestCase): service_type='container', region_name=None, project_domain_id='', project_domain_name='', user_domain_id='', user_domain_name='', profile=None, - zun_url=None, insecure=False, api_version='latest') + zun_url=None, insecure=False, + api_version=api_versions.APIVersion('1.2')) class ShellTestKeystoneV3(ShellTest): @@ -319,4 +322,4 @@ class ShellTestKeystoneV3(ShellTest): project_domain_id='', project_domain_name='Default', user_domain_id='', user_domain_name='Default', zun_url=None, insecure=False, profile=None, - api_version='latest') + api_version=api_versions.APIVersion('1.2'))