diff options
| author | Todd Leonhardt <todd.leonhardt@gmail.com> | 2021-02-19 21:35:13 -0500 |
|---|---|---|
| committer | Todd Leonhardt <todd.leonhardt@gmail.com> | 2021-02-19 21:35:13 -0500 |
| commit | 3e180a810e9c4b9d251c135667d1d150b0bbd0dd (patch) | |
| tree | 03e49d5da86d40efa9118eccfd8bd4bbf3dcf86b /cmd2 | |
| parent | 4c70bdb03d34c43f833bf77c441452cd402d0715 (diff) | |
| parent | 06aaf962689840631325c70ea7e9056d176c7f67 (diff) | |
| download | cmd2-git-3e180a810e9c4b9d251c135667d1d150b0bbd0dd.tar.gz | |
Merge branch 'master' into black
# Conflicts:
# cmd2/__init__.py
# cmd2/argparse_completer.py
# cmd2/argparse_custom.py
# cmd2/cmd2.py
# cmd2/decorators.py
# cmd2/exceptions.py
# cmd2/utils.py
# examples/arg_decorators.py
# examples/argparse_completion.py
# examples/modular_commands_main.py
# tests/test_argparse_completer.py
# tests/test_argparse_custom.py
# tests/test_cmd2.py
# tests/test_completion.py
# tests/test_history.py
Diffstat (limited to 'cmd2')
| -rw-r--r-- | cmd2/__init__.py | 6 | ||||
| -rw-r--r-- | cmd2/argparse_completer.py | 96 | ||||
| -rw-r--r-- | cmd2/argparse_custom.py | 181 | ||||
| -rw-r--r-- | cmd2/cmd2.py | 603 | ||||
| -rw-r--r-- | cmd2/constants.py | 1 | ||||
| -rw-r--r-- | cmd2/decorators.py | 63 | ||||
| -rw-r--r-- | cmd2/exceptions.py | 24 | ||||
| -rw-r--r-- | cmd2/utils.py | 115 |
8 files changed, 469 insertions, 620 deletions
diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 1f122b87..4bc73dbe 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -32,9 +32,9 @@ from .argparse_custom import DEFAULT_ARGUMENT_PARSER from .cmd2 import Cmd from .command_definition import CommandSet, with_default_category from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS -from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category, as_subcommand_to -from .exceptions import Cmd2ArgparseError, SkipPostcommandHooks, CommandSetRegistrationError +from .decorators import with_argument_list, with_argparser, with_category, as_subcommand_to +from .exceptions import Cmd2ArgparseError, CommandSetRegistrationError, CompletionError, SkipPostcommandHooks from . import plugin from .parsing import Statement from .py_bridge import CommandResult -from .utils import categorize, CompletionError, Settable +from .utils import categorize, CompletionMode, CustomCompletionSettings, Settable diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 88cd2938..99378733 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -37,14 +37,13 @@ from .argparse_custom import ( from .command_definition import ( CommandSet, ) +from .exceptions import ( + CompletionError, +) from .table_creator import ( Column, SimpleTable, ) -from .utils import ( - CompletionError, - basic_complete, -) # If no descriptive header is supplied, then this will be used instead DEFAULT_DESCRIPTIVE_HEADER = 'Description' @@ -209,11 +208,19 @@ 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, *, cmd_set: Optional[CommandSet] = None - ) -> List[str]: + def complete(self, text: str, line: str, begidx: int, endidx: int, tokens: List[str], *, + cmd_set: Optional[CommandSet] = None) -> List[str]: """ - Complete the command using the argparse metadata and provided argument dictionary + Complete text using argparse metadata + + :param text: the string prefix we are attempting to match (all matches must begin with it) + :param line: the current input line with leading whitespace removed + :param begidx: the beginning index of the prefix text + :param endidx: the ending index of the prefix text + :param tokens: list of argument tokens being passed to the parser + :param cmd_set: if tab completing a command, the CommandSet the command's function belongs to, if applicable. + Defaults to None. + :raises: CompletionError for various types of tab completion errors """ if not tokens: @@ -290,7 +297,7 @@ class ArgparseCompleter: ############################################################################################# # Parse all but the last token ############################################################################################# - for token_index, token in enumerate(tokens[1:-1], start=1): + for token_index, token in enumerate(tokens[:-1]): # If we're in a positional REMAINDER arg, force all future tokens to go to that if pos_arg_state is not None and pos_arg_state.is_remainder: @@ -384,12 +391,10 @@ class ArgparseCompleter: if action.dest != argparse.SUPPRESS: parent_tokens[action.dest] = [token] - 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, cmd_set=cmd_set - ) + completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app, + parent_tokens=parent_tokens) + return completer.complete(text, line, begidx, endidx, tokens[token_index + 1:], + cmd_set=cmd_set) else: # Invalid subcommand entered, so no way to complete remaining tokens return [] @@ -433,9 +438,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, text, line, begidx, endidx, consumed_arg_values, cmd_set=cmd_set - ) + completion_results = self._complete_arg(text, line, begidx, endidx, flag_arg_state, consumed_arg_values, + cmd_set=cmd_set) # If we have results, then return them if completion_results: @@ -460,9 +464,8 @@ class ArgparseCompleter: action = remaining_positionals.popleft() pos_arg_state = _ArgumentState(action) - completion_results = self._complete_for_arg( - pos_arg_state, text, line, begidx, endidx, consumed_arg_values, cmd_set=cmd_set - ) + completion_results = self._complete_arg(text, line, begidx, endidx, pos_arg_state, consumed_arg_values, + cmd_set=cmd_set) # If we have results, then return them if completion_results: @@ -496,7 +499,7 @@ class ArgparseCompleter: if action.help != argparse.SUPPRESS: match_against.append(flag) - matches = basic_complete(text, line, begidx, endidx, match_against) + matches = self._cmd2_app.basic_complete(text, line, begidx, endidx, match_against) # Build a dictionary linking actions with their matched flag names matched_actions = dict() # type: Dict[argparse.Action, List[str]] @@ -568,26 +571,26 @@ class ArgparseCompleter: return completions - def complete_subcommand_help(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_subcommand_help(self, text: str, line: str, begidx: int, endidx: int, tokens: List[str]) -> List[str]: """ Supports cmd2's help command in the completion of subcommand names - :param tokens: command line tokens :param text: the string prefix we are attempting to match (all matches must begin with it) :param line: the current input line with leading whitespace removed :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text + :param tokens: arguments passed to command/subcommand :return: List of subcommand completions """ # If our parser has subcommands, we must examine the tokens and check if they are subcommands # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. if self._subcommand_action is not None: - for token_index, token in enumerate(tokens[1:], start=1): + for token_index, token in enumerate(tokens): if token in self._subcommand_action.choices: completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app) - return completer.complete_subcommand_help(tokens[token_index:], text, line, begidx, endidx) + return completer.complete_subcommand_help(text, line, begidx, endidx, tokens[token_index + 1:]) elif token_index == len(tokens) - 1: # Since this is the last token, we will attempt to complete it - return basic_complete(text, line, begidx, endidx, self._subcommand_action.choices) + return self._cmd2_app.basic_complete(text, line, begidx, endidx, self._subcommand_action.choices) else: break return [] @@ -595,31 +598,23 @@ class ArgparseCompleter: def format_help(self, tokens: List[str]) -> str: """ Supports cmd2's help command in the retrieval of help text - :param tokens: command line tokens + :param tokens: arguments passed to help command :return: help text of the command being queried """ # If our parser has subcommands, we must examine the tokens and check if they are subcommands # If so, we will let the subcommand's parser handle the rest of the tokens via another ArgparseCompleter. if self._subcommand_action is not None: - for token_index, token in enumerate(tokens[1:], start=1): + for token_index, token in enumerate(tokens): if token in self._subcommand_action.choices: completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app) - return completer.format_help(tokens[token_index:]) + return completer.format_help(tokens[token_index + 1:]) else: break return self._parser.format_help() - def _complete_for_arg( - self, - arg_state: _ArgumentState, - text: str, - line: str, - begidx: int, - endidx: int, - consumed_arg_values: Dict[str, List[str]], - *, - cmd_set: Optional[CommandSet] = None - ) -> List[str]: + def _complete_arg(self, text: str, line: str, begidx: int, endidx: int, + arg_state: _ArgumentState, 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 @@ -647,16 +642,15 @@ class ArgparseCompleter: args = [] kwargs = {} if isinstance(arg_choices, ChoicesCallable): - if arg_choices.is_method: - # The completer may or may not be defined in the same class as the command. Since completer - # functions are registered with the command argparser before anything is instantiated, we - # need to find an instance at runtime that matches the types during declaration - cmd_set = self._cmd2_app._resolve_func_self(arg_choices.to_call, cmd_set) - if cmd_set is None: - # No cases matched, raise an error - raise CompletionError('Could not find CommandSet instance matching defining type for completer') + # The completer may or may not be defined in the same class as the command. Since completer + # functions are registered with the command argparser before anything is instantiated, we + # need to find an instance at runtime that matches the types during declaration + self_arg = self._cmd2_app._resolve_func_self(arg_choices.to_call, cmd_set) + if self_arg is None: + # No cases matched, raise an error + raise CompletionError('Could not find CommandSet instance matching defining type for completer') - args.append(cmd_set) + args.append(self_arg) # Check if arg_choices.to_call expects arg_tokens to_call_params = inspect.signature(arg_choices.to_call).parameters @@ -687,7 +681,7 @@ class ArgparseCompleter: arg_choices = [choice for choice in arg_choices if choice not in used_values] # Do tab completion on the choices - results = basic_complete(text, line, begidx, endidx, arg_choices) + results = self._cmd2_app.basic_complete(text, line, begidx, endidx, arg_choices) if not results: # Reset the value for matches_sorted. This is because completion of flag names diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 3624848b..26921ade 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -39,75 +39,36 @@ hints about the current argument that print when tab is pressed. In addition, you can add tab completion for each argument's values using parameters passed to add_argument(). -Below are the 5 add_argument() parameters for enabling tab completion of an +Below are the 3 add_argument() parameters for enabling tab completion of an argument's value. Only one can be used at a time. ``choices`` - pass a list of values to the choices parameter. Example:: - parser.add_argument('-o', '--options', choices=['An Option', 'SomeOtherOption']) + my_list = ['An Option', 'SomeOtherOption'] parser.add_argument('-o', '--options', choices=my_list) -``choices_function`` - pass a function that returns choices. This is good in +``choices_provider`` - pass a function that returns choices. This is good in cases where the choice list is dynamically generated when the user hits tab. Example:: - def my_choices_function(): + def my_choices_provider(self): ... return my_generated_list - parser.add_argument('-o', '--options', choices_function=my_choices_function) + parser.add_argument("arg", choices_provider=my_choices_provider) -``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. +``completer`` - pass a tab completion function that does custom completion. - Example:: - - def my_choices_method(self): - ... - 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 -completer function. completer_method should be used in those cases. - - Example:: - - def my_completer_function(text, line, begidx, endidx): - ... - return completions - parser.add_argument('-o', '--options', completer_function=my_completer_function) - -``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) +cmd2 provides a few completer methods for convenience (e.g., path_complete, +delimiter_complete) Example:: # This adds file-path completion to an argument - parser.add_argument('-o', '--options', completer_method=cmd2.Cmd.path_complete) - + parser.add_argument('-o', '--options', completer=cmd2.Cmd.path_complete) You can use functools.partial() to prepopulate values of the underlying choices and completer functions/methods. @@ -115,13 +76,27 @@ path_complete, delimiter_complete) Example:: # This says to call path_complete with a preset value for its path_filter argument - completer_method = functools.partial(path_complete, - path_filter=lambda path: os.path.isdir(path)) - parser.add_argument('-o', '--options', choices_method=completer_method) - -Of the 5 tab completion parameters, choices is the only one where argparse + dir_completer = functools.partial(path_complete, + path_filter=lambda path: os.path.isdir(path)) + parser.add_argument('-o', '--options', completer=dir_completer) + +For ``choices_provider`` and ``completer``, do not set them to a bound method. This +is because ArgparseCompleter passes the `self` argument explicitly to these +functions. When ArgparseCompleter calls one, it will detect whether it is bound +to a `Cmd` subclass or `CommandSet`. If bound to a `cmd2.Cmd subclass`, it will +pass the app instance as the `self` argument. If bound to a `cmd2.CommandSet` +subclass, it will pass the `CommandSet` instance as the `self` argument. +Therefore instead of passing something like `self.path_complete`, pass +`cmd2.Cmd.path_complete`. + +``choices_provider`` and ``completer`` functions can also be implemented as +standalone functions (i.e. not a member of a class). In this case, +ArgparseCompleter will pass its ``cmd2.Cmd`` app instance as the first +positional argument. + +Of the 3 tab completion parameters, ``choices`` is the only one where argparse validates user input against items in the choices list. This is because the -other 4 parameters are meant to tab complete data sets that are viewed as +other 2 parameters are meant to tab complete data sets that are viewed as dynamic. Therefore it is up to the developer to validate if the user has typed an acceptable value for these arguments. @@ -129,21 +104,19 @@ The following functions exist in cases where you may want to manually add a choice-providing function/method to an existing argparse action. For instance, in __init__() of a custom action class. - - set_choices_function(action, func) - - set_choices_method(action, method) - - set_completer_function(action, func) - - set_completer_method(action, method) + - set_choices_provider(action, func) + - set_completer(action, func) There are times when what's being tab completed is determined by a previous -argument on the command line. In theses cases, Autocompleter can pass a +argument on the command line. In theses cases, ArgparseCompleter can pass a dictionary that maps the command line tokens up through the one being completed to their argparse argument name. To receive this dictionary, your choices/completer function should have an argument called arg_tokens. Example:: - def my_choices_method(self, arg_tokens) - def my_completer_method(self, text, line, begidx, endidx, arg_tokens) + def my_choices_provider(self, arg_tokens) + def my_completer(self, text, line, begidx, endidx, arg_tokens) All values of the arg_tokens dictionary are lists, even if a particular argument expects only 1 token. Since ArgparseCompleter is for tab completion, @@ -312,16 +285,13 @@ class ChoicesCallable: Enables using a callable as the choices provider for an argparse argument. While argparse has the built-in choices attribute, it is limited to an iterable. """ - - def __init__(self, is_method: bool, is_completer: bool, to_call: Callable): + def __init__(self, is_completer: bool, to_call: Callable): """ Initializer - :param is_method: True if to_call is an instance method of a cmd2 app. False if it is a function. :param is_completer: True if to_call is a tab completion routine which expects the args: text, line, begidx, endidx :param to_call: the callable object that will be called to provide choices for the argument """ - self.is_method = is_method self.is_completer = is_completer self.to_call = to_call @@ -335,39 +305,25 @@ def _set_choices_callable(action: argparse.Action, choices_callable: ChoicesCall """ # Verify consistent use of parameters if action.choices is not None: - err_msg = ( - "None of the following parameters can be used alongside a choices parameter:\n" - "choices_function, choices_method, completer_function, completer_method" - ) + err_msg = ("None of the following parameters can be used alongside a choices parameter:\n" + "choices_provider, completer") raise (TypeError(err_msg)) elif action.nargs == 0: - err_msg = ( - "None of the following parameters can be used on an action that takes no arguments:\n" - "choices_function, choices_method, completer_function, completer_method" - ) + err_msg = ("None of the following parameters can be used on an action that takes no arguments:\n" + "choices_provider, completer") raise (TypeError(err_msg)) setattr(action, ATTR_CHOICES_CALLABLE, choices_callable) -def set_choices_function(action: argparse.Action, choices_function: Callable) -> None: - """Set choices_function on an argparse action""" - _set_choices_callable(action, ChoicesCallable(is_method=False, is_completer=False, to_call=choices_function)) +def set_choices_provider(action: argparse.Action, choices_provider: Callable) -> None: + """Set choices_provider on an argparse action""" + _set_choices_callable(action, ChoicesCallable(is_completer=False, to_call=choices_provider)) -def set_choices_method(action: argparse.Action, choices_method: Callable) -> None: - """Set choices_method on an argparse action""" - _set_choices_callable(action, ChoicesCallable(is_method=True, is_completer=False, to_call=choices_method)) - - -def set_completer_function(action: argparse.Action, completer_function: Callable) -> None: - """Set completer_function on an argparse action""" - _set_choices_callable(action, ChoicesCallable(is_method=False, is_completer=True, to_call=completer_function)) - - -def set_completer_method(action: argparse.Action, completer_method: Callable) -> None: - """Set completer_method on an argparse action""" - _set_choices_callable(action, ChoicesCallable(is_method=True, is_completer=True, to_call=completer_method)) +def set_completer(action: argparse.Action, completer: Callable) -> None: + """Set completer on an argparse action""" + _set_choices_callable(action, ChoicesCallable(is_completer=True, to_call=completer)) ############################################################################################################ @@ -379,18 +335,13 @@ def set_completer_method(action: argparse.Action, completer_method: Callable) -> orig_actions_container_add_argument = argparse._ActionsContainer.add_argument -def _add_argument_wrapper( - self, - *args, - nargs: Union[int, str, Tuple[int], Tuple[int, int], None] = None, - choices_function: Optional[Callable] = None, - choices_method: Optional[Callable] = None, - completer_function: Optional[Callable] = None, - completer_method: Optional[Callable] = None, - suppress_tab_hint: bool = False, - descriptive_header: Optional[str] = None, - **kwargs -) -> argparse.Action: +def _add_argument_wrapper(self, *args, + nargs: Union[int, str, Tuple[int], Tuple[int, int], None] = None, + choices_provider: Optional[Callable] = None, + completer: Optional[Callable] = None, + suppress_tab_hint: bool = False, + descriptive_header: Optional[str] = None, + **kwargs) -> argparse.Action: """ Wrapper around _ActionsContainer.add_argument() which supports more settings used by cmd2 @@ -403,10 +354,8 @@ def _add_argument_wrapper( to specify a max value with no upper bound, use a 1-item tuple (min,) # Added args used by ArgparseCompleter - :param choices_function: function that provides choices for this argument - :param choices_method: cmd2-app method that provides choices for this argument - :param completer_function: tab completion function that provides choices for this argument - :param completer_method: cmd2-app tab completion method that provides choices for this argument + :param choices_provider: function that provides choices for this argument + :param completer: tab completion function that provides choices for this argument :param suppress_tab_hint: when ArgparseCompleter has no results to show during tab completion, it displays the current argument's help text as a hint. Set this to True to suppress the hint. If this argument's help text is set to argparse.SUPPRESS, then tab hints will not display @@ -418,7 +367,7 @@ def _add_argument_wrapper( :param kwargs: keyword-arguments recognized by argparse._ActionsContainer.add_argument Note: You can only use 1 of the following in your argument: - choices, choices_function, choices_method, completer_function, completer_method + choices, choices_provider, completer See the header of this file for more information @@ -426,14 +375,12 @@ def _add_argument_wrapper( :raises: ValueError on incorrect parameter usage """ # Verify consistent use of arguments - choices_callables = [choices_function, choices_method, completer_function, completer_method] + choices_callables = [choices_provider, completer] num_params_set = len(choices_callables) - choices_callables.count(None) if num_params_set > 1: - err_msg = ( - "Only one of the following parameters may be used at a time:\n" - "choices_function, choices_method, completer_function, completer_method" - ) + err_msg = ("Only one of the following parameters may be used at a time:\n" + "choices_provider, completer") raise (ValueError(err_msg)) # Pre-process special ranged nargs @@ -495,14 +442,10 @@ def _add_argument_wrapper( # Set the custom attributes setattr(new_arg, ATTR_NARGS_RANGE, nargs_range) - if choices_function: - set_choices_function(new_arg, choices_function) - elif choices_method: - set_choices_method(new_arg, choices_method) - elif completer_function: - set_completer_function(new_arg, completer_function) - elif completer_method: - set_completer_method(new_arg, completer_method) + if choices_provider: + set_choices_provider(new_arg, choices_provider) + elif completer: + set_completer(new_arg, completer) setattr(new_arg, ATTR_SUPPRESS_TAB_HINT, suppress_tab_hint) setattr(new_arg, ATTR_DESCRIPTIVE_COMPLETION_HEADER, descriptive_header) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index a08c5d42..88fdcd87 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -30,6 +30,7 @@ Git repository on GitHub at https://github.com/python-cmd2/cmd2 # setting is True import argparse import cmd +import functools import glob import inspect import os @@ -91,6 +92,7 @@ from .decorators import ( from .exceptions import ( Cmd2ShlexError, CommandSetRegistrationError, + CompletionError, EmbeddedConsoleExit, EmptyStatement, RedirectionError, @@ -117,7 +119,6 @@ from .rl_utils import ( vt100_support, ) from .utils import ( - CompletionError, Settable, get_defining_class, ) @@ -1173,7 +1174,7 @@ class Cmd(cmd.Cmd): tmp_line = line[:endidx] tmp_line += unclosed_quote tmp_endidx = endidx + 1 - else: + else: # pragma: no cover # The parsing error is not caused by unclosed quotes. # Return empty lists since this means the line is malformed. return [], [] @@ -1192,9 +1193,23 @@ class Cmd(cmd.Cmd): return tokens, raw_tokens - def delimiter_complete( - self, text: str, line: str, begidx: int, endidx: int, match_against: Iterable, delimiter: str - ) -> List[str]: + # noinspection PyMethodMayBeStatic, PyUnusedLocal + def basic_complete(self, text: str, line: str, begidx: int, endidx: int, match_against: Iterable) -> List[str]: + """ + Basic tab completion function that matches against a list of strings without considering line contents + or cursor position. The args required by this function are defined in the header of Python's cmd.py. + + :param text: the string prefix we are attempting to match (all matches must begin with it) + :param line: the current input line with leading whitespace removed + :param begidx: the beginning index of the prefix text + :param endidx: the ending index of the prefix text + :param match_against: the strings being matched against + :return: a list of possible tab completions + """ + return [cur_match for cur_match in match_against if cur_match.startswith(text)] + + def delimiter_complete(self, text: str, line: str, begidx: int, endidx: int, + match_against: Iterable, delimiter: str) -> List[str]: """ Performs tab completion against a list but each match is split on a delimiter and only the portion of the match being tab completed is shown as the completion suggestions. @@ -1227,7 +1242,7 @@ class Cmd(cmd.Cmd): :param delimiter: what delimits each portion of the matches (ex: paths are delimited by a slash) :return: a list of possible tab completions """ - matches = utils.basic_complete(text, line, begidx, endidx, match_against) + matches = self.basic_complete(text, line, begidx, endidx, match_against) # Display only the portion of the match that's being completed based on delimiter if matches: @@ -1281,7 +1296,7 @@ class Cmd(cmd.Cmd): """ # Get all tokens through the one being completed tokens, _ = self.tokens_for_completion(line, begidx, endidx) - if not tokens: + if not tokens: # pragma: no cover return [] completions_matches = [] @@ -1295,7 +1310,7 @@ class Cmd(cmd.Cmd): # Perform tab completion using an Iterable if isinstance(match_against, Iterable): - completions_matches = utils.basic_complete(text, line, begidx, endidx, match_against) + completions_matches = self.basic_complete(text, line, begidx, endidx, match_against) # Perform tab completion using a function elif callable(match_against): @@ -1330,7 +1345,7 @@ class Cmd(cmd.Cmd): """ # Get all tokens through the one being completed tokens, _ = self.tokens_for_completion(line, begidx, endidx) - if not tokens: + if not tokens: # pragma: no cover return [] matches = [] @@ -1346,7 +1361,7 @@ class Cmd(cmd.Cmd): # Perform tab completion using a Iterable if isinstance(match_against, Iterable): - matches = utils.basic_complete(text, line, begidx, endidx, match_against) + matches = self.basic_complete(text, line, begidx, endidx, match_against) # Perform tab completion using a function elif callable(match_against): @@ -1541,7 +1556,7 @@ class Cmd(cmd.Cmd): # Get all tokens through the one being completed. We want the raw tokens # so we can tell if redirection strings are quoted and ignore them. _, raw_tokens = self.tokens_for_completion(line, begidx, endidx) - if not raw_tokens: + if not raw_tokens: # pragma: no cover return [] # Must at least have the command @@ -1722,48 +1737,96 @@ class Cmd(cmd.Cmd): # Display matches using actual display function. This also redraws the prompt and line. orig_pyreadline_display(matches_to_display) - def _completion_for_command(self, text: str, line: str, begidx: int, endidx: int, shortcut_to_restore: str) -> None: + def _perform_completion(self, text: str, line: str, begidx: int, endidx: int, + custom_settings: Optional[utils.CustomCompletionSettings] = None) -> None: """ - Helper function for complete() that performs command-specific tab completion + Helper function for complete() that performs the actual completion :param text: the string prefix we are attempting to match (all matches must begin with it) :param line: the current input line with leading whitespace removed :param begidx: the beginning index of the prefix text :param endidx: the ending index of the prefix text - :param shortcut_to_restore: if not blank, then this shortcut was removed from text and needs to be - prepended to all the matches + :param custom_settings: optional prepopulated completion settings """ + from .argparse_completer import ArgparseCompleter + unclosed_quote = '' + command = None - # 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 + # If custom_settings is None, then we are completing a command's arguments + if custom_settings is None: + # Parse the command line + statement = self.statement_parser.parse_command_only(line) + command = statement.command + + # Malformed command line (e.g. quoted command token) + if not command: + return + + expanded_line = statement.command_and_args - # We overwrote line with a properly formatted but fully stripped version - # Restore the end spaces since line is only supposed to be lstripped when - # passed to completer functions according to Python docs - rstripped_len = len(line) - len(line.rstrip()) - expanded_line += ' ' * rstripped_len + # We overwrote line with a properly formatted but fully stripped version + # Restore the end spaces since line is only supposed to be lstripped when + # passed to completer functions according to Python docs + rstripped_len = len(line) - len(line.rstrip()) + expanded_line += ' ' * rstripped_len - # Fix the index values if expanded_line has a different size than line - if len(expanded_line) != len(line): - diff = len(expanded_line) - len(line) - begidx += diff - endidx += diff + # Fix the index values if expanded_line has a different size than line + if len(expanded_line) != len(line): + diff = len(expanded_line) - len(line) + begidx += diff + endidx += diff - # Overwrite line to pass into completers - line = expanded_line + # Overwrite line to pass into completers + line = expanded_line # Get all tokens through the one being completed tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx) - - # Check if we either had a parsing error or are trying to complete the command token - # The latter can happen if " or ' was entered as the command - if len(tokens) <= 1: + if not tokens: # pragma: no cover return + # Determine the completer function to use + if command is not None: + # Check if a macro was entered + if command in self.macros: + completer_func = self.path_complete + + # Check if a command was entered + elif command in self.get_all_commands(): + # Get the completer function for this command + completer_func = getattr(self, constants.COMPLETER_FUNC_PREFIX + command, None) + + if completer_func is None: + # There's no completer function, next see if the command uses argparse + func = self.cmd_func(command) + argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None) + + if func is not None and argparser is not None: + cmd_set = self._cmd_to_command_sets[command] if command in self._cmd_to_command_sets else None + completer = ArgparseCompleter(argparser, self) + preserve_quotes = getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES) + + completer_func = functools.partial(completer.complete, + tokens=raw_tokens[1:] if preserve_quotes else tokens[1:], + cmd_set=cmd_set) + else: + completer_func = self.completedefault + + # Not a recognized macro or command + else: + # Check if this command should be run as a shell command + if self.default_to_shell and command in utils.get_exes_in_path(command): + completer_func = self.path_complete + else: + completer_func = self.completedefault + + # Otherwise we are completing the command token or performing custom completion + else: + completer = ArgparseCompleter(custom_settings.parser, self) + completer_func = functools.partial(completer.complete, + tokens=raw_tokens if custom_settings.preserve_quotes else tokens, + cmd_set=None) + # Text we need to remove from completions later text_to_remove = '' @@ -1791,43 +1854,9 @@ class Cmd(cmd.Cmd): text = text_to_remove + text begidx = actual_begidx - # Check if a macro was entered - if command in self.macros: - compfunc = self.path_complete - - # Check if a command was entered - elif command in self.get_all_commands(): - # Get the completer function for this command - compfunc = getattr(self, constants.COMPLETER_FUNC_PREFIX + command, None) - - if compfunc is None: - # There's no completer function, next see if the command uses argparse - func = self.cmd_func(command) - argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None) - - if func is not None and argparser is not None: - import functools - - compfunc = functools.partial( - self._complete_argparse_command, - argparser=argparser, - preserve_quotes=getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES), - cmd_set=cmd_set, - ) - else: - compfunc = self.completedefault - - # Not a recognized macro or command - else: - # Check if this command should be run as a shell command - if self.default_to_shell and command in utils.get_exes_in_path(command): - compfunc = self.path_complete - else: - compfunc = self.completedefault - # Attempt tab completion for redirection first, and if that isn't occurring, # call the completer function for the current command - self.completion_matches = self._redirect_complete(text, line, begidx, endidx, compfunc) + self.completion_matches = self._redirect_complete(text, line, begidx, endidx, completer_func) if self.completion_matches: @@ -1877,16 +1906,12 @@ class Cmd(cmd.Cmd): elif text_to_remove: self.completion_matches = [match.replace(text_to_remove, '', 1) for match in self.completion_matches] - # Check if we need to restore a shortcut in the tab completions - # so it doesn't get erased from the command line - if shortcut_to_restore: - self.completion_matches = [shortcut_to_restore + match for match in self.completion_matches] - # If we have one result, then add a closing quote if needed and allowed if len(self.completion_matches) == 1 and self.allow_closing_quote and unclosed_quote: self.completion_matches[0] += unclosed_quote - def complete(self, text: str, state: int) -> Optional[str]: + def complete(self, text: str, state: int, + custom_settings: Optional[utils.CustomCompletionSettings] = None) -> Optional[str]: """Override of cmd2's complete method which returns the next possible completion for 'text' This completer function is called by readline as complete(text, state), for state in 0, 1, 2, …, @@ -1898,6 +1923,7 @@ class Cmd(cmd.Cmd): :param text: the current word that user is typing :param state: non-negative integer + :param custom_settings: used when not tab completing the main command line :return: the next possible completion for text or None """ # noinspection PyBroadException @@ -1908,7 +1934,7 @@ class Cmd(cmd.Cmd): # Check if we are completing a multiline command if self._at_continuation_prompt: # lstrip and prepend the previously typed portion of this multiline command - lstripped_previous = self._multiline_in_progress.lstrip() + lstripped_previous = self._multiline_in_progress.lstrip().replace(constants.LINE_FEED, ' ') line = lstripped_previous + readline.get_line_buffer() # Increment the indexes to account for the prepended text @@ -1927,9 +1953,9 @@ class Cmd(cmd.Cmd): # Shortcuts are not word break characters when tab completing. Therefore shortcuts become part # of the text variable if there isn't a word break, like a space, after it. We need to remove it - # from text and update the indexes. This only applies if we are at the the beginning of the line. + # from text and update the indexes. This only applies if we are at the beginning of the command line. shortcut_to_restore = '' - if begidx == 0: + if begidx == 0 and custom_settings is None: for (shortcut, _) in self.statement_parser.shortcuts: if text.startswith(shortcut): # Save the shortcut to restore later @@ -1939,15 +1965,19 @@ class Cmd(cmd.Cmd): text = text[len(shortcut_to_restore) :] begidx += len(shortcut_to_restore) break + else: + # No shortcut was found. Complete the command token. + parser = DEFAULT_ARGUMENT_PARSER(add_help=False) + parser.add_argument('command', metavar="COMMAND", help="command, alias, or macro name", + choices=self._get_commands_aliases_and_macros_for_completion()) + custom_settings = utils.CustomCompletionSettings(parser) - # If begidx is greater than 0, then we are no longer completing the first token (command name) - if begidx > 0: - self._completion_for_command(text, line, begidx, endidx, shortcut_to_restore) + self._perform_completion(text, line, begidx, endidx, custom_settings) - # Otherwise complete token against anything a user can run - else: - match_against = self._get_commands_aliases_and_macros_for_completion() - self.completion_matches = utils.basic_complete(text, line, begidx, endidx, match_against) + # Check if we need to restore a shortcut in the tab completions + # so it doesn't get erased from the command line + if shortcut_to_restore: + self.completion_matches = [shortcut_to_restore + match for match in self.completion_matches] # If we have one result and we are at the end of the line, then add a space if allowed if len(self.completion_matches) == 1 and endidx == len(line) and self.allow_appended_space: @@ -1980,30 +2010,6 @@ class Cmd(cmd.Cmd): rl_force_redisplay() return None - def _complete_argparse_command( - self, - text: str, - line: str, - begidx: int, - endidx: int, - *, - 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) - tokens, raw_tokens = self.tokens_for_completion(line, begidx, endidx) - - # 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, cmd_set=cmd_set) - def in_script(self) -> bool: """Return whether a text script is running""" return self._current_script_dir is not None @@ -2668,36 +2674,115 @@ class Cmd(cmd.Cmd): # Set apply_style to False so default_error's style is not overridden self.perror(err_msg, apply_style=False) - def read_input(self, prompt: str, *, allow_completion: bool = False) -> str: + def read_input(self, prompt: str, *, + history: Optional[List[str]] = None, + completion_mode: utils.CompletionMode = utils.CompletionMode.NONE, + preserve_quotes: bool = False, + choices: Iterable = None, + choices_provider: Optional[Callable] = None, + completer: Optional[Callable] = None, + parser: Optional[argparse.ArgumentParser] = None) -> str: """ - Read input from appropriate stdin value. Also allows you to disable tab completion while input is being read. + Read input from appropriate stdin value. Also supports tab completion and up-arrow history while + input is being entered. :param prompt: prompt to display to user - :param allow_completion: if True, then tab completion of commands is enabled. This generally should be - set to False unless reading the command line. Defaults to False. + :param history: optional list of strings to use for up-arrow history. If completion_mode is + CompletionMode.COMMANDS and this is None, then cmd2's command list history will + be used. The passed in history will not be edited. It is the caller's responsibility + to add the returned input to history if desired. Defaults to None. + :param completion_mode: tells what type of tab completion to support. Tab completion only works when + self.use_rawinput is True and sys.stdin is a terminal. Defaults to + CompletionMode.NONE. + + The following optional settings apply when completion_mode is CompletionMode.CUSTOM: + + :param preserve_quotes: if True, then quoted tokens will keep their quotes when processed by + ArgparseCompleter. This is helpful in cases when you're tab completing + flag-like tokens (e.g. -o, --option) and you don't want them to be + treated as argparse flags when quoted. Set this to True if you plan + on passing the string to argparse with the tokens still quoted. + + A maximum of one of these should be provided: + + :param choices: iterable of accepted values for single argument + :param choices_provider: function that provides choices for single argument + :param completer: tab completion function that provides choices for single argument + :param parser: an argument parser which supports the tab completion of multiple arguments + :return: the line read from stdin with all trailing new lines removed :raises: any exceptions raised by input() and stdin.readline() """ - completion_disabled = False - orig_completer = None + readline_configured = False + saved_completer = None # type: Optional[Callable] + saved_history = None # type: Optional[List[str]] + + def configure_readline(): + """Configure readline tab completion and history""" + nonlocal readline_configured + nonlocal saved_completer + nonlocal saved_history + nonlocal parser + + if readline_configured: # pragma: no cover + return - def disable_completion(): - """Turn off completion while entering input""" - nonlocal orig_completer - nonlocal completion_disabled + # Configure tab completion + if self._completion_supported(): + saved_completer = readline.get_completer() + + # Disable completion + if completion_mode == utils.CompletionMode.NONE: + # noinspection PyUnusedLocal + def complete_none(text: str, state: int): # pragma: no cover + return None + complete_func = complete_none + + # Complete commands + elif completion_mode == utils.CompletionMode.COMMANDS: + complete_func = self.complete + + # Set custom completion settings + else: + if parser is None: + parser = DEFAULT_ARGUMENT_PARSER(add_help=False) + parser.add_argument('arg', suppress_tab_hint=True, choices=choices, + choices_provider=choices_provider, completer=completer) + + custom_settings = utils.CustomCompletionSettings(parser, preserve_quotes=preserve_quotes) + complete_func = functools.partial(self.complete, custom_settings=custom_settings) + + readline.set_completer(complete_func) - if self._completion_supported() and not completion_disabled: - orig_completer = readline.get_completer() - readline.set_completer(lambda *args, **kwargs: None) - completion_disabled = True + # Overwrite history if not completing commands or new history was provided + if completion_mode != utils.CompletionMode.COMMANDS or history is not None: + saved_history = [] + for i in range(1, readline.get_current_history_length() + 1): + # noinspection PyArgumentList + saved_history.append(readline.get_history_item(i)) - def enable_completion(): - """Restore tab completion when finished entering input""" - nonlocal completion_disabled + readline.clear_history() + if history is not None: + for item in history: + readline.add_history(item) - if self._completion_supported() and completion_disabled: - readline.set_completer(orig_completer) - completion_disabled = False + readline_configured = True + + def restore_readline(): + """Restore readline tab completion and history""" + nonlocal readline_configured + if not readline_configured: # pragma: no cover + return + + if self._completion_supported(): + readline.set_completer(saved_completer) + + if saved_history is not None: + readline.clear_history() + for item in saved_history: + readline.add_history(item) + + readline_configured = False # Check we are reading from sys.stdin if self.use_rawinput: @@ -2707,15 +2792,11 @@ class Cmd(cmd.Cmd): safe_prompt = rl_make_safe_prompt(prompt) with self.sigint_protection: - # Check if tab completion should be disabled - if not allow_completion: - disable_completion() + configure_readline() line = input(safe_prompt) finally: with self.sigint_protection: - # Check if we need to re-enable tab completion - if not allow_completion: - enable_completion() + restore_readline() else: line = input() if self.echo: @@ -2759,7 +2840,7 @@ class Cmd(cmd.Cmd): self.terminal_lock.release() except RuntimeError: pass - return self.read_input(prompt, allow_completion=True) + return self.read_input(prompt, completion_mode=utils.CompletionMode.COMMANDS) except EOFError: return 'eof' finally: @@ -2768,7 +2849,7 @@ class Cmd(cmd.Cmd): def _set_up_cmd2_readline(self) -> _SavedReadlineSettings: """ - Set up readline with cmd2-specific settings + Called at beginning of command loop to set up readline with cmd2-specific settings :return: Class containing saved readline settings """ @@ -2803,7 +2884,7 @@ class Cmd(cmd.Cmd): def _restore_readline(self, readline_settings: _SavedReadlineSettings): """ - Restore saved readline settings + Called at end of command loop to restore saved readline settings :param readline_settings: the readline settings to restore """ @@ -2894,16 +2975,11 @@ class Cmd(cmd.Cmd): ) alias_create_parser = DEFAULT_ARGUMENT_PARSER(description=alias_create_description, epilog=alias_create_epilog) - alias_create_parser.add_argument( - '-s', '--silent', action='store_true', help='do not print message confirming alias was created or\n' 'overwritten' - ) alias_create_parser.add_argument('name', help='name of this alias') - alias_create_parser.add_argument( - 'command', help='what the alias resolves to', choices_method=_get_commands_aliases_and_macros_for_completion - ) - alias_create_parser.add_argument( - 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer_method=path_complete - ) + alias_create_parser.add_argument('command', help='what the alias resolves to', + choices_provider=_get_commands_aliases_and_macros_for_completion) + alias_create_parser.add_argument('command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', + completer=path_complete) @as_subcommand_to('alias', 'create', alias_create_parser, help=alias_create_description.lower()) def _alias_create(self, args: argparse.Namespace) -> None: @@ -2933,9 +3009,8 @@ class Cmd(cmd.Cmd): value += ' ' + ' '.join(args.command_args) # Set the alias - if not args.silent: - result = "overwritten" if args.name in self.aliases else "created" - self.poutput("Alias '{}' {}".format(args.name, result)) + result = "overwritten" if args.name in self.aliases else "created" + self.poutput("Alias '{}' {}".format(args.name, result)) self.aliases[args.name] = value @@ -2945,13 +3020,8 @@ class Cmd(cmd.Cmd): alias_delete_parser = DEFAULT_ARGUMENT_PARSER(description=alias_delete_description) alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases") - alias_delete_parser.add_argument( - 'names', - nargs=argparse.ZERO_OR_MORE, - help='alias(es) to delete', - choices_method=_get_alias_completion_items, - descriptive_header='Value', - ) + alias_delete_parser.add_argument('names', nargs=argparse.ZERO_OR_MORE, help='alias(es) to delete', + choices_provider=_get_alias_completion_items, descriptive_header='Value') @as_subcommand_to('alias', 'delete', alias_delete_parser, help=alias_delete_help) def _alias_delete(self, args: argparse.Namespace) -> None: @@ -2979,29 +3049,12 @@ class Cmd(cmd.Cmd): ) alias_list_parser = DEFAULT_ARGUMENT_PARSER(description=alias_list_description) - alias_list_parser.add_argument( - '-w', - '--with_silent', - action='store_true', - help="include --silent flag with listed aliases\n" - "Use this option when saving to a startup script that\n" - "should silently create aliases.", - ) - alias_list_parser.add_argument( - 'names', - nargs=argparse.ZERO_OR_MORE, - help='alias(es) to list', - choices_method=_get_alias_completion_items, - descriptive_header='Value', - ) + alias_list_parser.add_argument('names', nargs=argparse.ZERO_OR_MORE, help='alias(es) to list', + choices_provider=_get_alias_completion_items, descriptive_header='Value') @as_subcommand_to('alias', 'list', alias_list_parser, help=alias_delete_help) def _alias_list(self, args: argparse.Namespace) -> None: """List some or all aliases as 'alias create' commands""" - create_cmd = "alias create" - if args.with_silent: - create_cmd += " --silent" - tokens_to_quote = constants.REDIRECTION_TOKENS tokens_to_quote.extend(self.statement_parser.terminators) @@ -3026,7 +3079,7 @@ class Cmd(cmd.Cmd): if args: val += ' ' + ' '.join(args) - self.poutput("{} {} {}".format(create_cmd, name, val)) + self.poutput("alias create {} {}".format(name, val)) for name in not_found: self.perror("Alias '{}' not found".format(name)) @@ -3092,16 +3145,11 @@ class Cmd(cmd.Cmd): ) macro_create_parser = DEFAULT_ARGUMENT_PARSER(description=macro_create_description, epilog=macro_create_epilog) - macro_create_parser.add_argument( - '-s', '--silent', action='store_true', help='do not print message confirming macro was created or\n' 'overwritten' - ) macro_create_parser.add_argument('name', help='name of this macro') - macro_create_parser.add_argument( - 'command', help='what the macro resolves to', choices_method=_get_commands_aliases_and_macros_for_completion - ) - macro_create_parser.add_argument( - 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer_method=path_complete - ) + macro_create_parser.add_argument('command', help='what the macro resolves to', + choices_provider=_get_commands_aliases_and_macros_for_completion) + macro_create_parser.add_argument('command_args', nargs=argparse.REMAINDER, + help='arguments to pass to command', completer=path_complete) @as_subcommand_to('macro', 'create', macro_create_parser, help=macro_create_help) def _macro_create(self, args: argparse.Namespace) -> None: @@ -3176,9 +3224,8 @@ class Cmd(cmd.Cmd): break # Set the macro - if not args.silent: - result = "overwritten" if args.name in self.macros else "created" - self.poutput("Macro '{}' {}".format(args.name, result)) + result = "overwritten" if args.name in self.macros else "created" + self.poutput("Macro '{}' {}".format(args.name, result)) self.macros[args.name] = Macro(name=args.name, value=value, minimum_arg_count=max_arg_num, arg_list=arg_list) @@ -3187,13 +3234,8 @@ class Cmd(cmd.Cmd): macro_delete_description = "Delete specified macros or all macros if --all is used" macro_delete_parser = DEFAULT_ARGUMENT_PARSER(description=macro_delete_description) macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros") - macro_delete_parser.add_argument( - 'names', - nargs=argparse.ZERO_OR_MORE, - help='macro(s) to delete', - choices_method=_get_macro_completion_items, - descriptive_header='Value', - ) + macro_delete_parser.add_argument('names', nargs=argparse.ZERO_OR_MORE, help='macro(s) to delete', + choices_provider=_get_macro_completion_items, descriptive_header='Value') @as_subcommand_to('macro', 'delete', macro_delete_parser, help=macro_delete_help) def _macro_delete(self, args: argparse.Namespace) -> None: @@ -3221,29 +3263,12 @@ class Cmd(cmd.Cmd): ) macro_list_parser = DEFAULT_ARGUMENT_PARSER(description=macro_list_description) - macro_list_parser.add_argument( - '-w', - '--with_silent', - action='store_true', - help="include --silent flag with listed macros\n" - "Use this option when saving to a startup script that\n" - "should silently create macros.", - ) - macro_list_parser.add_argument( - 'names', - nargs=argparse.ZERO_OR_MORE, - help='macro(s) to list', - choices_method=_get_macro_completion_items, - descriptive_header='Value', - ) + macro_list_parser.add_argument('names', nargs=argparse.ZERO_OR_MORE, help='macro(s) to list', + choices_provider=_get_macro_completion_items, descriptive_header='Value') @as_subcommand_to('macro', 'list', macro_list_parser, help=macro_list_help) def _macro_list(self, args: argparse.Namespace) -> None: """List some or all macros as 'macro create' commands""" - create_cmd = "macro create" - if args.with_silent: - create_cmd += " --silent" - tokens_to_quote = constants.REDIRECTION_TOKENS tokens_to_quote.extend(self.statement_parser.terminators) @@ -3268,7 +3293,7 @@ class Cmd(cmd.Cmd): if args: val += ' ' + ' '.join(args) - self.poutput("{} {} {}".format(create_cmd, name, val)) + self.poutput("macro create {} {}".format(name, val)) for name in not_found: self.perror("Macro '{}' not found".format(name)) @@ -3280,7 +3305,7 @@ class Cmd(cmd.Cmd): topics = set(self.get_help_topics()) visible_commands = set(self.get_visible_commands()) strs_to_match = list(topics | visible_commands) - return utils.basic_complete(text, line, begidx, endidx, strs_to_match) + return self.basic_complete(text, line, begidx, endidx, strs_to_match) def complete_help_subcommands( self, text: str, line: str, begidx: int, endidx: int, arg_tokens: Dict[str, List[str]] @@ -3298,31 +3323,18 @@ class Cmd(cmd.Cmd): if func is None or argparser is None: return [] - # Combine the command and its subcommand tokens for the ArgparseCompleter - tokens = [command] + arg_tokens['subcommands'] - - from .argparse_completer import ( - ArgparseCompleter, - ) - + from .argparse_completer import ArgparseCompleter completer = ArgparseCompleter(argparser, self) - return completer.complete_subcommand_help(tokens, text, line, begidx, endidx) + return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands']) - help_parser = DEFAULT_ARGUMENT_PARSER( - description="List available commands or provide " "detailed help for a specific command" - ) - help_parser.add_argument( - '-v', '--verbose', action='store_true', help="print a list of all commands with descriptions of each" - ) - help_parser.add_argument( - 'command', nargs=argparse.OPTIONAL, help="command to retrieve help for", completer_method=complete_help_command - ) - help_parser.add_argument( - 'subcommands', - nargs=argparse.REMAINDER, - help="subcommand(s) to retrieve help for", - completer_method=complete_help_subcommands, - ) + help_parser = DEFAULT_ARGUMENT_PARSER(description="List available commands or provide " + "detailed help for a specific command") + help_parser.add_argument('-v', '--verbose', action='store_true', + help="print a list of all commands with descriptions of each") + help_parser.add_argument('command', nargs=argparse.OPTIONAL, help="command to retrieve help for", + completer=complete_help_command) + help_parser.add_argument('subcommands', nargs=argparse.REMAINDER, help="subcommand(s) to retrieve help for", + completer=complete_help_subcommands) # Get rid of cmd's complete_help() functions so ArgparseCompleter will complete the help command if getattr(cmd.Cmd, 'complete_help', None) is not None: @@ -3347,10 +3359,9 @@ class Cmd(cmd.Cmd): ) completer = ArgparseCompleter(argparser, self) - tokens = [args.command] + args.subcommands # Set end to blank so the help output matches how it looks when "command -h" is used - self.poutput(completer.format_help(tokens), end='') + self.poutput(completer.format_help(args.subcommands), end='') # If there is a help func delegate to do_help elif help_func is not None: @@ -3556,10 +3567,6 @@ class Cmd(cmd.Cmd): if not response: continue - if rl_type != RlType.NONE: - hlen = readline.get_current_history_length() - if hlen >= 1: - readline.remove_history_item(hlen - 1) try: choice = int(response) if choice < 1: @@ -3584,26 +3591,17 @@ class Cmd(cmd.Cmd): # Settables with choices list the values of those choices instead of the arg name # in help text and this shows in tab completion hints. Set metavar to avoid this. arg_name = 'value' - settable_parser.add_argument( - arg_name, - metavar=arg_name, - help=settable.description, - choices=settable.choices, - choices_function=settable.choices_function, - choices_method=settable.choices_method, - completer_function=settable.completer_function, - completer_method=settable.completer_method, - ) - - from .argparse_completer import ( - ArgparseCompleter, - ) + settable_parser.add_argument(arg_name, metavar=arg_name, help=settable.description, + choices=settable.choices, + choices_provider=settable.choices_provider, + completer=settable.completer) + from .argparse_completer import ArgparseCompleter completer = ArgparseCompleter(settable_parser, self) # Use raw_tokens since quotes have been preserved _, raw_tokens = self.tokens_for_completion(line, begidx, endidx) - return completer.complete_command(raw_tokens, text, line, begidx, endidx) + return completer.complete(text, line, begidx, endidx, raw_tokens[1:]) # When tab completing value, we recreate the set command parser with a value argument specific to # the settable being edited. To make this easier, define a parent parser with all the common elements. @@ -3613,26 +3611,15 @@ class Cmd(cmd.Cmd): "Call with just param to view that parameter's value." ) set_parser_parent = DEFAULT_ARGUMENT_PARSER(description=set_description, add_help=False) - set_parser_parent.add_argument( - '-v', '--verbose', action='store_true', help='include description of parameters when viewing' - ) - set_parser_parent.add_argument( - 'param', - nargs=argparse.OPTIONAL, - help='parameter to set or view', - choices_method=_get_settable_completion_items, - descriptive_header='Description', - ) + set_parser_parent.add_argument('-v', '--verbose', action='store_true', + help='include description of parameters when viewing') + set_parser_parent.add_argument('param', nargs=argparse.OPTIONAL, help='parameter to set or view', + choices_provider=_get_settable_completion_items, descriptive_header='Description') # Create the parser for the set command set_parser = DEFAULT_ARGUMENT_PARSER(parents=[set_parser_parent]) - set_parser.add_argument( - 'value', - nargs=argparse.OPTIONAL, - help='new value for settable', - completer_method=complete_set_value, - suppress_tab_hint=True, - ) + set_parser.add_argument('value', nargs=argparse.OPTIONAL, help='new value for settable', + completer=complete_set_value, suppress_tab_hint=True) # Preserve quotes so users can pass in quoted empty strings and flags (e.g. -h) as the value @with_argparser(set_parser, preserve_quotes=True) @@ -3692,10 +3679,9 @@ class Cmd(cmd.Cmd): self.poutput(result_str) shell_parser = DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt") - shell_parser.add_argument('command', help='the command to run', completer_method=shell_cmd_complete) - shell_parser.add_argument( - 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer_method=path_complete - ) + shell_parser.add_argument('command', help='the command to run', completer=shell_cmd_complete) + shell_parser.add_argument('command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', + completer=path_complete) # Preserve quotes since we are passing these strings to the shell @with_argparser(shell_parser, preserve_quotes=True) @@ -3992,10 +3978,9 @@ class Cmd(cmd.Cmd): return py_bridge.stop run_pyscript_parser = DEFAULT_ARGUMENT_PARSER(description="Run a Python script file inside the console") - run_pyscript_parser.add_argument('script_path', help='path to the script file', completer_method=path_complete) - run_pyscript_parser.add_argument( - 'script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer_method=path_complete - ) + run_pyscript_parser.add_argument('script_path', help='path to the script file', completer=path_complete) + run_pyscript_parser.add_argument('script_arguments', nargs=argparse.REMAINDER, + help='arguments to pass to script', completer=path_complete) @with_argparser(run_pyscript_parser) def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]: @@ -4091,21 +4076,14 @@ class Cmd(cmd.Cmd): history_parser = DEFAULT_ARGUMENT_PARSER(description=history_description) history_action_group = history_parser.add_mutually_exclusive_group() history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items') - history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items') - history_action_group.add_argument( - '-o', - '--output_file', - metavar='FILE', - help='output commands to a script file, implies -s', - completer_method=path_complete, - ) - history_action_group.add_argument( - '-t', - '--transcript', - metavar='TRANSCRIPT_FILE', - help='output commands and results to a transcript file,\nimplies -s', - completer_method=path_complete, - ) + history_action_group.add_argument('-e', '--edit', action='store_true', + help='edit and then run selected history items') + history_action_group.add_argument('-o', '--output_file', metavar='FILE', + help='output commands to a script file, implies -s', + completer=path_complete) + history_action_group.add_argument('-t', '--transcript', metavar='TRANSCRIPT_FILE', + help='output commands and results to a transcript file,\nimplies -s', + completer=path_complete) history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') history_format_group = history_parser.add_argument_group(title='formatting') @@ -4436,9 +4414,8 @@ class Cmd(cmd.Cmd): ) edit_parser = DEFAULT_ARGUMENT_PARSER(description=edit_description) - edit_parser.add_argument( - 'file_path', nargs=argparse.OPTIONAL, help="optional path to a file to open in editor", completer_method=path_complete - ) + edit_parser.add_argument('file_path', nargs=argparse.OPTIONAL, + help="optional path to a file to open in editor", completer=path_complete) @with_argparser(edit_parser) def do_edit(self, args: argparse.Namespace) -> None: @@ -4481,14 +4458,10 @@ class Cmd(cmd.Cmd): ) run_script_parser = DEFAULT_ARGUMENT_PARSER(description=run_script_description) - run_script_parser.add_argument( - '-t', - '--transcript', - metavar='TRANSCRIPT_FILE', - help='record the output of the script as a transcript file', - completer_method=path_complete, - ) - run_script_parser.add_argument('script_path', help="path to the script file", completer_method=path_complete) + run_script_parser.add_argument('-t', '--transcript', metavar='TRANSCRIPT_FILE', + help='record the output of the script as a transcript file', + completer=path_complete) + run_script_parser.add_argument('script_path', help="path to the script file", completer=path_complete) @with_argparser(run_script_parser) def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: @@ -4808,8 +4781,6 @@ class Cmd(cmd.Cmd): command being disabled. ex: message_to_print = "{} is currently disabled".format(COMMAND_NAME) """ - import functools - # If the commands is already disabled, then return if command in self.disabled_commands: return diff --git a/cmd2/constants.py b/cmd2/constants.py index 552c1a74..9f29be86 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -57,4 +57,3 @@ PARSER_ATTR_COMMANDSET = 'command_set' # custom attributes added to argparse Namespaces NS_ATTR_SUBCMD_HANDLER = '__subcmd_handler__' -NS_ATTR_STATEMENT = '__statement__' diff --git a/cmd2/decorators.py b/cmd2/decorators.py index d85db300..e3a3eaa1 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -196,62 +196,10 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str): break -def with_argparser_and_unknown_args( - parser: argparse.ArgumentParser, - *, - ns_provider: Optional[Callable[..., argparse.Namespace]] = None, - preserve_quotes: bool = False -) -> Callable[[argparse.Namespace, List], Optional[bool]]: - """ - Deprecated decorator. Use `with_argparser(parser, with_unknown_args=True)` instead. - - A decorator to alter a cmd2 method to populate its ``args`` argument by parsing - arguments with the given instance of argparse.ArgumentParser, but also returning - unknown args as a list. - - :param parser: unique instance of ArgumentParser - :param ns_provider: An optional function that accepts a cmd2.Cmd object as an argument - and returns an argparse.Namespace. This is useful if the Namespace - needs to be prepopulated with state data that affects parsing. - :param preserve_quotes: if ``True``, then arguments passed to argparse maintain their quotes - :return: function that gets passed argparse-parsed args in a ``Namespace`` and a list - of unknown argument strings. A :class:`cmd2.argparse_custom.Cmd2AttributeWrapper` called - ``cmd2_statement`` is included in the ``Namespace`` to provide access to the :class:`cmd2.Statement` - object. that was created when parsing the command line. This can be useful if the command function - needs to know the command line. - - :Example: - - >>> parser = argparse.ArgumentParser() - >>> parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') - >>> parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') - >>> parser.add_argument('-r', '--repeat', type=int, help='output [n] times') - >>> - >>> class MyApp(cmd2.Cmd): - >>> @cmd2.with_argparser(parser, with_unknown_args=True) - >>> def do_argprint(self, args, unknown): - >>> "Print the options and argument list this options command was called with." - >>> self.poutput('args: {!r}'.format(args)) - >>> self.poutput('unknowns: {}'.format(unknown)) - """ - import warnings - - warnings.warn( - 'This decorator will be deprecated. Use `with_argparser(parser, with_unknown_args=True)`.', - PendingDeprecationWarning, - stacklevel=2, - ) - - return with_argparser(parser, ns_provider=ns_provider, preserve_quotes=preserve_quotes, with_unknown_args=True) - - -def with_argparser( - parser: argparse.ArgumentParser, - *, - ns_provider: Optional[Callable[..., argparse.Namespace]] = None, - preserve_quotes: bool = False, - with_unknown_args: bool = False -) -> Callable[[argparse.Namespace], Optional[bool]]: +def with_argparser(parser: argparse.ArgumentParser, *, + ns_provider: Optional[Callable[..., argparse.Namespace]] = None, + preserve_quotes: bool = False, + with_unknown_args: bool = False) -> Callable[[argparse.Namespace], Optional[bool]]: """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given instance of argparse.ArgumentParser. @@ -334,9 +282,6 @@ def with_argparser( except SystemExit: raise Cmd2ArgparseError else: - # Add statement to Namespace as __statement__ (this is deprecated and will be removed in 2.0) - setattr(ns, constants.NS_ATTR_STATEMENT, statement) - # Add wrapped statement to Namespace as cmd2_statement setattr(ns, 'cmd2_statement', Cmd2AttributeWrapper(statement)) diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index c5a08202..b6e18856 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -36,6 +36,30 @@ class CommandSetRegistrationError(Exception): pass +class CompletionError(Exception): + """ + Raised during tab completion operations to report any sort of error you want printed. This can also be used + just to display a message, even if it's not an error. For instance, ArgparseCompleter raises CompletionErrors + to display tab completion hints and sets apply_style to False so hints aren't colored like error text. + + Example use cases + + - Reading a database to retrieve a tab completion data set failed + - A previous command line argument that determines the data set being completed is invalid + - Tab completion hints + """ + def __init__(self, *args, apply_style: bool = True, **kwargs): + """ + Initializer for CompletionError + :param apply_style: If True, then ansi.style_error will be applied to the message text when printed. + Set to False in cases where the message text already has the desired style. + Defaults to True. + """ + self.apply_style = apply_style + + # noinspection PyArgumentList + super().__init__(*args, **kwargs) + ############################################################################################################ # The following exceptions are NOT part of the public API and are intended for internal use only. ############################################################################################################ diff --git a/cmd2/utils.py b/cmd2/utils.py index 7e5f5af5..8dad5f4c 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -1,6 +1,7 @@ # coding=utf-8 """Shared utility functions""" +import argparse import collections import collections.abc as collections_abc import functools @@ -90,48 +91,13 @@ def str_to_bool(val: str) -> bool: raise ValueError("must be True or False (case-insensitive)") -class CompletionError(Exception): - """ - Raised during tab completion operations to report any sort of error you want printed. This can also be used - just to display a message, even if it's not an error. For instance, ArgparseCompleter raises CompletionErrors - to display tab completion hints and sets apply_style to False so hints aren't colored like error text. - - Example use cases - - - Reading a database to retrieve a tab completion data set failed - - A previous command line argument that determines the data set being completed is invalid - - Tab completion hints - """ - - def __init__(self, *args, apply_style: bool = True, **kwargs): - """ - Initializer for CompletionError - :param apply_style: If True, then ansi.style_error will be applied to the message text when printed. - Set to False in cases where the message text already has the desired style. - Defaults to True. - """ - self.apply_style = apply_style - - # noinspection PyArgumentList - super().__init__(*args, **kwargs) - - class Settable: """Used to configure a cmd2 instance member to be settable via the set command in the CLI""" - - def __init__( - self, - name: str, - val_type: Callable, - description: str, - *, - onchange_cb: Callable[[str, Any, Any], Any] = None, - choices: Iterable = None, - choices_function: Optional[Callable] = None, - choices_method: Optional[Callable] = None, - completer_function: Optional[Callable] = None, - completer_method: Optional[Callable] = None - ): + def __init__(self, name: str, val_type: Callable, description: str, *, + onchange_cb: Callable[[str, Any, Any], Any] = None, + choices: Iterable = None, + choices_provider: Optional[Callable] = None, + completer: Optional[Callable] = None): """ Settable Initializer @@ -153,17 +119,8 @@ class Settable: same settings in argparse-based tab completion. A maximum of one of these should be provided. :param choices: iterable of accepted values - :param choices_function: function that provides choices for this argument - :param choices_method: cmd2-app method that provides choices for this argument (See note below) - :param completer_function: tab completion function that provides choices for this argument - :param completer_method: cmd2-app tab completion method that provides choices - for this argument (See note below) - - Note: - For choices_method and completer_method, do not set them to a bound method. This is because - ArgparseCompleter passes the self argument explicitly to these functions. - - Therefore instead of passing something like self.path_complete, pass cmd2.Cmd.path_complete. + :param choices_provider: function that provides choices for this argument + :param completer: tab completion function that provides choices for this argument """ if val_type == bool: val_type = str_to_bool @@ -174,10 +131,8 @@ class Settable: self.description = description self.onchange_cb = onchange_cb self.choices = choices - self.choices_function = choices_function - self.choices_method = choices_method - self.completer_function = completer_function - self.completer_method = completer_method + self.choices_provider = choices_provider + self.completer = completer def namedtuple_with_defaults(typename: str, field_names: Union[str, List[str]], default_values: collections_abc.Iterable = ()): @@ -732,22 +687,6 @@ class RedirectionSavedState: self.saved_redirecting = saved_redirecting -# noinspection PyUnusedLocal -def basic_complete(text: str, line: str, begidx: int, endidx: int, match_against: Iterable) -> List[str]: - """ - Basic tab completion function that matches against a list of strings without considering line contents - or cursor position. The args required by this function are defined in the header of Python's cmd.py. - - :param text: the string prefix we are attempting to match (all matches must begin with it) - :param line: the current input line with leading whitespace removed - :param begidx: the beginning index of the prefix text - :param endidx: the ending index of the prefix text - :param match_against: the strings being matched against - :return: a list of possible tab completions - """ - return [cur_match for cur_match in match_against if cur_match.startswith(text)] - - class TextAlignment(Enum): """Horizontal text alignment""" @@ -1132,3 +1071,37 @@ def get_defining_class(meth) -> Type: if isinstance(cls, type): return cls return getattr(meth, '__objclass__', None) # handle special descriptor objects + + +class CompletionMode(Enum): + """Enum for what type of tab completion to perform in cmd2.Cmd.read_input()""" + # Tab completion will be disabled during read_input() call + # Use of custom up-arrow history supported + NONE = 1 + + # read_input() will tab complete cmd2 commands and their arguments + # cmd2's command line history will be used for up arrow if history is not provided. + # Otherwise use of custom up-arrow history supported. + COMMANDS = 2 + + # read_input() will tab complete based on one of its following parameters: + # choices, choices_provider, completer, parser + # Use of custom up-arrow history supported + CUSTOM = 3 + + +class CustomCompletionSettings: + """Used by cmd2.Cmd.complete() to tab complete strings other than command arguments""" + def __init__(self, parser: argparse.ArgumentParser, *, preserve_quotes: bool = False): + """ + Initializer + + :param parser: arg parser defining format of string being tab completed + :param preserve_quotes: if True, then quoted tokens will keep their quotes when processed by + ArgparseCompleter. This is helpful in cases when you're tab completing + flag-like tokens (e.g. -o, --option) and you don't want them to be + treated as argparse flags when quoted. Set this to True if you plan + on passing the string to argparse with the tokens still quoted. + """ + self.parser = parser + self.preserve_quotes = preserve_quotes |
