summaryrefslogtreecommitdiff
path: root/cmd2/argparse_completer.py
diff options
context:
space:
mode:
Diffstat (limited to 'cmd2/argparse_completer.py')
-rw-r--r--cmd2/argparse_completer.py391
1 files changed, 203 insertions, 188 deletions
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
index 3ad50da0..7a099ed9 100644
--- a/cmd2/argparse_completer.py
+++ b/cmd2/argparse_completer.py
@@ -12,7 +12,7 @@ from typing import List, Union
from . import cmd2
from . import utils
-from .ansi import ansi_safe_wcswidth
+from .ansi import ansi_safe_wcswidth, style_error
from .argparse_custom import ATTR_SUPPRESS_TAB_HINT, ATTR_DESCRIPTIVE_COMPLETION_HEADER, ATTR_NARGS_RANGE
from .argparse_custom import ChoicesCallable, CompletionItem, ATTR_CHOICES_CALLABLE
from .rl_utils import rl_force_redisplay
@@ -22,27 +22,30 @@ DEFAULT_DESCRIPTIVE_HEADER = 'Description'
# noinspection PyProtectedMember
-def is_potential_flag(token: str, parser: argparse.ArgumentParser) -> bool:
- """Determine if a token looks like a potential flag. Based on argparse._parse_optional()."""
- # if it's an empty string, it was meant to be a positional
- if not token:
+def starts_like_flag(token: str, parser: argparse.ArgumentParser) -> bool:
+ """
+ Determine if a token starts like a flag. Unless an argument has nargs set to argparse.REMAINDER,
+ then anything that starts like a flag can't be consumed as a value for it.
+ Based on argparse._parse_optional().
+ """
+ # Flags have to be at least characters
+ if len(token) < 2:
return False
- # if it doesn't start with a prefix, it was meant to be positional
+ # Flags have to start with a prefix character
if not token[0] in parser.prefix_chars:
return False
- # if it looks like a negative number, it was meant to be positional
- # unless there are negative-number-like options
+ # If it looks like a negative number, it is not a flag unless there are negative-number-like flags
if parser._negative_number_matcher.match(token):
if not parser._has_negative_number_optionals:
return False
- # if it contains a space, it was meant to be a positional
+ # Flags can't have a space
if ' ' in token:
return False
- # Looks like a flag
+ # Starts like a flag
return True
@@ -58,7 +61,6 @@ class AutoCompleter(object):
self.min = None
self.max = None
self.count = 0
- self.needed = False
self.is_remainder = (self.action.nargs == argparse.REMAINDER)
# Check if nargs is a range
@@ -99,7 +101,6 @@ class AutoCompleter(object):
self._token_start_index = token_start_index
self._flags = [] # all flags in this command
- self._flags_without_args = [] # all flags that don't take arguments
self._flag_to_action = {} # maps flags to the argparse action object
self._positional_actions = [] # argument names for positional arguments (by position index)
@@ -125,8 +126,6 @@ class AutoCompleter(object):
for option in action.option_strings:
self._flags.append(option)
self._flag_to_action[option] = action
- if action.nargs == 0:
- self._flags_without_args.append(option)
# Otherwise this is a positional parameter
else:
@@ -168,67 +167,27 @@ class AutoCompleter(object):
flag_arg_state = None
matched_flags = []
- current_is_positional = False
consumed_arg_values = {} # dict(arg_name -> [values, ...])
- # the following are nested functions that have full access to all variables in the parent
- # function including variables declared and updated after this function. Variable values
- # are current at the point the nested functions are invoked (as in, they do not receive a
- # snapshot of these values, they directly access the current state of variables in the
- # parent function)
-
- def consume_flag_argument() -> None:
- """Consuming token as a flag argument"""
- # if the token does not look like a new flag, then count towards flag arguments
- if flag_arg_state is not None and not is_potential_flag(token, self._parser):
- flag_arg_state.count += 1
-
- # Does this complete an option item for the flag?
- arg_choices = self._resolve_choices_for_arg(flag_arg_state.action)
-
- # If the current token isn't the one being completed and it's in the flag
- # argument's autocomplete list, then track that we've used it already.
- if not is_last_token and token in arg_choices:
- consumed_arg_values.setdefault(flag_arg_state.action.dest, [])
- consumed_arg_values[flag_arg_state.action.dest].append(token)
-
- def consume_positional_argument() -> None:
- """Consuming token as positional argument"""
- if pos_arg_state is not None:
- pos_arg_state.count += 1
-
- # Does this complete an option item for the positional?
- arg_choices = self._resolve_choices_for_arg(pos_arg_state.action)
-
- # If the current token isn't the one being completed and it's in the positional
- # argument's autocomplete list, then track that we've used it already.
- if not is_last_token and token in arg_choices:
- consumed_arg_values.setdefault(pos_arg_state.action.dest, [])
- consumed_arg_values[pos_arg_state.action.dest].append(token)
-
- # This next block of processing tries to parse all parameters before the last parameter.
- # We're trying to determine what specific argument the current cursor position should be
- # matched with. When we finish parsing all of the arguments, we can determine whether the
- # last token is a positional or flag argument and which specific argument it is.
- #
- # We're also trying to save every flag that has been used as well as every value that
- # has been used for a positional or flag parameter. By saving this information we can exclude
- # it from the completion results we generate for the last token. For example, single-use flag
- # arguments will be hidden from the list of available flags. Also, arguments with a
- # defined list of possible values will exclude values that have already been used.
-
- # Notes when the token being completed has been reached
- is_last_token = False
-
- # Enumerate over the sliced list
- for loop_index, token in enumerate(tokens[self._token_start_index:]):
- token_index = loop_index + self._token_start_index
- if token_index >= len(tokens) - 1:
- is_last_token = True
+ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None:
+ """Consuming token as an argument"""
+ arg_state.count += 1
+
+ # Does this complete an option item for the flag?
+ arg_choices = self._resolve_choices_for_arg(arg_state.action)
+
+ # If the current token is in the flag argument's autocomplete list,
+ # then track that we've used it already.
+ if token in arg_choices:
+ consumed_arg_values.setdefault(arg_state.action.dest, [])
+ consumed_arg_values[arg_state.action.dest].append(token)
+
+ # Enumerate over the sliced list up to the token being completed
+ for loop_index, token in enumerate(tokens[self._token_start_index:-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:
- consume_positional_argument()
+ consume_argument(pos_arg_state)
continue
# If we're in a flag REMAINDER arg, force all future tokens to go to that until a double dash is hit
@@ -237,152 +196,190 @@ class AutoCompleter(object):
if token == '--':
flag_arg_state = None
else:
- consume_flag_argument()
+ consume_argument(flag_arg_state)
continue
# Handle '--' which tells argparse all remaining arguments are non-flags
elif token == '--' and not skip_remaining_flags:
- if is_last_token:
- # Exit loop and see if -- can be completed into a flag
- break
+ # 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 []
+
+ # Otherwise end the current flag
else:
- # End the current flag
flag_arg_state = None
skip_remaining_flags = True
continue
- current_is_positional = False
+ # Check the format of the current token to see if it can be an argument's value
+ if starts_like_flag(token, self._parser) and not skip_remaining_flags:
- # Are we consuming flag arguments?
- if flag_arg_state is not None and flag_arg_state.needed:
- consume_flag_argument()
- else:
- if not skip_remaining_flags:
- # Special case when each of the following is true:
- # - We're not in the middle of consuming flag arguments
- # - The current positional argument count has hit the max count
- # - The next positional argument is a REMAINDER argument
- # Argparse will now treat all future tokens as arguments to the positional including tokens that
- # look like flags so the completer should skip any flag related processing once this happens
- if pos_arg_state is not None and pos_arg_state.count >= pos_arg_state.max and \
- next_pos_arg_index < len(self._positional_actions) and \
- self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER:
- skip_remaining_flags = True
-
- # At this point we're no longer consuming flag arguments. Is the current argument a potential flag?
- if is_potential_flag(token, self._parser) and not skip_remaining_flags:
- # Reset flag arg state but not positional tracking because flags can be
- # interspersed anywhere between positionals
- flag_arg_state = None
- action = None
-
- # does the token fully match a known flag?
- if token in self._flag_to_action:
- action = self._flag_to_action[token]
- elif hasattr(self._parser, 'allow_abbrev') and self._parser.allow_abbrev:
- candidates_flags = [flag for flag in self._flag_to_action if flag.startswith(token)]
- if len(candidates_flags) == 1:
- action = self._flag_to_action[candidates_flags[0]]
-
- if action is not None:
- flag_arg_state = AutoCompleter._ArgumentState(action)
+ # 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 []
- # It's possible we already have consumed values for this flag if it was used earlier
- # in the command line. Reset them now for this use of the flag.
+ # Reset flag arg state but not positional tracking because flags can be
+ # interspersed anywhere between positionals
+ flag_arg_state = None
+ action = None
+
+ # Does the token match a known flag?
+ if token in self._flag_to_action:
+ action = self._flag_to_action[token]
+ elif self._parser.allow_abbrev:
+ candidates_flags = [flag for flag in self._flag_to_action if flag.startswith(token)]
+ if len(candidates_flags) == 1:
+ action = self._flag_to_action[candidates_flags[0]]
+
+ if action is not None:
+ # Keep track of what flags have already been used
+ # Flags with action set to append, append_const, and count can be reused
+ if not isinstance(action, (argparse._AppendAction,
+ argparse._AppendConstAction,
+ argparse._CountAction)):
+ matched_flags.extend(action.option_strings)
+
+ new_arg_state = AutoCompleter._ArgumentState(action)
+
+ # Keep track of this flag if it can receive arguments
+ if new_arg_state.max > 0:
+ flag_arg_state = new_arg_state
+
+ # It's possible we already have consumed values for this flag if it was used
+ # earlier in the command line. Reset them now for this use of it.
consumed_arg_values[flag_arg_state.action.dest] = []
- # Keep track of what flags have already been used
- # Flags with action set to append, append_const, and count can be reused
- if not is_last_token and not isinstance(flag_arg_state.action, (argparse._AppendAction,
- argparse._AppendConstAction,
- argparse._CountAction)):
- matched_flags.extend(flag_arg_state.action.option_strings)
-
- # current token isn't a potential flag
- # - Is there not a current flag or have we reached the max arg count for the flag?
- elif flag_arg_state is None or flag_arg_state.count >= flag_arg_state.max:
- # Count this as a positional argument
+ # Check if we are consuming a flag
+ elif flag_arg_state is not None:
+ consume_argument(flag_arg_state)
+
+ # Check if we have finished with this flag
+ if flag_arg_state.count >= flag_arg_state.max:
flag_arg_state = None
- current_is_positional = True
-
- if pos_arg_state is not None and pos_arg_state.count < pos_arg_state.max:
- # we have positional action match and we haven't reached the max arg count, consume
- # the positional argument and move on.
- consume_positional_argument()
- elif pos_arg_state is None or pos_arg_state.count >= pos_arg_state.max:
- # if we don't have a current positional action or we've reached the max count for the action
- # close out the current positional argument state and set up for the next one
- pos_index = next_pos_arg_index
- next_pos_arg_index += 1
+
+ # Otherwise treat as a positional argument
+ else:
+ # If we aren't current tracking a positional, then get the next positional arg to handle this token
+ if pos_arg_state is None:
+ pos_index = next_pos_arg_index
+ next_pos_arg_index += 1
+
+ # Make sure we are still have positional arguments to fill
+ if pos_index < len(self._positional_actions):
+ action = self._positional_actions[pos_index]
+ pos_name = action.dest
+
+ # Are we at a sub-command? If so, forward to the matching completer
+ if pos_name in self._positional_completers:
+ sub_completers = self._positional_completers[pos_name]
+ if token in sub_completers:
+ return sub_completers[token].complete_command(tokens, text, line,
+ begidx, endidx)
+
+ # Keep track of the argument
+ pos_arg_state = AutoCompleter._ArgumentState(action)
+
+ # Check if we have a positional to consume this token
+ if pos_arg_state is not None:
+ consume_argument(pos_arg_state)
+
+ # Check if we have finished with this positional
+ if pos_arg_state.count >= pos_arg_state.max:
pos_arg_state = None
- # are we at a sub-command? If so, forward to the matching completer
- if pos_index < len(self._positional_actions):
- action = self._positional_actions[pos_index]
- pos_name = action.dest
- if pos_name in self._positional_completers:
- sub_completers = self._positional_completers[pos_name]
- if token in sub_completers:
- return sub_completers[token].complete_command(tokens, text, line,
- begidx, endidx)
+ # Check if this a case in which we've finished all positionals before one that has nargs
+ # set to argparse.REMAINDER. At this point argparse allows no more flags to be processed.
+ if next_pos_arg_index < len(self._positional_actions) and \
+ self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER:
+ skip_remaining_flags = True
- pos_arg_state = AutoCompleter._ArgumentState(action)
- consume_positional_argument()
+ # Now try to complete the last token
+ last_token = tokens[-1]
- else:
- consume_flag_argument()
+ # Check if we are completing a flag name
+ if starts_like_flag(last_token, 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 []
+
+ return self._complete_flags(text, line, begidx, endidx, matched_flags)
- # To allow completion of the final token, we only do the following on preceding tokens
- if not is_last_token and flag_arg_state is not None:
- flag_arg_state.needed = flag_arg_state.count < flag_arg_state.min
+ # Check if we are completing a flag's argument
+ completion_results = []
- # Here we're done parsing all of the prior arguments. We know what the next argument is.
+ if flag_arg_state is not None:
+ consumed = consumed_arg_values.get(flag_arg_state.action.dest, [])
+ completion_results = self._complete_for_arg(flag_arg_state.action, text, line,
+ begidx, endidx, consumed)
- # if we don't have a flag to populate with arguments and the last token starts with
- # a flag prefix then we'll complete the list of flag options
- if (flag_arg_state is None or not flag_arg_state.needed) and \
- is_potential_flag(tokens[-1], self._parser) and not skip_remaining_flags:
+ # If we have results, then return them
+ if completion_results:
+ return completion_results
- # Build a list of flags that can be tab completed
- match_against = []
+ # Otherwise, if we haven't completed this flag, then print a hint
+ elif flag_arg_state.count < flag_arg_state.min:
+ self._print_arg_hint(flag_arg_state.action)
+ return []
- for flag in self._flags:
- # Make sure this flag hasn't already been used
- if flag not in matched_flags:
- # Make sure this flag isn't considered hidden
- action = self._flag_to_action[flag]
- if action.help != argparse.SUPPRESS:
- match_against.append(flag)
+ # Otherwise check if we are completing a positional's argument
+ else:
+ if pos_arg_state is None:
+ pos_index = next_pos_arg_index
+ next_pos_arg_index += 1
- return utils.basic_complete(text, line, begidx, endidx, match_against)
+ # Make sure we are still have positional arguments to fill
+ if pos_index < len(self._positional_actions):
+ action = self._positional_actions[pos_index]
+ pos_name = action.dest
- completion_results = []
+ # Are we at a sub-command? If so, forward to the matching completer
+ if pos_name in self._positional_completers:
+ sub_completers = self._positional_completers[pos_name]
+ if text in sub_completers:
+ return sub_completers[text].complete_command(tokens, text, line,
+ begidx, endidx)
- # we're not at a positional argument, see if we're in a flag argument
- if not current_is_positional:
- if flag_arg_state is not None:
- consumed = consumed_arg_values.get(flag_arg_state.action.dest, [])
- completion_results = self._complete_for_arg(flag_arg_state.action, text, line,
- begidx, endidx, consumed)
- if not completion_results:
- self._print_arg_hint(flag_arg_state.action)
- elif len(completion_results) > 1:
- completion_results = self._format_completions(flag_arg_state.action, completion_results)
+ # Keep track of the argument
+ pos_arg_state = AutoCompleter._ArgumentState(action)
- # ok, we're not a flag, see if there's a positional argument to complete
- else:
if pos_arg_state is not None:
consumed = consumed_arg_values.get(pos_arg_state.action.dest, [])
completion_results = self._complete_for_arg(pos_arg_state.action, text, line,
begidx, endidx, consumed)
- if not completion_results:
+ # If we have results, then return them
+ if completion_results:
+ return completion_results
+
+ # Otherwise, if we haven't completed this flag, then print a hint
+ elif pos_arg_state.count < pos_arg_state.min:
self._print_arg_hint(pos_arg_state.action)
- elif len(completion_results) > 1:
- completion_results = self._format_completions(pos_arg_state.action, completion_results)
+ return []
+
+ # If we've gotten this far, then our text did not complete for a flag name or a
+ if last_token and not skip_remaining_flags:
+ return self._complete_flags(text, line, begidx, endidx, matched_flags)
return completion_results
+ def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: List[str]) -> List[str]:
+ """Tab completion routine for a parsers unused flags"""
+
+ # Build a list of flags that can be tab completed
+ match_against = []
+
+ for flag in self._flags:
+ # Make sure this flag hasn't already been used
+ if flag not in matched_flags:
+ # Make sure this flag isn't considered hidden
+ action = self._flag_to_action[flag]
+ if action.help != argparse.SUPPRESS:
+ match_against.append(flag)
+
+ return utils.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
if 1 < len(completions) <= self._cmd2_app.max_completion_items and \
@@ -455,27 +452,29 @@ class AutoCompleter(object):
return completers[token].format_help(tokens)
return self._parser.format_help()
- def _complete_for_arg(self, arg: argparse.Action,
+ def _complete_for_arg(self, arg_action: argparse.Action,
text: str, line: str, begidx: int, endidx: int, used_values=()) -> List[str]:
"""Tab completion routine for argparse arguments"""
+ results = []
+
# Check the arg provides choices to the user
- if arg.dest in self._arg_choices:
- arg_choices = self._arg_choices[arg.dest]
+ if arg_action.dest in self._arg_choices:
+ arg_choices = self._arg_choices[arg_action.dest]
# Check if the argument uses a specific tab completion function to provide its choices
if isinstance(arg_choices, ChoicesCallable) and arg_choices.is_completer:
if arg_choices.is_method:
- return arg_choices.to_call(self._cmd2_app, text, line, begidx, endidx)
+ results = arg_choices.to_call(self._cmd2_app, text, line, begidx, endidx)
else:
- return arg_choices.to_call(text, line, begidx, endidx)
+ results = arg_choices.to_call(text, line, begidx, endidx)
# Otherwise use basic_complete on the choices
else:
- return utils.basic_complete(text, line, begidx, endidx,
- self._resolve_choices_for_arg(arg, used_values))
+ results = utils.basic_complete(text, line, begidx, endidx,
+ self._resolve_choices_for_arg(arg_action, used_values))
- return []
+ return self._format_completions(arg_action, results)
def _resolve_choices_for_arg(self, arg: argparse.Action, used_values=()) -> List[str]:
"""Retrieve a list of choices that are available for a particular argument"""
@@ -540,3 +539,19 @@ class AutoCompleter(object):
# Redraw prompt and input line
rl_force_redisplay()
+
+ @staticmethod
+ def _print_unfinished_flag_error(flag_arg_state: _ArgumentState) -> None:
+ """Print an error during tab completion when the user has not finished the current flag"""
+
+ flags = ', '.join(flag_arg_state.action.option_strings)
+ param = ' ' + str(flag_arg_state.action.dest).upper()
+ prefix = '{}{}'.format(flags, param)
+
+ prefix = ' {0: <{width}} '.format(prefix, width=20)
+
+ out_str = "Only {} of the minimum {} arguments were provided".format(flag_arg_state.count, flag_arg_state.min)
+ print(style_error('\nError:\n{}{}\n'.format(prefix, out_str)))
+
+ # Redraw prompt and input line
+ rl_force_redisplay()