diff options
Diffstat (limited to 'cmd2/argparse_completer.py')
-rw-r--r-- | cmd2/argparse_completer.py | 257 |
1 files changed, 112 insertions, 145 deletions
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 185e01a2..707b36ba 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -2,7 +2,7 @@ # flake8: noqa C901 # NOTE: Ignoring flake8 cyclomatic complexity in this file """ -This module defines the AutoCompleter class which provides argparse-based tab completion to cmd2 apps. +This module defines the ArgparseCompleter class which provides argparse-based tab completion to cmd2 apps. See the header of argparse_custom.py for instructions on how to use these features. """ @@ -10,17 +10,15 @@ import argparse import inspect import numbers import shutil -import textwrap from collections import deque from typing import Dict, List, Optional, Union from . import ansi from . import cmd2 -from . import utils from .argparse_custom import ATTR_CHOICES_CALLABLE, INFINITY, generate_range_error from .argparse_custom import ATTR_SUPPRESS_TAB_HINT, ATTR_DESCRIPTIVE_COMPLETION_HEADER, ATTR_NARGS_RANGE -from .argparse_custom import ChoicesCallable, CompletionError, CompletionItem -from .rl_utils import rl_force_redisplay +from .argparse_custom import ChoicesCallable, CompletionItem +from .utils import basic_complete, CompletionError # If no descriptive header is supplied, then this will be used instead DEFAULT_DESCRIPTIVE_HEADER = 'Description' @@ -63,52 +61,89 @@ def _looks_like_flag(token: str, parser: argparse.ArgumentParser) -> bool: return True +class _ArgumentState: + """Keeps state of an argument being parsed""" + def __init__(self, arg_action: argparse.Action) -> None: + self.action = arg_action + self.min = None + self.max = None + self.count = 0 + self.is_remainder = (self.action.nargs == argparse.REMAINDER) + + # Check if nargs is a range + nargs_range = getattr(self.action, ATTR_NARGS_RANGE, None) + if nargs_range is not None: + self.min = nargs_range[0] + self.max = nargs_range[1] + + # Otherwise check against argparse types + elif self.action.nargs is None: + self.min = 1 + self.max = 1 + elif self.action.nargs == argparse.OPTIONAL: + self.min = 0 + self.max = 1 + elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER: + self.min = 0 + self.max = INFINITY + elif self.action.nargs == argparse.ONE_OR_MORE: + self.min = 1 + self.max = INFINITY + else: + self.min = self.action.nargs + self.max = self.action.nargs + + # noinspection PyProtectedMember -class AutoCompleter: - """Automatic command line tab completion based on argparse parameters""" +class _UnfinishedFlagError(CompletionError): + def __init__(self, flag_arg_state: _ArgumentState) -> None: + """ + CompletionError which occurs when the user has not finished the current flag + :param flag_arg_state: information about the unfinished flag action + """ + error = "Error: argument {}: {} ({} entered)".\ + format(argparse._get_action_name(flag_arg_state.action), + generate_range_error(flag_arg_state.min, flag_arg_state.max), + flag_arg_state.count) + super().__init__(error) - class _ArgumentState: - """Keeps state of an argument being parsed""" - - def __init__(self, arg_action: argparse.Action) -> None: - self.action = arg_action - self.min = None - self.max = None - self.count = 0 - self.is_remainder = (self.action.nargs == argparse.REMAINDER) - - # Check if nargs is a range - nargs_range = getattr(self.action, ATTR_NARGS_RANGE, None) - if nargs_range is not None: - self.min = nargs_range[0] - self.max = nargs_range[1] - - # Otherwise check against argparse types - elif self.action.nargs is None: - self.min = 1 - self.max = 1 - elif self.action.nargs == argparse.OPTIONAL: - self.min = 0 - self.max = 1 - elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER: - self.min = 0 - self.max = INFINITY - elif self.action.nargs == argparse.ONE_OR_MORE: - self.min = 1 - self.max = INFINITY - else: - self.min = self.action.nargs - self.max = self.action.nargs +# noinspection PyProtectedMember +class _NoResultsError(CompletionError): + def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action) -> None: + """ + CompletionError which occurs when there are no results. If hinting is allowed, then its message will + be a hint about the argument being tab completed. + :param parser: ArgumentParser instance which owns the action being tab completed + :param arg_action: action being tab completed + """ + # Check if hinting is disabled + suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False) + if suppress_hint or arg_action.help == argparse.SUPPRESS: + hint_str = '' + else: + # Use the parser's help formatter to print just this action's help text + formatter = parser._get_formatter() + formatter.start_section("Hint") + formatter.add_argument(arg_action) + formatter.end_section() + hint_str = formatter.format_help() + # Set apply_style to False because we don't want hints to look like errors + super().__init__(hint_str, apply_style=False) + + +# noinspection PyProtectedMember +class ArgparseCompleter: + """Automatic command line tab completion based on argparse parameters""" def __init__(self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *, parent_tokens: Optional[Dict[str, List[str]]] = None) -> None: """ - Create an AutoCompleter + Create an ArgparseCompleter :param parser: ArgumentParser instance - :param cmd2_app: reference to the Cmd2 application that owns this AutoCompleter + :param cmd2_app: reference to the Cmd2 application that owns this ArgparseCompleter :param parent_tokens: optional dictionary mapping parent parsers' arg names to their tokens - this is only used by AutoCompleter when recursing on subcommand parsers + This is only used by ArgparseCompleter when recursing on subcommand parsers Defaults to None """ self._parser = parser @@ -141,7 +176,10 @@ class AutoCompleter: self._subcommand_action = action def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]: - """Complete the command using the argparse metadata and provided argument dictionary""" + """ + Complete the command using the argparse metadata and provided argument dictionary + :raises: CompletionError for various types of tab completion errors + """ if not tokens: return [] @@ -167,18 +205,18 @@ class AutoCompleter: # Completed mutually exclusive groups completed_mutex_groups = dict() # dict(argparse._MutuallyExclusiveGroup -> Action which completed group) - def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: + def consume_argument(arg_state: _ArgumentState) -> None: """Consuming token as an argument""" arg_state.count += 1 consumed_arg_values.setdefault(arg_state.action.dest, []) consumed_arg_values[arg_state.action.dest].append(token) - def update_mutex_groups(arg_action: argparse.Action) -> bool: + def update_mutex_groups(arg_action: argparse.Action) -> None: """ Check if an argument belongs to a mutually exclusive group and either mark that group as complete or print an error if the group has already been completed :param arg_action: the action of the argument - :return: False if the group has already been completed and there is a conflict, otherwise True + :raises: CompletionError if the group is already completed """ # Check if this action is in a mutually exclusive group for group in self._parser._mutually_exclusive_groups: @@ -191,13 +229,12 @@ class AutoCompleter: # since it's allowed to appear on the command line more than once. completer_action = completed_mutex_groups[group] if arg_action == completer_action: - return True + return - error = ansi.style_error("\nError: argument {}: not allowed with argument {}\n". - format(argparse._get_action_name(arg_action), - argparse._get_action_name(completer_action))) - self._print_message(error) - return False + error = ("Error: argument {}: not allowed with argument {}". + format(argparse._get_action_name(arg_action), + argparse._get_action_name(completer_action))) + raise CompletionError(error) # Mark that this action completed the group completed_mutex_groups[group] = arg_action @@ -214,8 +251,6 @@ class AutoCompleter: # Arg can only be in one group, so we are done break - return True - ############################################################################################# # Parse all but the last token ############################################################################################# @@ -238,8 +273,7 @@ class AutoCompleter: elif token == '--' and not skip_remaining_flags: # Check if there is an unfinished flag if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: - self._print_unfinished_flag_error(flag_arg_state) - return [] + raise _UnfinishedFlagError(flag_arg_state) # Otherwise end the current flag else: @@ -252,8 +286,7 @@ class AutoCompleter: # Check if there is an unfinished flag if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: - self._print_unfinished_flag_error(flag_arg_state) - return [] + raise _UnfinishedFlagError(flag_arg_state) # Reset flag arg state but not positional tracking because flags can be # interspersed anywhere between positionals @@ -269,9 +302,7 @@ class AutoCompleter: action = self._flag_to_action[candidates_flags[0]] if action is not None: - if not update_mutex_groups(action): - return [] - + update_mutex_groups(action) if isinstance(action, (argparse._AppendAction, argparse._AppendConstAction, argparse._CountAction)): @@ -286,7 +317,7 @@ class AutoCompleter: # earlier in the command line. Reset them now for this use of it. consumed_arg_values[action.dest] = [] - new_arg_state = AutoCompleter._ArgumentState(action) + new_arg_state = _ArgumentState(action) # Keep track of this flag if it can receive arguments if new_arg_state.max > 0: @@ -319,8 +350,8 @@ class AutoCompleter: if action.dest != argparse.SUPPRESS: parent_tokens[action.dest] = [token] - completer = AutoCompleter(self._subcommand_action.choices[token], self._cmd2_app, - parent_tokens=parent_tokens) + 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) else: # Invalid subcommand entered, so no way to complete remaining tokens @@ -328,14 +359,11 @@ class AutoCompleter: # Otherwise keep track of the argument else: - pos_arg_state = AutoCompleter._ArgumentState(action) + pos_arg_state = _ArgumentState(action) # Check if we have a positional to consume this token if pos_arg_state is not None: - # No need to check for an error since we remove a completed group's positional from - # remaining_positionals which means this action can't belong to a completed mutex group update_mutex_groups(pos_arg_state.action) - consume_argument(pos_arg_state) # No more flags are allowed if this is a REMAINDER argument @@ -361,21 +389,15 @@ class AutoCompleter: # character (-f) at the end. if _looks_like_flag(text, self._parser) and not skip_remaining_flags: if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: - self._print_unfinished_flag_error(flag_arg_state) - return [] - + raise _UnfinishedFlagError(flag_arg_state) return self._complete_flags(text, line, begidx, endidx, matched_flags) completion_results = [] # Check if we are completing a flag's argument if flag_arg_state is not None: - try: - completion_results = self._complete_for_arg(flag_arg_state.action, text, line, - begidx, endidx, consumed_arg_values) - except CompletionError as ex: - self._print_completion_error(flag_arg_state.action, ex) - return [] + completion_results = self._complete_for_arg(flag_arg_state.action, text, line, + begidx, endidx, consumed_arg_values) # If we have results, then return them if completion_results: @@ -384,8 +406,7 @@ class AutoCompleter: # Otherwise, print a hint if the flag isn't finished or text isn't possibly the start of a flag elif flag_arg_state.count < flag_arg_state.min or \ not _single_prefix_char(text, self._parser) or skip_remaining_flags: - self._print_arg_hint(flag_arg_state.action) - return [] + raise _NoResultsError(self._parser, flag_arg_state.action) # Otherwise check if we have a positional to complete elif pos_arg_state is not None or remaining_positionals: @@ -393,14 +414,10 @@ class AutoCompleter: # If we aren't current tracking a positional, then get the next positional arg to handle this token if pos_arg_state is None: action = remaining_positionals.popleft() - pos_arg_state = AutoCompleter._ArgumentState(action) + pos_arg_state = _ArgumentState(action) - try: - completion_results = self._complete_for_arg(pos_arg_state.action, text, line, - begidx, endidx, consumed_arg_values) - except CompletionError as ex: - self._print_completion_error(pos_arg_state.action, ex) - return [] + completion_results = self._complete_for_arg(pos_arg_state.action, text, line, + begidx, endidx, consumed_arg_values) # If we have results, then return them if completion_results: @@ -408,8 +425,7 @@ class AutoCompleter: # Otherwise, print a hint if text isn't possibly the start of a flag elif not _single_prefix_char(text, self._parser) or skip_remaining_flags: - self._print_arg_hint(pos_arg_state.action) - return [] + raise _NoResultsError(self._parser, pos_arg_state.action) # Handle case in which text is a single flag prefix character that # didn't complete against any argument values. @@ -432,7 +448,7 @@ class AutoCompleter: if action.help != argparse.SUPPRESS: match_against.append(flag) - return utils.basic_complete(text, line, begidx, endidx, match_against) + return basic_complete(text, line, begidx, endidx, match_against) def _format_completions(self, action, completions: List[Union[str, CompletionItem]]) -> List[str]: # Check if the results are CompletionItems and that there aren't too many to display @@ -483,15 +499,15 @@ class AutoCompleter: :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 AutoCompleter. + # 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): if token in self._subcommand_action.choices: - completer = AutoCompleter(self._subcommand_action.choices[token], self._cmd2_app) + completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app) return completer.complete_subcommand_help(tokens[token_index:], text, line, begidx, endidx) elif token_index == len(tokens) - 1: # Since this is the last token, we will attempt to complete it - return utils.basic_complete(text, line, begidx, endidx, self._subcommand_action.choices) + return basic_complete(text, line, begidx, endidx, self._subcommand_action.choices) else: break return [] @@ -503,11 +519,11 @@ class AutoCompleter: :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 AutoCompleter. + # 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): if token in self._subcommand_action.choices: - completer = AutoCompleter(self._subcommand_action.choices[token], self._cmd2_app) + completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app) return completer.format_help(tokens[token_index:]) else: break @@ -519,7 +535,7 @@ class AutoCompleter: """ Tab completion routine for an argparse argument :return: list of completions - :raises CompletionError if the completer or choices function this calls raises one + :raises: CompletionError if the completer or choices function this calls raises one """ # Check if the arg provides choices to the user if arg_action.choices is not None: @@ -579,55 +595,6 @@ class AutoCompleter: arg_choices = [choice for choice in arg_choices if choice not in used_values] # Do tab completion on the choices - results = utils.basic_complete(text, line, begidx, endidx, arg_choices) + results = basic_complete(text, line, begidx, endidx, arg_choices) return self._format_completions(arg_action, results) - - @staticmethod - def _print_message(msg: str) -> None: - """Print a message instead of tab completions and redraw the prompt and input line""" - import sys - ansi.style_aware_write(sys.stdout, msg + '\n') - rl_force_redisplay() - - def _print_arg_hint(self, arg_action: argparse.Action) -> None: - """ - Print argument hint to the terminal when tab completion results in no results - :param arg_action: action being tab completed - """ - # Check if hinting is disabled - suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False) - if suppress_hint or arg_action.help == argparse.SUPPRESS: - return - - # Use the parser's help formatter to print just this action's help text - formatter = self._parser._get_formatter() - formatter.start_section("Hint") - formatter.add_argument(arg_action) - formatter.end_section() - out_str = formatter.format_help() - self._print_message('\n' + out_str) - - def _print_unfinished_flag_error(self, flag_arg_state: _ArgumentState) -> None: - """ - Print an error during tab completion when the user has not finished the current flag - :param flag_arg_state: information about the unfinished flag action - """ - error = "\nError: argument {}: {} ({} entered)\n".\ - format(argparse._get_action_name(flag_arg_state.action), - generate_range_error(flag_arg_state.min, flag_arg_state.max), - flag_arg_state.count) - self._print_message(ansi.style_error('{}'.format(error))) - - def _print_completion_error(self, arg_action: argparse.Action, completion_error: CompletionError) -> None: - """ - Print a CompletionError to the user - :param arg_action: action being tab completed - :param completion_error: error that occurred - """ - # Indent all lines of completion_error - indented_error = textwrap.indent(str(completion_error), ' ') - - error = ("\nError tab completing {}:\n" - "{}\n".format(argparse._get_action_name(arg_action), indented_error)) - self._print_message(ansi.style_error('{}'.format(error))) |