From ea0b3bd60853dccd189b86c9198274a9cd00faf7 Mon Sep 17 00:00:00 2001
From: Andrey Kurilin <akurilin@mirantis.com>
Date: Thu, 2 Apr 2015 16:37:59 +0300
Subject: [PATCH] Implements 'microversions' api type - Part 1

Compute API version will be transmitted to API side via
X-OpenStack-Nova-API-Version header, if minor part of version is presented.

New module "novaclient.api_versions" was added as storage for all api versions
related functions, classes, variables and etc.

`novaclient.api_versions.APIVersion` class is similar to
`nova.api.openstack.api_version_request.APIVersionRequest`. The main
difference relates to compare methods(method `cmp` is missed from Py3) and
processing "latest" version.

Related to bp api-microversion-support

Change-Id: I0e6574ddaec11fdd053a49adb6b9de9056d0fbac
---
 novaclient/api_versions.py                 | 208 +++++++++++++++++++++
 novaclient/client.py                       |  76 ++++----
 novaclient/exceptions.py                   |  12 +-
 novaclient/shell.py                        |  52 +++---
 novaclient/tests/unit/test_api_versions.py | 170 +++++++++++++++++
 novaclient/tests/unit/test_client.py       |  10 +-
 novaclient/tests/unit/test_shell.py        |   8 +-
 novaclient/tests/unit/v2/fakes.py          |   3 +-
 novaclient/tests/unit/v2/test_shell.py     |   8 +-
 novaclient/v2/client.py                    |   6 +-
 10 files changed, 466 insertions(+), 87 deletions(-)
 create mode 100644 novaclient/api_versions.py
 create mode 100644 novaclient/tests/unit/test_api_versions.py

