summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd2/argparse_completer.py22
-rw-r--r--cmd2/argparse_custom.py46
-rw-r--r--cmd2/cmd2.py140
-rw-r--r--cmd2/constants.py3
-rw-r--r--cmd2/decorators.py9
-rw-r--r--cmd2/exceptions.py3
-rw-r--r--isolated_tests/test_commandset/test_commandset.py396
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')
+