diff options
Diffstat (limited to 'cmd2')
-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 |
6 files changed, 168 insertions, 55 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. ############################################################################################################ |