diff --git a/novaclient/api_versions.py b/novaclient/api_versions.py
new file mode 100644
index 000000000..6d1c0672b
--- /dev/null
+++ b/novaclient/api_versions.py
@@ -0,0 +1,208 @@
+#
+#    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 logging
+import os
+import pkgutil
+import re
+
+from oslo_utils import strutils
+
+from novaclient import exceptions
+from novaclient.i18n import _, _LW
+
+LOG = logging.getLogger(__name__)
+if not LOG.handlers:
+    LOG.addHandler(logging.StreamHandler())
+
+
+# key is a deprecated version and value is an alternative version.
+DEPRECATED_VERSIONS = {"1.1": "2"}
+
+
+_type_error_msg = _("'%(other)s' should be an instance of '%(cls)s'")
+
+
+class APIVersion(object):
+    """This class represents an API Version with 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."""
+        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 "<APIVersion: null>"
+        else:
+            return "<APIVersion: %s>" % 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):
+        """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):
+        """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)
+
+
+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 novaclient.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 version_string in DEPRECATED_VERSIONS:
+        LOG.warning(
+            _LW("Version %(deprecated_version)s is deprecated, using "
+                "alternative version %(alternative)s instead.") %
+            {"deprecated_version": version_string,
+             "alternative": DEPRECATED_VERSIONS[version_string]})
+        version_string = DEPRECATED_VERSIONS[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 'X-OpenStack-Nova-API-Version' header if api_version is not null"""
+
+    if not api_version.is_null() and api_version.ver_minor != 0:
+        headers["X-OpenStack-Nova-API-Version"] = api_version.get_string()
diff --git a/novaclient/client.py b/novaclient/client.py
index f8858b003..5ee6867dc 100644
--- a/novaclient/client.py
+++ b/novaclient/client.py
@@ -31,7 +31,6 @@ import os
 import pkgutil
 import re
 import socket
-import warnings
 
 from keystoneclient import adapter
 from oslo_utils import importutils
@@ -47,17 +46,14 @@ except ImportError:
 
 from six.moves.urllib import parse
 
+from novaclient import api_versions
 from novaclient import exceptions
 from novaclient import extension as ext
-from novaclient.i18n import _, _LW
+from novaclient.i18n import _
 from novaclient import service_catalog
 from novaclient import utils
 
 
-# key is a deprecated version and value is an alternative version.
-DEPRECATED_VERSIONS = {"1.1": "2"}
-
-
 class TCPKeepAliveAdapter(adapters.HTTPAdapter):
     """The custom adapter used to set TCP Keep-Alive on all connections."""
     def init_poolmanager(self, *args, **kwargs):
@@ -88,9 +84,13 @@ class SessionClient(adapter.LegacyJsonAdapter):
     def __init__(self, *args, **kwargs):
         self.times = []
         self.timings = kwargs.pop('timings', False)
+        self.api_version = kwargs.pop('api_version', None)
+        self.api_version = self.api_version or api_versions.APIVersion()
         super(SessionClient, self).__init__(*args, **kwargs)
 
     def request(self, url, method, **kwargs):
+        kwargs.setdefault('headers', kwargs.get('headers', {}))
+        api_versions.update_headers(kwargs["headers"], self.api_version)
         # NOTE(jamielennox): The standard call raises errors from
         # keystoneclient, where we need to raise the novaclient errors.
         raise_exc = kwargs.pop('raise_exc', True)
@@ -144,12 +144,13 @@ class HTTPClient(object):
                  http_log_debug=False, auth_system='keystone',
                  auth_plugin=None, auth_token=None,
                  cacert=None, tenant_id=None, user_id=None,
-                 connection_pool=False):
+                 connection_pool=False, api_version=None):
         self.user = user
         self.user_id = user_id
         self.password = password
         self.projectid = projectid
         self.tenant_id = tenant_id
+        self.api_version = api_version or api_versions.APIVersion()
 
         self._connection_pool = (_ClientConnectionPool()
                                  if connection_pool else None)
@@ -357,6 +358,7 @@ class HTTPClient(object):
             kwargs['headers']['Content-Type'] = 'application/json'
             kwargs['data'] = json.dumps(kwargs['body'])
             del kwargs['body']
+        api_versions.update_headers(kwargs["headers"], self.api_version)
         if self.timeout is not None:
             kwargs.setdefault('timeout', self.timeout)
         kwargs['verify'] = self.verify_cert
@@ -681,7 +683,7 @@ def _construct_http_client(username=None, password=None, project_id=None,
                            auth_token=None, cacert=None, tenant_id=None,
                            user_id=None, connection_pool=False, session=None,
                            auth=None, user_agent='python-novaclient',
-                           interface=None, **kwargs):
+                           interface=None, api_version=None, **kwargs):
     if session:
         return SessionClient(session=session,
                              auth=auth,
@@ -691,6 +693,7 @@ def _construct_http_client(username=None, password=None, project_id=None,
                              service_name=service_name,
                              user_agent=user_agent,
                              timings=timings,
+                             api_version=api_version,
                              **kwargs)
     else:
         # FIXME(jamielennox): username and password are now optional. Need
@@ -718,10 +721,13 @@ def _construct_http_client(username=None, password=None, project_id=None,
                           os_cache=os_cache,
                           http_log_debug=http_log_debug,
                           cacert=cacert,
-                          connection_pool=connection_pool)
+                          connection_pool=connection_pool,
+                          api_version=api_version)
 
 
 def discover_extensions(version):
+    if not isinstance(version, api_versions.APIVersion):
+        version = api_versions.get_api_version(version)
     extensions = []
     for name, module in itertools.chain(
             _discover_via_python_path(),
@@ -750,12 +756,7 @@ def _discover_via_python_path():
 
 def _discover_via_contrib_path(version):
     module_path = os.path.dirname(os.path.abspath(__file__))
-    version_str = "v%s" % version.replace('.', '_')
-    # NOTE(andreykurilin): v1.1 uses implementation of v2, so we should
-    # discover contrib modules in novaclient.v2 dir.
-    if version_str == "v1_1":
-        version_str = "v2"
-    ext_path = os.path.join(module_path, version_str, 'contrib')
+    ext_path = os.path.join(module_path, "v%s" % version.ver_major, 'contrib')
     ext_glob = os.path.join(ext_path, "*.py")
 
     for ext_path in glob.iglob(ext_glob):
@@ -776,38 +777,25 @@ def _discover_via_entry_points():
         yield name, module
 
 
-def _get_available_client_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:].replace("_", ".") for loader, name, ispkg in submodules
-        if matcher.search(name)]
-
-    return available_versions
+def _get_client_class_and_version(version):
+    if not isinstance(version, api_versions.APIVersion):
+        version = api_versions.get_api_version(version)
+    else:
+        api_versions.check_major_version(version)
+    if version.is_latest():
+        raise exceptions.UnsupportedVersion(
+            _("The version should be explicit, not latest."))
+    return version, importutils.import_class(
+        "novaclient.v%s.client.Client" % version.ver_major)
 
 
 def get_client_class(version):
-    version = str(version)
-    if version in DEPRECATED_VERSIONS:
-        warnings.warn(_LW(
-            "Version %(deprecated_version)s is deprecated, using "
-            "alternative version %(alternative)s instead.") %
-            {"deprecated_version": version,
-             "alternative": DEPRECATED_VERSIONS[version]})
-        version = DEPRECATED_VERSIONS[version]
-    try:
-        return importutils.import_class(
-            "novaclient.v%s.client.Client" % version)
-    except ImportError:
-        available_versions = _get_available_client_versions()
-        msg = _("Invalid client version '%(version)s'. must be one of: "
-                "%(keys)s") % {'version': version,
-                               'keys': ', '.join(available_versions)}
-        raise exceptions.UnsupportedVersion(msg)
+    """Returns Client class based on given version."""
+    _api_version, client_class = _get_client_class_and_version(version)
+    return client_class
 
 
 def Client(version, *args, **kwargs):
-    client_class = get_client_class(version)
-    return client_class(*args, **kwargs)
+    """Initialize client object based on given version."""
+    api_version, client_class = _get_client_class_and_version(version)
+    return client_class(api_version=api_version, *args, **kwargs)
diff --git a/novaclient/exceptions.py b/novaclient/exceptions.py
index d550b754b..c95925416 100644
--- a/novaclient/exceptions.py
+++ b/novaclient/exceptions.py
@@ -159,6 +159,14 @@ class MethodNotAllowed(ClientException):
     message = "Method Not Allowed"
 
 
+class NotAcceptable(ClientException):
+    """
+    HTTP 406 - Not Acceptable
+    """
+    http_status = 406
+    message = "Not Acceptable"
+
+
 class Conflict(ClientException):
     """
     HTTP 409 - Conflict
@@ -199,8 +207,8 @@ class HTTPNotImplemented(ClientException):
 #
 # Instead, we have to hardcode it:
 _error_classes = [BadRequest, Unauthorized, Forbidden, NotFound,
-                  MethodNotAllowed, Conflict, OverLimit, RateLimit,
-                  HTTPNotImplemented]
+                  MethodNotAllowed, NotAcceptable, Conflict, OverLimit,
+                  RateLimit, HTTPNotImplemented]
 _code_map = dict((c.http_status, c) for c in _error_classes)
 
 
