diff options
-rw-r--r-- | cmd2/argparse_completer.py | 22 | ||||
-rw-r--r-- | cmd2/argparse_custom.py | 46 | ||||
-rw-r--r-- | cmd2/cmd2.py | 140 | ||||
-rw-r--r-- | cmd2/constants.py | 3 | ||||
-rw-r--r-- | cmd2/decorators.py | 9 | ||||
-rw-r--r-- | cmd2/exceptions.py | 3 | ||||
-rw-r--r-- | isolated_tests/test_commandset/test_commandset.py | 396 |
7 files changed, 539 insertions, 80 deletions
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 6acb5abc..0225d22f 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -23,6 +23,7 @@ from .argparse_custom import ( CompletionItem, generate_range_error, ) +from .command_definition import CommandSet from .table_creator import Column, SimpleTable from .utils import CompletionError, basic_complete @@ -181,7 +182,8 @@ class ArgparseCompleter: if isinstance(action, argparse._SubParsersAction): self._subcommand_action = action - def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int, *, + cmd_set: Optional[CommandSet] = None) -> List[str]: """ Complete the command using the argparse metadata and provided argument dictionary :raises: CompletionError for various types of tab completion errors @@ -358,7 +360,8 @@ class ArgparseCompleter: completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app, parent_tokens=parent_tokens) - return completer.complete_command(tokens[token_index:], text, line, begidx, endidx) + return completer.complete_command(tokens[token_index:], text, line, begidx, endidx, + cmd_set=cmd_set) else: # Invalid subcommand entered, so no way to complete remaining tokens return [] @@ -403,7 +406,8 @@ class ArgparseCompleter: # Check if we are completing a flag's argument if flag_arg_state is not None: completion_results = self._complete_for_arg(flag_arg_state.action, text, line, - begidx, endidx, consumed_arg_values) + begidx, endidx, consumed_arg_values, + cmd_set=cmd_set) # If we have results, then return them if completion_results: @@ -423,7 +427,8 @@ class ArgparseCompleter: pos_arg_state = _ArgumentState(action) completion_results = self._complete_for_arg(pos_arg_state.action, text, line, - begidx, endidx, consumed_arg_values) + begidx, endidx, consumed_arg_values, + cmd_set=cmd_set) # If we have results, then return them if completion_results: @@ -543,7 +548,8 @@ class ArgparseCompleter: def _complete_for_arg(self, arg_action: argparse.Action, text: str, line: str, begidx: int, endidx: int, - consumed_arg_values: Dict[str, List[str]]) -> List[str]: + consumed_arg_values: Dict[str, List[str]], *, + cmd_set: Optional[CommandSet] = None) -> List[str]: """ Tab completion routine for an argparse argument :return: list of completions @@ -563,6 +569,12 @@ class ArgparseCompleter: kwargs = {} if isinstance(arg_choices, ChoicesCallable): if arg_choices.is_method: + cmd_set = getattr(self._parser, constants.PARSER_ATTR_COMMANDSET, cmd_set) + if cmd_set is not None: + if isinstance(cmd_set, CommandSet): + # If command is part of a CommandSet, `self` should be the CommandSet and Cmd will be next + if cmd_set is not None: + args.append(cmd_set) args.append(self._cmd2_app) # Check if arg_choices.to_call expects arg_tokens diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 39ce81f4..e08db005 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -60,18 +60,32 @@ cases where the choice list is dynamically generated when the user hits tab. parser.add_argument('-o', '--options', choices_function=my_choices_function) -``choices_method`` - this is exactly like choices_function, but the function -needs to be an instance method of a cmd2-based class. When ArgparseCompleter -calls the method, it will pass the app instance as the self argument. This is -good in cases where the list of choices being generated relies on state data of -the cmd2-based app - - Example:: +``choices_method`` - this is equivalent to choices_function, but the function +needs to be an instance method of a cmd2.Cmd or cmd2.CommandSet subclass. When +ArgparseCompleter calls the method, it well detect whether is is bound to a +CommandSet or Cmd subclass. +If bound to a cmd2.Cmd subclass, it will pass the app instance as the `self` +argument. This is good in cases where the list of choices being generated +relies on state data of the cmd2-based app. +If bound to a cmd2.CommandSet subclass, it will pass the CommandSet instance +as the `self` argument, and the app instance as the positional argument. + + Example bound to cmd2.Cmd:: def my_choices_method(self): ... return my_generated_list + parser.add_argument("arg", choices_method=my_choices_method) + + Example bound to cmd2.CommandSEt:: + + def my_choices_method(self, app: cmd2.Cmd): + ... + return my_generated_list + + parser.add_argument("arg", choices_method=my_choices_method) + ``completer_function`` - pass a tab completion function that does custom completion. Since custom tab completion operations commonly need to modify cmd2's instance variables related to tab completion, it will be rare to need a @@ -84,10 +98,16 @@ completer function. completer_method should be used in those cases. return completions parser.add_argument('-o', '--options', completer_function=my_completer_function) -``completer_method`` - this is exactly like completer_function, but the -function needs to be an instance method of a cmd2-based class. When -ArgparseCompleter calls the method, it will pass the app instance as the self -argument. cmd2 provides a few completer methods for convenience (e.g., +``completer_method`` - this is equivalent to completer_function, but the function +needs to be an instance method of a cmd2.Cmd or cmd2.CommandSet subclass. When +ArgparseCompleter calls the method, it well detect whether is is bound to a +CommandSet or Cmd subclass. +If bound to a cmd2.Cmd subclass, it will pass the app instance as the `self` +argument. This is good in cases where the list of choices being generated +relies on state data of the cmd2-based app. +If bound to a cmd2.CommandSet subclass, it will pass the CommandSet instance +as the `self` argument, and the app instance as the positional argument. +cmd2 provides a few completer methods for convenience (e.g., path_complete, delimiter_complete) Example:: @@ -560,6 +580,10 @@ def _SubParsersAction_remove_parser(self, name: str): for name in to_remove: del self._name_parser_map[name] + if name in self.choices: + del self.choices[name] + + # noinspection PyProtectedMember setattr(argparse._SubParsersAction, 'remove_parser', _SubParsersAction_remove_parser) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 999c97cb..65aa88e0 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -48,7 +48,14 @@ from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .command_definition import CommandSet, _partial_passthru from .constants import COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX from .decorators import with_argparser -from .exceptions import Cmd2ShlexError, EmbeddedConsoleExit, EmptyStatement, RedirectionError, SkipPostcommandHooks +from .exceptions import ( + CommandSetRegistrationError, + Cmd2ShlexError, + EmbeddedConsoleExit, + EmptyStatement, + RedirectionError, + SkipPostcommandHooks +) from .history import History, HistoryItem from .parsing import Macro, MacroArg, Statement, StatementParser, shlex_split from .rl_utils import RlType, rl_get_point, rl_make_safe_prompt, rl_set_prompt, rl_type, rl_warning, vt100_support @@ -245,8 +252,8 @@ class Cmd(cmd.Cmd): shortcuts=shortcuts) # Load modular commands - self._installed_functions = [] # type: List[str] self._installed_command_sets = [] # type: List[CommandSet] + self._cmd_to_command_sets = {} # type: Dict[str, CommandSet] if command_sets: for command_set in command_sets: self.install_command_set(command_set) @@ -260,8 +267,6 @@ class Cmd(cmd.Cmd): if not valid: raise ValueError("Invalid command name {!r}: {}".format(cur_cmd, errmsg)) - self._register_subcommands(self) - # Stores results from the last command run to enable usage of results in a Python script or interactive console # Built-in commands don't make use of this. It is purely there for user-defined commands and convenience. self.last_result = None @@ -399,6 +404,8 @@ class Cmd(cmd.Cmd): # If False, then complete() will sort the matches using self.default_sort_key before they are displayed. self.matches_sorted = False + self._register_subcommands(self) + def _autoload_commands(self) -> None: """Load modular command definitions.""" # Search for all subclasses of CommandSet, instantiate them if they weren't provided in the constructor @@ -406,12 +413,11 @@ class Cmd(cmd.Cmd): existing_commandset_types = [type(command_set) for command_set in self._installed_command_sets] for cmdset_type in all_commandset_defs: init_sig = inspect.signature(cmdset_type.__init__) - if cmdset_type in existing_commandset_types or \ - len(init_sig.parameters) != 1 or \ - 'self' not in init_sig.parameters: - continue - cmdset = cmdset_type() - self.install_command_set(cmdset) + if not (cmdset_type in existing_commandset_types or + len(init_sig.parameters) != 1 or + 'self' not in init_sig.parameters): + cmdset = cmdset_type() + self.install_command_set(cmdset) def install_command_set(self, cmdset: CommandSet) -> None: """ @@ -421,7 +427,7 @@ class Cmd(cmd.Cmd): """ existing_commandset_types = [type(command_set) for command_set in self._installed_command_sets] if type(cmdset) in existing_commandset_types: - raise ValueError('CommandSet ' + type(cmdset).__name__ + ' is already installed') + raise CommandSetRegistrationError('CommandSet ' + type(cmdset).__name__ + ' is already installed') cmdset.on_register(self) methods = inspect.getmembers( @@ -452,6 +458,8 @@ class Cmd(cmd.Cmd): self._install_help_function(command, help_wrapper) installed_attributes.append(help_func_name) + self._cmd_to_command_sets[command] = cmdset + self._installed_command_sets.append(cmdset) self._register_subcommands(cmdset) @@ -460,19 +468,22 @@ class Cmd(cmd.Cmd): delattr(self, attrib) if cmdset in self._installed_command_sets: self._installed_command_sets.remove(cmdset) + if cmdset in self._cmd_to_command_sets.values(): + self._cmd_to_command_sets = \ + {key: val for key, val in self._cmd_to_command_sets.items() if val is not cmdset} raise 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 + # Make sure command function doesn't share name with existing attribute if hasattr(self, cmd_func_name): - raise ValueError('Attribute already exists: {} ({})'.format(cmd_func_name, context)) + raise CommandSetRegistrationError('Attribute already exists: {} ({})'.format(cmd_func_name, context)) # Check if command has an invalid name valid, errmsg = self.statement_parser.is_valid_command(command) if not valid: - raise ValueError("Invalid command name {!r}: {}".format(command, errmsg)) + raise CommandSetRegistrationError("Invalid command name {!r}: {}".format(command, errmsg)) # Check if command shares a name with an alias if command in self.aliases: @@ -490,14 +501,14 @@ class Cmd(cmd.Cmd): completer_func_name = COMPLETER_FUNC_PREFIX + cmd_name if hasattr(self, completer_func_name): - raise ValueError('Attribute already exists: {}'.format(completer_func_name)) + raise CommandSetRegistrationError('Attribute already exists: {}'.format(completer_func_name)) setattr(self, completer_func_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)) + raise CommandSetRegistrationError('Attribute already exists: {}'.format(help_func_name)) setattr(self, help_func_name, cmd_help) def uninstall_command_set(self, cmdset: CommandSet): @@ -506,7 +517,7 @@ class Cmd(cmd.Cmd): :param cmdset: CommandSet to uninstall """ if cmdset in self._installed_command_sets: - + self._check_uninstallable(cmdset) self._unregister_subcommands(cmdset) methods = inspect.getmembers( @@ -522,6 +533,9 @@ class Cmd(cmd.Cmd): if cmd_name in self.disabled_commands: self.enable_command(cmd_name) + if cmd_name in self._cmd_to_command_sets: + del self._cmd_to_command_sets[cmd_name] + delattr(self, COMMAND_FUNC_PREFIX + cmd_name) if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name): @@ -532,14 +546,42 @@ class Cmd(cmd.Cmd): cmdset.on_unregister(self) self._installed_command_sets.remove(cmdset) + def _check_uninstallable(self, cmdset: CommandSet): + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: isinstance(meth, Callable) + and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + + for method in methods: + command_name = method[0][len(COMMAND_FUNC_PREFIX):] + + # Search for the base command function and verify it has an argparser defined + if command_name in self.disabled_commands: + command_func = self.disabled_commands[command_name].command_function + else: + command_func = self.cmd_func(command_name) + + command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) + def check_parser_uninstallable(parser): + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + for subparser in action.choices.values(): + attached_cmdset = getattr(subparser, constants.PARSER_ATTR_COMMANDSET, None) + if attached_cmdset is not None and attached_cmdset is not cmdset: + raise CommandSetRegistrationError( + 'Cannot uninstall CommandSet when another CommandSet depends on it') + check_parser_uninstallable(subparser) + if command_parser is not None: + check_parser_uninstallable(command_parser) + def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: """ Register subcommands with their base command - :param cmdset: CommandSet containing subcommands + :param cmdset: CommandSet or cmd2.Cmd subclass containing subcommands """ if not (cmdset is self or cmdset in self._installed_command_sets): - raise ValueError('Adding subcommands from an unregistered CommandSet') + raise CommandSetRegistrationError('Cannot register subcommands with an unregistered CommandSet') # find all methods that start with the subcommand prefix methods = inspect.getmembers( @@ -553,10 +595,14 @@ class Cmd(cmd.Cmd): # iterate through all matching methods for method_name, method in methods: subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME) - command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) + full_command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) # type: str subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER) parser_args = getattr(method, constants.SUBCMD_ATTR_PARSER_ARGS, {}) + command_tokens = full_command_name.split() + command_name = command_tokens[0] + subcommand_names = command_tokens[1:] + # Search for the base command function and verify it has an argparser defined if command_name in self.disabled_commands: command_func = self.disabled_commands[command_name].command_function @@ -564,12 +610,12 @@ class Cmd(cmd.Cmd): command_func = self.cmd_func(command_name) if command_func is None: - raise TypeError('Could not find command "{}" needed by subcommand: {}' - .format(command_name, str(method))) + raise CommandSetRegistrationError('Could not find command "{}" needed by subcommand: {}' + .format(command_name, str(method))) command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) if command_parser is None: - raise TypeError('Could not find argparser for command "{}" needed by subcommand: {}' - .format(command_name, str(method))) + raise CommandSetRegistrationError('Could not find argparser for command "{}" needed by subcommand: {}' + .format(command_name, str(method))) if isinstance(cmdset, CommandSet): command_handler = _partial_passthru(method, self) @@ -577,9 +623,23 @@ class Cmd(cmd.Cmd): command_handler = method subcmd_parser.set_defaults(handler=command_handler) - for action in command_parser._actions: + def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) -> argparse.ArgumentParser: + if not subcmd_names: + return action + cur_subcmd = subcmd_names.pop(0) + for sub_action in action._actions: + if isinstance(sub_action, argparse._SubParsersAction): + for choice_name, choice in sub_action.choices.items(): + if choice_name == cur_subcmd: + return find_subcommand(choice, subcmd_names) + raise CommandSetRegistrationError('Could not find sub-command "{}"'.format(full_command_name)) + + target_parser = find_subcommand(command_parser, subcommand_names) + + for action in target_parser._actions: if isinstance(action, argparse._SubParsersAction): - action.add_parser(subcommand_name, parents=[subcmd_parser], **parser_args) + attached_parser = action.add_parser(subcommand_name, parents=[subcmd_parser], **parser_args) + setattr(attached_parser, constants.PARSER_ATTR_COMMANDSET, cmdset) def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: """ @@ -588,7 +648,7 @@ class Cmd(cmd.Cmd): :param cmdset: CommandSet containing subcommands """ if not (cmdset is self or cmdset in self._installed_command_sets): - raise ValueError('Removing subcommands from an unregistered CommandSet') + raise CommandSetRegistrationError('Cannot unregister subcommands with an unregistered CommandSet') # find all methods that start with the subcommand prefix methods = inspect.getmembers( @@ -610,13 +670,17 @@ class Cmd(cmd.Cmd): else: command_func = self.cmd_func(command_name) - if command_func is None: - raise TypeError('Could not find command "{}" needed by subcommand: {}' - .format(command_name, str(method))) + if command_func is None: # pragma: no cover + # This really shouldn't be possible since _register_subcommands would prevent this from happening + # but keeping in case it does for some strange reason + raise CommandSetRegistrationError('Could not find command "{}" needed by subcommand: {}' + .format(command_name, str(method))) command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) - if command_parser is None: - raise TypeError('Could not find argparser for command "{}" needed by subcommand: {}' - .format(command_name, str(method))) + if command_parser is None: # pragma: no cover + # This really shouldn't be possible since _register_subcommands would prevent this from happening + # but keeping in case it does for some strange reason + raise CommandSetRegistrationError('Could not find argparser for command "{}" needed by subcommand: {}' + .format(command_name, str(method))) for action in command_parser._actions: if isinstance(action, argparse._SubParsersAction): @@ -1439,6 +1503,7 @@ class Cmd(cmd.Cmd): # Parse the command line statement = self.statement_parser.parse_command_only(line) command = statement.command + cmd_set = self._cmd_to_command_sets[command] if command in self._cmd_to_command_sets else None expanded_line = statement.command_and_args # We overwrote line with a properly formatted but fully stripped version @@ -1509,7 +1574,8 @@ class Cmd(cmd.Cmd): import functools compfunc = functools.partial(self._complete_argparse_command, argparser=argparser, - preserve_quotes=getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES)) + preserve_quotes=getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES), + cmd_set=cmd_set) else: compfunc = self.completedefault @@ -1677,7 +1743,9 @@ class Cmd(cmd.Cmd): return None def _complete_argparse_command(self, text: str, line: str, begidx: int, endidx: int, *, - argparser: argparse.ArgumentParser, preserve_quotes: bool) -> List[str]: + argparser: argparse.ArgumentParser, + preserve_quotes: bool, + cmd_set: Optional[CommandSet] = None) -> List[str]: """Completion function for argparse commands""" from .argparse_completer import ArgparseCompleter completer = ArgparseCompleter(argparser, self) @@ -1686,7 +1754,7 @@ class Cmd(cmd.Cmd): # To have tab completion parsing match command line parsing behavior, # use preserve_quotes to determine if we parse the quoted or unquoted tokens. tokens_to_parse = raw_tokens if preserve_quotes else tokens - return completer.complete_command(tokens_to_parse, text, line, begidx, endidx) + return completer.complete_command(tokens_to_parse, text, line, begidx, endidx, cmd_set=cmd_set) def in_script(self) -> bool: """Return whether a text script is running""" diff --git a/cmd2/constants.py b/cmd2/constants.py index 88a1bb82..a88ad1e2 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -54,3 +54,6 @@ CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes' SUBCMD_ATTR_COMMAND = 'parent_command' SUBCMD_ATTR_NAME = 'subcommand_name' SUBCMD_ATTR_PARSER_ARGS = 'subcommand_parser_args' + +# arpparse attribute linking to command set instance +PARSER_ATTR_COMMANDSET = 'command_set' diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 82ad8cd7..9704abbf 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -7,7 +7,7 @@ from . import constants from .exceptions import Cmd2ArgparseError from .parsing import Statement -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover import cmd2 @@ -53,7 +53,10 @@ def _parse_positionals(args: Tuple) -> Tuple['cmd2.Cmd', Union[Statement, str]]: next_arg = args[pos + 1] if isinstance(next_arg, (Statement, str)): return arg, args[pos + 1] - raise TypeError('Expected arguments: cmd: cmd2.Cmd, statement: Union[Statement, str] Not found') + + # This shouldn't happen unless we forget to pass statement in `Cmd.onecmd` or + # somehow call the unbound class method. + raise TypeError('Expected arguments: cmd: cmd2.Cmd, statement: Union[Statement, str] Not found') # pragma: no cover def _arg_swap(args: Union[Tuple[Any], List[Any]], search_arg: Any, *replace_arg: Any) -> List[Any]: @@ -346,7 +349,7 @@ def as_subcommand_to(command: str, """ Tag this method as a subcommand to an existing argparse decorated command. - :param command: Command Name + :param command: Command Name. Space-delimited subcommands may optionally be specified :param subcommand: Subcommand name :param parser: argparse Parser for this subcommand :param help_text: Help message for this subcommand diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index 8a7fd81f..c1815e1b 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -24,6 +24,9 @@ class Cmd2ArgparseError(SkipPostcommandHooks): pass +class CommandSetRegistrationError(Exception): + pass + ############################################################################################################ # The following exceptions are NOT part of the public API and are intended for internal use only. ############################################################################################################ diff --git a/isolated_tests/test_commandset/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py index f16a6ff4..5387a9ff 100644 --- a/isolated_tests/test_commandset/test_commandset.py +++ b/isolated_tests/test_commandset/test_commandset.py @@ -12,6 +12,8 @@ import pytest import cmd2 from cmd2 import utils from cmd2_ext_test import ExternalTestMixin +from .conftest import complete_tester, run_cmd +from cmd2.exceptions import CommandSetRegistrationError @cmd2.with_default_category('Fruits') @@ -58,12 +60,6 @@ class CommandSetA(cmd2.CommandSet): cmd.last_result = {'arg1': ns.arg1} -class WithCommandSets(ExternalTestMixin, cmd2.Cmd): - """Class for testing custom help_* methods which override docstring help.""" - def __init__(self, *args, **kwargs): - super(WithCommandSets, self).__init__(*args, **kwargs) - - @cmd2.with_default_category('Command Set B') class CommandSetB(cmd2.CommandSet): def __init__(self, arg1): @@ -81,6 +77,12 @@ class CommandSetB(cmd2.CommandSet): cmd.poutput('Crocodile!!') +class WithCommandSets(ExternalTestMixin, cmd2.Cmd): + """Class for testing custom help_* methods which override docstring help.""" + def __init__(self, *args, **kwargs): + super(WithCommandSets, self).__init__(*args, **kwargs) + + @pytest.fixture def command_sets_app(): app = WithCommandSets() @@ -107,6 +109,8 @@ def test_autoload_commands(command_sets_app): assert 'Fruits' in cmds_cats assert 'cranberry' in cmds_cats['Fruits'] + assert 'Command Set B' not in cmds_cats + def test_custom_construct_commandsets(): # Verifies that a custom initialized CommandSet loads correctly when passed into the constructor @@ -118,7 +122,7 @@ def test_custom_construct_commandsets(): # Verifies that the same CommandSet can not be loaded twice command_set_2 = CommandSetB('bar') - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): assert app.install_command_set(command_set_2) # Verify that autoload doesn't conflict with a manually loaded CommandSet that could be autoloaded. @@ -225,7 +229,7 @@ def test_load_commandset_errors(command_sets_manual, capsys): # 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): + with pytest.raises(CommandSetRegistrationError): command_sets_manual.install_command_set(cmd_set) # verify that the commands weren't installed @@ -250,19 +254,19 @@ def test_load_commandset_errors(command_sets_manual, capsys): assert "Deleting macro 'apple'" in err # verify duplicate commands are detected - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual._install_command_function('banana', cmd_set.do_banana) # verify bad command names are detected - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual._install_command_function('bad command', cmd_set.do_banana) # verify error conflict with existing completer function - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual._install_completer_function('durian', cmd_set.complete_durian) # verify error conflict with existing help function - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual._install_help_function('cranberry', cmd_set.help_cranberry) @@ -275,7 +279,7 @@ class LoadableBase(cmd2.CommandSet): cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut') @cmd2.with_argparser(cut_parser) - def do_cut(self, ns: argparse.Namespace): + def do_cut(self, cmd: cmd2.Cmd, ns: argparse.Namespace): """Cut something""" func = getattr(ns, 'handler', None) if func is not None: @@ -283,8 +287,37 @@ class LoadableBase(cmd2.CommandSet): func(ns) else: # No subcommand was provided, so call help - self.poutput('This command does nothing without sub-parsers registered') - self.do_help('cut') + cmd.poutput('This command does nothing without sub-parsers registered') + cmd.do_help('cut') + + + stir_parser = cmd2.Cmd2ArgumentParser('stir') + stir_subparsers = stir_parser.add_subparsers(title='item', help='what to stir') + + @cmd2.with_argparser(stir_parser) + def do_stir(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + """Stir 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 + cmd.poutput('This command does nothing without sub-parsers registered') + cmd.do_help('stir') + + stir_pasta_parser = cmd2.Cmd2ArgumentParser('pasta', add_help=False) + stir_pasta_parser.add_argument('--option', '-o') + stir_pasta_parser.add_subparsers(title='style', help='Stir style') + + @cmd2.as_subcommand_to('stir', 'pasta', stir_pasta_parser) + def stir_pasta(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + func = getattr(ns, 'handler', None) + if func is not None: + # Call whatever subcommand function was selected + func(ns) + else: + cmd.poutput('Stir pasta haphazardly') class LoadableBadBase(cmd2.CommandSet): @@ -292,7 +325,7 @@ class LoadableBadBase(cmd2.CommandSet): super(LoadableBadBase, self).__init__() self._dummy = dummy # prevents autoload - def do_cut(self, ns: argparse.Namespace): + def do_cut(self, cmd: cmd2.Cmd, ns: argparse.Namespace): """Cut something""" func = getattr(ns, 'handler', None) if func is not None: @@ -300,8 +333,8 @@ class LoadableBadBase(cmd2.CommandSet): func(ns) else: # No subcommand was provided, so call help - self.poutput('This command does nothing without sub-parsers registered') - self.do_help('cut') + cmd.poutput('This command does nothing without sub-parsers registered') + cmd.do_help('cut') @cmd2.with_default_category('Fruits') @@ -316,12 +349,25 @@ class LoadableFruits(cmd2.CommandSet): banana_parser = cmd2.Cmd2ArgumentParser(add_help=False) banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) - @cmd2.as_subcommand_to('cut', 'banana', banana_parser) + @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help_text='Cut banana', aliases=['bananer']) def cut_banana(self, cmd: cmd2.Cmd, ns: argparse.Namespace): """Cut banana""" cmd.poutput('cutting banana: ' + ns.direction) +class LoadablePastaStir(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadablePastaStir, self).__init__() + self._dummy = dummy # prevents autoload + + stir_pasta_vigor_parser = cmd2.Cmd2ArgumentParser('vigor', add_help=False) + stir_pasta_vigor_parser.add_argument('frequency') + + @cmd2.as_subcommand_to('stir pasta', 'vigorously', stir_pasta_vigor_parser) + def stir_pasta_vigorously(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + cmd.poutput('stir the pasta vigorously') + + @cmd2.with_default_category('Vegetables') class LoadableVegetables(cmd2.CommandSet): def __init__(self, dummy): @@ -331,8 +377,11 @@ class LoadableVegetables(cmd2.CommandSet): def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): cmd.poutput('Arugula') + def complete_style_arg(self, cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + return ['quartered', 'diced'] + bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False) - bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) + bokchoy_parser.add_argument('style', completer_method=complete_style_arg) @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) def cut_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): @@ -347,12 +396,12 @@ def test_subcommands(command_sets_manual): veg_cmds = LoadableVegetables(1) # installing subcommands without base command present raises exception - with pytest.raises(TypeError): + with pytest.raises(CommandSetRegistrationError): 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): + with pytest.raises(CommandSetRegistrationError): command_sets_manual.install_command_set(fruit_cmds) # verify that the commands weren't installed @@ -365,19 +414,316 @@ def test_subcommands(command_sets_manual): 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): + with pytest.raises(CommandSetRegistrationError): command_sets_manual._register_subcommands(fruit_cmds) - # verify that command set install and uninstalls without problems + # verify that command set install 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 + + text = '' + line = 'cut {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match is not None + # check that the alias shows up correctly + assert ['banana', 'bananer', 'bokchoy'] == command_sets_manual.completion_matches + + text = '' + line = 'cut bokchoy {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match is not None + # verify that argparse completer in commandset functions correctly + assert ['diced', 'quartered'] == command_sets_manual.completion_matches + + # verify that command set uninstalls without problems + 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(CommandSetRegistrationError): + command_sets_manual._unregister_subcommands(fruit_cmds) + command_sets_manual.uninstall_command_set(veg_cmds) + + # Disable command and verify subcommands still load and unload + command_sets_manual.disable_command('cut', 'disabled for test') + + # verify that command set install without problems command_sets_manual.install_command_set(fruit_cmds) command_sets_manual.install_command_set(veg_cmds) + + command_sets_manual.enable_command('cut') + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() assert 'Fruits' in cmds_cats + text = '' + line = 'cut {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match is not None + # check that the alias shows up correctly + assert ['banana', 'bananer', 'bokchoy'] == command_sets_manual.completion_matches + + text = '' + line = 'cut bokchoy {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match is not None + # verify that argparse completer in commandset functions correctly + assert ['diced', 'quartered'] == command_sets_manual.completion_matches + + # disable again and verify can still uninstnall + command_sets_manual.disable_command('cut', 'disabled for test') + + # verify that command set uninstalls without problems 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): + with pytest.raises(CommandSetRegistrationError): command_sets_manual._unregister_subcommands(fruit_cmds) + + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.uninstall_command_set(base_cmds) + + command_sets_manual.uninstall_command_set(veg_cmds) + command_sets_manual.uninstall_command_set(base_cmds) + +def test_nested_subcommands(command_sets_manual): + base_cmds = LoadableBase(1) + # fruit_cmds = LoadableFruits(1) + # veg_cmds = LoadableVegetables(1) + pasta_cmds = LoadablePastaStir(1) + + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.install_command_set(pasta_cmds) + + command_sets_manual.install_command_set(base_cmds) + + command_sets_manual.install_command_set(pasta_cmds) + + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.uninstall_command_set(base_cmds) + + class BadNestedSubcommands(cmd2.CommandSet): + def __init__(self, dummy): + super(BadNestedSubcommands, self).__init__() + self._dummy = dummy # prevents autoload + + stir_pasta_vigor_parser = cmd2.Cmd2ArgumentParser('vigor', add_help=False) + stir_pasta_vigor_parser.add_argument('frequency') + + @cmd2.as_subcommand_to('stir sauce', 'vigorously', stir_pasta_vigor_parser) + def stir_pasta_vigorously(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + cmd.poutput('stir the pasta vigorously') + + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.install_command_set(BadNestedSubcommands(1)) + + +class AppWithSubCommands(cmd2.Cmd): + """Class for testing custom help_* methods which override docstring help.""" + def __init__(self, *args, **kwargs): + super(AppWithSubCommands, self).__init__(*args, **kwargs) + + cut_parser = cmd2.Cmd2ArgumentParser('cut') + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut') + + @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') + + banana_parser = cmd2.Cmd2ArgumentParser(add_help=False) + banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) + + @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help_text='Cut banana', aliases=['bananer']) + def cut_banana(self, ns: argparse.Namespace): + """Cut banana""" + self.poutput('cutting banana: ' + ns.direction) + + def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + return ['quartered', 'diced'] + + bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False) + bokchoy_parser.add_argument('style', completer_method=complete_style_arg) + + @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) + def cut_bokchoy(self, _: cmd2.Statement): + self.poutput('Bok Choy') + + +@pytest.fixture +def static_subcommands_app(): + app = AppWithSubCommands() + return app + + +def test_static_subcommands(static_subcommands_app): + cmds_cats, cmds_doc, cmds_undoc, help_topics = static_subcommands_app._build_command_info() + assert 'Fruits' in cmds_cats + + text = '' + line = 'cut {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, static_subcommands_app) + + assert first_match is not None + # check that the alias shows up correctly + assert ['banana', 'bananer', 'bokchoy'] == static_subcommands_app.completion_matches + + text = '' + line = 'cut bokchoy {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, static_subcommands_app) + + assert first_match is not None + # verify that argparse completer in commandset functions correctly + assert ['diced', 'quartered'] == static_subcommands_app.completion_matches + + +# reproduces test_argparse.py except with SubCommands +class SubcommandSet(cmd2.CommandSet): + """ Example cmd2 application where we a base command which has a couple subcommands.""" + + def __init__(self, dummy): + super(SubcommandSet, self).__init__() + + # subcommand functions for the base command + def base_foo(self, cmd: cmd2.Cmd, args): + """foo subcommand of base command""" + cmd.poutput(args.x * args.y) + + def base_bar(self, cmd: cmd2.Cmd, args): + """bar subcommand of base command""" + cmd.poutput('((%s))' % args.z) + + def base_helpless(self, cmd: cmd2.Cmd, args): + """helpless subcommand of base command""" + cmd.poutput('((%s))' % args.z) + + # create the top-level parser for the base command + base_parser = argparse.ArgumentParser() + base_subparsers = base_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND') + base_subparsers.required = True + + # create the parser for the "foo" subcommand + parser_foo = base_subparsers.add_parser('foo', help='foo help') + parser_foo.add_argument('-x', type=int, default=1, help='integer') + parser_foo.add_argument('y', type=float, help='float') + parser_foo.set_defaults(func=base_foo) + + # create the parser for the "bar" subcommand + parser_bar = base_subparsers.add_parser('bar', help='bar help', aliases=['bar_1', 'bar_2']) + parser_bar.add_argument('z', help='string') + parser_bar.set_defaults(func=base_bar) + + # create the parser for the "helpless" subcommand + # This subcommand has aliases and no help text. It exists to prevent changes to _set_parser_prog() which + # use an approach which relies on action._choices_actions list. See comment in that function for more + # details. + parser_bar = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2']) + parser_bar.add_argument('z', help='string') + parser_bar.set_defaults(func=base_bar) + + @cmd2.with_argparser(base_parser) + def do_base(self, cmd: cmd2.Cmd, args): + """Base command help""" + # Call whatever subcommand function was selected + func = getattr(args, 'func') + func(self, cmd, args) + + +@pytest.fixture +def subcommand_app(): + app = WithCommandSets(auto_load_commands=False, + command_sets=[SubcommandSet(1)]) + return app + + +def test_subcommand_foo(subcommand_app): + out, err = run_cmd(subcommand_app, 'base foo -x2 5.0') + assert out == ['10.0'] + + +def test_subcommand_bar(subcommand_app): + out, err = run_cmd(subcommand_app, 'base bar baz') + assert out == ['((baz))'] + +def test_subcommand_invalid(subcommand_app): + out, err = run_cmd(subcommand_app, 'base baz') + assert err[0].startswith('usage: base') + assert err[1].startswith("base: error: argument SUBCOMMAND: invalid choice: 'baz'") + +def test_subcommand_base_help(subcommand_app): + out, err = run_cmd(subcommand_app, 'help base') + assert out[0].startswith('usage: base') + assert out[1] == '' + assert out[2] == 'Base command help' + +def test_subcommand_help(subcommand_app): + # foo has no aliases + out, err = run_cmd(subcommand_app, 'help base foo') + assert out[0].startswith('usage: base foo') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + # bar has aliases (usage should never show alias name) + out, err = run_cmd(subcommand_app, 'help base bar') + assert out[0].startswith('usage: base bar') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + out, err = run_cmd(subcommand_app, 'help base bar_1') + assert out[0].startswith('usage: base bar') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + out, err = run_cmd(subcommand_app, 'help base bar_2') + assert out[0].startswith('usage: base bar') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + # helpless has aliases and no help text (usage should never show alias name) + out, err = run_cmd(subcommand_app, 'help base helpless') + assert out[0].startswith('usage: base helpless') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + out, err = run_cmd(subcommand_app, 'help base helpless_1') + assert out[0].startswith('usage: base helpless') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + out, err = run_cmd(subcommand_app, 'help base helpless_2') + assert out[0].startswith('usage: base helpless') + assert out[1] == '' + assert out[2] == 'positional arguments:' + +def test_subcommand_invalid_help(subcommand_app): + out, err = run_cmd(subcommand_app, 'help base baz') + assert out[0].startswith('usage: base') + |