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
This commit is contained in:
Dean Troyer 2013-01-31 19:30:25 -06:00
parent b26cb5bf68
commit fbc412e533
10 changed files with 540 additions and 27 deletions

View File

@ -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

60
doc/source/commands.rst Normal file
View File

@ -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',
# ...
],

View File

@ -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)

View File

@ -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()

View File

@ -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()))

View File

@ -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

View File

@ -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',
],
}
)

View File

@ -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)

View File

@ -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()