diff --git a/novaclient/shell.py b/novaclient/shell.py
index ec415bb6e..c7713d3d5 100644
--- a/novaclient/shell.py
+++ b/novaclient/shell.py
@@ -29,6 +29,7 @@ from keystoneclient.auth.identity.generic import token
 from keystoneclient.auth.identity import v3 as identity
 from keystoneclient import session as ksession
 from oslo_utils import encodeutils
+from oslo_utils import importutils
 from oslo_utils import strutils
 import six
 
@@ -41,6 +42,7 @@ except ImportError:
     pass
 
 import novaclient
+from novaclient import api_versions
 import novaclient.auth_plugin
 from novaclient import client
 from novaclient import exceptions as exc
@@ -48,7 +50,6 @@ import novaclient.extension
 from novaclient.i18n import _
 from novaclient.openstack.common import cliutils
 from novaclient import utils
-from novaclient.v2 import shell as shell_v2
 
 DEFAULT_OS_COMPUTE_API_VERSION = "2"
 DEFAULT_NOVA_ENDPOINT_TYPE = 'publicURL'
@@ -402,7 +403,7 @@ class OpenStackComputeShell(object):
             metavar='<compute-api-ver>',
             default=cliutils.env('OS_COMPUTE_API_VERSION',
                                  default=DEFAULT_OS_COMPUTE_API_VERSION),
-            help=_('Accepts number of API version, '
+            help=_('Accepts X, X.Y (where X is major and Y is minor part), '
                    'defaults to env[OS_COMPUTE_API_VERSION].'))
         parser.add_argument(
             '--os_compute_api_version',
@@ -431,15 +432,10 @@ class OpenStackComputeShell(object):
         self.subcommands = {}
         subparsers = parser.add_subparsers(metavar='<subcommand>')
 
-        try:
-            actions_module = {
-                '1.1': shell_v2,
-                '2': shell_v2,
-                '3': shell_v2,
-            }[version]
-        except KeyError:
-            actions_module = shell_v2
+        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)
 
@@ -522,9 +518,11 @@ class OpenStackComputeShell(object):
         # Discover available auth plugins
         novaclient.auth_plugin.discover_auth_systems()
 
