From 6c06cbcf01914156d09be92b38148ff20e89b92b Mon Sep 17 00:00:00 2001 From: Petr Blaho Date: Wed, 13 Nov 2013 18:22:01 +0100 Subject: [PATCH] Adds help for subcommands Adds help subcommand which takes other subcommands as param. Adds -h/--help option for subcommands. This contains integration testing code for tuskar CLI. Following tests are implemented: o bare help command o help command with bad parameter o help command with -h / --help parameter o set of tests for "help ", " -h / --help" for following commands: - flavor-create - flavor-delete - flavor-list - flavor-show - flavor-update - rack-create - rack-delete - rack-list - rack-show - rack-update Fixes bug in tests/test_shell.py - {} -> [] Fixes bug: 1213050 Change-Id: Ic554198f4fb4cdbaeefb9831c15f969301a7be87 --- tuskarclient/shell.py | 61 +++- tuskarclient/tests/integration/__init__.py | 0 .../tests/integration/test_help_command.py | 311 ++++++++++++++++++ tuskarclient/tests/test_shell.py | 4 +- tuskarclient/tests/utils.py | 194 +++++++++++ 5 files changed, 556 insertions(+), 14 deletions(-) create mode 100644 tuskarclient/tests/integration/__init__.py create mode 100644 tuskarclient/tests/integration/test_help_command.py diff --git a/tuskarclient/shell.py b/tuskarclient/shell.py index 68cae96..1d4b324 100755 --- a/tuskarclient/shell.py +++ b/tuskarclient/shell.py @@ -31,24 +31,42 @@ logger = logging.getLogger(__name__) class TuskarShell(object): - def __init__(self, raw_args): + def __init__(self, raw_args, + argument_parser_class=argparse.ArgumentParser): self.raw_args = raw_args + self.argument_parser_class = argument_parser_class + + self.partial_args = None + self.parser = None + self.subparsers = None + self._prepare_parsers() + + def _prepare_parsers(self): + nonversioned_parser = self._nonversioned_parser() + self.partial_args =\ + nonversioned_parser.parse_known_args(self.raw_args)[0] + self.parser, self.subparsers =\ + self._parser(self.partial_args.tuskar_api_version) def run(self): '''Run the CLI. Parse arguments and do the respective action.''' - nonversioned_parser = self._nonversioned_parser() - partial_args = nonversioned_parser.parse_known_args(self.raw_args)[0] - parser = self._parser(partial_args.tuskar_api_version) - - if partial_args.help or not self.raw_args: - parser.print_help() + # run self.do_help() if we have no raw_args at all or just -h/--help + if not self.raw_args\ + or self.raw_args in (['-h'], ['--help']): + self.do_help(self.partial_args) + return 0 + + args = self.parser.parse_args(self.raw_args) + + # run self.do_help() if we have help subcommand or -h/--help option + if args.func == self.do_help or args.help: + self.do_help(args) return 0 - args = parser.parse_args(self.raw_args) self._ensure_auth_info(args) - tuskar_client = client.get_client(partial_args.tuskar_api_version, + tuskar_client = client.get_client(self.partial_args.tuskar_api_version, **args.__dict__) args.func(tuskar_client, args) @@ -87,12 +105,15 @@ class TuskarShell(object): :param version: version of Tuskar API (and corresponding CLI commands) to use + :return: main parser and subparsers + :rtype: (Parser, Subparsers) ''' parser = self._nonversioned_parser() subparsers = parser.add_subparsers(metavar='') versioned_shell = utils.import_versioned_module(version, 'shell') versioned_shell.enhance_parser(parser, subparsers) - return parser + utils.define_commands_from_module(subparsers, self) + return parser, subparsers def _nonversioned_parser(self): '''Create a basic parser that doesn't contain version-specific @@ -100,10 +121,10 @@ class TuskarShell(object): version should be used for the versioned full blown parser and defining common version-agnostic options. ''' - parser = argparse.ArgumentParser( + parser = self.argument_parser_class( prog='tuskar', description='OpenStack Management CLI', - add_help=False + add_help=False, ) parser.add_argument('-h', '--help', @@ -181,6 +202,22 @@ class TuskarShell(object): return parser + @utils.arg( + 'command', metavar='', nargs='?', + help='Display help for ') + def do_help(self, args): + """Display help about this program or one of its subcommands.""" + if getattr(args, 'command', None): + if args.command in self.subparsers.choices: + # print help for subcommand + self.subparsers.choices[args.command].print_help() + else: + raise exc.CommandError("'%s' is not a valid subcommand" % + args.command) + else: + # print general help + self.parser.print_help() + def main(): try: diff --git a/tuskarclient/tests/integration/__init__.py b/tuskarclient/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tuskarclient/tests/integration/test_help_command.py b/tuskarclient/tests/integration/test_help_command.py new file mode 100644 index 0000000..08a6118 --- /dev/null +++ b/tuskarclient/tests/integration/test_help_command.py @@ -0,0 +1,311 @@ +# 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 tuskarclient.tests.utils as tutils + + +class HelpCommandTest(tutils.CommandTestCase): + pass + +tests = [ + # help + { + 'commands': ['help'], # commands to test "tuskar help" + # helps find failed tests in code - needs "test_" prefix + 'test_identifiers': ['test_help'], + 'out_includes': [ # what should be in output + 'usage:', + 'positional arguments:', + 'optional arguments:', + ], + 'out_excludes': [ # what should not be in output + 'foo bar baz', + ], + 'err_string': '', # how error output should look like + 'return_code': 0, + }, + { + 'commands': ['help -h', 'help --help', 'help help'], + 'test_identifiers': ['test_help_dash_h', + 'test_help_dashdash_help', + 'test_help_help'], + 'out_includes': [ + 'usage:', + 'positional arguments:', + 'optional arguments:', + 'Display help for ', + ], + 'out_excludes': [ + 'flavor-list', + '--os-username OS_USERNAME', + ], + 'err_string': '', + 'return_code': 0, + }, + { + 'commands': ['help -r'], + 'test_identifiers': ['test_help_dash_r'], + 'out_string': '', + 'err_includes': [ + 'error: unrecognized arguments: -r', + ], + 'return_code': 2, + }, + # rack + { + 'commands': ['rack-delete -h', + 'rack-delete --help', + 'help rack-delete'], + 'test_identifiers': ['test_rack_delete_dash_h', + 'test_rack_delete_dashdash_help', + 'test_help_rack_delete'], + 'out_includes': [ + 'usage: tuskar rack-delete [-h] ', + 'positional arguments:', + 'optional arguments:', + '', + ], + 'out_excludes': [ + 'rack-list', + '--os-username OS_USERNAME', + 'Display help for ', + ], + 'err_string': '', + 'return_code': 0, + }, + { + 'commands': ['rack-update -h', + 'rack-update --help', + 'help rack-update'], + 'test_identifiers': ['test_rack_update_dash_h', + 'test_rack_update_dashdash_help', + 'test_help_rack_update'], + 'out_includes': [ + 'usage: tuskar rack-update [-h] [--name NAME] [--subnet SUBNET]', + 'positional arguments:', + '', + 'optional arguments:', + '--name NAME', + '--subnet SUBNET', + '--slots SLOTS', + '--capacities CAPACITIES', + '--resource-class RESOURCE_CLASS', + ], + 'out_excludes': [ + 'rack-list', + '--os-username OS_USERNAME', + 'Display help for ', + ], + 'err_string': '', + 'return_code': 0, + }, + { + 'commands': ['rack-create -h', + 'rack-create --help', + 'help rack-create'], + 'test_identifiers': ['test_rack_create_dash_h', + 'test_rack_create_dashdash_help', + 'test_help_rack_create'], + 'out_includes': [ + 'usage: tuskar rack-create [-h] --subnet SUBNET --slots SLOTS', + 'positional arguments:', + 'optional arguments:', + '--subnet SUBNET', + '--slots SLOTS', + '--capacities CAPACITIES', + '--resource-class RESOURCE_CLASS', + ], + 'out_excludes': [ + 'rack-list', + '--os-username OS_USERNAME', + 'Display help for ', + ], + 'err_string': '', + 'return_code': 0, + }, + { + 'commands': ['rack-show -h', 'rack-show --help', 'help rack-show'], + 'test_identifiers': ['test_rack_show_dash_h', + 'test_rack_show_dashdash_help', + 'test_help_rack_show'], + 'out_includes': [ + 'usage: tuskar rack-show [-h] ', + 'positional arguments:', + 'optional arguments:', + '', + ], + 'out_excludes': [ + 'rack-list', + '--os-username OS_USERNAME', + 'Display help for ', + ], + 'err_string': '', + 'return_code': 0, + }, + { + 'commands': ['rack-list -h', 'rack-list --help', 'help rack-list'], + 'test_identifiers': ['test_rack_list_dash_h', + 'test_rack_list_dashdash_help', + 'test_help_rack_list'], + 'out_includes': [ + 'usage: tuskar rack-list [-h]', + 'optional arguments:', + ], + 'out_excludes': [ + 'rack-show', + '--os-username OS_USERNAME', + 'Display help for ', + 'positional arguments:', + ], + 'err_string': '', + 'return_code': 0, + }, + # flavor + { + 'commands': ['flavor-delete -h', + 'flavor-delete --help', + 'help flavor-delete'], + 'test_identifiers': ['test_flavor_delete_dash_h', + 'test_flavor_delete_dashdash_help', + 'test_help_flavor_delete'], + 'out_includes': [ + 'usage: tuskar flavor-delete [-h]', + 'optional arguments:', + 'positional arguments:', + ], + 'out_excludes': [ + 'flavor-list', + '--os-username OS_USERNAME', + 'Display help for ', + '--capacities CAPACITIES', + ], + 'err_string': '', + 'return_code': 0, + }, + { + 'commands': ['flavor-update -h', + 'flavor-update --help', + 'help flavor-update'], + 'test_identifiers': ['test_flavor_update_dash_h', + 'test_flavor_update_dashdash_help', + 'test_help_flavor_update'], + 'out_includes': [ + 'usage: tuskar flavor-update [-h] [--name NAME]' + + ' [--capacities CAPACITIES]', + 'positional arguments:', + 'optional arguments:', + '--capacities CAPACITIES', + '--name NAME', + '--max-vms MAX_VMS', + ], + 'out_excludes': [ + 'flavor-list', + '--os-username OS_USERNAME', + 'Display help for ', + ], + 'err_string': '', + 'return_code': 0, + }, + { + 'commands': ['flavor-create -h', + 'flavor-create --help', + 'help flavor-create'], + 'test_identifiers': ['test_flavor_create_dash_h', + 'test_flavor_create_dashdash_help', + 'test_help_flavor_create'], + 'out_includes': [ + 'usage:', + 'positional arguments:', + 'optional arguments:', + '--capacities CAPACITIES', + ], + 'out_excludes': [ + 'flavor-list', + '--os-username OS_USERNAME', + 'Display help for ', + ], + 'err_string': '', + 'return_code': 0, + }, + { + 'commands': ['flavor-show -h', + 'flavor-show --help', + 'help flavor-show'], + 'test_identifiers': ['test_flavor_show_dash_h', + 'test_flavor_show_dashdash_help', + 'test_help_flavor_show'], + 'out_includes': [ + 'usage: tuskar flavor-show [-h] ' + + ' ', + 'positional arguments:', + 'optional arguments:', + 'Name or ID of resource class associated to.', + ], + 'out_excludes': [ + 'flavor-list', + '--os-username OS_USERNAME', + 'Display help for ', + '--capacities CAPACITIES', + ], + 'err_string': '', + 'return_code': 0, + }, + { + 'commands': ['flavor-list -h', + 'flavor-list --help', + 'help flavor-list'], + 'test_identifiers': ['test_flavor_list_dash_h', + 'test_flavor_list_dashdash_help', + 'test_help_flavor_list'], + 'out_includes': [ + 'usage: tuskar flavor-list [-h] ', + 'positional arguments:', + 'optional arguments:', + ], + 'out_excludes': [ + '--os-username OS_USERNAME', + 'Display help for ', + '--capacities CAPACITIES', + ], + 'err_string': '', + 'return_code': 0, + }, +] + + +def create_test_method(command, expected_values): + def test_command_method(self): + self.assertThat( + self.run_tuskar(command), + tutils.CommandOutputMatches( + out_str=expected_values.get('out_string'), + out_inc=expected_values.get('out_includes'), + out_exc=expected_values.get('out_excludes'), + err_str=expected_values.get('err_string'), + err_inc=expected_values.get('err_includes'), + err_exc=expected_values.get('err_excludes'), + )) + return test_command_method + +# creates a method for each command found in tests +# to let developer see what test is failing in test results, +# ie: ... HelpCommandTest.test_help_flavor_list +# this way dev can "just search" for "test_help_flavor_list" +# and he will find actual data used in failing test +for test in tests: + commands = test.get('commands') + for index, command in enumerate(commands): + test_command_method = create_test_method(command, test) + test_command_method.__name__ = test.get('test_identifiers')[index] + setattr(HelpCommandTest, + test_command_method.__name__, + test_command_method) diff --git a/tuskarclient/tests/test_shell.py b/tuskarclient/tests/test_shell.py index 32b76ed..de21b33 100644 --- a/tuskarclient/tests/test_shell.py +++ b/tuskarclient/tests/test_shell.py @@ -24,7 +24,7 @@ class ShellTest(tutils.TestCase): def setUp(self): super(ShellTest, self).setUp() - self.s = shell.TuskarShell({}) + self.s = shell.TuskarShell([]) def empty_args(self): args = lambda: None # i'd use object(), but it can't have attributes @@ -61,7 +61,7 @@ class ShellTest(tutils.TestCase): v1_commands = [ 'rack-list', 'rack-show', ] - parser = self.s._parser(1) + parser, subparsers = self.s._parser(1) tuskar_help = parser.format_help() for arg in map(lambda a: a.replace('_', '-'), self.args_attributes): diff --git a/tuskarclient/tests/utils.py b/tuskarclient/tests/utils.py index a66d728..85a63a5 100644 --- a/tuskarclient/tests/utils.py +++ b/tuskarclient/tests/utils.py @@ -10,14 +10,18 @@ # License for the specific language governing permissions and limitations # under the License. +import argparse import copy import fixtures +from gettext import gettext as _ import os +import sys from six import StringIO import testtools from tuskarclient.common import http +from tuskarclient import shell class TestCase(testtools.TestCase): @@ -34,6 +38,196 @@ class TestCase(testtools.TestCase): self.useFixture(fixtures.MonkeyPatch('sys.stderr', stderr)) +class CommandTestCase(TestCase): + def setUp(self): + super(CommandTestCase, self).setUp() + self.tuskar_bin = os.path.join( + os.path.dirname(os.path.realpath(sys.executable)), + 'tuskar') + + def run_tuskar(self, params=''): + args = params.split() + out = StringIO() + err = StringIO() + ArgumentParserForTests.OUT = out + ArgumentParserForTests.ERR = err + try: + shell.TuskarShell( + args, argument_parser_class=ArgumentParserForTests).run() + except TestExit: + pass + outvalue = out.getvalue() + errvalue = err.getvalue() + return [outvalue, errvalue] + + +class CommandOutputMatches(object): + def __init__(self, + out_str=None, out_inc=None, out_exc=None, + err_str=None, err_inc=None, err_exc=None, + return_code=None): + self.out_str = out_str + self.out_inc = out_inc or [] + self.out_exc = out_exc or [] + self.err_str = err_str + self.err_inc = err_inc or [] + self.err_exc = err_exc or [] + self.return_code = return_code + + def match(self, outputs): + out, err = outputs[0], outputs[1] + errors = [] + + # tests for exact output and error output match + errors.append(self.match_output(out, self.out_str, type='output')) + errors.append(self.match_output(err, self.err_str, type='error')) + + # tests for what output should include and what it should not + errors.append(self.match_includes(out, self.out_inc, type='output')) + errors.append(self.match_excludes(out, self.out_exc, type='output')) + + # tests for what error output should include and what it should not + errors.append(self.match_includes(err, self.err_inc, type='error')) + errors.append(self.match_excludes(err, self.err_exc, type='error')) + + # get first non None item or None if none is found and return it + return next((item for item in errors if item is not None), None) + + def match_return_code(self, return_code, expected_return_code): + if expected_return_code is not None: + if expected_return_code != return_code: + return CommandOutputReturnCodeMismatch( + return_code, expected_return_code) + + def match_output(self, output, expected_output, type='output'): + if expected_output is not None: + if expected_output != output: + return CommandOutputMismatch( + output, expected_output, type=type) + + def match_includes(self, output, includes, type='output'): + for part in includes: + if part not in output: + return CommandOutputMissingMismatch(output, part, type=type) + + def match_excludes(self, output, excludes, type='error'): + for part in excludes: + if part in output: + return CommandOutputExtraMismatch(output, part, type=type) + + +class CommandOutputMismatch(object): + def __init__(self, out, out_str, type='output'): + if type == 'error': + self.type = 'Error output' + else: + self.type = 'Output' + self.out = out + self.out_str = out_str + + def describe(self): + return "%s '%s' should be '%s'" % (self.type, self.out, self.out_str) + + def get_details(self): + return {} + + +class CommandOutputMissingMismatch(object): + def __init__(self, out, out_inc, type='output'): + if type == 'error': + self.type = 'Error output' + else: + self.type = 'Output' + self.out = out + self.out_inc = out_inc + + def describe(self): + return "%s '%s' should contain '%s'"\ + % (self.type, self.out, self.out_inc) + + def get_details(self): + return {} + + +class CommandOutputExtraMismatch(object): + def __init__(self, out, out_exc, type='output'): + if type == 'error': + self.type = 'Error output' + else: + self.type = 'Output' + self.out = out + self.out_exc = out_exc + + def describe(self): + return "%s '%s' should not contain '%s'"\ + % (self.type, self.out, self.out_exc) + + def get_details(self): + return {} + + +class CommandOutputReturnCodeMismatch(object): + def __init__(self, ret, ret_exp): + self.ret = ret + self.ret_exp = ret_exp + + def describe(self): + return "Return code is '%s' but expected '%s'"\ + % (self.ret, self.ret_exp) + + def get_details(self): + return {} + + +class TestExit(Exception): + pass + + +class ArgumentParserForTests(argparse.ArgumentParser): + OUT = sys.stdout + ERR = sys.stderr + + def __init__(self, **kwargs): + self.out = ArgumentParserForTests.OUT + self.err = ArgumentParserForTests.ERR + + super(ArgumentParserForTests, self).__init__(**kwargs) + + def error(self, message): + self.print_usage(self.err) + self.exit(2, _('%(prog)s: error: %(message)s\n') % + {'prog': self.prog, 'message': message}) + + def exit(self, status=0, message=None): + if message: + self._print_message(message, self.err) + raise TestExit + + def print_usage(self, file=None): + if file is None: + file = self.out + self._print_message(self.format_usage(), file) + + def print_help(self, file=None): + if file is None: + file = self.out + self._print_message(self.format_help(), file) + + def print_version(self, file=None): + import warnings + warnings.warn( + 'The print_version method is deprecated -- the "version" ' + 'argument to ArgumentParser is no longer supported.', + DeprecationWarning) + self._print_message(self.format_version(), file) + + def _print_message(self, message, file=None): + if message: + if file is None: + file = self.err + file.write(message) + + class FakeAPI(object): def __init__(self, fixtures): self.fixtures = fixtures