diff options
-rw-r--r-- | cmd2/cmd2.py | 29 | ||||
-rw-r--r-- | isolated_tests/test_commandset/test_commandset.py | 170 |
2 files changed, 187 insertions, 12 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 2a1bcbf2..ea590fac 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -427,7 +427,7 @@ class Cmd(cmd.Cmd): methods = inspect.getmembers( cmdset, predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) - and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) installed_attributes = [] try: @@ -435,21 +435,21 @@ class Cmd(cmd.Cmd): command = method_name[len(COMMAND_FUNC_PREFIX):] command_wrapper = _partial_passthru(method, self) - self.__install_command_function(command, command_wrapper, type(cmdset).__name__) + self._install_command_function(command, command_wrapper, type(cmdset).__name__) installed_attributes.append(method_name) completer_func_name = COMPLETER_FUNC_PREFIX + command cmd_completer = getattr(cmdset, completer_func_name, None) if cmd_completer is not None: completer_wrapper = _partial_passthru(cmd_completer, self) - self.__install_completer_function(command, completer_wrapper) + self._install_completer_function(command, completer_wrapper) installed_attributes.append(completer_func_name) help_func_name = HELP_FUNC_PREFIX + command cmd_help = getattr(cmdset, help_func_name, None) if cmd_help is not None: help_wrapper = _partial_passthru(cmd_help, self) - self.__install_help_function(command, help_wrapper) + self._install_help_function(command, help_wrapper) installed_attributes.append(help_func_name) self._installed_command_sets.append(cmdset) @@ -458,9 +458,10 @@ class Cmd(cmd.Cmd): except Exception: for attrib in installed_attributes: delattr(self, attrib) + self._installed_command_sets.remove(cmdset) raise - def __install_command_function(self, command, command_wrapper, context=''): + def _install_command_function(self, command: str, command_wrapper: Callable, context=''): cmd_func_name = COMMAND_FUNC_PREFIX + command # Make sure command function doesn't share naem with existing attribute @@ -484,19 +485,19 @@ class Cmd(cmd.Cmd): setattr(self, cmd_func_name, command_wrapper) - def __install_completer_function(self, cmd_name, cmd_completer): + def _install_completer_function(self, cmd_name: str, cmd_completer: Callable): completer_func_name = COMPLETER_FUNC_PREFIX + cmd_name if hasattr(self, completer_func_name): raise ValueError('Attribute already exists: {}'.format(completer_func_name)) setattr(self, completer_func_name, cmd_completer) - def __install_help_function(self, cmd_name, cmd_completer): + def _install_help_function(self, cmd_name: str, cmd_help: Callable): help_func_name = HELP_FUNC_PREFIX + cmd_name if hasattr(self, help_func_name): raise ValueError('Attribute already exists: {}'.format(help_func_name)) - setattr(self, help_func_name, cmd_completer) + setattr(self, help_func_name, cmd_help) def uninstall_command_set(self, cmdset: CommandSet): """ @@ -551,10 +552,12 @@ class Cmd(cmd.Cmd): # Search for the base command function and verify it has an argparser defined command_func = self.cmd_func(command_name) if command_func is None or not hasattr(command_func, constants.CMD_ATTR_ARGPARSER): - raise TypeError('Could not find command: ' + command_name + ' needed by sub-command ' + str(method)) + raise TypeError('Could not find command "{}" needed by sub-command: {}' + .format(command_name, str(method))) command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER) if command_parser is None: - raise TypeError('Could not find argparser for command: ' + command_name + ' needed by sub-command ' + str(method)) + raise TypeError('Could not find argparser for command "{}" needed by sub-command: {}' + .format(command_name, str(method))) if hasattr(method, '__doc__') and method.__doc__ is not None: help_text = method.__doc__.splitlines()[0] @@ -597,10 +600,12 @@ class Cmd(cmd.Cmd): # Search for the base command function and verify it has an argparser defined command_func = self.cmd_func(command_name) if command_func is None or not hasattr(command_func, constants.CMD_ATTR_ARGPARSER): - raise TypeError('Could not find command: ' + command_name + ' needed by sub-command ' + str(method)) + raise TypeError('Could not find command "{}" needed by sub-command: {}' + .format(command_name, str(method))) command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER) if command_parser is None: - raise TypeError('Could not find argparser for command: ' + command_name + ' needed by sub-command ' + str(method)) + raise TypeError('Could not find argparser for command "{}" needed by sub-command: {}' + .format(command_name, str(method))) for action in command_parser._actions: if isinstance(action, _UnloadableSubParsersAction): diff --git a/isolated_tests/test_commandset/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py index c94c6690..c7b2ac70 100644 --- a/isolated_tests/test_commandset/test_commandset.py +++ b/isolated_tests/test_commandset/test_commandset.py @@ -121,6 +121,10 @@ def test_custom_construct_commandsets(): with pytest.raises(ValueError): assert app.install_command_set(command_set_2) + # Verify that autoload doesn't conflict with a manually loaded CommandSet that could be autoloaded. + app2 = WithCommandSets(command_sets=[CommandSetA()]) + assert hasattr(app2, 'do_apple') + def test_load_commands(command_sets_manual): @@ -144,6 +148,9 @@ def test_load_commands(command_sets_manual): assert 'Alone' not in cmds_cats assert 'Fruits' not in cmds_cats + # uninstall a second time and verify no errors happen + command_sets_manual.uninstall_command_set(cmd_set) + # reinstall the command set and verify it is accessible command_sets_manual.install_command_set(cmd_set) @@ -211,3 +218,166 @@ def test_commandset_decorators(command_sets_app): assert len(result.stderr) > 0 assert 'unrecognized arguments' in result.stderr assert result.data is None + + +def test_load_commandset_errors(command_sets_manual, capsys): + cmd_set = CommandSetA() + + # create a conflicting command before installing CommandSet to verify rollback behavior + command_sets_manual._install_command_function('durian', cmd_set.do_durian) + with pytest.raises(ValueError): + command_sets_manual.install_command_set(cmd_set) + + # verify that the commands weren't installed + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'Alone' not in cmds_cats + assert 'Fruits' not in cmds_cats + assert not command_sets_manual._installed_command_sets + + delattr(command_sets_manual, 'do_durian') + + # pre-create intentionally conflicting macro and alias names + command_sets_manual.app_cmd('macro create apple run_pyscript') + command_sets_manual.app_cmd('alias create banana run_pyscript') + + # now install a command set and verify the commands are now present + command_sets_manual.install_command_set(cmd_set) + out, err = capsys.readouterr() + + # verify aliases and macros are deleted with warning if they conflict with a command + assert "Deleting alias 'banana'" in err + assert "Deleting macro 'apple'" in err + + # verify duplicate commands are detected + with pytest.raises(ValueError): + command_sets_manual._install_command_function('banana', cmd_set.do_banana) + + # verify bad command names are detected + with pytest.raises(ValueError): + command_sets_manual._install_command_function('bad command', cmd_set.do_banana) + + # verify error conflict with existing completer function + with pytest.raises(ValueError): + command_sets_manual._install_completer_function('durian', cmd_set.complete_durian) + + # verify error conflict with existing help function + with pytest.raises(ValueError): + command_sets_manual._install_help_function('cranberry', cmd_set.help_cranberry) + + +class LoadableBase(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadableBase, self).__init__() + self._dummy = dummy # prevents autoload + + cut_parser = cmd2.Cmd2ArgumentParser('cut') + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut', unloadable=True) + + @cmd2.with_argparser(cut_parser) + def do_cut(self, ns: argparse.Namespace): + """Cut something""" + func = getattr(ns, 'handler', None) + if func is not None: + # Call whatever subcommand function was selected + func(ns) + else: + # No subcommand was provided, so call help + self.poutput('This command does nothing without sub-parsers registered') + self.do_help('cut') + + +class LoadableBadBase(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadableBadBase, self).__init__() + self._dummy = dummy # prevents autoload + + def do_cut(self, ns: argparse.Namespace): + """Cut something""" + func = getattr(ns, 'handler', None) + if func is not None: + # Call whatever subcommand function was selected + func(ns) + else: + # No subcommand was provided, so call help + self.poutput('This command does nothing without sub-parsers registered') + self.do_help('cut') + + +@cmd2.with_default_category('Fruits') +class LoadableFruits(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadableFruits, self).__init__() + self._dummy = dummy # prevents autoload + + def do_apple(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Apple') + + banana_parser = cmd2.Cmd2ArgumentParser(add_help=False) + banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) + + @cmd2.as_subcommand_to('cut', 'banana', banana_parser) + def cut_banana(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + """Cut banana""" + cmd.poutput('cutting banana: ' + ns.direction) + + +@cmd2.with_default_category('Vegetables') +class LoadableVegetables(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadableVegetables, self).__init__() + self._dummy = dummy # prevents autoload + + def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arugula') + + bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False) + bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) + + @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) + def cut_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Bok Choy') + + +def test_subcommands(command_sets_manual): + + base_cmds = LoadableBase(1) + badbase_cmds = LoadableBadBase(1) + fruit_cmds = LoadableFruits(1) + veg_cmds = LoadableVegetables(1) + + # installing sub-commands without base command present raises exception + with pytest.raises(TypeError): + command_sets_manual.install_command_set(fruit_cmds) + + # if the base command is present but isn't an argparse command, expect exception + command_sets_manual.install_command_set(badbase_cmds) + with pytest.raises(TypeError): + command_sets_manual.install_command_set(fruit_cmds) + + # verify that the commands weren't installed + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'cut' in cmds_doc + assert 'Fruits' not in cmds_cats + + # Now install the good base commands + command_sets_manual.uninstall_command_set(badbase_cmds) + command_sets_manual.install_command_set(base_cmds) + + # verify that we catch an attempt to register subcommands when the commandset isn't installed + with pytest.raises(ValueError): + command_sets_manual._register_subcommands(fruit_cmds) + + # verify that command set install and uninstalls without problems + command_sets_manual.install_command_set(fruit_cmds) + command_sets_manual.install_command_set(veg_cmds) + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'Fruits' in cmds_cats + + command_sets_manual.uninstall_command_set(fruit_cmds) + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'Fruits' not in cmds_cats + + # verify a double-unregister raises exception + with pytest.raises(ValueError): + command_sets_manual._unregister_subcommands(fruit_cmds) |