From fbc412e533bd7cb07c6d930e194f660e14b2319f Mon Sep 17 00:00:00 2001 From: Dean Troyer 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 e9bcb7eaf..dd31ccd5d 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 000000000..40a242580 --- /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 [] [] + +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 000000000..e366034aa --- /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 2017e5e3d..840cc5008 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 000000000..bf592d811 --- /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='', + help='New user name', + ) + parser.add_argument( + '--password', + metavar='', + help='New user password', + ) + parser.add_argument( + '--email', + metavar='', + help='New user email address', + ) + parser.add_argument( + '--project', + metavar='', + 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='', + 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='', + 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='', + help='Name or ID of user to change', + ) + parser.add_argument( + '--name', + metavar='', + help='New user name', + ) + parser.add_argument( + '--password', + metavar='', + help='New user password', + ) + parser.add_argument( + '--email', + metavar='', + help='New user email address', + ) + parser.add_argument( + '--project', + metavar='', + 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='', + 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 5dc045721..2654d658d 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='', - 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='', - 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='', - 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='', @@ -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 6ee3d4511..ffd72f7bc 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 000000000..f0a0b3418 --- /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 ac634c323..d259785fa 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()