From fbc412e533bd7cb07c6d930e194f660e14b2319f Mon Sep 17 00:00:00 2001
From: Dean Troyer <dtroyer@gmail.com>
Date: Thu, 31 Jan 2013 19:30:25 -0600
Subject: [PATCH] Multiple API version support

* Use multiple entry point groups to represent each API+version
  combination supported
* Add some tests

Try it out:
* Right now only '* user' commands have multiple overlapping versions;
  you can see the selection between v2.0 and v3 by looking at the
  command help output for 'tenant' vs 'project':

  os --os-identity-api-version=2.0 help set user
  os --os-identity-api-version=3 help set user

Change-Id: I7114fd246843df0243d354a7cce697810bb7de62
---
 HACKING                                  |   2 +-
 doc/source/commands.rst                  |  60 ++++++
 openstackclient/common/commandmanager.py |  42 ++++
 openstackclient/identity/v2_0/user.py    |   4 +-
 openstackclient/identity/v3/user.py      | 247 +++++++++++++++++++++++
 openstackclient/shell.py                 |  72 ++++++-
 setup.py                                 |  45 +++--
 tests/{ => common}/test_clientmanager.py |   0
 tests/common/test_commandmanager.py      |  71 +++++++
 tests/test_shell.py                      |  24 +++
 10 files changed, 540 insertions(+), 27 deletions(-)
 create mode 100644 doc/source/commands.rst
 create mode 100644 openstackclient/common/commandmanager.py
 create mode 100644 openstackclient/identity/v3/user.py
 rename tests/{ => common}/test_clientmanager.py (100%)
 create mode 100644 tests/common/test_commandmanager.py

diff --git a/HACKING b/HACKING
index e9bcb7eaf4..dd31ccd5d1 100644
--- a/HACKING
+++ b/HACKING
@@ -39,8 +39,8 @@ Human Alphabetical Order Examples
   import logging
   import random
   import StringIO
+  import testtools
   import time
-  import unittest
 
   from nova import flags
   from nova import test
diff --git a/doc/source/commands.rst b/doc/source/commands.rst
new file mode 100644
index 0000000000..40a2425803
--- /dev/null
+++ b/doc/source/commands.rst
@@ -0,0 +1,60 @@
+========
+Commands
+========
+
+Command Structure
+=================
+
+OpenStack Client uses a command form ``verb object``.
+
+Note that 'object' here refers to the target of a command's action.  In coding
+discussions 'object' has its usual Python meaning.  Go figure.
+
+Commands take the form::
+
+    openstack [<global-options>] <verb> <object> [<command-local-arguments>]
+
+Command Arguments
+-----------------
+
+  * All long option names use two dashes ('--') as the prefix and a single dash
+    ('-') as the interpolation character.  Some common options also have the
+    traditional single letter name prefixed by a single dash ('-').
+  * Global options generally have a corresponding environment variable that
+    may also be used to set the value. If both are present, the command-line
+    option takes priority. The environment variable names can be derived from
+    the option name by dropping the leading '--', converting all embedded dashes
+    ('-') to underscores ('_'), and converting the name to upper case.
+  * Positional arguments trail command options. In commands that require two or
+    more objects be acted upon, such as 'attach A to B', both objects appear
+    as positional arguments. If they also appear in the command object they are
+    in the same order.
+
+
+Implementation
+==============
+
+The command structure is designed to support seamless addition of extension
+command modules via entry points.  The extensions are assumed to be subclasses
+of Cliff's command.Command object.
+
+Command Entry Points
+--------------------
+
+Commands are added to the client using distribute's entry points in ``setup.py``.
+There is a single common group ``openstack.cli`` for commands that are not versioned,
+and a group for each combination of OpenStack API and version that is
+supported.  For example, to support Identity API v3 there is a group called
+``openstack.identity.v3`` that contains the individual commands.  The command
+entry points have the form::
+
+    "verb_object=fully.qualified.module.vXX.object:VerbObject"
+
+For example, the 'list user' command fir the Identity API is identified in
+``setup.py`` with::
+
+    'openstack.identity.v3': [
+        # ...
+        'list_user=openstackclient.identity.v3.user:ListUser',
+        # ...
+    ],
diff --git a/openstackclient/common/commandmanager.py b/openstackclient/common/commandmanager.py
new file mode 100644
index 0000000000..e366034aab
--- /dev/null
+++ b/openstackclient/common/commandmanager.py
@@ -0,0 +1,42 @@
+#   Copyright 2012-2013 OpenStack, LLC.
+#
+#   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.
+#
+
+"""Modify Cliff's CommandManager"""
+
+import logging
+import pkg_resources
+
+import cliff.commandmanager
+
+
+LOG = logging.getLogger(__name__)
+
+
+class CommandManager(cliff.commandmanager.CommandManager):
+    """Alters Cliff's default CommandManager behaviour to load additiona
+       command groups after initialization.
+    """
+    def _load_commands(self, group=None):
+        if not group:
+            group = self.namespace
+        for ep in pkg_resources.iter_entry_points(group):
+            LOG.debug('found command %r' % ep.name)
+            self.commands[ep.name.replace('_', ' ')] = ep
+        return
+
+    def add_command_group(self, group=None):
+        """Adds another group of command entrypoints"""
+        if group:
+            self._load_commands(group)
diff --git a/openstackclient/identity/v2_0/user.py b/openstackclient/identity/v2_0/user.py
index 2017e5e3d2..840cc50084 100644
--- a/openstackclient/identity/v2_0/user.py
+++ b/openstackclient/identity/v2_0/user.py
@@ -13,7 +13,7 @@
 #   under the License.
 #
 