-        # build available subcommands based on version
-        self.extensions = self._discover_extensions(
+        api_version = api_versions.get_api_version(
             options.os_compute_api_version)
+
+        # build available subcommands based on version
+        self.extensions = self._discover_extensions(api_version)
         self._run_extension_hooks('__pre_parse_args__')
 
         # NOTE(dtroyer): Hackery to handle --endpoint_type due to argparse
@@ -535,8 +533,7 @@ class OpenStackComputeShell(object):
             spot = argv.index('--endpoint_type')
             argv[spot] = '--endpoint-type'
 
-        subcommand_parser = self.get_subcommand_parser(
-            options.os_compute_api_version)
+        subcommand_parser = self.get_subcommand_parser(api_version)
         self.parser = subcommand_parser
 
         if options.help or not argv:
@@ -669,23 +666,22 @@ class OpenStackComputeShell(object):
                         project_domain_id=args.os_project_domain_id,
                         project_domain_name=args.os_project_domain_name)
 
-        if options.os_compute_api_version:
-            if not any([args.os_tenant_id, args.os_tenant_name,
-                        args.os_project_id, args.os_project_name]):
-                raise exc.CommandError(_("You must provide a project name or"
-                                         " project id via --os-project-name,"
-                                         " --os-project-id, env[OS_PROJECT_ID]"
-                                         " or env[OS_PROJECT_NAME]. You may"
-                                         " use os-project and os-tenant"
-                                         " interchangeably."))
+        if not any([args.os_tenant_id, args.os_tenant_name,
+                    args.os_project_id, args.os_project_name]):
+            raise exc.CommandError(_("You must provide a project name or"
+                                     " project id via --os-project-name,"
+                                     " --os-project-id, env[OS_PROJECT_ID]"
+                                     " or env[OS_PROJECT_NAME]. You may"
+                                     " use os-project and os-tenant"
+                                     " interchangeably."))
 
-            if not os_auth_url:
-                raise exc.CommandError(
-                    _("You must provide an auth url "
-                      "via either --os-auth-url or env[OS_AUTH_URL]"))
+        if not os_auth_url:
+            raise exc.CommandError(
+                _("You must provide an auth url "
+                  "via either --os-auth-url or env[OS_AUTH_URL]"))
 
         self.cs = client.Client(
-            options.os_compute_api_version,
+            api_version,
             os_username, os_password, os_tenant_name,
             tenant_id=os_tenant_id, user_id=os_user_id,
             auth_url=os_auth_url, insecure=insecure,
diff --git a/novaclient/tests/unit/test_api_versions.py b/novaclient/tests/unit/test_api_versions.py
new file mode 100644
index 000000000..13f557cfd
--- /dev/null
+++ b/novaclient/tests/unit/test_api_versions.py
@@ -0,0 +1,170 @@
+# 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 novaclient import api_versions
+from novaclient import exceptions
+from novaclient.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(
+            {"X-OpenStack-Nova-API-Version": 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")
+
+    def test_wrong_major_version(self):
+        self.assertRaises(exceptions.UnsupportedVersion,
+                          api_versions.get_api_version, "1")
+
+    @mock.patch("novaclient.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("novaclient.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)
diff --git a/novaclient/tests/unit/test_client.py b/novaclient/tests/unit/test_client.py
index ace5751a1..1cade390d 100644
--- a/novaclient/tests/unit/test_client.py
+++ b/novaclient/tests/unit/test_client.py
@@ -161,10 +161,6 @@ class ClientTest(utils.TestCase):
         self._check_version_url('http://foo.com/nova/v2/%s',
                                 'http://foo.com/nova/')
 
-    def test_get_available_client_versions(self):
-        output = novaclient.client._get_available_client_versions()
-        self.assertNotEqual([], output)
-
     def test_get_client_class_v2(self):
         output = novaclient.client.get_client_class('2')
         self.assertEqual(output, novaclient.v2.client.Client)
@@ -181,6 +177,12 @@ class ClientTest(utils.TestCase):
         self.assertRaises(novaclient.exceptions.UnsupportedVersion,
                           novaclient.client.get_client_class, '0')
 
+    def test_get_client_class_latest(self):
+        self.assertRaises(novaclient.exceptions.UnsupportedVersion,
+                          novaclient.client.get_client_class, 'latest')
+        self.assertRaises(novaclient.exceptions.UnsupportedVersion,
+                          novaclient.client.get_client_class, '2.latest')
+
     def test_client_with_os_cache_enabled(self):
         cs = novaclient.v2.client.Client("user", "password", "project_id",
                                          auth_url="foo/v2", os_cache=True)
diff --git a/novaclient/tests/unit/test_shell.py b/novaclient/tests/unit/test_shell.py
index 4958023d7..3d6fc63c0 100644
--- a/novaclient/tests/unit/test_shell.py
+++ b/novaclient/tests/unit/test_shell.py
@@ -97,8 +97,8 @@ class ShellTest(utils.TestCase):
     def setUp(self):
         super(ShellTest, self).setUp()
         self.useFixture(fixtures.MonkeyPatch(
-                        'novaclient.client.get_client_class',
-                        mock.MagicMock))
+                        'novaclient.client.Client',
+                        mock.MagicMock()))
         self.nc_util = mock.patch(
             'novaclient.openstack.common.cliutils.isunauthenticated').start()
         self.nc_util.return_value = False
@@ -344,7 +344,9 @@ class ShellTest(utils.TestCase):
 
     @mock.patch('novaclient.client.Client')
     def test_v_unknown_service_type(self, mock_client):
-        self._test_service_type('unknown', 'compute', mock_client)
+        self.assertRaises(exceptions.UnsupportedVersion,
+                          self._test_service_type,
+                          'unknown', 'compute', mock_client)
 
     @mock.patch('sys.argv', ['nova'])
     @mock.patch('sys.stdout', six.StringIO())
diff --git a/novaclient/tests/unit/v2/fakes.py b/novaclient/tests/unit/v2/fakes.py
index f3052252e..5b5971e4d 100644
--- a/novaclient/tests/unit/v2/fakes.py
+++ b/novaclient/tests/unit/v2/fakes.py
@@ -30,10 +30,11 @@ from novaclient.v2 import client
 
 class FakeClient(fakes.FakeClient, client.Client):
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, api_version=None, *args, **kwargs):
         client.Client.__init__(self, 'username', 'password',
                                'project_id', 'auth_url',
                                extensions=kwargs.get('extensions'))
