diff options
author | Todd Leonhardt <todd.leonhardt@gmail.com> | 2019-07-15 22:59:54 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-07-15 22:59:54 -0400 |
commit | 9bbebbd312dbe0331510f39cd6de70f4d9dcefa8 (patch) | |
tree | ef224bb15175be33c5fff45cf3b2dcfbb6b04471 /cmd2 | |
parent | ab3a01517a18582d1bcd35d728482e73ac707b20 (diff) | |
parent | 94b424e9c41f99c6eb268c6c97f09e99a8342de8 (diff) | |
download | cmd2-git-9bbebbd312dbe0331510f39cd6de70f4d9dcefa8.tar.gz |
Merge branch 'master' into migrating_docs
Diffstat (limited to 'cmd2')
-rw-r--r-- | cmd2/__init__.py | 1 | ||||
-rw-r--r-- | cmd2/argparse_completer.py | 1552 | ||||
-rw-r--r-- | cmd2/argparse_custom.py | 725 | ||||
-rw-r--r-- | cmd2/cmd2.py | 227 | ||||
-rw-r--r-- | cmd2/utils.py | 16 |
5 files changed, 1232 insertions, 1289 deletions
diff --git a/cmd2/__init__.py b/cmd2/__init__.py index d3c92636..f05e29ec 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -11,6 +11,7 @@ except DistributionNotFound: pass from .ansi import style +from .argparse_custom import ArgParser, CompletionItem from .cmd2 import Cmd, Statement, EmptyStatement, categorize from .cmd2 import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category from .constants import DEFAULT_SHORTCUTS diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 539132dd..737286c1 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -1,290 +1,114 @@ # coding=utf-8 # flake8: noqa C901 -# NOTE: Ignoreing flake8 cyclomatic complexity in this file because the complexity due to copy-and-paste overrides from -# argparse +# NOTE: Ignoring flake8 cyclomatic complexity in this file """ -AutoCompleter interprets the argparse.ArgumentParser internals to automatically -generate the completion options for each argument. - -How to supply completion options for each argument: - argparse Choices - - pass a list of values to the choices parameter of an argparse argument. - ex: parser.add_argument('-o', '--options', dest='options', choices=['An Option', 'SomeOtherOption']) - - arg_choices dictionary lookup - arg_choices is a dict() mapping from argument name to one of 3 possible values: - ex: - parser = argparse.ArgumentParser() - parser.add_argument('-o', '--options', dest='options') - choices = {} - mycompleter = AutoCompleter(parser, cmd2_app, completer, 1, choices) - - - static list - provide a static list for each argument name - ex: - choices['options'] = ['An Option', 'SomeOtherOption'] - - - choices function - provide a function that returns a list for each argument name - ex: - def generate_choices(): - return ['An Option', 'SomeOtherOption'] - choices['options'] = generate_choices - - - custom completer function - provide a completer function that will return the list - of completion arguments - ex 1: - def my_completer(text: str, line: str, begidx: int, endidx:int): - my_choices = [...] - return my_choices - choices['options'] = (my_completer) - ex 2: - def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str, another: int): - my_choices = [...] - return my_choices - completer_params = {'extra_param': 'my extra', 'another': 5} - choices['options'] = (my_completer, completer_params) - -How to supply completion choice lists or functions for sub-commands: - subcmd_args_lookup is used to supply a unique pair of arg_choices and subcmd_args_lookup - for each sub-command in an argparser subparser group. - This requires your subparser group to be named with the dest parameter - ex: - parser = ArgumentParser() - subparsers = parser.add_subparsers(title='Actions', dest='action') - - subcmd_args_lookup maps a named subparser group to a subcommand group dictionary - The subcommand group dictionary maps subcommand names to tuple(arg_choices, subcmd_args_lookup) - - For more details of this more complex approach see tab_autocompletion.py in the examples +This module defines the AutoCompleter 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. """ import argparse -import os -import re as _re -import sys - -# imports copied from argparse to support our customized argparse functions -from argparse import ZERO_OR_MORE, ONE_OR_MORE, ArgumentError, _, _get_action_name, SUPPRESS -from typing import List, Dict, Tuple, Callable, Union - -from .ansi import ansi_aware_write, ansi_safe_wcswidth, style_error +import shutil +from typing import List, Union + +from . import cmd2 +from . import utils +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, INFINITY, generate_range_error from .rl_utils import rl_force_redisplay -# attribute that can optionally added to an argparse argument (called an Action) to -# define the completion choices for the argument. You may provide a Collection or a Function. -ACTION_ARG_CHOICES = 'arg_choices' -ACTION_SUPPRESS_HINT = 'suppress_hint' -ACTION_DESCRIPTIVE_COMPLETION_HEADER = 'desc_header' - - -class CompletionItem(str): - """ - Completion item with descriptive text attached - - Returning this instead of a regular string for completion results will signal the - autocompleter to output the completions results in a table of completion tokens - with descriptions instead of just a table of tokens. +# If no descriptive header is supplied, then this will be used instead +DEFAULT_DESCRIPTIVE_HEADER = 'Description' - For example, you'd see this: - TOKEN Description - MY_TOKEN Info about my token - SOME_TOKEN Info about some token - YET_ANOTHER Yet more info - Instead of this: - TOKEN_ID SOME_TOKEN YET_ANOTHER +def _single_prefix_char(token: str, parser: argparse.ArgumentParser) -> bool: + """Returns if a token is just a single flag prefix character""" + return len(token) == 1 and token[0] in parser.prefix_chars - This is especially useful if you want to complete ID numbers in a more - user-friendly manner. For example, you can provide this: - ITEM_ID Item Name - 1 My item - 2 Another item - 3 Yet another item - - Instead of this: - 1 2 3 +# noinspection PyProtectedMember +def _looks_like_flag(token: str, parser: argparse.ArgumentParser) -> bool: """ - def __new__(cls, o, desc='', *args, **kwargs) -> str: - return str.__new__(cls, o, *args, **kwargs) - - # noinspection PyMissingConstructor,PyUnusedLocal - def __init__(self, o, desc='', *args, **kwargs) -> None: - self.description = desc - - -class _RangeAction(object): - def __init__(self, nargs: Union[int, str, Tuple[int, int], None]) -> None: - self.nargs_min = None - self.nargs_max = None - - # pre-process special ranged nargs - if isinstance(nargs, tuple): - if len(nargs) != 2 or not isinstance(nargs[0], int) or not isinstance(nargs[1], int): - raise ValueError('Ranged values for nargs must be a tuple of 2 integers') - if nargs[0] >= nargs[1]: - raise ValueError('Invalid nargs range. The first value must be less than the second') - if nargs[0] < 0: - raise ValueError('Negative numbers are invalid for nargs range.') - narg_range = nargs - self.nargs_min = nargs[0] - self.nargs_max = nargs[1] - if narg_range[0] == 0: - if narg_range[1] > 1: - self.nargs_adjusted = '*' - else: - # this shouldn't use a range tuple, but yet here we are - self.nargs_adjusted = '?' - else: - self.nargs_adjusted = '+' - else: - self.nargs_adjusted = nargs - - -# noinspection PyShadowingBuiltins,PyShadowingBuiltins -class _StoreRangeAction(argparse._StoreAction, _RangeAction): - def __init__(self, - option_strings, - dest, - nargs=None, - const=None, - default=None, - type=None, - choices=None, - required=False, - help=None, - metavar=None) -> None: - - _RangeAction.__init__(self, nargs) - - argparse._StoreAction.__init__(self, - option_strings=option_strings, - dest=dest, - nargs=self.nargs_adjusted, - const=const, - default=default, - type=type, - choices=choices, - required=required, - help=help, - metavar=metavar) - - -# noinspection PyShadowingBuiltins,PyShadowingBuiltins -class _AppendRangeAction(argparse._AppendAction, _RangeAction): - def __init__(self, - option_strings, - dest, - nargs=None, - const=None, - default=None, - type=None, - choices=None, - required=False, - help=None, - metavar=None) -> None: - - _RangeAction.__init__(self, nargs) - - argparse._AppendAction.__init__(self, - option_strings=option_strings, - dest=dest, - nargs=self.nargs_adjusted, - const=const, - default=default, - type=type, - choices=choices, - required=required, - help=help, - metavar=metavar) - - -def register_custom_actions(parser: argparse.ArgumentParser) -> None: - """Register custom argument action types""" - parser.register('action', None, _StoreRangeAction) - parser.register('action', 'store', _StoreRangeAction) - parser.register('action', 'append', _AppendRangeAction) - - -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: + Determine if a token looks like a flag. Unless an argument has nargs set to argparse.REMAINDER, + then anything that looks 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's just a single character, it was meant to be positional - if len(token) == 1: - 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 +# noinspection PyProtectedMember class AutoCompleter(object): - """Automatically command line tab completion based on argparse parameters""" + """Automatic command line tab completion based on argparse parameters""" class _ArgumentState(object): - def __init__(self) -> None: - self.min = None - self.max = None - self.count = 0 - self.needed = False - self.variable = False + """Keeps state of an argument being parsed""" - def reset(self) -> None: - """reset tracking values""" + def __init__(self, arg_action: argparse.Action) -> None: + self.action = arg_action self.min = None self.max = None self.count = 0 - self.needed = False - self.variable = False - - def __init__(self, - parser: argparse.ArgumentParser, - cmd2_app, - token_start_index: int = 1, - arg_choices: Dict[str, Union[List, Tuple, Callable]] = None, - subcmd_args_lookup: dict = None, - tab_for_arg_help: bool = True) -> None: + 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 + + def __init__(self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *, + token_start_index: int = 1) -> None: """ Create an AutoCompleter :param parser: ArgumentParser instance - :param cmd2_app: reference to the Cmd2 application. Enables argparse argument completion with class methods + :param cmd2_app: reference to the Cmd2 application that owns this AutoCompleter :param token_start_index: index of the token to start parsing at - :param arg_choices: dictionary mapping from argparse argument 'dest' name to list of choices - :param subcmd_args_lookup: mapping a sub-command group name to a tuple to fill the child\ - AutoCompleter's arg_choices and subcmd_args_lookup parameters - :param tab_for_arg_help: Enable of disable argument help when there's no completion result """ - if not subcmd_args_lookup: - subcmd_args_lookup = {} - forward_arg_choices = True - else: - forward_arg_choices = False self._parser = parser self._cmd2_app = cmd2_app - self._arg_choices = arg_choices.copy() if arg_choices is not None else {} + self._arg_choices = {} self._token_start_index = token_start_index - self._tab_for_arg_help = tab_for_arg_help 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) + # maps action name to sub-command autocompleter: # action_name -> dict(sub_command -> completer) self._positional_completers = {} @@ -295,10 +119,11 @@ class AutoCompleter(object): # if there are choices defined, record them in the arguments dictionary if action.choices is not None: self._arg_choices[action.dest] = action.choices - # if completion choices are tagged on the action, record them - elif hasattr(action, ACTION_ARG_CHOICES): - action_arg_choices = getattr(action, ACTION_ARG_CHOICES) - self._arg_choices[action.dest] = action_arg_choices + + # otherwise check if a callable provides the choices for this argument + elif hasattr(action, ATTR_CHOICES_CALLABLE): + arg_choice_callable = getattr(action, ATTR_CHOICES_CALLABLE) + self._arg_choices[action.dest] = arg_choice_callable # if the parameter is flag based, it will have option_strings if action.option_strings: @@ -306,33 +131,32 @@ 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: self._positional_actions.append(action) if isinstance(action, argparse._SubParsersAction): sub_completers = {} sub_commands = [] - args_for_action = subcmd_args_lookup[action.dest]\ - if action.dest in subcmd_args_lookup else {} + + # Create an AutoCompleter for each subcommand of this command for subcmd in action.choices: - (subcmd_args, subcmd_lookup) = args_for_action[subcmd] if \ - subcmd in args_for_action else \ - (arg_choices, subcmd_args_lookup) if forward_arg_choices else ({}, {}) + subcmd_start = token_start_index + len(self._positional_actions) sub_completers[subcmd] = AutoCompleter(action.choices[subcmd], cmd2_app, - token_start_index=subcmd_start, - arg_choices=subcmd_args, - subcmd_args_lookup=subcmd_lookup, - tab_for_arg_help=tab_for_arg_help) + token_start_index=subcmd_start) sub_commands.append(subcmd) + self._positional_completers[action.dest] = sub_completers self._arg_choices[action.dest] = sub_commands 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""" + if len(tokens) <= self._token_start_index: + return [] + # Count which positional argument index we're at now. Loop through all tokens on the command line so far # Skip any flags or flag parameter tokens next_pos_arg_index = 0 @@ -341,246 +165,229 @@ class AutoCompleter(object): # That can happen when -- is used or an argument with nargs=argparse.REMAINDER is used skip_remaining_flags = False - pos_arg = AutoCompleter._ArgumentState() - pos_action = None + # _ArgumentState of the current positional + pos_arg_state = None - flag_arg = AutoCompleter._ArgumentState() - flag_action = None - - # dict is used because object wrapper is necessary to allow inner functions to modify outer variables - remainder = {'arg': None, 'action': None} + # _ArgumentState of the current flag + 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""" - # we're consuming flag arguments - # if the token does not look like a new flag, then count towards flag arguments - if not is_potential_flag(token, self._parser) and flag_action is not None: - flag_arg.count += 1 - - # does this complete a option item for the flag - arg_choices = self._resolve_choices_for_arg(flag_action) - # if the current token matches the current position's autocomplete argument list, - # track that we've used it already. Unless this is the current token, then keep it. - if not is_last_token and token in arg_choices: - consumed_arg_values.setdefault(flag_action.dest, []) - consumed_arg_values[flag_action.dest].append(token) - - def consume_positional_argument() -> None: - """Consuming token as positional argument""" - pos_arg.count += 1 - - # does this complete a option item for the flag - arg_choices = self._resolve_choices_for_arg(pos_action) - # if the current token matches the current position's autocomplete argument list, - # track that we've used it already. Unless this is the current token, then keep it. - if not is_last_token and token in arg_choices: - consumed_arg_values.setdefault(pos_action.dest, []) - consumed_arg_values[pos_action.dest].append(token) - - def process_action_nargs(action: argparse.Action, arg_state: AutoCompleter._ArgumentState) -> None: - """Process the current argparse Action and initialize the ArgumentState object used - to track what arguments we have processed for this action""" - if isinstance(action, _RangeAction): - arg_state.min = action.nargs_min - arg_state.max = action.nargs_max - arg_state.variable = True - if arg_state.min is None or arg_state.max is None: - if action.nargs is None: - arg_state.min = 1 - arg_state.max = 1 - elif action.nargs == '+': - arg_state.min = 1 - arg_state.max = float('inf') - arg_state.variable = True - elif action.nargs == '*' or action.nargs == argparse.REMAINDER: - arg_state.min = 0 - arg_state.max = float('inf') - arg_state.variable = True - if action.nargs == argparse.REMAINDER: - remainder['action'] = action - remainder['arg'] = arg_state - elif action.nargs == '?': - arg_state.min = 0 - arg_state.max = 1 - arg_state.variable = 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) + + ############################################################################################# + # Parse all but the last token + ############################################################################################# + 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_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 + elif flag_arg_state is not None and flag_arg_state.is_remainder: + if token == '--': + flag_arg_state = None else: - arg_state.min = action.nargs - arg_state.max = action.nargs - - # 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 positition 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 last token has been reached - is_last_token = False - - for idx, token in enumerate(tokens): - is_last_token = idx >= len(tokens) - 1 - - # Only start at the start token index - if idx >= self._token_start_index: - - # If a remainder action is found, force all future tokens to go to that - if remainder['arg'] is not None: - if remainder['action'] == pos_action: - consume_positional_argument() - continue - elif remainder['action'] == flag_action: - consume_flag_argument() - continue - - current_is_positional = False - # Are we consuming flag arguments? - if not flag_arg.needed: - - 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_action is not None) and pos_arg.count >= pos_arg.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 + consume_argument(flag_arg_state) + continue - # 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 some tracking values - flag_arg.reset() - # don't reset positional tracking because flags can be interspersed anywhere between positionals - flag_action = None - - if token == '--': - if is_last_token: - # Exit loop and see if -- can be completed into a flag - break - else: - # In argparse, all args after -- are non-flags - skip_remaining_flags = True - - # does the token fully match a known flag? - if token in self._flag_to_action: - flag_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: - flag_action = self._flag_to_action[candidates_flags[0]] - - if flag_action is not None: - # resolve argument counts - process_action_nargs(flag_action, flag_arg) - if not is_last_token and not isinstance(flag_action, argparse._AppendAction): - matched_flags.extend(flag_action.option_strings) - - # current token isn't a potential flag - # - does the last flag accept variable arguments? - # - have we reached the max arg count for the flag? - elif not flag_arg.variable or flag_arg.count >= flag_arg.max: - # previous flag doesn't accept variable arguments, count this as a positional argument - - # reset flag tracking variables - flag_arg.reset() - flag_action = None - current_is_positional = True - - if len(token) > 0 and pos_action is not None and pos_arg.count < pos_arg.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_action is None or pos_arg.count >= pos_arg.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 - pos_arg.reset() - pos_action = 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) - pos_action = action - process_action_nargs(pos_action, pos_arg) - consume_positional_argument() - - elif not is_last_token and pos_arg.max is not None: - pos_action = None - pos_arg.reset() - - else: - consume_flag_argument() + # Handle '--' which tells argparse all remaining arguments are non-flags + 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 [] + # Otherwise end the current flag else: - consume_flag_argument() - - if remainder['arg'] is not None: + flag_arg_state = None skip_remaining_flags = True + continue + + # Check the format of the current token to see if it can be an argument's value + if _looks_like_flag(token, self._parser) 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 [] + + # 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 + skip_remaining_flags = flag_arg_state.is_remainder + + # 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] = [] + + # 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 + + # 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) + + # No more flags are allowed if this is a REMAINDER argument + if pos_arg_state.is_remainder: + skip_remaining_flags = True + + # Check if we have finished with this positional + elif pos_arg_state.count >= pos_arg_state.max: + pos_arg_state = None + + # 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 - # don't reset this if we're on the last token - this allows completion to occur on the current token - elif not is_last_token and flag_arg.min is not None: - flag_arg.needed = flag_arg.count < flag_arg.min + ############################################################################################# + # We have parsed all but the last token and have enough information to complete it + ############################################################################################# - # Here we're done parsing all of the prior arguments. We know what the next argument is. + # Check if we are completing a flag name. This check ignores strings with a length of one, like '-'. + # This is because that could be the start of a negative number which may be a valid completion for + # the current argument. We will handle the completion of flags that start with only one prefix + # 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 [] + + return self._complete_flags(text, line, begidx, endidx, matched_flags) completion_results = [] - # 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 not flag_arg.needed and len(tokens[-1]) > 0 and tokens[-1][0] in self._parser.prefix_chars and \ - not skip_remaining_flags: - return self._cmd2_app.basic_complete(text, line, begidx, endidx, - [flag for flag in self._flags if flag not in matched_flags]) - # we're not at a positional argument, see if we're in a flag argument - elif not current_is_positional: - if flag_action is not None: - consumed = consumed_arg_values[flag_action.dest]\ - if flag_action.dest in consumed_arg_values else [] - # current_items.extend(self._resolve_choices_for_arg(flag_action, consumed)) - completion_results = self._complete_for_arg(flag_action, text, line, begidx, endidx, consumed) - if not completion_results: - self._print_action_help(flag_action) - elif len(completion_results) > 1: - completion_results = self._format_completions(flag_action, completion_results) - - # ok, we're not a flag, see if there's a positional argument to complete - else: - if pos_action is not None: - pos_name = pos_action.dest - consumed = consumed_arg_values[pos_name] if pos_name in consumed_arg_values else [] - completion_results = self._complete_for_arg(pos_action, text, line, begidx, endidx, consumed) - if not completion_results: - self._print_action_help(pos_action) - elif len(completion_results) > 1: - completion_results = self._format_completions(pos_action, completion_results) + # Check if we are completing a flag's argument + 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 have results, then return them + if completion_results: + return completion_results + + # 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 [] + + # Otherwise check if we have a positional to complete + elif pos_arg_state is not None or next_pos_arg_index < len(self._positional_actions): + + # 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 + action = self._positional_actions[pos_index] + pos_arg_state = AutoCompleter._ArgumentState(action) + + 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 we have results, then return them + if completion_results: + return completion_results + + # 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 [] + + # Handle case in which text is a single flag prefix character that + # didn't complete against any argument values. + if _single_prefix_char(text, self._parser) 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]: - if completions and len(completions) > 1 and isinstance(completions[0], CompletionItem): + # 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 \ + isinstance(completions[0], CompletionItem): # If the user has not already sorted the CompletionItems, then sort them before appending the descriptions if not self._cmd2_app.matches_sorted: @@ -595,7 +402,7 @@ class AutoCompleter(object): if item_width > token_width: token_width = item_width - term_size = os.get_terminal_size() + term_size = shutil.get_terminal_size() fill_width = int(term_size.columns * .6) - (token_width + 2) for item in completions: entry = '{: <{token_width}}{: <{fill_width}}'.format(item, item.description, @@ -603,10 +410,9 @@ class AutoCompleter(object): fill_width=fill_width) completions_with_desc.append(entry) - try: - desc_header = action.desc_header - except AttributeError: - desc_header = 'Description' + desc_header = getattr(action, ATTR_DESCRIPTIVE_COMPLETION_HEADER, None) + if desc_header is None: + desc_header = DEFAULT_DESCRIPTIVE_HEADER header = '\n{: <{token_width}}{}'.format(action.dest.upper(), desc_header, token_width=token_width + 2) self._cmd2_app.completion_header = header @@ -615,139 +421,117 @@ class AutoCompleter(object): return completions def complete_command_help(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]: - """Supports the completion of sub-commands for commands through the cmd2 help command.""" - for idx, token in enumerate(tokens): - if idx >= self._token_start_index: - if self._positional_completers: - # For now argparse only allows 1 sub-command group per level - # so this will only loop once. - for completers in self._positional_completers.values(): - if token in completers: - return completers[token].complete_command_help(tokens, text, line, begidx, endidx) - else: - return self._cmd2_app.basic_complete(text, line, begidx, endidx, completers.keys()) + """ + Supports cmd2's help command in the completion of sub-command names + :param tokens: command line tokens + :param text: the string prefix we are attempting to match (all returned 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 + :return: List of subcommand completions + """ + for token in tokens[self._token_start_index:]: + if self._positional_completers: + # For now argparse only allows 1 sub-command group per level + # so this will only loop once. + for completers in self._positional_completers.values(): + if token in completers: + return completers[token].complete_command_help(tokens, text, line, begidx, endidx) + else: + return utils.basic_complete(text, line, begidx, endidx, completers.keys()) return [] def format_help(self, tokens: List[str]) -> str: - """Supports the completion of sub-commands for commands through the cmd2 help command.""" - for idx, token in enumerate(tokens): - if idx >= self._token_start_index: - if self._positional_completers: - # For now argparse only allows 1 sub-command group per level - # so this will only loop once. - for completers in self._positional_completers.values(): - if token in completers: - return completers[token].format_help(tokens) + """ + Retrieve help text of a subcommand + :param tokens: command line tokens + :return: help text of the subcommand being queried + """ + for token in tokens[self._token_start_index:]: + if self._positional_completers: + # For now argparse only allows 1 sub-command group per level + # so this will only loop once. + for completers in self._positional_completers.values(): + if token in completers: + return completers[token].format_help(tokens) return self._parser.format_help() - def _complete_for_arg(self, action: argparse.Action, - text: str, - line: str, - begidx: int, - endidx: int, - used_values=()) -> List[str]: - if action.dest in self._arg_choices: - arg_choices = self._arg_choices[action.dest] - - # if arg_choices is a tuple - # Let's see if it's a custom completion function. If it is, return what it provides - # To do this, we make sure the first element is either a callable - # or it's the name of a callable in the application - if isinstance(arg_choices, tuple) and len(arg_choices) > 0 and \ - (callable(arg_choices[0]) or - (isinstance(arg_choices[0], str) and hasattr(self._cmd2_app, arg_choices[0]) and - callable(getattr(self._cmd2_app, arg_choices[0])) - ) - ): - - if callable(arg_choices[0]): - completer = arg_choices[0] - else: - completer = getattr(self._cmd2_app, arg_choices[0]) - - # extract the positional and keyword arguments from the tuple - list_args = None - kw_args = None - for index in range(1, len(arg_choices)): - if isinstance(arg_choices[index], list) or isinstance(arg_choices[index], tuple): - list_args = arg_choices[index] - elif isinstance(arg_choices[index], dict): - kw_args = arg_choices[index] - - # call the provided function differently depending on the provided positional and keyword arguments - if list_args is not None and kw_args is not None: - return completer(text, line, begidx, endidx, *list_args, **kw_args) - elif list_args is not None: - return completer(text, line, begidx, endidx, *list_args) - elif kw_args is not None: - return completer(text, line, begidx, endidx, **kw_args) + 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_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: + results = arg_choices.to_call(self._cmd2_app, text, line, begidx, endidx) else: - return completer(text, line, begidx, endidx) + results = arg_choices.to_call(text, line, begidx, endidx) + + # Otherwise use basic_complete on the choices else: - return self._cmd2_app.basic_complete(text, line, begidx, endidx, - self._resolve_choices_for_arg(action, 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, action: argparse.Action, used_values=()) -> List[str]: - if action.dest in self._arg_choices: - args = self._arg_choices[action.dest] + 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""" + if arg.dest in self._arg_choices: + arg_choices = self._arg_choices[arg.dest] - # is the argument a string? If so, see if we can find an attribute in the - # application matching the string. - if isinstance(args, str): - args = getattr(self._cmd2_app, args) + # Check if arg_choices is a ChoicesCallable that generates a choice list + if isinstance(arg_choices, ChoicesCallable): + if arg_choices.is_completer: + # Tab completion routines are handled in other functions + return [] + else: + if arg_choices.is_method: + arg_choices = arg_choices.to_call(self._cmd2_app) + else: + arg_choices = arg_choices.to_call() - # is the provided argument a callable. If so, call it - if callable(args): - try: - args = args(self._cmd2_app) - except TypeError: - args = args() + # Since arg_choices can be any iterable type, convert to a list + arg_choices = list(arg_choices) - # filter out arguments we already used - return [arg for arg in args if arg not in used_values] + # Since choices can be various types like int, we must convert them to strings + for index, choice in enumerate(arg_choices): + if not isinstance(choice, str): + arg_choices[index] = str(choice) - return [] + # Filter out arguments we already used + return [choice for choice in arg_choices if choice not in used_values] - def _print_action_help(self, action: argparse.Action) -> None: - # is parameter hinting disabled globally? - if not self._tab_for_arg_help: - return + return [] - # is parameter hinting disabled for this parameter? - try: - suppress_hint = getattr(action, ACTION_SUPPRESS_HINT) - except AttributeError: - pass - else: - if suppress_hint: - return + @staticmethod + def _print_arg_hint(arg: argparse.Action) -> None: + """Print argument hint to the terminal when tab completion results in no results""" - if action.option_strings: - flags = ', '.join(action.option_strings) - param = '' - if action.nargs is None or action.nargs != 0: - param += ' ' + str(action.dest).upper() + # Check if hinting is disabled + suppress_hint = getattr(arg, ATTR_SUPPRESS_TAB_HINT, False) + if suppress_hint or arg.help == argparse.SUPPRESS: + return + # Check if this is a flag + if arg.option_strings: + flags = ', '.join(arg.option_strings) + param = ' ' + str(arg.dest).upper() prefix = '{}{}'.format(flags, param) - else: - if action.dest != SUPPRESS: - prefix = '{}'.format(str(action.dest).upper()) - else: - prefix = '' - if action.help is None: - help_text = '' + # Otherwise this is a positional else: - help_text = action.help - - # is there anything to print for this parameter? - if not prefix and not help_text: - return + prefix = '{}'.format(str(arg.dest).upper()) prefix = ' {0: <{width}} '.format(prefix, width=20) pref_len = len(prefix) + + help_text = '' if arg.help is None else arg.help help_lines = help_text.splitlines() if len(help_lines) == 1: @@ -760,581 +544,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) -############################################################################### -# Unless otherwise noted, everything below this point are copied from Python's -# argparse implementation with minor tweaks to adjust output. -# Changes are noted if it's buried in a block of copied code. Otherwise the -# function will check for a special case and fall back to the parent function -############################################################################### - - -# noinspection PyCompatibility,PyShadowingBuiltins,PyShadowingBuiltins -class ACHelpFormatter(argparse.RawTextHelpFormatter): - """Custom help formatter to configure ordering of help text""" - - def _format_usage(self, usage, actions, groups, prefix) -> str: - if prefix is None: - prefix = _('Usage: ') - - # if usage is specified, use that - if usage is not None: - usage %= dict(prog=self._prog) - - # if no optionals or positionals are available, usage is just prog - elif usage is None and not actions: - usage = '%(prog)s' % dict(prog=self._prog) - - # if optionals and positionals are available, calculate usage - elif usage is None: - prog = '%(prog)s' % dict(prog=self._prog) - - # split optionals from positionals - optionals = [] - positionals = [] - # Begin cmd2 customization (separates required and optional, applies to all changes in this function) - required_options = [] - for action in actions: - if action.option_strings: - if action.required: - required_options.append(action) - else: - optionals.append(action) - else: - positionals.append(action) - # End cmd2 customization - - # build full usage string - format = self._format_actions_usage - action_usage = format(required_options + optionals + positionals, groups) - usage = ' '.join([s for s in [prog, action_usage] if s]) - - # wrap the usage parts if it's too long - text_width = self._width - self._current_indent - if len(prefix) + len(usage) > text_width: - - # Begin cmd2 customization - - # break usage into wrappable parts - part_regexp = r'\(.*?\)+|\[.*?\]+|\S+' - req_usage = format(required_options, groups) - opt_usage = format(optionals, groups) - pos_usage = format(positionals, groups) - req_parts = _re.findall(part_regexp, req_usage) - opt_parts = _re.findall(part_regexp, opt_usage) - pos_parts = _re.findall(part_regexp, pos_usage) - assert ' '.join(req_parts) == req_usage - assert ' '.join(opt_parts) == opt_usage - assert ' '.join(pos_parts) == pos_usage - - # End cmd2 customization - - # helper for wrapping lines - # noinspection PyMissingOrEmptyDocstring,PyShadowingNames - def get_lines(parts, indent, prefix=None): - lines = [] - line = [] - if prefix is not None: - line_len = len(prefix) - 1 - else: - line_len = len(indent) - 1 - for part in parts: - if line_len + 1 + len(part) > text_width and line: - lines.append(indent + ' '.join(line)) - line = [] - line_len = len(indent) - 1 - line.append(part) - line_len += len(part) + 1 - if line: - lines.append(indent + ' '.join(line)) - if prefix is not None: - lines[0] = lines[0][len(indent):] - return lines - - # if prog is short, follow it with optionals or positionals - if len(prefix) + len(prog) <= 0.75 * text_width: - indent = ' ' * (len(prefix) + len(prog) + 1) - # Begin cmd2 customization - if req_parts: - lines = get_lines([prog] + req_parts, indent, prefix) - lines.extend(get_lines(opt_parts, indent)) - lines.extend(get_lines(pos_parts, indent)) - elif opt_parts: - lines = get_lines([prog] + opt_parts, indent, prefix) - lines.extend(get_lines(pos_parts, indent)) - elif pos_parts: - lines = get_lines([prog] + pos_parts, indent, prefix) - else: - lines = [prog] - # End cmd2 customization - - # if prog is long, put it on its own line - else: - indent = ' ' * len(prefix) - # Begin cmd2 customization - parts = req_parts + opt_parts + pos_parts - lines = get_lines(parts, indent) - if len(lines) > 1: - lines = [] - lines.extend(get_lines(req_parts, indent)) - lines.extend(get_lines(opt_parts, indent)) - lines.extend(get_lines(pos_parts, indent)) - # End cmd2 customization - lines = [prog] + lines - - # join lines into usage - usage = '\n'.join(lines) - - # prefix with 'Usage:' - return '%s%s\n\n' % (prefix, usage) - - def _format_action_invocation(self, action) -> str: - if not action.option_strings: - default = self._get_default_metavar_for_positional(action) - metavar, = self._metavar_formatter(action, default)(1) - return metavar - - else: - parts = [] - - # if the Optional doesn't take a value, format is: - # -s, --long - if action.nargs == 0: - parts.extend(action.option_strings) - return ', '.join(parts) - - # Begin cmd2 customization (less verbose) - # if the Optional takes a value, format is: - # -s, --long ARGS - else: - default = self._get_default_metavar_for_optional(action) - args_string = self._format_args(action, default) - - return ', '.join(action.option_strings) + ' ' + args_string - # End cmd2 customization - - def _metavar_formatter(self, action, default_metavar) -> Callable: - if action.metavar is not None: - result = action.metavar - elif action.choices is not None: - choice_strs = [str(choice) for choice in action.choices] - # Begin cmd2 customization (added space after comma) - result = '{%s}' % ', '.join(choice_strs) - # End cmd2 customization - else: - result = default_metavar - - # noinspection PyMissingOrEmptyDocstring - def format(tuple_size): - if isinstance(result, tuple): - return result - else: - return (result, ) * tuple_size - return format - - def _format_args(self, action, default_metavar) -> str: - get_metavar = self._metavar_formatter(action, default_metavar) - # Begin cmd2 customization (less verbose) - if isinstance(action, _RangeAction) and \ - action.nargs_min is not None and action.nargs_max is not None: - result = '{}{{{}..{}}}'.format('%s' % get_metavar(1), action.nargs_min, action.nargs_max) - elif action.nargs == ZERO_OR_MORE: - result = '[%s [...]]' % get_metavar(1) - elif action.nargs == ONE_OR_MORE: - result = '%s [...]' % get_metavar(1) - # End cmd2 customization - else: - result = super()._format_args(action, default_metavar) - return result - - -# noinspection PyCompatibility -class ACArgumentParser(argparse.ArgumentParser): - """Custom argparse class to override error method to change default help text.""" - - def __init__(self, *args, **kwargs) -> None: - if 'formatter_class' not in kwargs: - kwargs['formatter_class'] = ACHelpFormatter - - super().__init__(*args, **kwargs) - register_custom_actions(self) - - self._custom_error_message = '' - - # Begin cmd2 customization - def set_custom_message(self, custom_message: str = '') -> None: - """ - Allows an error message override to the error() function, useful when forcing a - re-parse of arguments with newly required parameters - """ - self._custom_error_message = custom_message - # End cmd2 customization - - def add_subparsers(self, **kwargs): - """Custom override. Sets a default title if one was not given.""" - if 'title' not in kwargs: - kwargs['title'] = 'sub-commands' - - return super().add_subparsers(**kwargs) - - def error(self, message: str) -> None: - """Custom error override. Allows application to control the error being displayed by argparse""" - if self._custom_error_message: - message = self._custom_error_message - self._custom_error_message = '' - - lines = message.split('\n') - linum = 0 - formatted_message = '' - for line in lines: - if linum == 0: - formatted_message = 'Error: ' + line - else: - formatted_message += '\n ' + line - linum += 1 - - self.print_usage(sys.stderr) - formatted_message = style_error(formatted_message) - self.exit(2, '{}\n\n'.format(formatted_message)) - - def format_help(self) -> str: - """Copy of format_help() from argparse.ArgumentParser with tweaks to separately display required parameters""" - formatter = self._get_formatter() - - # usage - formatter.add_usage(self.usage, self._actions, - self._mutually_exclusive_groups) - - # description - formatter.add_text(self.description) - - # Begin cmd2 customization (separate required and optional arguments) - - # positionals, optionals and user-defined groups - for action_group in self._action_groups: - if action_group.title == 'optional arguments': - # check if the arguments are required, group accordingly - req_args = [] - opt_args = [] - for action in action_group._group_actions: - if action.required: - req_args.append(action) - else: - opt_args.append(action) - - # separately display required arguments - formatter.start_section('required arguments') - formatter.add_text(action_group.description) - formatter.add_arguments(req_args) - formatter.end_section() - - # now display truly optional arguments - formatter.start_section(action_group.title) - formatter.add_text(action_group.description) - formatter.add_arguments(opt_args) - formatter.end_section() - else: - formatter.start_section(action_group.title) - formatter.add_text(action_group.description) - formatter.add_arguments(action_group._group_actions) - formatter.end_section() - - # End cmd2 customization - - # epilog - formatter.add_text(self.epilog) - - # determine help from format above - return formatter.format_help() + '\n' + out_str = "\nError:\n" + out_str += ' {0: <{width}} '.format(prefix, width=20) + out_str += generate_range_error(flag_arg_state.min, flag_arg_state.max) - def _print_message(self, message, file=None): - # Override _print_message to use ansi_aware_write() since we use ANSI escape characters to support color - if message: - if file is None: - file = _sys.stderr - ansi_aware_write(file, message) + out_str += ' ({} entered)'.format(flag_arg_state.count) + print(style_error('{}\n'.format(out_str))) - def _get_nargs_pattern(self, action) -> str: - # Override _get_nargs_pattern behavior to use the nargs ranges provided by AutoCompleter - if isinstance(action, _RangeAction) and \ - action.nargs_min is not None and action.nargs_max is not None: - nargs_pattern = '(-*A{{{},{}}}-*)'.format(action.nargs_min, action.nargs_max) - - # if this is an optional action, -- is not allowed - if action.option_strings: - nargs_pattern = nargs_pattern.replace('-*', '') - nargs_pattern = nargs_pattern.replace('-', '') - return nargs_pattern - return super(ACArgumentParser, self)._get_nargs_pattern(action) - - def _match_argument(self, action, arg_strings_pattern) -> int: - # match the pattern for this action to the arg strings - nargs_pattern = self._get_nargs_pattern(action) - match = _re.match(nargs_pattern, arg_strings_pattern) - - # raise an exception if we weren't able to find a match - if match is None: - if isinstance(action, _RangeAction) and \ - action.nargs_min is not None and action.nargs_max is not None: - raise ArgumentError(action, - 'Expected between {} and {} arguments'.format(action.nargs_min, action.nargs_max)) - - return super(ACArgumentParser, self)._match_argument(action, arg_strings_pattern) - - # This is the official python implementation with a 5 year old patch applied - # See the comment below describing the patch - def _parse_known_args(self, arg_strings, namespace) -> Tuple[argparse.Namespace, List[str]]: # pragma: no cover - # replace arg strings that are file references - if self.fromfile_prefix_chars is not None: - arg_strings = self._read_args_from_files(arg_strings) - - # map all mutually exclusive arguments to the other arguments - # they can't occur with - action_conflicts = {} - for mutex_group in self._mutually_exclusive_groups: - group_actions = mutex_group._group_actions - for i, mutex_action in enumerate(mutex_group._group_actions): - conflicts = action_conflicts.setdefault(mutex_action, []) - conflicts.extend(group_actions[:i]) - conflicts.extend(group_actions[i + 1:]) - - # find all option indices, and determine the arg_string_pattern - # which has an 'O' if there is an option at an index, - # an 'A' if there is an argument, or a '-' if there is a '--' - option_string_indices = {} - arg_string_pattern_parts = [] - arg_strings_iter = iter(arg_strings) - for i, arg_string in enumerate(arg_strings_iter): - - # all args after -- are non-options - if arg_string == '--': - arg_string_pattern_parts.append('-') - for cur_string in arg_strings_iter: - arg_string_pattern_parts.append('A') - - # otherwise, add the arg to the arg strings - # and note the index if it was an option - else: - option_tuple = self._parse_optional(arg_string) - if option_tuple is None: - pattern = 'A' - else: - option_string_indices[i] = option_tuple - pattern = 'O' - arg_string_pattern_parts.append(pattern) - - # join the pieces together to form the pattern - arg_strings_pattern = ''.join(arg_string_pattern_parts) - - # converts arg strings to the appropriate and then takes the action - seen_actions = set() - seen_non_default_actions = set() - - def take_action(action, argument_strings, option_string=None): - seen_actions.add(action) - argument_values = self._get_values(action, argument_strings) - - # error if this argument is not allowed with other previously - # seen arguments, assuming that actions that use the default - # value don't really count as "present" - if argument_values is not action.default: - seen_non_default_actions.add(action) - for conflict_action in action_conflicts.get(action, []): - if conflict_action in seen_non_default_actions: - msg = _('not allowed with argument %s') - action_name = _get_action_name(conflict_action) - raise ArgumentError(action, msg % action_name) - - # take the action if we didn't receive a SUPPRESS value - # (e.g. from a default) - if argument_values is not SUPPRESS: - action(self, namespace, argument_values, option_string) - - # function to convert arg_strings into an optional action - def consume_optional(start_index): - - # get the optional identified at this index - option_tuple = option_string_indices[start_index] - action, option_string, explicit_arg = option_tuple - - # identify additional optionals in the same arg string - # (e.g. -xyz is the same as -x -y -z if no args are required) - match_argument = self._match_argument - action_tuples = [] - while True: - - # if we found no optional action, skip it - if action is None: - extras.append(arg_strings[start_index]) - return start_index + 1 - - # if there is an explicit argument, try to match the - # optional's string arguments to only this - if explicit_arg is not None: - arg_count = match_argument(action, 'A') - - # if the action is a single-dash option and takes no - # arguments, try to parse more single-dash options out - # of the tail of the option string - chars = self.prefix_chars - if arg_count == 0 and option_string[1] not in chars: - action_tuples.append((action, [], option_string)) - char = option_string[0] - option_string = char + explicit_arg[0] - new_explicit_arg = explicit_arg[1:] or None - optionals_map = self._option_string_actions - if option_string in optionals_map: - action = optionals_map[option_string] - explicit_arg = new_explicit_arg - else: - msg = _('ignored explicit argument %r') - raise ArgumentError(action, msg % explicit_arg) - - # if the action expect exactly one argument, we've - # successfully matched the option; exit the loop - elif arg_count == 1: - stop = start_index + 1 - args = [explicit_arg] - action_tuples.append((action, args, option_string)) - break - - # error if a double-dash option did not use the - # explicit argument - else: - msg = _('ignored explicit argument %r') - raise ArgumentError(action, msg % explicit_arg) - - # if there is no explicit argument, try to match the - # optional's string arguments with the following strings - # if successful, exit the loop - else: - start = start_index + 1 - selected_patterns = arg_strings_pattern[start:] - arg_count = match_argument(action, selected_patterns) - stop = start + arg_count - args = arg_strings[start:stop] - action_tuples.append((action, args, option_string)) - break - - # add the Optional to the list and return the index at which - # the Optional's string args stopped - assert action_tuples - for action, args, option_string in action_tuples: - take_action(action, args, option_string) - return stop - - # the list of Positionals left to be parsed; this is modified - # by consume_positionals() - positionals = self._get_positional_actions() - - # function to convert arg_strings into positional actions - def consume_positionals(start_index): - # match as many Positionals as possible - match_partial = self._match_arguments_partial - selected_pattern = arg_strings_pattern[start_index:] - arg_counts = match_partial(positionals, selected_pattern) - - #################################################################### - # Applied mixed.patch from https://bugs.python.org/issue15112 - if 'O' in arg_strings_pattern[start_index:]: - # if there is an optional after this, remove - # 'empty' positionals from the current match - - while len(arg_counts) > 1 and arg_counts[-1] == 0: - arg_counts = arg_counts[:-1] - #################################################################### - - # slice off the appropriate arg strings for each Positional - # and add the Positional and its args to the list - for action, arg_count in zip(positionals, arg_counts): - args = arg_strings[start_index: start_index + arg_count] - start_index += arg_count - take_action(action, args) - - # slice off the Positionals that we just parsed and return the - # index at which the Positionals' string args stopped - positionals[:] = positionals[len(arg_counts):] - return start_index - - # consume Positionals and Optionals alternately, until we have - # passed the last option string - extras = [] - start_index = 0 - if option_string_indices: - max_option_string_index = max(option_string_indices) - else: - max_option_string_index = -1 - while start_index <= max_option_string_index: - - # consume any Positionals preceding the next option - next_option_string_index = min([ - index - for index in option_string_indices - if index >= start_index]) - if start_index != next_option_string_index: - positionals_end_index = consume_positionals(start_index) - - # only try to parse the next optional if we didn't consume - # the option string during the positionals parsing - if positionals_end_index > start_index: - start_index = positionals_end_index - continue - else: - start_index = positionals_end_index - - # if we consumed all the positionals we could and we're not - # at the index of an option string, there were extra arguments - if start_index not in option_string_indices: - strings = arg_strings[start_index:next_option_string_index] - extras.extend(strings) - start_index = next_option_string_index - - # consume the next optional and any arguments for it - start_index = consume_optional(start_index) - - # consume any positionals following the last Optional - stop_index = consume_positionals(start_index) - - # if we didn't consume all the argument strings, there were extras - extras.extend(arg_strings[stop_index:]) - - # make sure all required actions were present and also convert - # action defaults which were not given as arguments - required_actions = [] - for action in self._actions: - if action not in seen_actions: - if action.required: - required_actions.append(_get_action_name(action)) - else: - # Convert action default now instead of doing it before - # parsing arguments to avoid calling convert functions - # twice (which may fail) if the argument was given, but - # only if it was defined already in the namespace - if (action.default is not None and - isinstance(action.default, str) and - hasattr(namespace, action.dest) and - action.default is getattr(namespace, action.dest)): - setattr(namespace, action.dest, - self._get_value(action, action.default)) - - if required_actions: - self.error(_('the following arguments are required: %s') % - ', '.join(required_actions)) - - # make sure all required groups had one option present - for group in self._mutually_exclusive_groups: - if group.required: - for action in group._group_actions: - if action in seen_non_default_actions: - break - - # if no actions were used, report the error - else: - names = [_get_action_name(action) - for action in group._group_actions - if action.help is not SUPPRESS] - msg = _('one of the arguments %s is required') - self.error(msg % ' '.join(names)) - - # return the updated namespace and the extra arguments - return namespace, extras + # Redraw prompt and input line + rl_force_redisplay() diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py new file mode 100644 index 00000000..1cdb7840 --- /dev/null +++ b/cmd2/argparse_custom.py @@ -0,0 +1,725 @@ +# coding=utf-8 +""" +This module adds capabilities to argparse by patching a few of its functions. It also defines a parser +class called ArgParser which improves error and help output over normal argparse. All cmd2 code uses +this parser and it is recommended that developers of cmd2-based apps either use it or write their own parser +that inherits from it. This will give a consistent look-and-feel between the help/error output of built-in +cmd2 commands and the app-specific commands. + +Since the new capabilities are added by patching at the argparse API level, they are available whether or +not ArgParser is used. However, the help and error output of ArgParser is customized to notate nargs ranges +whereas any other parser class won't be as explicit in their output. + +############################################################################################################ +# Added capabilities +############################################################################################################ + +Extends argparse nargs functionality by allowing tuples which specify a range (min, max). To specify a max +value with no upper bound, use a 1-item tuple (min,) + + Example: + # -f argument expects at least 3 values + parser.add_argument('-f', nargs=(3,)) + + # -f argument expects 3 to 5 values + parser.add_argument('-f', nargs=(3, 5)) + +Tab Completion: + cmd2 uses its AutoCompleter class to enable argparse-based tab completion on all commands that use the + @with_argparse wrappers. Out of the box you get tab completion of commands, sub-commands, and flag names, + as well as instructive 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 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']) + parser.add_argument('-o', '--options', choices=my_list) + + choices_function + 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): + ... + return my_generated_list + + parser.add_argument('-o', '--options', choices_function=my_choices_function) + + choices_method + This is exactly like choices_function, but the function needs to be an instance method of a cmd2-based class. + When AutoCompleter calls the method, it will pass the app instance as the self argument. This is good in + cases where the list of choices being generated relies on state data of the cmd2-based app + + Example: + def my_choices_method(self): + ... + return my_generated_list + + 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 exactly like completer_function, but the function needs to be an instance method of a cmd2-based class. + When AutoCompleter calls the method, it will pass the app instance as the self argument. 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) + + + In all cases in which function/methods are passed you can use functools.partial() to prepopulate + values of the underlying function. + + 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) + +CompletionItem Class: + This class was added to help in cases where uninformative data is being tab completed. For instance, + tab completing ID numbers isn't very helpful to a user without context. Returning a list of CompletionItems + instead of a regular string for completion results will signal the AutoCompleter to output the completion + results in a table of completion tokens with descriptions instead of just a table of tokens. + + Instead of this: + 1 2 3 + + The user sees this: + ITEM_ID Item Name + 1 My item + 2 Another item + 3 Yet another item + + + The left-most column is the actual value being tab completed and its header is that value's name. + The right column header is defined using the descriptive_header parameter of add_argument(). The right + column values come from the CompletionItem.description value. + + Example: + token = 1 + token_description = "My Item" + completion_item = CompletionItem(token, token_description) + + Since descriptive_header and CompletionItem.description are just strings, you can format them in + such a way to have multiple columns. + + ITEM_ID Item Name Checked Out Due Date + 1 My item True 02/02/2022 + 2 Another item False + 3 Yet another item False + + To use CompletionItems, just return them from your choices or completer functions. + + To avoid printing a ton of information to the screen at once when a user presses tab, there is + a maximum threshold for the number of CompletionItems that will be shown. It's value is defined + in cmd2.Cmd.max_completion_items. It defaults to 50, but can be changed. If the number of completion + suggestions exceeds this number, they will be displayed in the typical columnized format and will + not include the description value of the CompletionItems. + +############################################################################################################ +# Patched argparse functions: +########################################################################################################### +argparse._ActionsContainer.add_argument - adds arguments related to tab completion and enables nargs range parsing + See _add_argument_wrapper for more details on these argument + +argparse.ArgumentParser._get_nargs_pattern - adds support to for nargs ranges + See _get_nargs_pattern_wrapper for more details + +argparse.ArgumentParser._match_argument - adds support to for nargs ranges + See _match_argument_wrapper for more details +""" + +import argparse +import re +import sys + +# noinspection PyUnresolvedReferences,PyProtectedMember +from argparse import ZERO_OR_MORE, ONE_OR_MORE, ArgumentError, _ +from typing import Any, Callable, Iterable, List, Optional, Tuple, Union + +from .ansi import ansi_aware_write, style_error + +############################################################################################################ +# The following are names of custom argparse argument attributes added by cmd2 +############################################################################################################ + +# Used in nargs ranges to signify there is no maximum +INFINITY = float('inf') + +# A tuple specifying nargs as a range (min, max) +ATTR_NARGS_RANGE = 'nargs_range' + +# ChoicesCallable object that specifies the function to be called which provides choices to the argument +ATTR_CHOICES_CALLABLE = 'choices_callable' + +# Pressing tab normally displays the help text for the argument if no choices are available +# Setting this attribute to True will suppress these hints +ATTR_SUPPRESS_TAB_HINT = 'suppress_tab_hint' + +# Descriptive header that prints when using CompletionItems +ATTR_DESCRIPTIVE_COMPLETION_HEADER = 'desc_completion_header' + + +def generate_range_error(range_min: int, range_max: Union[int, float]) -> str: + """Generate an error message when the the number of arguments provided is not within the expected range""" + err_str = "expected " + + if range_max == INFINITY: + err_str += "at least {} argument".format(range_min) + + if range_min != 1: + err_str += "s" + else: + if range_min == range_max: + err_str += "{} argument".format(range_min) + else: + err_str += "{} to {} argument".format(range_min, range_max) + + if range_max != 1: + err_str += "s" + + return err_str + + +class CompletionItem(str): + """ + Completion item with descriptive text attached + + See header of this file for more information + """ + def __new__(cls, value: object, *args, **kwargs) -> str: + return super().__new__(cls, value) + + # noinspection PyUnusedLocal + def __init__(self, value: object, desc: str = '', *args, **kwargs) -> None: + """ + CompletionItem Initializer + + :param value: the value being tab completed + :param desc: description text to display + :param args: args for str __init__ + :param kwargs: kwargs for str __init__ + """ + super().__init__(*args, **kwargs) + self.description = desc + + +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): + """ + 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 + + +############################################################################################################ +# Patch _ActionsContainer.add_argument with our wrapper to support more arguments +############################################################################################################ + +# Save original _ActionsContainer.add_argument so we can call it in our wrapper +# noinspection PyProtectedMember +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[[], Iterable[Any]]] = None, + choices_method: Optional[Callable[[Any], Iterable[Any]]] = None, + completer_function: Optional[Callable[[str, str, int, int], List[str]]] = None, + completer_method: Optional[Callable[[Any, str, str, int, int], List[str]]] = 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 + + # Args from original function + :param self: instance of the _ActionsContainer being added to + :param args: arguments expected by argparse._ActionsContainer.add_argument + + # Customized arguments from original function + :param nargs: extends argparse nargs functionality by allowing tuples which specify a range (min, max) + to specify a max value with no upper bound, use a 1-item tuple (min,) + + # Added args used by AutoCompleter + :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 suppress_tab_hint: when AutoCompleter 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 regardless of the + value passed for suppress_tab_hint. Defaults to False. + :param descriptive_header: if the provided choices are CompletionItems, then this header will display + during tab completion. Defaults to None. + + # Args from original function + :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 + + See the header of this file for more information + + :return: the created argument action + """ + # Pre-process special ranged nargs + nargs_range = None + + if nargs is not None: + # Check if nargs was given as a range + if isinstance(nargs, tuple): + + # Handle 1-item tuple by setting max to INFINITY + if len(nargs) == 1: + nargs = (nargs[0], INFINITY) + + # Validate nargs tuple + if len(nargs) != 2 or not isinstance(nargs[0], int) or \ + not (isinstance(nargs[1], int) or nargs[1] == INFINITY): + raise ValueError('Ranged values for nargs must be a tuple of 1 or 2 integers') + if nargs[0] >= nargs[1]: + raise ValueError('Invalid nargs range. The first value must be less than the second') + if nargs[0] < 0: + raise ValueError('Negative numbers are invalid for nargs range') + + # Save the nargs tuple as our range setting + nargs_range = nargs + range_min = nargs_range[0] + range_max = nargs_range[1] + + # Convert nargs into a format argparse recognizes + if range_min == 0: + if range_max == 1: + nargs_adjusted = argparse.OPTIONAL + + # No range needed since (0, 1) is just argparse.OPTIONAL + nargs_range = None + else: + nargs_adjusted = argparse.ZERO_OR_MORE + if range_max == INFINITY: + # No range needed since (0, INFINITY) is just argparse.ZERO_OR_MORE + nargs_range = None + elif range_min == 1 and range_max == INFINITY: + nargs_adjusted = argparse.ONE_OR_MORE + + # No range needed since (1, INFINITY) is just argparse.ONE_OR_MORE + nargs_range = None + else: + nargs_adjusted = argparse.ONE_OR_MORE + else: + nargs_adjusted = nargs + + # Add the argparse-recognized version of nargs to kwargs + kwargs['nargs'] = nargs_adjusted + + # Create the argument using the original add_argument function + new_arg = orig_actions_container_add_argument(self, *args, **kwargs) + + # Verify consistent use of arguments + choice_params = [new_arg.choices, choices_function, choices_method, completer_function, completer_method] + num_set = len(choice_params) - choice_params.count(None) + + if num_set > 1: + err_msg = ("Only one of the following may be used in an argparser argument at a time:\n" + "choices, choices_function, choices_method, completer_function, completer_method") + raise (ValueError(err_msg)) + + # Set the custom attributes + setattr(new_arg, ATTR_NARGS_RANGE, nargs_range) + + if choices_function: + setattr(new_arg, ATTR_CHOICES_CALLABLE, + ChoicesCallable(is_method=False, is_completer=False, to_call=choices_function)) + elif choices_method: + setattr(new_arg, ATTR_CHOICES_CALLABLE, + ChoicesCallable(is_method=True, is_completer=False, to_call=choices_method)) + elif completer_function: + setattr(new_arg, ATTR_CHOICES_CALLABLE, + ChoicesCallable(is_method=False, is_completer=True, to_call=completer_function)) + elif completer_method: + setattr(new_arg, ATTR_CHOICES_CALLABLE, + ChoicesCallable(is_method=True, is_completer=True, to_call=completer_method)) + + setattr(new_arg, ATTR_SUPPRESS_TAB_HINT, suppress_tab_hint) + setattr(new_arg, ATTR_DESCRIPTIVE_COMPLETION_HEADER, descriptive_header) + + return new_arg + + +# Overwrite _ActionsContainer.add_argument with our wrapper +# noinspection PyProtectedMember +argparse._ActionsContainer.add_argument = _add_argument_wrapper + +############################################################################################################ +# Patch ArgumentParser._get_nargs_pattern with our wrapper to nargs ranges +############################################################################################################ + +# Save original ArgumentParser._get_nargs_pattern so we can call it in our wrapper +# noinspection PyProtectedMember +orig_argument_parser_get_nargs_pattern = argparse.ArgumentParser._get_nargs_pattern + + +# noinspection PyProtectedMember +def _get_nargs_pattern_wrapper(self, action) -> str: + # Wrapper around ArgumentParser._get_nargs_pattern behavior to support nargs ranges + nargs_range = getattr(action, ATTR_NARGS_RANGE, None) + if nargs_range is not None: + if nargs_range[1] == INFINITY: + range_max = '' + else: + range_max = nargs_range[1] + + nargs_pattern = '(-*A{{{},{}}}-*)'.format(nargs_range[0], range_max) + + # if this is an optional action, -- is not allowed + if action.option_strings: + nargs_pattern = nargs_pattern.replace('-*', '') + nargs_pattern = nargs_pattern.replace('-', '') + return nargs_pattern + + return orig_argument_parser_get_nargs_pattern(self, action) + + +# Overwrite ArgumentParser._get_nargs_pattern with our wrapper +# noinspection PyProtectedMember +argparse.ArgumentParser._get_nargs_pattern = _get_nargs_pattern_wrapper + + +############################################################################################################ +# Patch ArgumentParser._match_argument with our wrapper to nargs ranges +############################################################################################################ +# noinspection PyProtectedMember +orig_argument_parser_match_argument = argparse.ArgumentParser._match_argument + + +# noinspection PyProtectedMember +def _match_argument_wrapper(self, action, arg_strings_pattern) -> int: + # Wrapper around ArgumentParser._match_argument behavior to support nargs ranges + nargs_pattern = self._get_nargs_pattern(action) + match = re.match(nargs_pattern, arg_strings_pattern) + + # raise an exception if we weren't able to find a match + if match is None: + nargs_range = getattr(action, ATTR_NARGS_RANGE, None) + if nargs_range is not None: + raise ArgumentError(action, generate_range_error(nargs_range[0], nargs_range[1])) + + return orig_argument_parser_match_argument(self, action, arg_strings_pattern) + + +# Overwrite ArgumentParser._match_argument with our wrapper +# noinspection PyProtectedMember +argparse.ArgumentParser._match_argument = _match_argument_wrapper + +############################################################################################################ +# Unless otherwise noted, everything below this point are copied from Python's +# argparse implementation with minor tweaks to adjust output. +# Changes are noted if it's buried in a block of copied code. Otherwise the +# function will check for a special case and fall back to the parent function +############################################################################################################ + + +# noinspection PyCompatibility,PyShadowingBuiltins,PyShadowingBuiltins +class Cmd2HelpFormatter(argparse.RawTextHelpFormatter): + """Custom help formatter to configure ordering of help text""" + + def _format_usage(self, usage, actions, groups, prefix) -> str: + if prefix is None: + prefix = _('Usage: ') + + # if usage is specified, use that + if usage is not None: + usage %= dict(prog=self._prog) + + # if no optionals or positionals are available, usage is just prog + elif usage is None and not actions: + usage = '%(prog)s' % dict(prog=self._prog) + + # if optionals and positionals are available, calculate usage + elif usage is None: + prog = '%(prog)s' % dict(prog=self._prog) + + # split optionals from positionals + optionals = [] + positionals = [] + # Begin cmd2 customization (separates required and optional, applies to all changes in this function) + required_options = [] + for action in actions: + if action.option_strings: + if action.required: + required_options.append(action) + else: + optionals.append(action) + else: + positionals.append(action) + # End cmd2 customization + + # build full usage string + format = self._format_actions_usage + action_usage = format(required_options + optionals + positionals, groups) + usage = ' '.join([s for s in [prog, action_usage] if s]) + + # wrap the usage parts if it's too long + text_width = self._width - self._current_indent + if len(prefix) + len(usage) > text_width: + + # Begin cmd2 customization + + # break usage into wrappable parts + part_regexp = r'\(.*?\)+|\[.*?\]+|\S+' + req_usage = format(required_options, groups) + opt_usage = format(optionals, groups) + pos_usage = format(positionals, groups) + req_parts = re.findall(part_regexp, req_usage) + opt_parts = re.findall(part_regexp, opt_usage) + pos_parts = re.findall(part_regexp, pos_usage) + assert ' '.join(req_parts) == req_usage + assert ' '.join(opt_parts) == opt_usage + assert ' '.join(pos_parts) == pos_usage + + # End cmd2 customization + + # helper for wrapping lines + # noinspection PyMissingOrEmptyDocstring,PyShadowingNames + def get_lines(parts, indent, prefix=None): + lines = [] + line = [] + if prefix is not None: + line_len = len(prefix) - 1 + else: + line_len = len(indent) - 1 + for part in parts: + if line_len + 1 + len(part) > text_width and line: + lines.append(indent + ' '.join(line)) + line = [] + line_len = len(indent) - 1 + line.append(part) + line_len += len(part) + 1 + if line: + lines.append(indent + ' '.join(line)) + if prefix is not None: + lines[0] = lines[0][len(indent):] + return lines + + # if prog is short, follow it with optionals or positionals + if len(prefix) + len(prog) <= 0.75 * text_width: + indent = ' ' * (len(prefix) + len(prog) + 1) + # Begin cmd2 customization + if req_parts: + lines = get_lines([prog] + req_parts, indent, prefix) + lines.extend(get_lines(opt_parts, indent)) + lines.extend(get_lines(pos_parts, indent)) + elif opt_parts: + lines = get_lines([prog] + opt_parts, indent, prefix) + lines.extend(get_lines(pos_parts, indent)) + elif pos_parts: + lines = get_lines([prog] + pos_parts, indent, prefix) + else: + lines = [prog] + # End cmd2 customization + + # if prog is long, put it on its own line + else: + indent = ' ' * len(prefix) + # Begin cmd2 customization + parts = req_parts + opt_parts + pos_parts + lines = get_lines(parts, indent) + if len(lines) > 1: + lines = [] + lines.extend(get_lines(req_parts, indent)) + lines.extend(get_lines(opt_parts, indent)) + lines.extend(get_lines(pos_parts, indent)) + # End cmd2 customization + lines = [prog] + lines + + # join lines into usage + usage = '\n'.join(lines) + + # prefix with 'Usage:' + return '%s%s\n\n' % (prefix, usage) + + def _format_action_invocation(self, action) -> str: + if not action.option_strings: + default = self._get_default_metavar_for_positional(action) + metavar, = self._metavar_formatter(action, default)(1) + return metavar + + else: + parts = [] + + # if the Optional doesn't take a value, format is: + # -s, --long + if action.nargs == 0: + parts.extend(action.option_strings) + return ', '.join(parts) + + # Begin cmd2 customization (less verbose) + # if the Optional takes a value, format is: + # -s, --long ARGS + else: + default = self._get_default_metavar_for_optional(action) + args_string = self._format_args(action, default) + + return ', '.join(action.option_strings) + ' ' + args_string + # End cmd2 customization + + def _metavar_formatter(self, action, default_metavar) -> Callable: + if action.metavar is not None: + result = action.metavar + elif action.choices is not None: + choice_strs = [str(choice) for choice in action.choices] + # Begin cmd2 customization (added space after comma) + result = '{%s}' % ', '.join(choice_strs) + # End cmd2 customization + else: + result = default_metavar + + # noinspection PyMissingOrEmptyDocstring + def format(tuple_size): + if isinstance(result, tuple): + return result + else: + return (result, ) * tuple_size + return format + + # noinspection PyProtectedMember + def _format_args(self, action, default_metavar) -> str: + get_metavar = self._metavar_formatter(action, default_metavar) + # Begin cmd2 customization (less verbose) + nargs_range = getattr(action, ATTR_NARGS_RANGE, None) + + if nargs_range is not None: + if nargs_range[1] == INFINITY: + range_str = '{}+'.format(nargs_range[0]) + else: + range_str = '{}..{}'.format(nargs_range[0], nargs_range[1]) + + result = '{}{{{}}}'.format('%s' % get_metavar(1), range_str) + elif action.nargs == ZERO_OR_MORE: + result = '[%s [...]]' % get_metavar(1) + elif action.nargs == ONE_OR_MORE: + result = '%s [...]' % get_metavar(1) + elif isinstance(action.nargs, int) and action.nargs > 1: + result = '{}{{{}}}'.format('%s' % get_metavar(1), action.nargs) + # End cmd2 customization + else: + result = super()._format_args(action, default_metavar) + return result + + +# noinspection PyCompatibility +class ArgParser(argparse.ArgumentParser): + """Custom ArgumentParser class that improves error and help output""" + + def __init__(self, *args, **kwargs) -> None: + if 'formatter_class' not in kwargs: + kwargs['formatter_class'] = Cmd2HelpFormatter + + super().__init__(*args, **kwargs) + + def add_subparsers(self, **kwargs): + """Custom override. Sets a default title if one was not given.""" + if 'title' not in kwargs: + kwargs['title'] = 'sub-commands' + + return super().add_subparsers(**kwargs) + + def error(self, message: str) -> None: + """Custom override that applies custom formatting to the error message""" + lines = message.split('\n') + linum = 0 + formatted_message = '' + for line in lines: + if linum == 0: + formatted_message = 'Error: ' + line + else: + formatted_message += '\n ' + line + linum += 1 + + self.print_usage(sys.stderr) + formatted_message = style_error(formatted_message) + self.exit(2, '{}\n\n'.format(formatted_message)) + + # noinspection PyProtectedMember + def format_help(self) -> str: + """Copy of format_help() from argparse.ArgumentParser with tweaks to separately display required parameters""" + formatter = self._get_formatter() + + # usage + formatter.add_usage(self.usage, self._actions, + self._mutually_exclusive_groups) + + # description + formatter.add_text(self.description) + + # Begin cmd2 customization (separate required and optional arguments) + + # positionals, optionals and user-defined groups + for action_group in self._action_groups: + if action_group.title == 'optional arguments': + # check if the arguments are required, group accordingly + req_args = [] + opt_args = [] + for action in action_group._group_actions: + if action.required: + req_args.append(action) + else: + opt_args.append(action) + + # separately display required arguments + formatter.start_section('required arguments') + formatter.add_text(action_group.description) + formatter.add_arguments(req_args) + formatter.end_section() + + # now display truly optional arguments + formatter.start_section(action_group.title) + formatter.add_text(action_group.description) + formatter.add_arguments(opt_args) + formatter.end_section() + else: + formatter.start_section(action_group.title) + formatter.add_text(action_group.description) + formatter.add_arguments(action_group._group_actions) + formatter.end_section() + + # End cmd2 customization + + # epilog + formatter.add_text(self.epilog) + + # determine help from format above + return formatter.format_help() + '\n' + + def _print_message(self, message, file=None): + # Override _print_message to use ansi_aware_write() since we use ANSI escape characters to support color + if message: + if file is None: + file = sys.stderr + ansi_aware_write(file, message) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index eeeeb0d1..a4036a8e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -42,11 +42,11 @@ from collections import namedtuple from contextlib import redirect_stdout from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Type, Union +from . import ArgParser, CompletionItem from . import ansi from . import constants from . import plugin from . import utils -from .argparse_completer import AutoCompleter, ACArgumentParser, ACTION_ARG_CHOICES from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .history import History, HistoryItem from .parsing import StatementParser, Statement, Macro, MacroArg, shlex_split @@ -352,6 +352,12 @@ class Cmd(cmd.Cmd): self.editor = self.DEFAULT_EDITOR self.feedback_to_output = False # Do not include nonessentials in >, | output by default (things like timing) self.locals_in_py = False + + # The maximum number of CompletionItems to display during tab completion. If the number of completion + # suggestions exceeds this number, they will be displayed in the typical columnized format and will + # not include the description value of the CompletionItems. + self.max_completion_items = 50 + self.quiet = False # Do not suppress nonessential output self.timing = False # Prints elapsed time for each command @@ -369,6 +375,7 @@ class Cmd(cmd.Cmd): 'editor': 'Program used by ``edit``', 'feedback_to_output': 'Include nonessentials in `|`, `>` results', 'locals_in_py': 'Allow access to your application in py via self', + 'max_completion_items': 'Maximum number of CompletionItems to display during tab completion', 'prompt': 'The prompt issued to solicit input', 'quiet': "Don't print nonessential feedback", 'timing': 'Report execution times' @@ -594,7 +601,8 @@ class Cmd(cmd.Cmd): if self.broken_pipe_warning: sys.stderr.write(self.broken_pipe_warning) - def perror(self, msg: Any, *, end: str = '\n', apply_style: bool = True) -> None: + @staticmethod + def perror(msg: Any, *, end: str = '\n', apply_style: bool = True) -> None: """Print message to sys.stderr :param msg: message to print (anything convertible to a str with '{}'.format() is OK) @@ -835,23 +843,8 @@ class Cmd(cmd.Cmd): return tokens, raw_tokens - # noinspection PyUnusedLocal - @staticmethod - def basic_complete(text: str, line: str, begidx: int, endidx: int, match_against: Iterable) -> List[str]: - """ - Performs tab completion against a list - - :param text: the string prefix we are attempting to match (all returned 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 list 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]: + 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. @@ -884,7 +877,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 = self.basic_complete(text, line, begidx, endidx, match_against) + matches = utils.basic_complete(text, line, begidx, endidx, match_against) # Display only the portion of the match that's being completed based on delimiter if matches: @@ -912,7 +905,7 @@ class Cmd(cmd.Cmd): return matches def flag_based_complete(self, text: str, line: str, begidx: int, endidx: int, - flag_dict: Dict[str, Union[Iterable, Callable]], + flag_dict: Dict[str, Union[Iterable, Callable]], *, all_else: Union[None, Iterable, Callable] = None) -> List[str]: """Tab completes based on a particular flag preceding the token being completed. @@ -945,7 +938,7 @@ class Cmd(cmd.Cmd): # Perform tab completion using an Iterable if isinstance(match_against, Iterable): - completions_matches = self.basic_complete(text, line, begidx, endidx, match_against) + completions_matches = utils.basic_complete(text, line, begidx, endidx, match_against) # Perform tab completion using a function elif callable(match_against): @@ -954,7 +947,7 @@ class Cmd(cmd.Cmd): return completions_matches def index_based_complete(self, text: str, line: str, begidx: int, endidx: int, - index_dict: Mapping[int, Union[Iterable, Callable]], + index_dict: Mapping[int, Union[Iterable, Callable]], *, all_else: Union[None, Iterable, Callable] = None) -> List[str]: """Tab completes based on a fixed position in the input string. @@ -989,7 +982,7 @@ class Cmd(cmd.Cmd): # Perform tab completion using a Iterable if isinstance(match_against, Iterable): - matches = self.basic_complete(text, line, begidx, endidx, match_against) + matches = utils.basic_complete(text, line, begidx, endidx, match_against) # Perform tab completion using a function elif callable(match_against): @@ -998,7 +991,7 @@ class Cmd(cmd.Cmd): return matches # noinspection PyUnusedLocal - def path_complete(self, text: str, line: str, begidx: int, endidx: int, + def path_complete(self, text: str, line: str, begidx: int, endidx: int, *, path_filter: Optional[Callable[[str], bool]] = None) -> List[str]: """Performs completion of local file system paths @@ -1142,7 +1135,7 @@ class Cmd(cmd.Cmd): return matches - def shell_cmd_complete(self, text: str, line: str, begidx: int, endidx: int, + def shell_cmd_complete(self, text: str, line: str, begidx: int, endidx: int, *, complete_blank: bool = False) -> List[str]: """Performs completion of executables either in a user's path or a given path @@ -1165,7 +1158,7 @@ class Cmd(cmd.Cmd): # Otherwise look for executables in the given path else: return self.path_complete(text, line, begidx, endidx, - lambda path: os.path.isdir(path) or os.access(path, os.X_OK)) + path_filter=lambda path: os.path.isdir(path) or os.access(path, os.X_OK)) def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, compfunc: Callable) -> List[str]: """Called by complete() as the first tab completion function for all commands @@ -1541,8 +1534,8 @@ class Cmd(cmd.Cmd): else: # Complete token against anything a user can run - self.completion_matches = self.basic_complete(text, line, begidx, endidx, - self._get_commands_aliases_and_macros_for_completion()) + self.completion_matches = utils.basic_complete(text, line, begidx, endidx, + self._get_commands_aliases_and_macros_for_completion()) # Handle single result if len(self.completion_matches) == 1: @@ -1589,22 +1582,19 @@ class Cmd(cmd.Cmd): def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int, argparser: argparse.ArgumentParser) -> List[str]: - """Default completion function for argparse commands.""" + """Default completion function for argparse commands""" + from .argparse_completer import AutoCompleter completer = AutoCompleter(argparser, self) - tokens, _ = self.tokens_for_completion(line, begidx, endidx) - if not tokens: - return [] - return completer.complete_command(tokens, text, line, begidx, endidx) def get_all_commands(self) -> List[str]: - """Returns a list of all commands.""" + """Return a list of all commands""" return [name[len(COMMAND_FUNC_PREFIX):] for name in self.get_names() if name.startswith(COMMAND_FUNC_PREFIX) and callable(getattr(self, name))] def get_visible_commands(self) -> List[str]: - """Returns a list of commands that have not been hidden or disabled.""" + """Return a list of commands that have not been hidden or disabled""" commands = self.get_all_commands() # Remove the hidden commands @@ -1619,23 +1609,23 @@ class Cmd(cmd.Cmd): return commands - def _get_alias_names(self) -> List[str]: - """Return list of current alias names""" - return list(self.aliases) + def _get_alias_completion_items(self) -> List[CompletionItem]: + """Return list of current alias names and values as CompletionItems""" + return [CompletionItem(cur_key, self.aliases[cur_key]) for cur_key in self.aliases] - def _get_macro_names(self) -> List[str]: - """Return list of current macro names""" - return list(self.macros) + def _get_macro_completion_items(self) -> List[CompletionItem]: + """Return list of current macro names and values as CompletionItems""" + return [CompletionItem(cur_key, self.macros[cur_key].value) for cur_key in self.macros] - def _get_settable_names(self) -> List[str]: - """Return list of current settable names""" - return list(self.settable) + def _get_settable_completion_items(self) -> List[CompletionItem]: + """Return list of current settable names and descriptions as CompletionItems""" + return [CompletionItem(cur_key, self.settable[cur_key]) for cur_key in self.settable] def _get_commands_aliases_and_macros_for_completion(self) -> List[str]: """Return a list of visible commands, aliases, and macros for tab completion""" visible_commands = set(self.get_visible_commands()) - alias_names = set(self._get_alias_names()) - macro_names = set(self._get_macro_names()) + alias_names = set(self.aliases) + macro_names = set(self.macros) return list(visible_commands | alias_names | macro_names) def get_help_topics(self) -> List[str]: @@ -1938,8 +1928,6 @@ class Cmd(cmd.Cmd): :param statement: the parsed statement from the command line :return: the resolved macro or None on error """ - from itertools import islice - if statement.command not in self.macros.keys(): raise KeyError('{} is not a macro'.format(statement.command)) @@ -1972,7 +1960,7 @@ class Cmd(cmd.Cmd): resolved = parts[0] + replacement + parts[1] # Append extra arguments and use statement.arg_list since these arguments need their quotes preserved - for arg in islice(statement.arg_list, macro.minimum_arg_count, None): + for arg in statement.arg_list[macro.minimum_arg_count:]: resolved += ' ' + arg # Restore any terminator, suffix, redirection, etc. @@ -2348,7 +2336,7 @@ class Cmd(cmd.Cmd): "An alias is a command that enables replacement of a word by another string.") alias_epilog = ("See also:\n" " macro") - alias_parser = ACArgumentParser(description=alias_description, epilog=alias_epilog, prog='alias') + alias_parser = ArgParser(description=alias_description, epilog=alias_epilog, prog='alias') # Add sub-commands to alias alias_subparsers = alias_parser.add_subparsers() @@ -2373,11 +2361,10 @@ class Cmd(cmd.Cmd): description=alias_create_description, epilog=alias_create_epilog) alias_create_parser.add_argument('name', help='name of this alias') - setattr(alias_create_parser.add_argument('command', help='what the alias resolves to'), - ACTION_ARG_CHOICES, _get_commands_aliases_and_macros_for_completion) - setattr(alias_create_parser.add_argument('command_args', nargs=argparse.REMAINDER, - help='arguments to pass to command'), - ACTION_ARG_CHOICES, ('path_complete',)) + 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.set_defaults(func=_alias_create) # alias -> delete @@ -2385,8 +2372,8 @@ class Cmd(cmd.Cmd): alias_delete_description = "Delete specified aliases or all aliases if --all is used" alias_delete_parser = alias_subparsers.add_parser('delete', help=alias_delete_help, description=alias_delete_description) - setattr(alias_delete_parser.add_argument('name', nargs='*', help='alias to delete'), - ACTION_ARG_CHOICES, _get_alias_names) + alias_delete_parser.add_argument('name', nargs=argparse.ZERO_OR_MORE, help='alias to delete', + choices_method=_get_alias_completion_items, descriptive_header='Value') alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases") alias_delete_parser.set_defaults(func=_alias_delete) @@ -2399,8 +2386,8 @@ class Cmd(cmd.Cmd): alias_list_parser = alias_subparsers.add_parser('list', help=alias_list_help, description=alias_list_description) - setattr(alias_list_parser.add_argument('name', nargs="*", help='alias to list'), - ACTION_ARG_CHOICES, _get_alias_names) + alias_list_parser.add_argument('name', nargs=argparse.ZERO_OR_MORE, help='alias to list', + choices_method=_get_alias_completion_items, descriptive_header='Value') alias_list_parser.set_defaults(func=_alias_list) # Preserve quotes since we are passing strings to other commands @@ -2530,7 +2517,7 @@ class Cmd(cmd.Cmd): "A macro is similar to an alias, but it can contain argument placeholders.") macro_epilog = ("See also:\n" " alias") - macro_parser = ACArgumentParser(description=macro_description, epilog=macro_epilog, prog='macro') + macro_parser = ArgParser(description=macro_description, epilog=macro_epilog, prog='macro') # Add sub-commands to macro macro_subparsers = macro_parser.add_subparsers() @@ -2578,11 +2565,10 @@ class Cmd(cmd.Cmd): description=macro_create_description, epilog=macro_create_epilog) macro_create_parser.add_argument('name', help='name of this macro') - setattr(macro_create_parser.add_argument('command', help='what the macro resolves to'), - ACTION_ARG_CHOICES, _get_commands_aliases_and_macros_for_completion) - setattr(macro_create_parser.add_argument('command_args', nargs=argparse.REMAINDER, - help='arguments to pass to command'), - ACTION_ARG_CHOICES, ('path_complete',)) + 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.set_defaults(func=_macro_create) # macro -> delete @@ -2590,8 +2576,8 @@ class Cmd(cmd.Cmd): macro_delete_description = "Delete specified macros or all macros if --all is used" macro_delete_parser = macro_subparsers.add_parser('delete', help=macro_delete_help, description=macro_delete_description) - setattr(macro_delete_parser.add_argument('name', nargs='*', help='macro to delete'), - ACTION_ARG_CHOICES, _get_macro_names) + macro_delete_parser.add_argument('name', nargs=argparse.ZERO_OR_MORE, help='macro to delete', + choices_method=_get_macro_completion_items, descriptive_header='Value') macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros") macro_delete_parser.set_defaults(func=_macro_delete) @@ -2603,8 +2589,8 @@ class Cmd(cmd.Cmd): "Without arguments, all macros will be listed.") macro_list_parser = macro_subparsers.add_parser('list', help=macro_list_help, description=macro_list_description) - setattr(macro_list_parser.add_argument('name', nargs="*", help='macro to list'), - ACTION_ARG_CHOICES, _get_macro_names) + macro_list_parser.add_argument('name', nargs=argparse.ZERO_OR_MORE, help='macro to list', + choices_method=_get_macro_completion_items, descriptive_header='Value') macro_list_parser.set_defaults(func=_macro_list) # Preserve quotes since we are passing strings to other commands @@ -2626,7 +2612,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 self.basic_complete(text, line, begidx, endidx, strs_to_match) + return utils.basic_complete(text, line, begidx, endidx, strs_to_match) def complete_help_subcommand(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """Completes the subcommand argument of help""" @@ -2657,18 +2643,17 @@ class Cmd(cmd.Cmd): # Check if this is a command with an argparse function func = self.cmd_func(command) if func and hasattr(func, 'argparser'): + from .argparse_completer import AutoCompleter completer = AutoCompleter(getattr(func, 'argparser'), self) matches = completer.complete_command_help(tokens[cmd_index:], text, line, begidx, endidx) return matches - help_parser = ACArgumentParser() - - setattr(help_parser.add_argument('command', help="command to retrieve help for", nargs="?"), - ACTION_ARG_CHOICES, ('complete_help_command',)) - setattr(help_parser.add_argument('subcommand', help="sub-command to retrieve help for", - nargs=argparse.REMAINDER), - ACTION_ARG_CHOICES, ('complete_help_subcommand',)) + help_parser = ArgParser() + help_parser.add_argument('command', nargs=argparse.OPTIONAL, help="command to retrieve help for", + completer_method=complete_help_command) + help_parser.add_argument('subcommand', nargs=argparse.REMAINDER, help="sub-command to retrieve help for", + completer_method=complete_help_subcommand) help_parser.add_argument('-v', '--verbose', action='store_true', help="print a list of all commands with descriptions of each") @@ -2689,6 +2674,7 @@ class Cmd(cmd.Cmd): # If the command function uses argparse, then use argparse's help if func and hasattr(func, 'argparser'): + from .argparse_completer import AutoCompleter completer = AutoCompleter(getattr(func, 'argparser'), self) tokens = [args.command] + args.subcommand self.poutput(completer.format_help(tokens)) @@ -2827,19 +2813,19 @@ class Cmd(cmd.Cmd): command = '' self.stdout.write("\n") - @with_argparser(ACArgumentParser()) + @with_argparser(ArgParser()) def do_shortcuts(self, _: argparse.Namespace) -> None: """List available shortcuts""" result = "\n".join('%s: %s' % (sc[0], sc[1]) for sc in sorted(self.statement_parser.shortcuts)) self.poutput("Shortcuts for other commands:\n{}".format(result)) - @with_argparser(ACArgumentParser(epilog=INTERNAL_COMMAND_EPILOG)) + @with_argparser(ArgParser(epilog=INTERNAL_COMMAND_EPILOG)) def do_eof(self, _: argparse.Namespace) -> bool: """Called when <Ctrl>-D is pressed""" # Return True to stop the command loop return True - @with_argparser(ACArgumentParser()) + @with_argparser(ArgParser()) def do_quit(self, _: argparse.Namespace) -> bool: """Exit this application""" # Return True to stop the command loop @@ -2934,12 +2920,12 @@ class Cmd(cmd.Cmd): "Accepts abbreviated parameter names so long as there is no ambiguity.\n" "Call without arguments for a list of settable parameters with their values.") - set_parser = ACArgumentParser(description=set_description) + set_parser = ArgParser(description=set_description) set_parser.add_argument('-a', '--all', action='store_true', help='display read-only settings as well') set_parser.add_argument('-l', '--long', action='store_true', help='describe function of parameter') - setattr(set_parser.add_argument('param', nargs='?', help='parameter to set or view'), - ACTION_ARG_CHOICES, _get_settable_names) - set_parser.add_argument('value', nargs='?', help='the new value for settable') + set_parser.add_argument('param', nargs=argparse.OPTIONAL, help='parameter to set or view', + choices_method=_get_settable_completion_items, descriptive_header='Description') + set_parser.add_argument('value', nargs=argparse.OPTIONAL, help='the new value for settable') @with_argparser(set_parser) def do_set(self, args: argparse.Namespace) -> None: @@ -2979,12 +2965,10 @@ class Cmd(cmd.Cmd): if onchange_hook is not None: onchange_hook(old=orig_value, new=new_value) # pylint: disable=not-callable - shell_parser = ACArgumentParser() - setattr(shell_parser.add_argument('command', help='the command to run'), - ACTION_ARG_CHOICES, ('shell_cmd_complete',)) - setattr(shell_parser.add_argument('command_args', nargs=argparse.REMAINDER, - help='arguments to pass to command'), - ACTION_ARG_CHOICES, ('path_complete',)) + shell_parser = ArgParser() + 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) # Preserve quotes since we are passing these strings to the shell @with_argparser(shell_parser, preserve_quotes=True) @@ -3044,9 +3028,9 @@ class Cmd(cmd.Cmd): "If you see strange parsing behavior, it's best to just open the Python shell\n" "by providing no arguments to py and run more complex statements there.") - py_parser = ACArgumentParser(description=py_description) - py_parser.add_argument('command', help="command to run", nargs='?') - py_parser.add_argument('remainder', help="remainder of command", nargs=argparse.REMAINDER) + py_parser = ArgParser(description=py_description) + py_parser.add_argument('command', nargs=argparse.OPTIONAL, help="command to run") + py_parser.add_argument('remainder', nargs=argparse.REMAINDER, help="remainder of command") # Preserve quotes since we are passing these strings to Python @with_argparser(py_parser, preserve_quotes=True) @@ -3230,12 +3214,10 @@ class Cmd(cmd.Cmd): return bridge.stop - run_pyscript_parser = ACArgumentParser() - setattr(run_pyscript_parser.add_argument('script_path', help='path to the script file'), - ACTION_ARG_CHOICES, ('path_complete',)) - setattr(run_pyscript_parser.add_argument('script_arguments', nargs=argparse.REMAINDER, - help='arguments to pass to script'), - ACTION_ARG_CHOICES, ('path_complete',)) + run_pyscript_parser = ArgParser() + 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) @with_argparser(run_pyscript_parser) def do_run_pyscript(self, args: argparse.Namespace) -> bool: @@ -3265,7 +3247,7 @@ class Cmd(cmd.Cmd): # Only include the do_ipy() method if IPython is available on the system if ipython_available: # pragma: no cover - @with_argparser(ACArgumentParser()) + @with_argparser(ArgParser()) def do_ipy(self, _: argparse.Namespace) -> None: """Enter an interactive IPython shell""" from .pyscript_bridge import PyscriptBridge @@ -3288,18 +3270,17 @@ class Cmd(cmd.Cmd): history_description = "View, run, edit, save, or clear previously entered commands" - history_parser = ACArgumentParser(description=history_description) + history_parser = ArgParser(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') - setattr(history_action_group.add_argument('-o', '--output-file', metavar='FILE', - help='output commands to a script file, implies -s'), - ACTION_ARG_CHOICES, ('path_complete',)) - setattr(history_action_group.add_argument('-t', '--transcript', - help='output commands and results to a transcript file,\n' - 'implies -s'), - ACTION_ARG_CHOICES, ('path_complete',)) + 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', help='output commands and results to a transcript file,\n' + 'implies -s', + completer_method=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') @@ -3321,7 +3302,7 @@ class Cmd(cmd.Cmd): "a..b, a:b, a:, ..b items by indices (inclusive)\n" "string items containing string\n" "/regex/ items matching regular expression") - history_parser.add_argument('arg', nargs='?', help=history_arg_help) + history_parser.add_argument('arg', nargs=argparse.OPTIONAL, help=history_arg_help) @with_argparser(history_parser) def do_history(self, args: argparse.Namespace) -> Optional[bool]: @@ -3588,9 +3569,9 @@ class Cmd(cmd.Cmd): "\n" " set editor (program-name)") - edit_parser = ACArgumentParser(description=edit_description) - setattr(edit_parser.add_argument('file_path', help="path to a file to open in editor", nargs="?"), - ACTION_ARG_CHOICES, ('path_complete',)) + edit_parser = ArgParser(description=edit_description) + edit_parser.add_argument('file_path', nargs=argparse.OPTIONAL, + help="path to a file to open in editor", completer_method=path_complete) @with_argparser(edit_parser) def do_edit(self, args: argparse.Namespace) -> None: @@ -3618,15 +3599,12 @@ class Cmd(cmd.Cmd): "typed in the console.\n" "\n" "If the -r/--record_transcript flag is used, this command instead records\n" - "the output of the script commands to a transcript for testing purposes.\n" - ) + "the output of the script commands to a transcript for testing purposes.\n") - run_script_parser = ACArgumentParser(description=run_script_description) - setattr(run_script_parser.add_argument('-t', '--transcript', - help='record the output of the script as a transcript file'), - ACTION_ARG_CHOICES, ('path_complete',)) - setattr(run_script_parser.add_argument('script_path', help="path to the script file"), - ACTION_ARG_CHOICES, ('path_complete',)) + run_script_parser = ArgParser(description=run_script_description) + run_script_parser.add_argument('-t', '--transcript', 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) @with_argparser(run_script_parser) def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: @@ -3689,8 +3667,8 @@ class Cmd(cmd.Cmd): relative_run_script_epilog = ("Notes:\n" " This command is intended to only be used within text file scripts.") - relative_run_script_parser = ACArgumentParser(description=relative_run_script_description, - epilog=relative_run_script_epilog) + relative_run_script_parser = ArgParser(description=relative_run_script_description, + epilog=relative_run_script_epilog) relative_run_script_parser.add_argument('file_path', help='a file path pointing to a script') @with_argparser(relative_run_script_parser) @@ -3955,7 +3933,8 @@ class Cmd(cmd.Cmd): self.disable_command(cmd_name, message_to_print) # noinspection PyUnusedLocal - def _report_disabled_command_usage(self, *args, message_to_print: str, **kwargs) -> None: + @staticmethod + def _report_disabled_command_usage(*args, message_to_print: str, **kwargs) -> None: """ Report when a disabled command has been run or had help called on it :param args: not used diff --git a/cmd2/utils.py b/cmd2/utils.py index 872c2192..7f357a6c 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -616,3 +616,19 @@ class RedirectionSavedState(object): # If the command created a process to pipe to, then then is its reader self.pipe_proc_reader = None + + +# 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 Pythons's cmd.py. + + :param text: the string prefix we are attempting to match (all returned 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)] |