-"""User action implementations"""
+"""Identity v2.0 User action implementations"""
 
 import logging
 
@@ -126,7 +126,7 @@ class ListUser(lister.Lister):
     def take_action(self, parsed_args):
         self.log.debug('take_action(%s)' % parsed_args)
         if parsed_args.long:
-            columns = ('ID', 'Name', 'TenantId', 'Email', 'Enabled')
+            columns = ('ID', 'Name', 'Tenant Id', 'Email', 'Enabled')
         else:
             columns = ('ID', 'Name')
         data = self.app.client_manager.identity.users.list()
diff --git a/openstackclient/identity/v3/user.py b/openstackclient/identity/v3/user.py
new file mode 100644
index 0000000000..bf592d8117
--- /dev/null
+++ b/openstackclient/identity/v3/user.py
@@ -0,0 +1,247 @@
+#   Copyright 2012-2013 OpenStack, LLC.
+#
+#   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.
+#
+
+"""Identity v3 User action implementations"""
+
+import logging
+
+from cliff import command
+from cliff import lister
+from cliff import show
+
+from openstackclient.common import utils
+
+
+class CreateUser(show.ShowOne):
+    """Create user command"""
+
+    api = 'identity'
+    log = logging.getLogger(__name__ + '.CreateUser')
+
+    def get_parser(self, prog_name):
+        parser = super(CreateUser, self).get_parser(prog_name)
+        parser.add_argument(
+            'name',
+            metavar='<user-name>',
+            help='New user name',
+        )
+        parser.add_argument(
+            '--password',
+            metavar='<user-password>',
+            help='New user password',
+        )
+        parser.add_argument(
+            '--email',
+            metavar='<user-email>',
+            help='New user email address',
+        )
+        parser.add_argument(
+            '--project',
+            metavar='<project>',
+            help='New default project name or ID',
+        )
+        enable_group = parser.add_mutually_exclusive_group()
+        enable_group.add_argument(
+            '--enable',
+            dest='enabled',
+            action='store_true',
+            default=True,
+            help='Enable user',
+        )
+        enable_group.add_argument(
+            '--disable',
+            dest='enabled',
+            action='store_false',
+            help='Disable user',
+        )
+        return parser
+
+    def take_action(self, parsed_args):
+        self.log.debug('take_action(%s)' % parsed_args)
+        identity_client = self.app.client_manager.identity
+        if parsed_args.project:
+            project_id = utils.find_resource(
+                identity_client.projects, parsed_args.project).id
+        else:
+            project_id = None
+        user = identity_client.users.create(
+            parsed_args.name,
+            parsed_args.password,
+            parsed_args.email,
+            project_id=project_id,
+            enabled=parsed_args.enabled,
+        )
+
+        info = {}
+        info.update(user._info)
+        return zip(*sorted(info.iteritems()))
+
+
+class DeleteUser(command.Command):
+    """Delete user command"""
+
+    api = 'identity'
+    log = logging.getLogger(__name__ + '.DeleteUser')
+
+    def get_parser(self, prog_name):
+        parser = super(DeleteUser, self).get_parser(prog_name)
+        parser.add_argument(
+            'user',
+            metavar='<user>',
+            help='Name or ID of user to delete',
+        )
+        return parser
+
+    def take_action(self, parsed_args):
+        self.log.debug('take_action(%s)' % parsed_args)
+        identity_client = self.app.client_manager.identity
+        user = utils.find_resource(
+            identity_client.users, parsed_args.user)
+        identity_client.users.delete(user.id)
+        return
+
+
+class ListUser(lister.Lister):
+    """List user command"""
+
+    api = 'identity'
+    log = logging.getLogger(__name__ + '.ListUser')
+
+    def get_parser(self, prog_name):
+        parser = super(ListUser, self).get_parser(prog_name)
+        parser.add_argument(
+            '--project',
+            metavar='<project>',
+            help='Name or ID of project to filter users',
+        )
+        parser.add_argument(
+            '--long',
+            action='store_true',
+            default=False,
+            help='Additional fields are listed in output',
+        )
+        return parser
+
+    def take_action(self, parsed_args):
+        self.log.debug('take_action(%s)' % parsed_args)
+        if parsed_args.long:
+            columns = ('ID', 'Name', 'Project Id', 'Email', 'Enabled')
+        else:
+            columns = ('ID', 'Name')
+        data = self.app.client_manager.identity.users.list()
+        return (columns,
+                (utils.get_item_properties(
+                    s, columns,
+                    formatters={},
+                ) for s in data))
+
+
+class SetUser(command.Command):
+    """Set user command"""
+
+    api = 'identity'
+    log = logging.getLogger(__name__ + '.SetUser')
+
+    def get_parser(self, prog_name):
+        parser = super(SetUser, self).get_parser(prog_name)
+        parser.add_argument(
+            'user',
+            metavar='<user>',
+            help='Name or ID of user to change',
+        )
+        parser.add_argument(
+            '--name',
+            metavar='<new-user-name>',
+            help='New user name',
+        )
+        parser.add_argument(
+            '--password',
+            metavar='<user-password>',
+            help='New user password',
+        )
+        parser.add_argument(
+            '--email',
+            metavar='<user-email>',
+            help='New user email address',
+        )
+        parser.add_argument(
+            '--project',
+            metavar='<project>',
+            help='New default project name or ID',
+        )
+        enable_group = parser.add_mutually_exclusive_group()
+        enable_group.add_argument(
+            '--enable',
+            dest='enabled',
+            action='store_true',
+            default=True,
+            help='Enable user (default)',
+        )
+        enable_group.add_argument(
+            '--disable',
+            dest='enabled',
+            action='store_false',
+            help='Disable user',
+        )
+        return parser
+
+    def take_action(self, parsed_args):
+        self.log.debug('take_action(%s)' % parsed_args)
+        identity_client = self.app.client_manager.identity
+        user = utils.find_resource(
+            identity_client.users, parsed_args.user)
+        kwargs = {}
+        if parsed_args.name:
+            kwargs['name'] = parsed_args.name
+        if parsed_args.email:
+            kwargs['email'] = parsed_args.email
+        if parsed_args.project:
+            project_id = utils.find_resource(
+                identity_client.projects, parsed_args.project).id
+            kwargs['projectId'] = project_id
+        if 'enabled' in parsed_args:
+            kwargs['enabled'] = parsed_args.enabled
+
+        if not len(kwargs):
+            stdout.write("User not updated, no arguments present")
+            return
+        identity_client.users.update(user.id, **kwargs)
+        return
+
+
+class ShowUser(show.ShowOne):
+    """Show user command"""
+
+    api = 'identity'
+    log = logging.getLogger(__name__ + '.ShowUser')
+
+    def get_parser(self, prog_name):
+        parser = super(ShowUser, self).get_parser(prog_name)
+        parser.add_argument(
+            'user',
+            metavar='<user>',
+            help='Name or ID of user to display',
+        )
+        return parser
+
+    def take_action(self, parsed_args):
+        self.log.debug('take_action(%s)' % parsed_args)
+        identity_client = self.app.client_manager.identity
+        user = utils.find_resource(
+            identity_client.users, parsed_args.user)
+
+        info = {}
+        info.update(user._info)
+        return zip(*sorted(info.iteritems()))
diff --git a/openstackclient/shell.py b/openstackclient/shell.py
index 5dc0457213..2654d658d8 100644
--- a/openstackclient/shell.py
+++ b/openstackclient/shell.py
@@ -21,17 +21,22 @@ import os
 import sys
 
 from cliff.app import App