+        self.api_version = api_version
         self.client = FakeHTTPClient(**kwargs)
 
 
diff --git a/novaclient/tests/unit/v2/test_shell.py b/novaclient/tests/unit/v2/test_shell.py
index e5d8914a5..1a540ee22 100644
--- a/novaclient/tests/unit/v2/test_shell.py
+++ b/novaclient/tests/unit/v2/test_shell.py
@@ -70,8 +70,8 @@ class ShellTest(utils.TestCase):
         self.shell = self.useFixture(ShellFixture()).shell
 
         self.useFixture(fixtures.MonkeyPatch(
-            'novaclient.client.get_client_class',
-            lambda *_: fakes.FakeClient))
+            'novaclient.client.Client',
+            lambda *args, **kwargs: fakes.FakeClient(*args, **kwargs)))
 
     @mock.patch('sys.stdout', new_callable=six.StringIO)
     @mock.patch('sys.stderr', new_callable=six.StringIO)
@@ -2433,8 +2433,8 @@ class ShellWithSessionClientTest(ShellTest):
         """Run before each test."""
         super(ShellWithSessionClientTest, self).setUp()
         self.useFixture(fixtures.MonkeyPatch(
-            'novaclient.client.get_client_class',
-            lambda *_: fakes.FakeSessionClient))
+            'novaclient.client.Client',
+            lambda *args, **kwargs: fakes.FakeSessionClient(*args, **kwargs)))
 
 
 class GetSecgroupTest(utils.TestCase):
diff --git a/novaclient/v2/client.py b/novaclient/v2/client.py
index c759dcec6..1c195d5a1 100644
--- a/novaclient/v2/client.py
+++ b/novaclient/v2/client.py
@@ -103,7 +103,7 @@ class Client(object):
                  auth_system='keystone', auth_plugin=None, auth_token=None,
                  cacert=None, tenant_id=None, user_id=None,
                  connection_pool=False, session=None, auth=None,
-                 **kwargs):
+                 api_version=None, **kwargs):
         """
         :param str username: Username
         :param str api_key: API Key
@@ -133,6 +133,8 @@ class Client(object):
         :param bool connection_pool: Use a connection pool
         :param str session: Session
         :param str auth: Auth
+        :param api_version: Compute API version
+        :type api_version: novaclient.api_versions.APIVersion
         """
         # FIXME(comstud): Rename the api_key argument above when we
         # know it's not being used as keyword argument
@@ -153,6 +155,7 @@ class Client(object):
         self.limits = limits.LimitsManager(self)
         self.servers = servers.ServerManager(self)
         self.versions = versions.VersionManager(self)
+        self.api_version = api_version
 
         # extensions
         self.agents = agents.AgentsManager(self)
@@ -224,6 +227,7 @@ class Client(object):
             connection_pool=connection_pool,
             session=session,
             auth=auth,
+            api_version=api_version,
             **kwargs)
 
     @client._original_only