diff options
author | anselor <anselor@gmail.com> | 2020-08-01 16:26:11 -0400 |
---|---|---|
committer | anselor <anselor@gmail.com> | 2020-08-04 13:38:08 -0400 |
commit | 9d309939286f4944cb083dfd4f320c442bc99d76 (patch) | |
tree | dbfc6c82ede0c146bade33f12016ac072c90b5d3 | |
parent | c854ba817fb3e69931990a7c2f79ce3863dbb596 (diff) | |
download | cmd2-git-9d309939286f4944cb083dfd4f320c442bc99d76.tar.gz |
Now maintains a command->CommandSet mapping and passes the CommandSet
through to the ArgparseCompleter if one is registered.
For subcommands, the registered argparse instance for the subcommand is now tagged with the
CommandSet from which it originated.
If a CommandSet is detected, it's now passed in as 'self' for the
completion functions.
Fixes some issue found with removing a subcommand.
Adds additional tests.
Added a check to prevent removal of a CommandSet if it has commands with sub-commands
from another CommandSet bound to it.
Documentation improvements.
Standardized around using CommandSetRegistrationException during commandset install/uninstall related errors.
Added support for nested sub-command injection.
-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') + |