-from cliff.commandmanager import CommandManager
+from cliff.help import HelpAction
 
 from openstackclient.common import clientmanager
 from openstackclient.common import exceptions as exc
 from openstackclient.common import openstackkeyring
 from openstackclient.common import utils
+from openstackclient.common.commandmanager import CommandManager
 
 
 VERSION = '0.1'
 KEYRING_SERVICE = 'openstack'
 
+DEFAULT_COMPUTE_API_VERSION = '2'
+DEFAULT_IDENTITY_API_VERSION = '2.0'
+DEFAULT_IMAGE_API_VERSION = '1.0'
+
 
 def env(*vars, **kwargs):
     """Search for the first defined of possibly many env vars
@@ -63,6 +68,35 @@ class OpenStackShell(App):
         # password flow auth
         self.auth_client = None
 
+        # NOTE(dtroyer): This hack changes the help action that Cliff
+        #                automatically adds to the parser so we can defer
+        #                its execution until after the api-versioned commands
+        #                have been loaded.  There doesn't seem to be a
+        #                way to edit/remove anything from an existing parser.
+
+        # Replace the cliff-added HelpAction to defer its execution
+        self.DeferredHelpAction = None
+        for a in self.parser._actions:
+            if type(a) == HelpAction:
+                # Found it, save and replace it
+                self.DeferredHelpAction = a
+
+                # These steps are argparse-implementation-dependent
+                self.parser._actions.remove(a)
+                if self.parser._option_string_actions['-h']:
+                    del self.parser._option_string_actions['-h']
+                if self.parser._option_string_actions['--help']:
+                    del self.parser._option_string_actions['--help']
+
+                # Make a new help option to just set a flag
+                self.parser.add_argument(
+                    '-h', '--help',
+                    action='store_true',
+                    dest='deferred_help',
+                    default=False,
+                    help="show this help message and exit",
+                )
+
     def build_option_parser(self, description, version):
         parser = super(OpenStackShell, self).build_option_parser(
             description,
@@ -102,20 +136,30 @@ class OpenStackShell(App):
         parser.add_argument(
             '--os-identity-api-version',
             metavar='<identity-api-version>',
-            default=env('OS_IDENTITY_API_VERSION', default='2.0'),
-            help='Identity API version, default=2.0 '
-                 '(Env: OS_IDENTITY_API_VERSION)')
+            default=env(
+                'OS_IDENTITY_API_VERSION',
+                default=DEFAULT_IDENTITY_API_VERSION),
+            help='Identity API version, default=' +
+                 DEFAULT_IDENTITY_API_VERSION +
+                 ' (Env: OS_IDENTITY_API_VERSION)')
         parser.add_argument(
             '--os-compute-api-version',
             metavar='<compute-api-version>',
-            default=env('OS_COMPUTE_API_VERSION', default='2'),
-            help='Compute API version, default=2 '
-                 '(Env: OS_COMPUTE_API_VERSION)')
+            default=env(
+                'OS_COMPUTE_API_VERSION',
+                default=DEFAULT_COMPUTE_API_VERSION),
+            help='Compute API version, default=' +
+                 DEFAULT_COMPUTE_API_VERSION +
+                 ' (Env: OS_COMPUTE_API_VERSION)')
         parser.add_argument(
             '--os-image-api-version',
             metavar='<image-api-version>',
-            default=env('OS_IMAGE_API_VERSION', default='1.0'),
-            help='Image API version, default=1.0 (Env: OS_IMAGE_API_VERSION)')
+            default=env(
+                'OS_IMAGE_API_VERSION',
+                default=DEFAULT_IMAGE_API_VERSION),
+            help='Image API version, default=' +
+                 DEFAULT_IMAGE_API_VERSION +
+                 ' (Env: OS_IMAGE_API_VERSION)')
         parser.add_argument(
             '--os-token',
             metavar='<token>',
@@ -251,6 +295,16 @@ class OpenStackShell(App):
             'image': self.options.os_image_api_version,
         }
 
+        # Add the API version-specific commands
+        for api in self.api_version.keys():
+            version = '.v' + self.api_version[api].replace('.', '_')
+            self.command_manager.add_command_group(
+                'openstack.' + api + version)
+
+        # Handle deferred help and exit
+        if self.options.deferred_help:
+            self.DeferredHelpAction(self.parser, self.parser, None, None)
+
         # If the user is not asking for help, make sure they
         # have given us auth.
         cmd_name = None
diff --git a/setup.py b/setup.py
index 6ee3d45119..ffd72f7bce 100644
--- a/setup.py
+++ b/setup.py
@@ -55,6 +55,8 @@ setuptools.setup(
     entry_points={
         'console_scripts': ['openstack=openstackclient.shell:main'],
         'openstack.cli': [
+        ],
+        'openstack.identity.v2_0': [
             'create_endpoint=' +
             'openstackclient.identity.v2_0.endpoint:CreateEndpoint',
             'delete_endpoint=' +
@@ -73,16 +75,6 @@ setuptools.setup(
             'remove_role=' +
             'openstackclient.identity.v2_0.role:RemoveRole',
             'show_role=openstackclient.identity.v2_0.role:ShowRole',
-            'create_server=openstackclient.compute.v2.server:CreateServer',
-            'delete_server=openstackclient.compute.v2.server:DeleteServer',
-            'list_server=openstackclient.compute.v2.server:ListServer',
-            'pause_server=openstackclient.compute.v2.server:PauseServer',
-            'reboot_server=openstackclient.compute.v2.server:RebootServer',
-            'rebuild_server=openstackclient.compute.v2.server:RebuildServer',
-            'resume_server=openstackclient.compute.v2.server:ResumeServer',
-            'show_server=openstackclient.compute.v2.server:ShowServer',
-            'suspend_server=openstackclient.compute.v2.server:SuspendServer',
-            'unpause_server=openstackclient.compute.v2.server:UnpauseServer',
             'create_service=' +
             'openstackclient.identity.v2_0.service:CreateService',
             'delete_service=' +
@@ -96,6 +88,7 @@ setuptools.setup(
             'list_tenant=openstackclient.identity.v2_0.tenant:ListTenant',
             'set_tenant=openstackclient.identity.v2_0.tenant:SetTenant',
             'show_tenant=openstackclient.identity.v2_0.tenant:ShowTenant',
+            'list_user-role=openstackclient.identity.v2_0.role:ListUserRole',
             'create_user=' +
             'openstackclient.identity.v2_0.user:CreateUser',
             'delete_user=' +
@@ -103,10 +96,8 @@ setuptools.setup(
             'list_user=openstackclient.identity.v2_0.user:ListUser',
             'set_user=openstackclient.identity.v2_0.user:SetUser',
             'show_user=openstackclient.identity.v2_0.user:ShowUser',
-            'list_user-role=openstackclient.identity.v2_0.role:ListUserRole',
-            'list_image=openstackclient.image.v2.image:ListImage',
-            'show_image=openstackclient.image.v2.image:ShowImage',
-            'save_image=openstackclient.image.v2.image:SaveImage',
+        ],
+        'openstack.identity.v3': [
             'create_group=openstackclient.identity.v3.group:CreateGroup',
             'delete_group=openstackclient.identity.v3.group:DeleteGroup',
             'set_group=openstackclient.identity.v3.group:SetGroup',
@@ -119,6 +110,30 @@ setuptools.setup(
             'set_project=openstackclient.identity.v3.project:SetProject',
             'show_project=openstackclient.identity.v3.project:ShowProject',
             'list_project=openstackclient.identity.v3.project:ListProject',
-        ]
+            'create_user=' +
+            'openstackclient.identity.v3.user:CreateUser',
+            'delete_user=' +
+            'openstackclient.identity.v3.user:DeleteUser',
+            'list_user=openstackclient.identity.v3.user:ListUser',
+            'set_user=openstackclient.identity.v3.user:SetUser',
+            'show_user=openstackclient.identity.v3.user:ShowUser',
+        ],
+        'openstack.image.v2': [
+            'list_image=openstackclient.image.v2.image:ListImage',
+            'show_image=openstackclient.image.v2.image:ShowImage',
+            'save_image=openstackclient.image.v2.image:SaveImage',
+        ],
+        'openstack.compute.v2': [
+            'create_server=openstackclient.compute.v2.server:CreateServer',
+            'delete_server=openstackclient.compute.v2.server:DeleteServer',
+            'list_server=openstackclient.compute.v2.server:ListServer',
+            'pause_server=openstackclient.compute.v2.server:PauseServer',
+            'reboot_server=openstackclient.compute.v2.server:RebootServer',
+            'rebuild_server=openstackclient.compute.v2.server:RebuildServer',
+            'resume_server=openstackclient.compute.v2.server:ResumeServer',
+            'show_server=openstackclient.compute.v2.server:ShowServer',
+            'suspend_server=openstackclient.compute.v2.server:SuspendServer',
+            'unpause_server=openstackclient.compute.v2.server:UnpauseServer',
+        ],
     }
 )
diff --git a/tests/test_clientmanager.py b/tests/common/test_clientmanager.py
similarity index 100%
rename from tests/test_clientmanager.py
rename to tests/common/test_clientmanager.py
diff --git a/tests/common/test_commandmanager.py b/tests/common/test_commandmanager.py
new file mode 100644
index 0000000000..f0a0b3418d
--- /dev/null
+++ b/tests/common/test_commandmanager.py
@@ -0,0 +1,71 @@
+#   Copyright 2012-2013 OpenStack, LLC.
+#
+#   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 openstackclient.common import commandmanager
+from tests import utils
+
+
+class FakeCommand(object):
+    @classmethod
+    def load(cls):
+        return cls
+
+    def __init__(self):
+        return
+
+FAKE_CMD_ONE = FakeCommand
+FAKE_CMD_TWO = FakeCommand
+FAKE_CMD_ALPHA = FakeCommand
+FAKE_CMD_BETA = FakeCommand
+
+
+class FakeCommandManager(commandmanager.CommandManager):
+    commands = {}
+
+    def _load_commands(self, group=None):
+        if not group:
+            self.commands['one'] = FAKE_CMD_ONE
+            self.commands['two'] = FAKE_CMD_TWO
+        else:
+            self.commands['alpha'] = FAKE_CMD_ALPHA
+            self.commands['beta'] = FAKE_CMD_BETA
+
+
+class TestCommandManager(utils.TestCase):
+    def test_add_command_group(self):
+        mgr = FakeCommandManager('test')
+
+        # Make sure add_command() still functions
+        mock_cmd_one = mock.Mock()
+        mgr.add_command('mock', mock_cmd_one)
+        cmd_mock, name, args = mgr.find_command(['mock'])
+        self.assertEqual(cmd_mock, mock_cmd_one)
+
+        # Find a command added in initialization
+        cmd_one, name, args = mgr.find_command(['one'])
+        self.assertEqual(cmd_one, FAKE_CMD_ONE)
+
+        # Load another command group
+        mgr.add_command_group('latin')
+
+        # Find a new command
+        cmd_alpha, name, args = mgr.find_command(['alpha'])
+        self.assertEqual(cmd_alpha, FAKE_CMD_ALPHA)
+
+        # Ensure that the original commands were not overwritten
+        cmd_two, name, args = mgr.find_command(['two'])
+        self.assertEqual(cmd_two, FAKE_CMD_TWO)
diff --git a/tests/test_shell.py b/tests/test_shell.py
index ac634c3235..d259785fa7 100644
--- a/tests/test_shell.py
+++ b/tests/test_shell.py
@@ -108,6 +108,30 @@ class TestShell(utils.TestCase):
                              default_args["image_api_version"])
 
 
+class TestShellHelp(TestShell):
+    """Test the deferred help flag"""
+    def setUp(self):
+        super(TestShellHelp, self).setUp()
+        self.orig_env, os.environ = os.environ, {}
+
+    def tearDown(self):
+        super(TestShellHelp, self).tearDown()
+        os.environ = self.orig_env
+
+    def test_help_options(self):
+        flag = "-h list server"
+        kwargs = {
+            "deferred_help": True,
+        }
+        with mock.patch("openstackclient.shell.OpenStackShell.initialize_app",
+                        self.app):
+            _shell, _cmd = make_shell(), flag
+            fake_execute(_shell, _cmd)
+
+            self.assertEqual(_shell.options.deferred_help,
+                             kwargs["deferred_help"])
+
+
 class TestShellPasswordAuth(TestShell):
     def setUp(self):
         super(TestShellPasswordAuth, self).setUp()