From 18207f62d4f70240e79935507bcde7cbb22212a7 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 1 Jul 2019 12:30:08 -0400 Subject: [PATCH 01/88] Small refactor and documentation updates --- cmd2/argparse_completer.py | 49 ++++++++++++++++++++++++++------------ cmd2/cmd2.py | 4 ---- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 539132dd4..da57a9f92 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -1,6 +1,6 @@ # coding=utf-8 # flake8: noqa C901 -# NOTE: Ignoreing flake8 cyclomatic complexity in this file because the complexity due to copy-and-paste overrides from +# NOTE: Ignoring flake8 cyclomatic complexity in this file because the complexity due to copy-and-paste overrides from # argparse """ AutoCompleter interprets the argparse.ArgumentParser internals to automatically @@ -252,29 +252,30 @@ def reset(self) -> None: self.needed = False self.variable = False - def __init__(self, - parser: argparse.ArgumentParser, - cmd2_app, - token_start_index: int = 1, + def __init__(self, parser: argparse.ArgumentParser, cmd2_app, *, + tab_for_arg_help: bool = True, 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: + subcmd_args_lookup: dict = None, ) -> None: """ Create an AutoCompleter :param parser: ArgumentParser instance :param cmd2_app: reference to the Cmd2 application. Enables argparse argument completion with class methods + :param tab_for_arg_help: Enable of disable argument help when there's no completion result + + # The following parameters are intended for internal use when AutoCompleter creates other AutoCompleters + # for subcommands. Developers don't need to worry about overriding these values. :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 + :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 """ 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 {} @@ -285,6 +286,7 @@ def __init__(self, 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,6 +297,7 @@ def __init__(self, # 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) @@ -308,18 +311,30 @@ def __init__(self, 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 {} + + if action.dest in subcmd_args_lookup: + args_for_action = subcmd_args_lookup[action.dest] + else: + args_for_action = {} + + # 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 ({}, {}) + + if subcmd in args_for_action: + (subcmd_args, subcmd_lookup) = args_for_action[subcmd] + elif forward_arg_choices: + subcmd_args, subcmd_lookup = arg_choices, subcmd_args_lookup + else: + subcmd_args, subcmd_lookup = {}, {} + subcmd_start = token_start_index + len(self._positional_actions) sub_completers[subcmd] = AutoCompleter(action.choices[subcmd], cmd2_app, @@ -328,11 +343,15 @@ def __init__(self, subcmd_args_lookup=subcmd_lookup, tab_for_arg_help=tab_for_arg_help) 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 not tokens: + 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 diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index a10219b10..0ca9f3585 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1617,11 +1617,7 @@ def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int, argparser: argparse.ArgumentParser) -> List[str]: """Default completion function for argparse commands.""" 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]: From b7fa4e46593151086a4186c9d90dc72b809c9b45 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 2 Jul 2019 10:47:52 -0400 Subject: [PATCH 02/88] Moved basic_complete to utils --- cmd2/argparse_completer.py | 12 +++++++----- cmd2/cmd2.py | 12 ++++++------ cmd2/utils.py | 16 ++++++++++++++++ tests/test_completion.py | 10 +++++----- 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index da57a9f92..5f4a7a875 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -69,6 +69,8 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str from .ansi import ansi_aware_write, ansi_safe_wcswidth, style_error from .rl_utils import rl_force_redisplay +from . import utils + # 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' @@ -571,8 +573,8 @@ def process_action_nargs(action: argparse.Action, arg_state: AutoCompleter._Argu # 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]) + return utils.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: @@ -644,7 +646,7 @@ def complete_command_help(self, tokens: List[str], text: str, line: str, begidx: 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()) + return utils.basic_complete(text, line, begidx, endidx, completers.keys()) return [] def format_help(self, tokens: List[str]) -> str: @@ -703,8 +705,8 @@ def _complete_for_arg(self, action: argparse.Action, else: return completer(text, line, begidx, endidx) else: - return self._cmd2_app.basic_complete(text, line, begidx, endidx, - self._resolve_choices_for_arg(action, used_values)) + return utils.basic_complete(text, line, begidx, endidx, + self._resolve_choices_for_arg(action, used_values)) return [] diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 0ca9f3585..d6fc78eba 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -910,7 +910,7 @@ def delimiter_complete(self, text: str, line: str, begidx: int, endidx: int, mat :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: @@ -971,7 +971,7 @@ def flag_based_complete(self, text: str, line: str, begidx: int, endidx: int, # Perform tab completion using a Collection if isinstance(match_against, Collection): - 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): @@ -1015,7 +1015,7 @@ def index_based_complete(self, text: str, line: str, begidx: int, endidx: int, # Perform tab completion using a Collection if isinstance(match_against, Collection): - 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): @@ -1567,8 +1567,8 @@ def _complete_worker(self, text: str, state: int) -> Optional[str]: 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: @@ -2648,7 +2648,7 @@ def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) 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""" diff --git a/cmd2/utils.py b/cmd2/utils.py index 812fa2279..3ba1be725 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -615,3 +615,19 @@ def __init__(self, self_stdout: Union[StdSim, TextIO], sys_stdout: Union[StdSim, # 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)] diff --git a/tests/test_completion.py b/tests/test_completion.py index eea34ba6e..9bf6fc5f5 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -67,7 +67,7 @@ def do_test_basic(self, args): pass def complete_test_basic(self, text, line, begidx, endidx): - return self.basic_complete(text, line, begidx, endidx, food_item_strs) + return utils.basic_complete(text, line, begidx, endidx, food_item_strs) def do_test_delimited(self, args): pass @@ -80,7 +80,7 @@ def do_test_sort_key(self, args): def complete_test_sort_key(self, text, line, begidx, endidx): num_strs = ['2', '11', '1'] - return self.basic_complete(text, line, begidx, endidx, num_strs) + return utils.basic_complete(text, line, begidx, endidx, num_strs) def do_test_raise_exception(self, args): pass @@ -524,7 +524,7 @@ def test_basic_completion_single(cmd2_app): endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs) == ['Pizza'] + assert utils.basic_complete(text, line, begidx, endidx, food_item_strs) == ['Pizza'] def test_basic_completion_multiple(cmd2_app): text = '' @@ -532,7 +532,7 @@ def test_basic_completion_multiple(cmd2_app): endidx = len(line) begidx = endidx - len(text) - matches = sorted(cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs)) + matches = sorted(utils.basic_complete(text, line, begidx, endidx, food_item_strs)) assert matches == sorted(food_item_strs) def test_basic_completion_nomatch(cmd2_app): @@ -541,7 +541,7 @@ def test_basic_completion_nomatch(cmd2_app): endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs) == [] + assert utils.basic_complete(text, line, begidx, endidx, food_item_strs) == [] def test_delimiter_completion(cmd2_app): text = '/home/' From 479cab00b4c0bd6a2ce20605f97a8f904dc0136f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 2 Jul 2019 10:50:19 -0400 Subject: [PATCH 03/88] Removed cmd2.basic_complete function since it was added to utils --- cmd2/cmd2.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d6fc78eba..d65b750cd 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -861,21 +861,6 @@ def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> Tuple[Li 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]: """ From b10cc8f39e94e60d9d6adbd4f2ca19f1866cd9ca Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 2 Jul 2019 13:23:16 -0400 Subject: [PATCH 04/88] Added functions to enable tab completion and choices provider functions to argparse argument values --- cmd2/argparse_completer.py | 195 +++++++++++++++++++++++-------------- cmd2/cmd2.py | 111 ++++++++++----------- 2 files changed, 177 insertions(+), 129 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 5f4a7a875..8c539017d 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -64,20 +64,93 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str # 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 typing import Any, List, Dict, Tuple, Callable, Union +from . import utils from .ansi import ansi_aware_write, ansi_safe_wcswidth, style_error from .rl_utils import rl_force_redisplay -from . import utils +# Custom argparse argument attribute that means the argument's choices come from a ArgChoicesCallable +ARG_CHOICES_CALLABLE = 'arg_choices_callable' -# 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 ArgChoicesCallable: + """ + 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 + :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_completer = is_completer + self.is_method = is_method + self.to_call = to_call + + +def set_arg_completer_function(arg_action: argparse.Action, + completer: Callable[[str, str, int, int], List[str]]): + """ + Set a tab completion function for an argparse argument to provide its choices. + + Note: If completer is an instance method of a cmd2 app, then use set_arg_completer_method() instead. + + :param arg_action: the argument action being added to + :param completer: the completer function to call + """ + choices_callable = ArgChoicesCallable(is_method=False, is_completer=True, to_call=completer) + setattr(arg_action, ARG_CHOICES_CALLABLE, choices_callable) + + +def set_arg_completer_method(arg_action: argparse.Action, completer: Callable[[Any, str, str, int, int], List[str]]): + """ + Set a tab completion method for an argparse argument to provide its choices. + + Note: This function expects completer to be an instance method of a cmd2 app. If completer is a function, + then use set_arg_completer_function() instead. + + :param arg_action: the argument action being added to + :param completer: the completer function to call + """ + choices_callable = ArgChoicesCallable(is_method=True, is_completer=True, to_call=completer) + setattr(arg_action, ARG_CHOICES_CALLABLE, choices_callable) + + +def set_arg_choices_function(arg_action: argparse.Action, choices_func: Callable[[], List[str]]): + """ + Set a function for an argparse argument to provide its choices. + + Note: If choices_func is an instance method of a cmd2 app, then use set_arg_choices_method() instead. + + :param arg_action: the argument action being added to + :param choices_func: the function to call + """ + choices_callable = ArgChoicesCallable(is_method=False, is_completer=False, to_call=choices_func) + setattr(arg_action, ARG_CHOICES_CALLABLE, choices_callable) + + +def set_arg_choices_method(arg_action: argparse.Action, choices_method: Callable[[Any], List[str]]): + """ + Set a method for an argparse argument to provide its choices. + + Note: This function expects choices_method to be an instance method of a cmd2 app. If choices_method is a function, + then use set_arg_choices_function() instead. + + :param arg_action: the argument action being added to + :param choices_method: the method to call + """ + choices_callable = ArgChoicesCallable(is_method=True, is_completer=False, to_call=choices_method) + setattr(arg_action, ARG_CHOICES_CALLABLE, choices_callable) + + class CompletionItem(str): """ Completion item with descriptive text attached @@ -262,7 +335,7 @@ def __init__(self, parser: argparse.ArgumentParser, cmd2_app, *, 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 tab_for_arg_help: Enable of disable argument help when there's no completion result # The following parameters are intended for internal use when AutoCompleter creates other AutoCompleters @@ -300,10 +373,10 @@ def __init__(self, parser: argparse.ArgumentParser, cmd2_app, *, 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, ARG_CHOICES_CALLABLE): + arg_choice_callable = getattr(action, ARG_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: @@ -388,9 +461,9 @@ def consume_flag_argument() -> None: 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 + # does this complete an 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, + # if the current token matches the current flag'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, []) @@ -400,7 +473,7 @@ def consume_positional_argument() -> None: """Consuming token as positional argument""" pos_arg.count += 1 - # does this complete a option item for the flag + # does this complete an option item for the positional 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. @@ -580,7 +653,6 @@ def process_action_nargs(action: argparse.Action, arg_state: AutoCompleter._Argu 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) @@ -661,73 +733,48 @@ def format_help(self, tokens: List[str]) -> str: 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: argparse.Action, + text: str, line: str, begidx: int, endidx: int, used_values=()) -> List[str]: + """Tab completion routine for argparse arguments""" + + # Check the arg provides choices to the user + if arg.dest in self._arg_choices: + arg_choices = self._arg_choices[arg.dest] + + # Check if the argument uses a specific tab completion function to provide its choices + if isinstance(arg_choices, ArgChoicesCallable) and arg_choices.is_completer: + if arg_choices.is_method: + return arg_choices.to_call(self._cmd2_app, text, line, begidx, endidx) else: - return completer(text, line, begidx, endidx) + return arg_choices.to_call(text, line, begidx, endidx) + + # Otherwise use basic_complete on the choices else: - return utils.basic_complete(text, line, begidx, endidx, - self._resolve_choices_for_arg(action, used_values)) + # Since choices can be various types like int, we must convert them to + # before strings before doing tab completion matching. + choices = [str(choice) for choice in self._resolve_choices_for_arg(arg, used_values)] + return utils.basic_complete(text, line, begidx, endidx, choices) return [] - 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] - - # 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) + 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 provided argument a callable. If so, call it - if callable(args): - try: - args = args(self._cmd2_app) - except TypeError: - args = args() + # Check if arg_choices is an ArgChoicesCallable that generates a choice list + if isinstance(arg_choices, ArgChoicesCallable): + 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() - # filter out arguments we already used - return [arg for arg in args if arg not in used_values] + # Filter out arguments we already used + return [choice for choice in arg_choices if choice not in used_values] return [] diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d65b750cd..da2e83b36 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -46,7 +46,7 @@ from . import constants from . import plugin from . import utils -from .argparse_completer import AutoCompleter, ACArgumentParser, ACTION_ARG_CHOICES +from .argparse_completer import AutoCompleter, ACArgumentParser, set_arg_choices_method, set_arg_completer_method 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 @@ -617,7 +617,8 @@ def poutput(self, msg: Any, *, end: str = '\n') -> None: 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) @@ -2380,11 +2381,11 @@ def _alias_list(self, args: argparse.Namespace) -> None: 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',)) + set_arg_choices_method(alias_create_parser.add_argument('command', help='what the alias resolves to'), + _get_commands_aliases_and_macros_for_completion) + set_arg_completer_method(alias_create_parser.add_argument('command_args', nargs=argparse.REMAINDER, + help='arguments to pass to command'), + path_complete) alias_create_parser.set_defaults(func=_alias_create) # alias -> delete @@ -2392,8 +2393,8 @@ def _alias_list(self, args: argparse.Namespace) -> None: 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) + set_arg_choices_method(alias_delete_parser.add_argument('name', nargs='*', help='alias to delete'), + _get_alias_names) alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases") alias_delete_parser.set_defaults(func=_alias_delete) @@ -2406,8 +2407,8 @@ def _alias_list(self, args: argparse.Namespace) -> None: 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) + set_arg_choices_method(alias_list_parser.add_argument('name', nargs="*", help='alias to list'), + _get_alias_names) alias_list_parser.set_defaults(func=_alias_list) # Preserve quotes since we are passing strings to other commands @@ -2585,11 +2586,11 @@ def _macro_list(self, args: argparse.Namespace) -> None: 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',)) + set_arg_choices_method(macro_create_parser.add_argument('command', help='what the macro resolves to'), + _get_commands_aliases_and_macros_for_completion) + set_arg_completer_method(macro_create_parser.add_argument('command_args', nargs=argparse.REMAINDER, + help='arguments to pass to command'), + path_complete) macro_create_parser.set_defaults(func=_macro_create) # macro -> delete @@ -2597,8 +2598,8 @@ def _macro_list(self, args: argparse.Namespace) -> None: 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) + set_arg_choices_method(macro_delete_parser.add_argument('name', nargs='*', help='macro to delete'), + _get_macro_names) macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros") macro_delete_parser.set_defaults(func=_macro_delete) @@ -2610,8 +2611,8 @@ def _macro_list(self, args: argparse.Namespace) -> None: "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) + set_arg_choices_method(macro_list_parser.add_argument('name', nargs="*", help='macro to list'), + _get_macro_names) macro_list_parser.set_defaults(func=_macro_list) # Preserve quotes since we are passing strings to other commands @@ -2670,12 +2671,11 @@ def complete_help_subcommand(self, text: str, line: str, begidx: int, endidx: in 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',)) + set_arg_completer_method(help_parser.add_argument('command', nargs="?", help="command to retrieve help for"), + complete_help_command) + set_arg_completer_method(help_parser.add_argument('subcommand', nargs=argparse.REMAINDER, + help="sub-command to retrieve help for"), + complete_help_subcommand) help_parser.add_argument('-v', '--verbose', action='store_true', help="print a list of all commands with descriptions of each") @@ -2944,8 +2944,8 @@ def _show(self, args: argparse.Namespace, parameter: str = '') -> None: set_parser = ACArgumentParser(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_arg_choices_method(set_parser.add_argument('param', nargs='?', help='parameter to set or view'), + _get_settable_names) set_parser.add_argument('value', nargs='?', help='the new value for settable') @with_argparser(set_parser) @@ -2987,11 +2987,11 @@ def do_set(self, args: argparse.Namespace) -> None: onchange_hook(old=orig_value, new=new_value) 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',)) + set_arg_completer_method(shell_parser.add_argument('command', help='the command to run'), + shell_cmd_complete) + set_arg_completer_method(shell_parser.add_argument('command_args', nargs=argparse.REMAINDER, + help='arguments to pass to command'), + path_complete) # Preserve quotes since we are passing these strings to the shell @with_argparser(shell_parser, preserve_quotes=True) @@ -3052,8 +3052,8 @@ def _reset_py_display() -> None: "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.add_argument('command', nargs='?', 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) @@ -3238,11 +3238,11 @@ def py_quit(): 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',)) + set_arg_completer_method(run_pyscript_parser.add_argument('script_path', help='path to the script file'), + path_complete) + set_arg_completer_method(run_pyscript_parser.add_argument('script_arguments', nargs=argparse.REMAINDER, + help='arguments to pass to script'), + path_complete) @with_argparser(run_pyscript_parser) def do_run_pyscript(self, args: argparse.Namespace) -> bool: @@ -3300,13 +3300,13 @@ def load_ipy(app): 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',)) + set_arg_completer_method(history_action_group.add_argument('-o', '--output-file', metavar='FILE', + help='output commands to a script file, implies -s'), + path_complete) + set_arg_completer_method(history_action_group.add_argument('-t', '--transcript', + help='output commands and results to a transcript\n' + 'file, implies -s'), + 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') @@ -3596,8 +3596,8 @@ def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcrip " 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',)) + set_arg_completer_method(edit_parser.add_argument('file_path', nargs="?", help="path to a file to open in editor"), + path_complete) @with_argparser(edit_parser) def do_edit(self, args: argparse.Namespace) -> None: @@ -3629,11 +3629,11 @@ def _current_script_dir(self) -> Optional[str]: ) 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',)) + set_arg_completer_method(run_script_parser.add_argument('-t', '--transcript', help='record the output of the ' + 'script as a transcript file'), + path_complete) + set_arg_completer_method(run_script_parser.add_argument('script_path', help="path to the script file"), + path_complete) @with_argparser(run_script_parser) def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: @@ -3962,7 +3962,8 @@ def disable_category(self, category: str, message_to_print: str) -> None: 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 From 5014fecf58bb546a453c57eaff7a226f29bfba46 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 2 Jul 2019 19:30:50 -0400 Subject: [PATCH 05/88] Patched argparse._ActionsContainer.add_argument() to support more settings like enabling tab completion and providing choice generating functions --- cmd2/argparse_completer.py | 118 +++++++++++++++++++------------------ cmd2/cmd2.py | 88 +++++++++++---------------- 2 files changed, 97 insertions(+), 109 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 8c539017d..e0e38a1d3 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -64,17 +64,20 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str # 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 Any, List, Dict, Tuple, Callable, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Union from . import utils from .ansi import ansi_aware_write, ansi_safe_wcswidth, style_error from .rl_utils import rl_force_redisplay -# Custom argparse argument attribute that means the argument's choices come from a ArgChoicesCallable +# Argparse argument attribute that stores an ArgChoicesCallable ARG_CHOICES_CALLABLE = 'arg_choices_callable' -ACTION_SUPPRESS_HINT = 'suppress_hint' -ACTION_DESCRIPTIVE_COMPLETION_HEADER = 'desc_header' +# Argparse argument attribute that suppresses tab-completion hints +ARG_SUPPRESS_HINT = 'arg_suppress_hint' + +# Argparse argument attribute that prints descriptive header when using CompletionItems +ARG_DESCRIPTIVE_COMPLETION_HEADER = 'desc_header' class ArgChoicesCallable: @@ -85,70 +88,75 @@ class ArgChoicesCallable: 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 + :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_completer = is_completer self.is_method = is_method + self.is_completer = is_completer self.to_call = to_call -def set_arg_completer_function(arg_action: argparse.Action, - completer: Callable[[str, str, int, int], List[str]]): - """ - Set a tab completion function for an argparse argument to provide its choices. +# Save the original _ActionsContainer.add_argument because we need to patch it +actual_actions_container_add_argument = argparse._ActionsContainer.add_argument - Note: If completer is an instance method of a cmd2 app, then use set_arg_completer_method() instead. - :param arg_action: the argument action being added to - :param completer: the completer function to call +def patched_add_argument(self, *args, + choices_function: Optional[Callable[[], List[str]]] = None, + choices_method: Optional[Callable[[Any], List[str]]] = 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_hint: bool = False, + description_header: Optional[str] = None, + **kwargs): """ - choices_callable = ArgChoicesCallable(is_method=False, is_completer=True, to_call=completer) - setattr(arg_action, ARG_CHOICES_CALLABLE, choices_callable) - - -def set_arg_completer_method(arg_action: argparse.Action, completer: Callable[[Any, str, str, int, int], List[str]]): + This is a patched version of _ActionsContainer.add_argument() that supports more settings needed by cmd2 + :param self: + :param args: + :param choices_function: + :param choices_method: + :param completer_function: + :param completer_method: + :param suppress_hint: + :param description_header: + :param kwargs: + :return: """ - Set a tab completion method for an argparse argument to provide its choices. + # Call the actual add_argument function + new_arg = actual_actions_container_add_argument(self, *args, **kwargs) - Note: This function expects completer to be an instance method of a cmd2 app. If completer is a function, - then use set_arg_completer_function() instead. + # 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) - :param arg_action: the argument action being added to - :param completer: the completer function to call - """ - choices_callable = ArgChoicesCallable(is_method=True, is_completer=True, to_call=completer) - setattr(arg_action, ARG_CHOICES_CALLABLE, choices_callable) + 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 + if choices_function: + setattr(new_arg, ARG_CHOICES_CALLABLE, + ArgChoicesCallable(is_method=False, is_completer=False, to_call=choices_function)) + elif choices_method: + setattr(new_arg, ARG_CHOICES_CALLABLE, + ArgChoicesCallable(is_method=True, is_completer=False, to_call=choices_method)) + elif completer_function: + setattr(new_arg, ARG_CHOICES_CALLABLE, + ArgChoicesCallable(is_method=False, is_completer=True, to_call=completer_function)) + elif completer_method: + setattr(new_arg, ARG_CHOICES_CALLABLE, + ArgChoicesCallable(is_method=True, is_completer=True, to_call=completer_method)) -def set_arg_choices_function(arg_action: argparse.Action, choices_func: Callable[[], List[str]]): - """ - Set a function for an argparse argument to provide its choices. + setattr(new_arg, ARG_SUPPRESS_HINT, suppress_hint) + setattr(new_arg, ARG_DESCRIPTIVE_COMPLETION_HEADER, description_header) - Note: If choices_func is an instance method of a cmd2 app, then use set_arg_choices_method() instead. + return new_arg - :param arg_action: the argument action being added to - :param choices_func: the function to call - """ - choices_callable = ArgChoicesCallable(is_method=False, is_completer=False, to_call=choices_func) - setattr(arg_action, ARG_CHOICES_CALLABLE, choices_callable) - - -def set_arg_choices_method(arg_action: argparse.Action, choices_method: Callable[[Any], List[str]]): - """ - Set a method for an argparse argument to provide its choices. - Note: This function expects choices_method to be an instance method of a cmd2 app. If choices_method is a function, - then use set_arg_choices_function() instead. - - :param arg_action: the argument action being added to - :param choices_method: the method to call - """ - choices_callable = ArgChoicesCallable(is_method=True, is_completer=False, to_call=choices_method) - setattr(arg_action, ARG_CHOICES_CALLABLE, choices_callable) +# Overwrite _ActionsContainer.add_argument with our patched version +argparse._ActionsContainer.add_argument = patched_add_argument class CompletionItem(str): @@ -697,7 +705,7 @@ def _format_completions(self, action, completions: List[Union[str, CompletionIte completions_with_desc.append(entry) try: - desc_header = action.desc_header + desc_header = getattr(action, ARG_DESCRIPTIVE_COMPLETION_HEADER) except AttributeError: desc_header = 'Description' header = '\n{: <{token_width}}{}'.format(action.dest.upper(), desc_header, token_width=token_width + 2) @@ -784,13 +792,9 @@ def _print_action_help(self, action: argparse.Action) -> None: return # is parameter hinting disabled for this parameter? - try: - suppress_hint = getattr(action, ACTION_SUPPRESS_HINT) - except AttributeError: - pass - else: - if suppress_hint: - return + suppress_hint = getattr(action, ARG_SUPPRESS_HINT, False) + if suppress_hint: + return if action.option_strings: flags = ', '.join(action.option_strings) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index da2e83b36..0272a7c43 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -46,7 +46,7 @@ from . import constants from . import plugin from . import utils -from .argparse_completer import AutoCompleter, ACArgumentParser, set_arg_choices_method, set_arg_completer_method +from .argparse_completer import AutoCompleter, ACArgumentParser 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 @@ -2381,11 +2381,10 @@ def _alias_list(self, args: argparse.Namespace) -> None: description=alias_create_description, epilog=alias_create_epilog) alias_create_parser.add_argument('name', help='name of this alias') - set_arg_choices_method(alias_create_parser.add_argument('command', help='what the alias resolves to'), - _get_commands_aliases_and_macros_for_completion) - set_arg_completer_method(alias_create_parser.add_argument('command_args', nargs=argparse.REMAINDER, - help='arguments to pass to command'), - 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 @@ -2393,8 +2392,7 @@ def _alias_list(self, args: argparse.Namespace) -> None: 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) - set_arg_choices_method(alias_delete_parser.add_argument('name', nargs='*', help='alias to delete'), - _get_alias_names) + alias_delete_parser.add_argument('name', nargs='*', help='alias to delete', choices_method=_get_alias_names) alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases") alias_delete_parser.set_defaults(func=_alias_delete) @@ -2407,8 +2405,7 @@ def _alias_list(self, args: argparse.Namespace) -> None: alias_list_parser = alias_subparsers.add_parser('list', help=alias_list_help, description=alias_list_description) - set_arg_choices_method(alias_list_parser.add_argument('name', nargs="*", help='alias to list'), - _get_alias_names) + alias_list_parser.add_argument('name', nargs="*", help='alias to list', choices_method=_get_alias_names) alias_list_parser.set_defaults(func=_alias_list) # Preserve quotes since we are passing strings to other commands @@ -2586,11 +2583,10 @@ def _macro_list(self, args: argparse.Namespace) -> None: description=macro_create_description, epilog=macro_create_epilog) macro_create_parser.add_argument('name', help='name of this macro') - set_arg_choices_method(macro_create_parser.add_argument('command', help='what the macro resolves to'), - _get_commands_aliases_and_macros_for_completion) - set_arg_completer_method(macro_create_parser.add_argument('command_args', nargs=argparse.REMAINDER, - help='arguments to pass to command'), - 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 @@ -2598,8 +2594,7 @@ def _macro_list(self, args: argparse.Namespace) -> None: 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) - set_arg_choices_method(macro_delete_parser.add_argument('name', nargs='*', help='macro to delete'), - _get_macro_names) + macro_delete_parser.add_argument('name', nargs='*', help='macro to delete', choices_method=_get_macro_names) macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros") macro_delete_parser.set_defaults(func=_macro_delete) @@ -2611,8 +2606,7 @@ def _macro_list(self, args: argparse.Namespace) -> None: "Without arguments, all macros will be listed.") macro_list_parser = macro_subparsers.add_parser('list', help=macro_list_help, description=macro_list_description) - set_arg_choices_method(macro_list_parser.add_argument('name', nargs="*", help='macro to list'), - _get_macro_names) + macro_list_parser.add_argument('name', nargs="*", help='macro to list', choices_method=_get_macro_names) macro_list_parser.set_defaults(func=_macro_list) # Preserve quotes since we are passing strings to other commands @@ -2671,11 +2665,10 @@ def complete_help_subcommand(self, text: str, line: str, begidx: int, endidx: in return matches help_parser = ACArgumentParser() - set_arg_completer_method(help_parser.add_argument('command', nargs="?", help="command to retrieve help for"), - complete_help_command) - set_arg_completer_method(help_parser.add_argument('subcommand', nargs=argparse.REMAINDER, - help="sub-command to retrieve help for"), - complete_help_subcommand) + help_parser.add_argument('command', nargs="?", 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") @@ -2944,8 +2937,7 @@ def _show(self, args: argparse.Namespace, parameter: str = '') -> None: set_parser = ACArgumentParser(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') - set_arg_choices_method(set_parser.add_argument('param', nargs='?', help='parameter to set or view'), - _get_settable_names) + set_parser.add_argument('param', nargs='?', help='parameter to set or view', choices_method=_get_settable_names) set_parser.add_argument('value', nargs='?', help='the new value for settable') @with_argparser(set_parser) @@ -2987,11 +2979,9 @@ def do_set(self, args: argparse.Namespace) -> None: onchange_hook(old=orig_value, new=new_value) shell_parser = ACArgumentParser() - set_arg_completer_method(shell_parser.add_argument('command', help='the command to run'), - shell_cmd_complete) - set_arg_completer_method(shell_parser.add_argument('command_args', nargs=argparse.REMAINDER, - help='arguments to pass to command'), - path_complete) + 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) @@ -3238,11 +3228,9 @@ def py_quit(): return bridge.stop run_pyscript_parser = ACArgumentParser() - set_arg_completer_method(run_pyscript_parser.add_argument('script_path', help='path to the script file'), - path_complete) - set_arg_completer_method(run_pyscript_parser.add_argument('script_arguments', nargs=argparse.REMAINDER, - help='arguments to pass to script'), - path_complete) + 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: @@ -3300,13 +3288,12 @@ def load_ipy(app): 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') - set_arg_completer_method(history_action_group.add_argument('-o', '--output-file', metavar='FILE', - help='output commands to a script file, implies -s'), - path_complete) - set_arg_completer_method(history_action_group.add_argument('-t', '--transcript', - help='output commands and results to a transcript\n' - 'file, implies -s'), - 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') @@ -3596,8 +3583,8 @@ def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcrip " set editor (program-name)") edit_parser = ACArgumentParser(description=edit_description) - set_arg_completer_method(edit_parser.add_argument('file_path', nargs="?", help="path to a file to open in editor"), - path_complete) + edit_parser.add_argument('file_path', nargs="?", + 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: @@ -3625,15 +3612,12 @@ def _current_script_dir(self) -> Optional[str]: "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) - set_arg_completer_method(run_script_parser.add_argument('-t', '--transcript', help='record the output of the ' - 'script as a transcript file'), - path_complete) - set_arg_completer_method(run_script_parser.add_argument('script_path', help="path to the script file"), - path_complete) + 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]: From 7a4dc0dbe22ab70d0a1d94177d24da08cfc50453 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 3 Jul 2019 09:56:51 -0400 Subject: [PATCH 06/88] Updated comments --- cmd2/argparse_completer.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index e0e38a1d3..6131cfa39 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -98,11 +98,11 @@ def __init__(self, is_method: bool, is_completer: bool, to_call: Callable): self.to_call = to_call -# Save the original _ActionsContainer.add_argument because we need to patch it -actual_actions_container_add_argument = argparse._ActionsContainer.add_argument +# Save original _ActionsContainer.add_argument's value because we will replace it with our wrapper +orig_actions_container_add_argument = argparse._ActionsContainer.add_argument -def patched_add_argument(self, *args, +def add_argument_wrapper(self, *args, choices_function: Optional[Callable[[], List[str]]] = None, choices_method: Optional[Callable[[Any], List[str]]] = None, completer_function: Optional[Callable[[str, str, int, int], List[str]]] = None, @@ -111,7 +111,7 @@ def patched_add_argument(self, *args, description_header: Optional[str] = None, **kwargs): """ - This is a patched version of _ActionsContainer.add_argument() that supports more settings needed by cmd2 + This is a wrapper around _ActionsContainer.add_argument() that supports more settings needed by cmd2 :param self: :param args: :param choices_function: @@ -123,8 +123,8 @@ def patched_add_argument(self, *args, :param kwargs: :return: """ - # Call the actual add_argument function - new_arg = actual_actions_container_add_argument(self, *args, **kwargs) + # Call 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] @@ -155,8 +155,8 @@ def patched_add_argument(self, *args, return new_arg -# Overwrite _ActionsContainer.add_argument with our patched version -argparse._ActionsContainer.add_argument = patched_add_argument +# Overwrite _ActionsContainer.add_argument with our wrapper +argparse._ActionsContainer.add_argument = add_argument_wrapper class CompletionItem(str): From e6d319185d5000c36b452c048fcb9f8746955280 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 3 Jul 2019 11:00:12 -0400 Subject: [PATCH 07/88] Fixed some bugs in _resolve_choices_for_arg --- cmd2/argparse_completer.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 6131cfa39..781297e20 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -64,7 +64,7 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str # 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 Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Iterable, Dict, List, Optional, Tuple, Union from . import utils from .ansi import ansi_aware_write, ansi_safe_wcswidth, style_error @@ -103,25 +103,31 @@ def __init__(self, is_method: bool, is_completer: bool, to_call: Callable): def add_argument_wrapper(self, *args, - choices_function: Optional[Callable[[], List[str]]] = None, - choices_method: Optional[Callable[[Any], List[str]]] = 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_hint: bool = False, description_header: Optional[str] = None, - **kwargs): + **kwargs) -> argparse.Action: """ - This is a wrapper around _ActionsContainer.add_argument() that supports more settings needed by cmd2 + This is a wrapper around _ActionsContainer.add_argument() that supports more settings needed by AutoCompleter + + # Args from original function :param self: :param args: + + # Added args used by AutoCompleter :param choices_function: :param choices_method: :param completer_function: :param completer_method: :param suppress_hint: :param description_header: + + # Args from original function :param kwargs: - :return: + :return: the created argument action """ # Call the original add_argument function new_arg = orig_actions_container_add_argument(self, *args, **kwargs) @@ -758,10 +764,8 @@ def _complete_for_arg(self, arg: argparse.Action, # Otherwise use basic_complete on the choices else: - # Since choices can be various types like int, we must convert them to - # before strings before doing tab completion matching. - choices = [str(choice) for choice in self._resolve_choices_for_arg(arg, used_values)] - return utils.basic_complete(text, line, begidx, endidx, choices) + return utils.basic_complete(text, line, begidx, endidx, + self._resolve_choices_for_arg(arg, used_values)) return [] @@ -781,6 +785,14 @@ def _resolve_choices_for_arg(self, arg: argparse.Action, used_values=()) -> List else: arg_choices = arg_choices.to_call() + # Since arg_choices can be any iterable type, convert to a list + arg_choices = list(arg_choices) + + # 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) + # Filter out arguments we already used return [choice for choice in arg_choices if choice not in used_values] From 5208170c694c027008ba4bca8a09476a01e0dc50 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 3 Jul 2019 15:08:14 -0400 Subject: [PATCH 08/88] Moved cmd2 custom argparse types to argparse_custom.py Rename ACArgumentParser to Cmd2ArgParser --- README.md | 2 +- cmd2/argparse_completer.py | 718 ++----------------------------------- cmd2/argparse_custom.py | 424 ++++++++++++++++++++++ cmd2/cmd2.py | 35 +- 4 files changed, 469 insertions(+), 710 deletions(-) create mode 100644 cmd2/argparse_custom.py diff --git a/README.md b/README.md index 451a7bf08..fa42e52c1 100755 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ Instructions for implementing each feature follow. See https://cmd2.readthedocs.io/en/latest/argument_processing.html for more details - NOTE: `cmd2` also provides the `ACArgumentParser` customization of `argparse.ArgumentParser` for prettier formatting + NOTE: `cmd2` also provides the `Cmd2ArgParser` customization of `argparse.ArgumentParser` for prettier formatting of help and RangeAction type - `cmd2` applications function like a full-featured shell in many ways (and are cross-platform) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 781297e20..bcbc9d57a 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -1,8 +1,9 @@ # coding=utf-8 # flake8: noqa C901 -# NOTE: Ignoring 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 """ +This module adds tab completion to argparse parsers within cmd2 apps. + AutoCompleter interprets the argparse.ArgumentParser internals to automatically generate the completion options for each argument. @@ -59,22 +60,19 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str 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 argparse import SUPPRESS from typing import Any, Callable, Iterable, Dict, List, Optional, Tuple, Union from . import utils -from .ansi import ansi_aware_write, ansi_safe_wcswidth, style_error +from .ansi import ansi_safe_wcswidth +from .argparse_custom import _RangeAction from .rl_utils import rl_force_redisplay # Argparse argument attribute that stores an ArgChoicesCallable -ARG_CHOICES_CALLABLE = 'arg_choices_callable' +ARG_CHOICES_CALLABLE = 'choices_callable' # Argparse argument attribute that suppresses tab-completion hints -ARG_SUPPRESS_HINT = 'arg_suppress_hint' +ARG_SUPPRESS_HINT = 'suppress_hint' # Argparse argument attribute that prints descriptive header when using CompletionItems ARG_DESCRIPTIVE_COMPLETION_HEADER = 'desc_header' @@ -114,19 +112,27 @@ def add_argument_wrapper(self, *args, This is a wrapper around _ActionsContainer.add_argument() that supports more settings needed by AutoCompleter # Args from original function - :param self: - :param args: + :param self: instance of the _ActionsContainer being added to + :param args: arguments expected by argparse._ActionsContainer.add_argument # Added args used by AutoCompleter - :param choices_function: - :param choices_method: - :param completer_function: - :param completer_method: - :param suppress_hint: - :param description_header: + :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_hint: when AutoCompleter has no choices to show during tab completion, it displays the current + argument's help text as a hint. Set this to True to suppress the hint. Defaults to False. + :param description_header: if the provided choices are CompletionItems, then this header will display + during tab completion. Defaults to None. # Args from original function - :param kwargs: + :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 """ # Call the original add_argument function @@ -141,7 +147,7 @@ def add_argument_wrapper(self, *args, "choices, choices_function, choices_method, completer_function, completer_method") raise (ValueError(err_msg)) - # Set the custom attributes + # Set the custom attributes used by AutoCompleter if choices_function: setattr(new_arg, ARG_CHOICES_CALLABLE, ArgChoicesCallable(is_method=False, is_completer=False, to_call=choices_function)) @@ -201,99 +207,6 @@ 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 @@ -843,582 +756,3 @@ def _print_action_help(self, action: argparse.Action) -> None: # Redraw prompt and input line rl_force_redisplay() - - -############################################################################### -# 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' - - 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) - - 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 diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py new file mode 100644 index 000000000..a36d03764 --- /dev/null +++ b/cmd2/argparse_custom.py @@ -0,0 +1,424 @@ +# coding=utf-8 +import argparse +import re as _re +import sys + +from argparse import ZERO_OR_MORE, ONE_OR_MORE, ArgumentError, _ +from typing import Callable, Tuple, Union + +from .ansi import ansi_aware_write, style_error + + +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) + + +############################################################################### +# 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 Cmd2ArgParser(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 override that applies custom formatting to the error message""" + 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' + + 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) + + 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()._get_nargs_pattern(action) + + def _match_argument(self, action, arg_strings_pattern) -> int: + # Override _match_argument behavior to use the nargs ranges provided by AutoCompleter + 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()._match_argument(action, arg_strings_pattern) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ac9cdb6a7..24e57f985 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -46,7 +46,8 @@ from . import constants from . import plugin from . import utils -from .argparse_completer import AutoCompleter, ACArgumentParser +from .argparse_completer import AutoCompleter +from .argparse_custom import Cmd2ArgParser 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 @@ -2333,7 +2334,7 @@ def _alias_list(self, args: argparse.Namespace) -> None: "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 = Cmd2ArgParser(description=alias_description, epilog=alias_epilog, prog='alias') # Add sub-commands to alias alias_subparsers = alias_parser.add_subparsers() @@ -2512,7 +2513,7 @@ def _macro_list(self, args: argparse.Namespace) -> None: "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 = Cmd2ArgParser(description=macro_description, epilog=macro_epilog, prog='macro') # Add sub-commands to macro macro_subparsers = macro_parser.add_subparsers() @@ -2641,7 +2642,7 @@ def complete_help_subcommand(self, text: str, line: str, begidx: int, endidx: in return matches - help_parser = ACArgumentParser() + help_parser = Cmd2ArgParser() help_parser.add_argument('command', nargs="?", 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", @@ -2804,19 +2805,19 @@ def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None: command = '' self.stdout.write("\n") - @with_argparser(ACArgumentParser()) + @with_argparser(Cmd2ArgParser()) 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(Cmd2ArgParser(epilog=INTERNAL_COMMAND_EPILOG)) def do_eof(self, _: argparse.Namespace) -> bool: """Called when -D is pressed""" # Return True to stop the command loop return True - @with_argparser(ACArgumentParser()) + @with_argparser(Cmd2ArgParser()) def do_quit(self, _: argparse.Namespace) -> bool: """Exit this application""" # Return True to stop the command loop @@ -2911,7 +2912,7 @@ def _show(self, args: argparse.Namespace, parameter: str = '') -> None: "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 = Cmd2ArgParser(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') set_parser.add_argument('param', nargs='?', help='parameter to set or view', choices_method=_get_settable_names) @@ -2955,7 +2956,7 @@ def do_set(self, args: argparse.Namespace) -> None: if onchange_hook is not None: onchange_hook(old=orig_value, new=new_value) - shell_parser = ACArgumentParser() + shell_parser = Cmd2ArgParser() 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) @@ -3018,7 +3019,7 @@ def _reset_py_display() -> None: "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 = Cmd2ArgParser(description=py_description) py_parser.add_argument('command', nargs='?', help="command to run") py_parser.add_argument('remainder', nargs=argparse.REMAINDER, help="remainder of command") @@ -3204,7 +3205,7 @@ def py_quit(): return bridge.stop - run_pyscript_parser = ACArgumentParser() + run_pyscript_parser = Cmd2ArgParser() 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) @@ -3237,7 +3238,7 @@ def do_run_pyscript(self, args: argparse.Namespace) -> bool: # Only include the do_ipy() method if IPython is available on the system if ipython_available: # pragma: no cover - @with_argparser(ACArgumentParser()) + @with_argparser(Cmd2ArgParser()) def do_ipy(self, _: argparse.Namespace) -> None: """Enter an interactive IPython shell""" from .pyscript_bridge import PyscriptBridge @@ -3260,7 +3261,7 @@ def load_ipy(app): history_description = "View, run, edit, save, or clear previously entered commands" - history_parser = ACArgumentParser(description=history_description) + history_parser = Cmd2ArgParser(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', @@ -3559,7 +3560,7 @@ def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcrip "\n" " set editor (program-name)") - edit_parser = ACArgumentParser(description=edit_description) + edit_parser = Cmd2ArgParser(description=edit_description) edit_parser.add_argument('file_path', nargs="?", help="path to a file to open in editor", completer_method=path_complete) @@ -3591,7 +3592,7 @@ def _current_script_dir(self) -> Optional[str]: "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") - run_script_parser = ACArgumentParser(description=run_script_description) + run_script_parser = Cmd2ArgParser(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) @@ -3657,8 +3658,8 @@ def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: 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 = Cmd2ArgParser(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) From 5b2ef868e7b3354dc4016dfeb28997d1b6549b2d Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 3 Jul 2019 15:10:35 -0400 Subject: [PATCH 09/88] Renamed ACHelpFormatter to Cmd2HelpFormatter --- cmd2/argparse_custom.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index a36d03764..0b0fa9819 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -111,7 +111,7 @@ def register_custom_actions(parser: argparse.ArgumentParser) -> None: # noinspection PyCompatibility,PyShadowingBuiltins,PyShadowingBuiltins -class ACHelpFormatter(argparse.RawTextHelpFormatter): +class Cmd2HelpFormatter(argparse.RawTextHelpFormatter): """Custom help formatter to configure ordering of help text""" def _format_usage(self, usage, actions, groups, prefix) -> str: @@ -295,7 +295,7 @@ class Cmd2ArgParser(argparse.ArgumentParser): def __init__(self, *args, **kwargs) -> None: if 'formatter_class' not in kwargs: - kwargs['formatter_class'] = ACHelpFormatter + kwargs['formatter_class'] = Cmd2HelpFormatter super().__init__(*args, **kwargs) register_custom_actions(self) From 76d584f46104d2d92e98990e3cd3302974763ac5 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 3 Jul 2019 15:25:50 -0400 Subject: [PATCH 10/88] Removed unused custom_error_message code --- cmd2/argparse_custom.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 0b0fa9819..965b0bf08 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -300,17 +300,6 @@ def __init__(self, *args, **kwargs) -> None: 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: @@ -320,10 +309,6 @@ def add_subparsers(self, **kwargs): def error(self, message: str) -> None: """Custom override that applies custom formatting to the error message""" - if self._custom_error_message: - message = self._custom_error_message - self._custom_error_message = '' - lines = message.split('\n') linum = 0 formatted_message = '' From c9d5fc35166b4f6dcdb46fcb1255a013b3660f4a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 3 Jul 2019 16:35:01 -0400 Subject: [PATCH 11/88] Fixed spelling --- cmd2/argparse_completer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index bcbc9d57a..74853fa8d 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -439,7 +439,7 @@ def process_action_nargs(action: argparse.Action, arg_state: AutoCompleter._Argu 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 + # We're trying to determine what specific argument the current cursor position should be # matched with. When we finish parsing all of the arguments, we can determine whether the # last token is a positional or flag argument and which specific argument it is. # From 8f1bc02f2028ac869e61c9a88475a933046f4ee8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 4 Jul 2019 11:11:31 -0400 Subject: [PATCH 12/88] No longer restricting nargs range support to Cmd2ArgParser --- cmd2/argparse_completer.py | 128 ++--------------- cmd2/argparse_custom.py | 273 ++++++++++++++++++++++--------------- 2 files changed, 179 insertions(+), 222 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 74853fa8d..c5a4c004b 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -61,115 +61,14 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str import argparse import os from argparse import SUPPRESS -from typing import Any, Callable, Iterable, Dict, List, Optional, Tuple, Union +from typing import Callable, Dict, List, Tuple, Union from . import utils from .ansi import ansi_safe_wcswidth -from .argparse_custom import _RangeAction +from .argparse_custom import ATTR_SUPPRESS_TAB_HINT, ATTR_DESCRIPTIVE_COMPLETION_HEADER, ATTR_NARGS_RANGE +from .argparse_custom import ChoicesCallable, ATTR_CHOICES_CALLABLE from .rl_utils import rl_force_redisplay -# Argparse argument attribute that stores an ArgChoicesCallable -ARG_CHOICES_CALLABLE = 'choices_callable' - -# Argparse argument attribute that suppresses tab-completion hints -ARG_SUPPRESS_HINT = 'suppress_hint' - -# Argparse argument attribute that prints descriptive header when using CompletionItems -ARG_DESCRIPTIVE_COMPLETION_HEADER = 'desc_header' - - -class ArgChoicesCallable: - """ - 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 - - -# Save original _ActionsContainer.add_argument's value because we will replace it with our wrapper -orig_actions_container_add_argument = argparse._ActionsContainer.add_argument - - -def add_argument_wrapper(self, *args, - 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_hint: bool = False, - description_header: Optional[str] = None, - **kwargs) -> argparse.Action: - """ - This is a wrapper around _ActionsContainer.add_argument() that supports more settings needed by AutoCompleter - - # Args from original function - :param self: instance of the _ActionsContainer being added to - :param args: arguments expected by argparse._ActionsContainer.add_argument - - # 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_hint: when AutoCompleter has no choices to show during tab completion, it displays the current - argument's help text as a hint. Set this to True to suppress the hint. Defaults to False. - :param description_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 - """ - # Call 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 used by AutoCompleter - if choices_function: - setattr(new_arg, ARG_CHOICES_CALLABLE, - ArgChoicesCallable(is_method=False, is_completer=False, to_call=choices_function)) - elif choices_method: - setattr(new_arg, ARG_CHOICES_CALLABLE, - ArgChoicesCallable(is_method=True, is_completer=False, to_call=choices_method)) - elif completer_function: - setattr(new_arg, ARG_CHOICES_CALLABLE, - ArgChoicesCallable(is_method=False, is_completer=True, to_call=completer_function)) - elif completer_method: - setattr(new_arg, ARG_CHOICES_CALLABLE, - ArgChoicesCallable(is_method=True, is_completer=True, to_call=completer_method)) - - setattr(new_arg, ARG_SUPPRESS_HINT, suppress_hint) - setattr(new_arg, ARG_DESCRIPTIVE_COMPLETION_HEADER, description_header) - - return new_arg - - -# Overwrite _ActionsContainer.add_argument with our wrapper -argparse._ActionsContainer.add_argument = add_argument_wrapper - class CompletionItem(str): """ @@ -301,8 +200,8 @@ def __init__(self, parser: argparse.ArgumentParser, cmd2_app, *, self._arg_choices[action.dest] = action.choices # otherwise check if a callable provides the choices for this argument - elif hasattr(action, ARG_CHOICES_CALLABLE): - arg_choice_callable = getattr(action, ARG_CHOICES_CALLABLE) + 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 @@ -411,9 +310,10 @@ def consume_positional_argument() -> None: 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 + nargs_range = getattr(action, ATTR_NARGS_RANGE, None) + if nargs_range is not None: + arg_state.min = nargs_range[0] + arg_state.max = nargs_range[1] arg_state.variable = True if arg_state.min is None or arg_state.max is None: if action.nargs is None: @@ -624,7 +524,7 @@ def _format_completions(self, action, completions: List[Union[str, CompletionIte completions_with_desc.append(entry) try: - desc_header = getattr(action, ARG_DESCRIPTIVE_COMPLETION_HEADER) + desc_header = getattr(action, ATTR_DESCRIPTIVE_COMPLETION_HEADER) except AttributeError: desc_header = 'Description' header = '\n{: <{token_width}}{}'.format(action.dest.upper(), desc_header, token_width=token_width + 2) @@ -669,7 +569,7 @@ def _complete_for_arg(self, arg: argparse.Action, arg_choices = self._arg_choices[arg.dest] # Check if the argument uses a specific tab completion function to provide its choices - if isinstance(arg_choices, ArgChoicesCallable) and arg_choices.is_completer: + if isinstance(arg_choices, ChoicesCallable) and arg_choices.is_completer: if arg_choices.is_method: return arg_choices.to_call(self._cmd2_app, text, line, begidx, endidx) else: @@ -687,8 +587,8 @@ def _resolve_choices_for_arg(self, arg: argparse.Action, used_values=()) -> List if arg.dest in self._arg_choices: arg_choices = self._arg_choices[arg.dest] - # Check if arg_choices is an ArgChoicesCallable that generates a choice list - if isinstance(arg_choices, ArgChoicesCallable): + # 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 [] @@ -717,7 +617,7 @@ def _print_action_help(self, action: argparse.Action) -> None: return # is parameter hinting disabled for this parameter? - suppress_hint = getattr(action, ARG_SUPPRESS_HINT, False) + suppress_hint = getattr(action, ATTR_SUPPRESS_TAB_HINT, False) if suppress_hint: return diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 965b0bf08..475105ec8 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -2,112 +2,165 @@ import argparse import re as _re import sys - +# noinspection PyUnresolvedReferences,PyProtectedMember from argparse import ZERO_OR_MORE, ONE_OR_MORE, ArgumentError, _ -from typing import Callable, Tuple, Union +from typing import Any, Callable, Iterable, List, Optional, Tuple, Union from .ansi import ansi_aware_write, style_error - -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 = '?' +############################################################################################################ +# The following are names of custom argparse argument attributes added by cmd2 +############################################################################################################ + +# 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' + + +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 + + +# Save original _ActionsContainer.add_argument's value because we will replace it with 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, 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_hint: bool = False, + descriptive_header: Optional[str] = None, + **kwargs) -> argparse.Action: + """ + This is a 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 attribute by allowing tuples which specify a range (min, max) + + # 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_hint: when AutoCompleter has no choices to show during tab completion, it displays the current + argument's help text as a hint. Set this to True to suppress the 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 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.') + + # nargs_range is a two-item tuple (min, max) + nargs_range = nargs + + if nargs[0] == 0: + if nargs[1] > 1: + nargs_adjusted = '*' else: - self.nargs_adjusted = '+' + # this shouldn't use a range tuple, but yet here we are + 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) - - -############################################################################### + nargs_adjusted = '+' + else: + nargs_adjusted = nargs + + # Call the original add_argument function + if nargs_adjusted is not None: + kwargs['nargs'] = nargs_adjusted + new_arg = orig_actions_container_add_argument(self, *args, **kwargs) + + if nargs_range is not None: + setattr(new_arg, ATTR_NARGS_RANGE, nargs_range) + + # 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 used by AutoCompleter + 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_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 + + +############################################################################################################ # 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 @@ -273,12 +326,14 @@ def format(tuple_size): 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) - 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) + nargs_range = getattr(action, ATTR_NARGS_RANGE, None) + + if nargs_range is not None: + result = '{}{{{}..{}}}'.format('%s' % get_metavar(1), nargs_range[0], nargs_range[1]) elif action.nargs == ZERO_OR_MORE: result = '[%s [...]]' % get_metavar(1) elif action.nargs == ONE_OR_MORE: @@ -298,7 +353,6 @@ def __init__(self, *args, **kwargs) -> None: kwargs['formatter_class'] = Cmd2HelpFormatter super().__init__(*args, **kwargs) - register_custom_actions(self) def add_subparsers(self, **kwargs): """Custom override. Sets a default title if one was not given.""" @@ -323,6 +377,7 @@ def error(self, message: str) -> None: 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() @@ -380,11 +435,12 @@ def _print_message(self, message, file=None): file = sys.stderr ansi_aware_write(file, message) + # noinspection PyProtectedMember 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) + # Override _get_nargs_pattern behavior to support the nargs ranges + nargs_range = getattr(action, ATTR_NARGS_RANGE, None) + if nargs_range is not None: + nargs_pattern = '(-*A{{{},{}}}-*)'.format(nargs_range[0], nargs_range[1]) # if this is an optional action, -- is not allowed if action.option_strings: @@ -394,16 +450,17 @@ def _get_nargs_pattern(self, action) -> str: return super()._get_nargs_pattern(action) + # noinspection PyProtectedMember def _match_argument(self, action, arg_strings_pattern) -> int: - # Override _match_argument behavior to use the nargs ranges provided by AutoCompleter + # Override _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: - if isinstance(action, _RangeAction) and \ - action.nargs_min is not None and action.nargs_max is not None: + nargs_range = getattr(action, ATTR_NARGS_RANGE, None) + if nargs_range is not None: raise ArgumentError(action, - 'Expected between {} and {} arguments'.format(action.nargs_min, action.nargs_max)) + 'Expected between {} and {} arguments'.format(nargs_range[0], nargs_range[1])) return super()._match_argument(action, arg_strings_pattern) From 5b4ef18ab5f74666ffa5a0125315181f0adad71c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 4 Jul 2019 11:43:26 -0400 Subject: [PATCH 13/88] Refactoring and updating documentation --- cmd2/argparse_custom.py | 58 ++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 475105ec8..21f5a8ab4 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -66,7 +66,7 @@ def add_argument_wrapper(self, *args, :param args: arguments expected by argparse._ActionsContainer.add_argument # Customized arguments from original function - :param nargs: extends argparse nargs attribute by allowing tuples which specify a range (min, max) + :param nargs: extends argparse nargs functionality by allowing tuples which specify a range (min, max) # Added args used by AutoCompleter :param choices_function: function that provides choices for this argument @@ -88,38 +88,40 @@ def add_argument_wrapper(self, *args, :return: the created argument action """ - # pre-process special ranged nargs + # Pre-process special ranged nargs nargs_range = None - 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.') - - # nargs_range is a two-item tuple (min, max) - nargs_range = nargs - - if nargs[0] == 0: - if nargs[1] > 1: - nargs_adjusted = '*' + if nargs is not None: + # Check if nargs was given as a range + if isinstance(nargs, tuple): + + # Validate 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') + + # Save the nargs tuple as our range setting + nargs_range = nargs + + # Convert nargs into a format argparse recognizes + if nargs_range[0] == 0: + if nargs_range[1] > 1: + nargs_adjusted = '*' + else: + nargs_adjusted = '?' else: - # this shouldn't use a range tuple, but yet here we are - nargs_adjusted = '?' + nargs_adjusted = '+' else: - nargs_adjusted = '+' - else: - nargs_adjusted = nargs + nargs_adjusted = nargs - # Call the original add_argument function - if nargs_adjusted is not None: + # Add the argparse-recognized version of nargs to kwargs kwargs['nargs'] = nargs_adjusted - new_arg = orig_actions_container_add_argument(self, *args, **kwargs) - if nargs_range is not None: - setattr(new_arg, ATTR_NARGS_RANGE, nargs_range) + # 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] @@ -130,7 +132,9 @@ def add_argument_wrapper(self, *args, "choices, choices_function, choices_method, completer_function, completer_method") raise (ValueError(err_msg)) - # Set the custom attributes used by AutoCompleter + # 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)) From bb8f6c4212ecd12e9b74363ddb662732fa07527b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 4 Jul 2019 13:07:36 -0400 Subject: [PATCH 14/88] Patched 2 more argparse functions to make nargs ranges work with all parsers --- cmd2/argparse_custom.py | 116 +++++++++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 43 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 21f5a8ab4..67a4b1925 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -44,22 +44,26 @@ def __init__(self, is_method: bool, is_completer: bool, to_call: Callable): self.to_call = to_call -# Save original _ActionsContainer.add_argument's value because we will replace it with our wrapper +############################################################################################################ +# 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, 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_hint: bool = False, - descriptive_header: Optional[str] = None, - **kwargs) -> argparse.Action: +def _add_argument_wrapper(self, *args, + nargs: Union[int, str, 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_hint: bool = False, + descriptive_header: Optional[str] = None, + **kwargs) -> argparse.Action: """ - This is a wrapper around _ActionsContainer.add_argument() which supports more settings used by cmd2 + 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 @@ -156,8 +160,64 @@ def add_argument_wrapper(self, *args, # Overwrite _ActionsContainer.add_argument with our wrapper # noinspection PyProtectedMember -argparse._ActionsContainer.add_argument = add_argument_wrapper +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: + nargs_pattern = '(-*A{{{},{}}}-*)'.format(nargs_range[0], nargs_range[1]) + + # 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, + 'Expected between {} and {} arguments'.format(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 @@ -350,7 +410,7 @@ def _format_args(self, action, default_metavar) -> str: # noinspection PyCompatibility class Cmd2ArgParser(argparse.ArgumentParser): - """Custom argparse class to override error method to change default help text.""" + """Custom ArgumentParser class that improves error and help output""" def __init__(self, *args, **kwargs) -> None: if 'formatter_class' not in kwargs: @@ -438,33 +498,3 @@ def _print_message(self, message, file=None): if file is None: file = sys.stderr ansi_aware_write(file, message) - - # noinspection PyProtectedMember - def _get_nargs_pattern(self, action) -> str: - # Override _get_nargs_pattern behavior to support the nargs ranges - nargs_range = getattr(action, ATTR_NARGS_RANGE, None) - if nargs_range is not None: - nargs_pattern = '(-*A{{{},{}}}-*)'.format(nargs_range[0], nargs_range[1]) - - # 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()._get_nargs_pattern(action) - - # noinspection PyProtectedMember - def _match_argument(self, action, arg_strings_pattern) -> int: - # Override _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, - 'Expected between {} and {} arguments'.format(nargs_range[0], nargs_range[1])) - - return super()._match_argument(action, arg_strings_pattern) From 39127e6a16b8e7ef8da82f45a25e19e2545d820b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 4 Jul 2019 13:21:51 -0400 Subject: [PATCH 15/88] Made optional args on completers keyword-only --- cmd2/cmd2.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 24e57f985..5893ef032 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -840,8 +840,8 @@ def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> Tuple[Li return tokens, raw_tokens - 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. @@ -902,7 +902,7 @@ def delimiter_complete(self, text: str, line: str, begidx: int, endidx: int, mat 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 @@ -944,7 +944,7 @@ def flag_based_complete(self, text: str, line: str, begidx: int, endidx: int, 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 @@ -988,7 +988,7 @@ def index_based_complete(self, text: str, line: str, begidx: int, endidx: int, 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 @@ -1132,7 +1132,7 @@ def complete_users() -> List[str]: 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 :param text: the string prefix we are attempting to match (all returned matches must begin with it) @@ -1155,7 +1155,7 @@ def shell_cmd_complete(self, text: str, line: str, begidx: int, endidx: int, # 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 From 4f2d1ba0b1aec77116d660ae8315c74491098a79 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 4 Jul 2019 14:23:06 -0400 Subject: [PATCH 16/88] Using argparse constants instead of hardcoded strings --- cmd2/argparse_completer.py | 6 +++--- cmd2/argparse_custom.py | 6 +++--- cmd2/cmd2.py | 25 +++++++++++++++---------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index c5a4c004b..3d2ca1e92 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -319,18 +319,18 @@ def process_action_nargs(action: argparse.Action, arg_state: AutoCompleter._Argu if action.nargs is None: arg_state.min = 1 arg_state.max = 1 - elif action.nargs == '+': + elif action.nargs == argparse.ONE_OR_MORE: arg_state.min = 1 arg_state.max = float('inf') arg_state.variable = True - elif action.nargs == '*' or action.nargs == argparse.REMAINDER: + elif action.nargs == argparse.ZERO_OR_MORE 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 == '?': + elif action.nargs == argparse.OPTIONAL: arg_state.min = 0 arg_state.max = 1 arg_state.variable = True diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 67a4b1925..6361bdb9c 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -113,11 +113,11 @@ def _add_argument_wrapper(self, *args, # Convert nargs into a format argparse recognizes if nargs_range[0] == 0: if nargs_range[1] > 1: - nargs_adjusted = '*' + nargs_adjusted = argparse.ZERO_OR_MORE else: - nargs_adjusted = '?' + nargs_adjusted = argparse.OPTIONAL else: - nargs_adjusted = '+' + nargs_adjusted = argparse.ONE_OR_MORE else: nargs_adjusted = nargs diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 5893ef032..eaae5de7c 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2370,7 +2370,8 @@ def _alias_list(self, args: argparse.Namespace) -> None: 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) - alias_delete_parser.add_argument('name', nargs='*', help='alias to delete', choices_method=_get_alias_names) + alias_delete_parser.add_argument('name', nargs=argparse.ZERO_OR_MORE, + help='alias to delete', choices_method=_get_alias_names) alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases") alias_delete_parser.set_defaults(func=_alias_delete) @@ -2383,7 +2384,8 @@ def _alias_list(self, args: argparse.Namespace) -> None: alias_list_parser = alias_subparsers.add_parser('list', help=alias_list_help, description=alias_list_description) - alias_list_parser.add_argument('name', nargs="*", help='alias to list', choices_method=_get_alias_names) + alias_list_parser.add_argument('name', nargs=argparse.ZERO_OR_MORE, + help='alias to list', choices_method=_get_alias_names) alias_list_parser.set_defaults(func=_alias_list) # Preserve quotes since we are passing strings to other commands @@ -2572,7 +2574,8 @@ def _macro_list(self, args: argparse.Namespace) -> None: 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) - macro_delete_parser.add_argument('name', nargs='*', help='macro to delete', choices_method=_get_macro_names) + macro_delete_parser.add_argument('name', nargs=argparse.ZERO_OR_MORE, + help='macro to delete', choices_method=_get_macro_names) macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros") macro_delete_parser.set_defaults(func=_macro_delete) @@ -2584,7 +2587,8 @@ def _macro_list(self, args: argparse.Namespace) -> None: "Without arguments, all macros will be listed.") macro_list_parser = macro_subparsers.add_parser('list', help=macro_list_help, description=macro_list_description) - macro_list_parser.add_argument('name', nargs="*", help='macro to list', choices_method=_get_macro_names) + macro_list_parser.add_argument('name', nargs=argparse.ZERO_OR_MORE, + help='macro to list', choices_method=_get_macro_names) macro_list_parser.set_defaults(func=_macro_list) # Preserve quotes since we are passing strings to other commands @@ -2643,7 +2647,7 @@ def complete_help_subcommand(self, text: str, line: str, begidx: int, endidx: in return matches help_parser = Cmd2ArgParser() - help_parser.add_argument('command', nargs="?", help="command to retrieve help for", + 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) @@ -2915,8 +2919,9 @@ def _show(self, args: argparse.Namespace, parameter: str = '') -> None: set_parser = Cmd2ArgParser(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') - set_parser.add_argument('param', nargs='?', help='parameter to set or view', choices_method=_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_names) + 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: @@ -3020,7 +3025,7 @@ def _reset_py_display() -> None: "by providing no arguments to py and run more complex statements there.") py_parser = Cmd2ArgParser(description=py_description) - py_parser.add_argument('command', nargs='?', help="command to run") + 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 @@ -3293,7 +3298,7 @@ def load_ipy(app): "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]: @@ -3561,7 +3566,7 @@ def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcrip " set editor (program-name)") edit_parser = Cmd2ArgParser(description=edit_description) - edit_parser.add_argument('file_path', nargs="?", + 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) From c233b75147f01e8b34beb8ada6cec3468371c896 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 4 Jul 2019 15:22:01 -0400 Subject: [PATCH 17/88] Fixing unit tests --- examples/subcommands.py | 6 ++---- tests/test_completion.py | 9 ++++----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/examples/subcommands.py b/examples/subcommands.py index d1b7c9dbd..89bcaf85d 100755 --- a/examples/subcommands.py +++ b/examples/subcommands.py @@ -33,8 +33,7 @@ # create the parser for the "sport" sub-command parser_sport = base_subparsers.add_parser('sport', help='sport help') -sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport') -setattr(sport_arg, 'arg_choices', sport_item_strs) +sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) # create the top-level parser for the alternate command @@ -60,8 +59,7 @@ # create the parser for the "sport" sub-command parser_sport2 = base2_subparsers.add_parser('sport', help='sport help') -sport2_arg = parser_sport2.add_argument('sport', help='Enter name of a sport') -setattr(sport2_arg, 'arg_choices', sport_item_strs) +sport2_arg = parser_sport2.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) class SubcommandsExample(cmd2.Cmd): diff --git a/tests/test_completion.py b/tests/test_completion.py index 9bf6fc5f5..03208a881 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -516,7 +516,7 @@ def test_path_completion_directories_only(cmd2_app, request): expected = [text + 'cripts' + os.path.sep] - assert cmd2_app.path_complete(text, line, begidx, endidx, os.path.isdir) == expected + assert cmd2_app.path_complete(text, line, begidx, endidx, path_filter=os.path.isdir) == expected def test_basic_completion_single(cmd2_app): text = 'Pi' @@ -592,7 +592,7 @@ def test_flag_based_default_completer(cmd2_app, request): begidx = endidx - len(text) assert cmd2_app.flag_based_complete(text, line, begidx, endidx, - flag_dict, cmd2_app.path_complete) == [text + 'onftest.py'] + flag_dict, all_else=cmd2_app.path_complete) == [text + 'onftest.py'] def test_flag_based_callable_completer(cmd2_app, request): test_dir = os.path.dirname(request.module.__file__) @@ -642,7 +642,7 @@ def test_index_based_default_completer(cmd2_app, request): begidx = endidx - len(text) assert cmd2_app.index_based_complete(text, line, begidx, endidx, - index_dict, cmd2_app.path_complete) == [text + 'onftest.py'] + index_dict, all_else=cmd2_app.path_complete) == [text + 'onftest.py'] def test_index_based_callable_completer(cmd2_app, request): test_dir = os.path.dirname(request.module.__file__) @@ -1072,8 +1072,7 @@ def base_sport(self, args): # create the parser for the "sport" sub-command parser_sport = base_subparsers.add_parser('sport', help='sport help') - sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport') - setattr(sport_arg, 'arg_choices', sport_item_strs) + sport_arg = parser_sport.add_argument('sport', help='Enter name of a sport', choices=sport_item_strs) @cmd2.with_argparser_and_unknown_args(base_parser) def do_base(self, args): From 60d731da49436322cdd11da3c4b8c67388d36534 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 4 Jul 2019 15:24:19 -0400 Subject: [PATCH 18/88] Fixing unit tests --- tests/test_acargparse.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/test_acargparse.py b/tests/test_acargparse.py index 436158db6..c8f09f76c 100644 --- a/tests/test_acargparse.py +++ b/tests/test_acargparse.py @@ -3,56 +3,57 @@ Unit/functional testing for argparse customizations in cmd2 """ import pytest -from cmd2.argparse_completer import ACArgumentParser, is_potential_flag +from cmd2.argparse_custom import Cmd2ArgParser +from cmd2.argparse_completer import is_potential_flag def test_acarg_narg_empty_tuple(): with pytest.raises(ValueError) as excinfo: - parser = ACArgumentParser(prog='test') + parser = Cmd2ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=()) assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value) def test_acarg_narg_single_tuple(): with pytest.raises(ValueError) as excinfo: - parser = ACArgumentParser(prog='test') + parser = Cmd2ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=(1,)) assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value) def test_acarg_narg_tuple_triple(): with pytest.raises(ValueError) as excinfo: - parser = ACArgumentParser(prog='test') + parser = Cmd2ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=(1, 2, 3)) assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value) def test_acarg_narg_tuple_order(): with pytest.raises(ValueError) as excinfo: - parser = ACArgumentParser(prog='test') + parser = Cmd2ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=(2, 1)) assert 'Invalid nargs range. The first value must be less than the second' in str(excinfo.value) def test_acarg_narg_tuple_negative(): with pytest.raises(ValueError) as excinfo: - parser = ACArgumentParser(prog='test') + parser = Cmd2ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=(-1, 1)) assert 'Negative numbers are invalid for nargs range' in str(excinfo.value) def test_acarg_narg_tuple_zero_base(): - parser = ACArgumentParser(prog='test') + parser = Cmd2ArgParser(prog='test') parser.add_argument('tuple', nargs=(0, 3)) def test_acarg_narg_tuple_zero_to_one(): - parser = ACArgumentParser(prog='test') + parser = Cmd2ArgParser(prog='test') parser.add_argument('tuple', nargs=(0, 1)) def test_is_potential_flag(): - parser = ACArgumentParser() + parser = Cmd2ArgParser() # Not valid flags assert not is_potential_flag('', parser) From b3359eb2ed03b9ede79972512978418dfe80e5ab Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 4 Jul 2019 16:15:14 -0400 Subject: [PATCH 19/88] Fixing examples --- examples/table_display.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/table_display.py b/examples/table_display.py index cedd2ca01..54d5b7a49 100755 --- a/examples/table_display.py +++ b/examples/table_display.py @@ -15,6 +15,7 @@ from typing import Tuple import cmd2 +from cmd2.argparse_custom import Cmd2ArgParser import tableformatter as tf # Configure colors for when users chooses the "-c" flag to enable color in the table output @@ -142,14 +143,14 @@ def high_density_objs(row_obj: CityInfo) -> dict: return opts -def make_table_parser() -> cmd2.argparse_completer.ACArgumentParser: +def make_table_parser() -> Cmd2ArgParser: """Create a unique instance of an argparse Argument parser for processing table arguments. NOTE: The two cmd2 argparse decorators require that each parser be unique, even if they are essentially a deep copy of each other. For cases like that, you can create a function to return a unique instance of a parser, which is what is being done here. """ - table_parser = cmd2.argparse_completer.ACArgumentParser() + table_parser = Cmd2ArgParser() table_item_group = table_parser.add_mutually_exclusive_group() table_item_group.add_argument('-c', '--color', action='store_true', help='Enable color') table_item_group.add_argument('-f', '--fancy', action='store_true', help='Fancy Grid') From 7817c03b0b36515c3f59de8b4a50aca3261044af Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 4 Jul 2019 16:45:16 -0400 Subject: [PATCH 20/88] Fixing examples --- examples/tab_autocompletion.py | 64 +++++++++++++--------------------- 1 file changed, 25 insertions(+), 39 deletions(-) diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index 8f27cb908..64bcbb652 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -4,11 +4,13 @@ A example usage of the AutoCompleter """ import argparse +import functools import itertools from typing import List import cmd2 from cmd2 import argparse_completer, utils +from cmd2.argparse_custom import Cmd2ArgParser actors = ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', 'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels', 'Adam Driver', 'Daisy Ridley', 'John Boyega', 'Oscar Isaac', @@ -102,6 +104,7 @@ def __init__(self): '/home/other user/tests.db' ] + # noinspection PyMethodMayBeStatic def instance_query_actors(self) -> List[str]: """Simulating a function that queries and returns a completion values""" return actors @@ -123,11 +126,11 @@ def instance_query_movie_ids(self) -> List[str]: # This demonstrates a number of customizations of the AutoCompleter version of ArgumentParser # - The help output will separately group required vs optional flags # - The help output for arguments with multiple flags or with append=True is more concise - # - ACArgumentParser adds the ability to specify ranges of argument counts in 'nargs' + # - cmd2 adds the ability to specify ranges of argument counts in 'nargs' suggest_description = "Suggest command demonstrates argparse customizations.\n" suggest_description += "See hybrid_suggest and orig_suggest to compare the help output." - suggest_parser = argparse_completer.ACArgumentParser(description=suggest_description) + suggest_parser = Cmd2ArgParser(description=suggest_description) suggest_parser.add_argument('-t', '--type', choices=['movie', 'show'], required=True) suggest_parser.add_argument('-d', '--duration', nargs=(1, 2), action='append', @@ -146,9 +149,6 @@ def do_suggest(self, args) -> None: # to enable narg ranges without the help changes using this method suggest_parser_hybrid = argparse.ArgumentParser() - # This registers the custom narg range handling - argparse_completer.register_custom_actions(suggest_parser_hybrid) - suggest_parser_hybrid.add_argument('-t', '--type', choices=['movie', 'show'], required=True) suggest_parser_hybrid.add_argument('-d', '--duration', nargs=(1, 2), action='append', help='Duration constraint in minutes.\n' @@ -211,7 +211,7 @@ def _do_vid_media_shows(self, args) -> None: '\n '.join(ep_list))) print() - video_parser = argparse_completer.ACArgumentParser(prog='media') + video_parser = Cmd2ArgParser(prog='media') video_types_subparsers = video_parser.add_subparsers(title='Media Types', dest='type') @@ -225,45 +225,29 @@ def _do_vid_media_shows(self, args) -> None: vid_movies_list_parser.add_argument('-t', '--title', help='Title Filter') vid_movies_list_parser.add_argument('-r', '--rating', help='Rating Filter', nargs='+', choices=ratings_types) - # save a reference to the action object - director_action = vid_movies_list_parser.add_argument('-d', '--director', help='Director Filter') - actor_action = vid_movies_list_parser.add_argument('-a', '--actor', help='Actor Filter', action='append') - - # tag the action objects with completion providers. This can be a collection or a callable - setattr(director_action, argparse_completer.ACTION_ARG_CHOICES, static_list_directors) - setattr(actor_action, argparse_completer.ACTION_ARG_CHOICES, query_actors) + vid_movies_list_parser.add_argument('-d', '--director', help='Director Filter', choices=static_list_directors) + vid_movies_list_parser.add_argument('-a', '--actor', help='Actor Filter', action='append', + choices_function=query_actors) vid_movies_add_parser = vid_movies_commands_subparsers.add_parser('add') vid_movies_add_parser.add_argument('title', help='Movie Title') vid_movies_add_parser.add_argument('rating', help='Movie Rating', choices=ratings_types) - # save a reference to the action object - director_action = vid_movies_add_parser.add_argument('-d', '--director', help='Director', nargs=(1, 2), - required=True) - actor_action = vid_movies_add_parser.add_argument('actor', help='Actors', nargs='*') + vid_movies_add_parser.add_argument('-d', '--director', help='Director', nargs=(1, 2), required=True, + choices=static_list_directors) + vid_movies_add_parser.add_argument('actor', help='Actors', nargs='*', choices_method=instance_query_actors) vid_movies_load_parser = vid_movies_commands_subparsers.add_parser('load') - vid_movie_file_action = vid_movies_load_parser.add_argument('movie_file', help='Movie database') + vid_movies_load_parser.add_argument('movie_file', help='Movie database', + completer_method=functools.partial(cmd2.Cmd.delimiter_complete, + delimiter='/', match_against=file_list)) vid_movies_read_parser = vid_movies_commands_subparsers.add_parser('read') - vid_movie_fread_action = vid_movies_read_parser.add_argument('movie_file', help='Movie database') - - # tag the action objects with completion providers. This can be a collection or a callable - setattr(director_action, argparse_completer.ACTION_ARG_CHOICES, static_list_directors) - setattr(actor_action, argparse_completer.ACTION_ARG_CHOICES, 'instance_query_actors') - - # tag the file property with a custom completion function 'delimiter_complete' provided by cmd2. - setattr(vid_movie_file_action, argparse_completer.ACTION_ARG_CHOICES, - ('delimiter_complete', - {'delimiter': '/', - 'match_against': file_list})) - setattr(vid_movie_fread_action, argparse_completer.ACTION_ARG_CHOICES, - ('path_complete',)) + vid_movies_read_parser.add_argument('movie_file', help='Movie database', completer_method=cmd2.Cmd.path_complete) vid_movies_delete_parser = vid_movies_commands_subparsers.add_parser('delete') - vid_delete_movie_id = vid_movies_delete_parser.add_argument('movie_id', help='Movie ID') - setattr(vid_delete_movie_id, argparse_completer.ACTION_ARG_CHOICES, instance_query_movie_ids) - setattr(vid_delete_movie_id, argparse_completer.ACTION_DESCRIPTIVE_COMPLETION_HEADER, 'Title') + vid_movies_delete_parser.add_argument('movie_id', help='Movie ID', choices_method=instance_query_movie_ids, + descriptive_header='Title') vid_shows_parser = video_types_subparsers.add_parser('shows') vid_shows_parser.set_defaults(func=_do_vid_media_shows) @@ -318,7 +302,7 @@ def _do_media_shows(self, args) -> None: '\n '.join(ep_list))) print() - media_parser = argparse_completer.ACArgumentParser(prog='media') + media_parser = Cmd2ArgParser(prog='media') media_types_subparsers = media_parser.add_subparsers(title='Media Types', dest='type') @@ -342,9 +326,8 @@ def _do_media_shows(self, args) -> None: movies_add_parser.add_argument('actor', help='Actors', nargs=argparse.REMAINDER) movies_delete_parser = movies_commands_subparsers.add_parser('delete') - movies_delete_movie_id = movies_delete_parser.add_argument('movie_id', help='Movie ID') - setattr(movies_delete_movie_id, argparse_completer.ACTION_ARG_CHOICES, 'instance_query_movie_ids') - setattr(movies_delete_movie_id, argparse_completer.ACTION_DESCRIPTIVE_COMPLETION_HEADER, 'Title') + movies_delete_parser.add_argument('movie_id', help='Movie ID', choices_method=instance_query_movie_ids, + descriptive_header='Title') movies_load_parser = movies_commands_subparsers.add_parser('load') movie_file_action = movies_load_parser.add_argument('movie_file', help='Movie database') @@ -403,17 +386,20 @@ def _do_library_show(self, args): if not args.type: self.do_help('library show') + # noinspection PyMethodMayBeStatic def _query_movie_database(self): return list(set(TabCompleteExample.MOVIE_DATABASE_IDS).difference(set(TabCompleteExample.USER_MOVIE_LIBRARY))) + # noinspection PyMethodMayBeStatic def _query_movie_user_library(self): return TabCompleteExample.USER_MOVIE_LIBRARY + # noinspection PyMethodMayBeStatic, PyUnusedLocal def _filter_library(self, text, line, begidx, endidx, full, exclude=()): candidates = list(set(full).difference(set(exclude))) return [entry for entry in candidates if entry.startswith(text)] - library_parser = argparse_completer.ACArgumentParser(prog='library') + library_parser = Cmd2ArgParser(prog='library') library_subcommands = library_parser.add_subparsers(title='Media Types', dest='type') From aa41c31821f26b0c741b5478cedd4e1968657d3a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 5 Jul 2019 13:32:11 -0400 Subject: [PATCH 21/88] Removed AutoCompleter parameters that are obsolete --- cmd2/argparse_completer.py | 54 ++++++++++++++------------------------ 1 file changed, 19 insertions(+), 35 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 3d2ca1e92..45c8d9d6b 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -61,7 +61,7 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str import argparse import os from argparse import SUPPRESS -from typing import Callable, Dict, List, Tuple, Union +from typing import List, Union from . import utils from .ansi import ansi_safe_wcswidth @@ -135,7 +135,7 @@ def is_potential_flag(token: str, parser: argparse.ArgumentParser) -> bool: 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: @@ -154,32 +154,18 @@ def reset(self) -> None: self.variable = False def __init__(self, parser: argparse.ArgumentParser, cmd2_app, *, - tab_for_arg_help: bool = True, token_start_index: int = 1, - arg_choices: Dict[str, Union[List, Tuple, Callable]] = None, - subcmd_args_lookup: dict = None, ) -> None: + tab_for_arg_help: bool = True, token_start_index: int = 1) -> None: """ Create an AutoCompleter :param parser: ArgumentParser instance :param cmd2_app: reference to the Cmd2 application that owns this AutoCompleter - :param tab_for_arg_help: Enable of disable argument help when there's no completion result - - # The following parameters are intended for internal use when AutoCompleter creates other AutoCompleters - # for subcommands. Developers don't need to worry about overriding these values. + :param tab_for_arg_help: If True, then argument help will display when there's no completion result :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 """ - 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 @@ -221,27 +207,13 @@ def __init__(self, parser: argparse.ArgumentParser, cmd2_app, *, sub_completers = {} sub_commands = [] - if action.dest in subcmd_args_lookup: - args_for_action = subcmd_args_lookup[action.dest] - else: - args_for_action = {} - # Create an AutoCompleter for each subcommand of this command for subcmd in action.choices: - if subcmd in args_for_action: - (subcmd_args, subcmd_lookup) = args_for_action[subcmd] - elif forward_arg_choices: - subcmd_args, subcmd_lookup = arg_choices, subcmd_args_lookup - else: - subcmd_args, subcmd_lookup = {}, {} - 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) sub_commands.append(subcmd) @@ -535,7 +507,15 @@ def _format_completions(self, action, completions: List[Union[str, CompletionIte 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.""" + """ + Supports 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 idx, token in enumerate(tokens): if idx >= self._token_start_index: if self._positional_completers: @@ -549,7 +529,11 @@ def complete_command_help(self, tokens: List[str], text: str, line: str, begidx: return [] def format_help(self, tokens: List[str]) -> str: - """Supports the completion of sub-commands for commands through the cmd2 help command.""" + """ + Retrieve help text of a subcommand + :param tokens: command line tokens + :return: help text of the subcommand being queried + """ for idx, token in enumerate(tokens): if idx >= self._token_start_index: if self._positional_completers: From 891975e24cc0860fbaeefcd01c7670aede479be5 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 5 Jul 2019 13:38:37 -0400 Subject: [PATCH 22/88] Fixed some warnings --- cmd2/argparse_completer.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 45c8d9d6b..d41557627 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -106,6 +106,7 @@ def __init__(self, o, desc='', *args, **kwargs) -> None: self.description = desc +# noinspection PyProtectedMember def is_potential_flag(token: str, parser: argparse.ArgumentParser) -> bool: """Determine if a token looks like a potential flag. Based on argparse._parse_optional().""" # if it's an empty string, it was meant to be a positional @@ -134,6 +135,7 @@ def is_potential_flag(token: str, parser: argparse.ArgumentParser) -> bool: return True +# noinspection PyProtectedMember class AutoCompleter(object): """Automatic command line tab completion based on argparse parameters""" @@ -279,36 +281,36 @@ def consume_positional_argument() -> None: 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: + def process_action_nargs(arg_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""" - nargs_range = getattr(action, ATTR_NARGS_RANGE, None) + nargs_range = getattr(arg_action, ATTR_NARGS_RANGE, None) if nargs_range is not None: arg_state.min = nargs_range[0] arg_state.max = nargs_range[1] arg_state.variable = True if arg_state.min is None or arg_state.max is None: - if action.nargs is None: + if arg_action.nargs is None: arg_state.min = 1 arg_state.max = 1 - elif action.nargs == argparse.ONE_OR_MORE: + elif arg_action.nargs == argparse.ONE_OR_MORE: arg_state.min = 1 arg_state.max = float('inf') arg_state.variable = True - elif action.nargs == argparse.ZERO_OR_MORE or action.nargs == argparse.REMAINDER: + elif arg_action.nargs == argparse.ZERO_OR_MORE or arg_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 + if arg_action.nargs == argparse.REMAINDER: + remainder['action'] = arg_action remainder['arg'] = arg_state - elif action.nargs == argparse.OPTIONAL: + elif arg_action.nargs == argparse.OPTIONAL: arg_state.min = 0 arg_state.max = 1 arg_state.variable = True else: - arg_state.min = action.nargs - arg_state.max = action.nargs + arg_state.min = arg_action.nargs + arg_state.max = arg_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 position should be From 7024d7e4b558f61433562703808e0a780ca1f0d3 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 5 Jul 2019 14:04:08 -0400 Subject: [PATCH 23/88] Added documentation --- cmd2/argparse_completer.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index d41557627..4300365c0 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -97,12 +97,26 @@ class CompletionItem(str): Instead of this: 1 2 3 + + Example: + token = 1 + token_description = "My Item" + completion_item = CompletionItem(token, token_description) """ - def __new__(cls, o, desc='', *args, **kwargs) -> str: - return str.__new__(cls, o, *args, **kwargs) + 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 - # noinspection PyMissingConstructor,PyUnusedLocal - def __init__(self, o, desc='', *args, **kwargs) -> None: + :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 From 627d4bda1ac790e34a7b87358defe4b436737292 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 5 Jul 2019 15:12:56 -0400 Subject: [PATCH 24/88] Fixed unit tests --- examples/tab_autocomp_dynamic.py | 234 ---------------------------- examples/tab_autocompletion.py | 259 ------------------------------- tests/test_autocompletion.py | 101 +----------- 3 files changed, 1 insertion(+), 593 deletions(-) delete mode 100755 examples/tab_autocomp_dynamic.py diff --git a/examples/tab_autocomp_dynamic.py b/examples/tab_autocomp_dynamic.py deleted file mode 100755 index b518c0132..000000000 --- a/examples/tab_autocomp_dynamic.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env python3 -# coding=utf-8 -""" -A example usage of AutoCompleter with delayed initialization of the argparse object -""" -from typing import List - -import cmd2 -from cmd2 import argparse_completer, utils - -actors = ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', 'Alec Guinness', 'Peter Mayhew', - 'Anthony Daniels', 'Adam Driver', 'Daisy Ridley', 'John Boyega', 'Oscar Isaac', - 'Lupita Nyong\'o', 'Andy Serkis', 'Liam Neeson', 'Ewan McGregor', 'Natalie Portman', - 'Jake Lloyd', 'Hayden Christensen', 'Christopher Lee'] - - -def query_actors() -> List[str]: - """Simulating a function that queries and returns a completion values""" - return actors - - -class TabCompleteExample(cmd2.Cmd): - """ Example cmd2 application where we a base command which has a couple sub-commands.""" - - CAT_AUTOCOMPLETE = 'AutoComplete Examples' - - def __init__(self): - super().__init__() - - video_types_subparsers = TabCompleteExample.video_parser.add_subparsers(title='Media Types', dest='type') - - vid_movies_parser = argparse_completer.ACArgumentParser(prog='movies') - vid_movies_parser.set_defaults(func=TabCompleteExample._do_vid_media_movies) - - vid_movies_commands_subparsers = vid_movies_parser.add_subparsers(title='Commands', dest='command') - - vid_movies_list_parser = vid_movies_commands_subparsers.add_parser('list') - - vid_movies_list_parser.add_argument('-t', '--title', help='Title Filter') - vid_movies_list_parser.add_argument('-r', '--rating', help='Rating Filter', nargs='+', - choices=TabCompleteExample.ratings_types) - # save a reference to the action object - director_action = vid_movies_list_parser.add_argument('-d', '--director', help='Director Filter') - actor_action = vid_movies_list_parser.add_argument('-a', '--actor', help='Actor Filter', action='append') - - # tag the action objects with completion providers. This can be a collection or a callable - setattr(director_action, argparse_completer.ACTION_ARG_CHOICES, TabCompleteExample.static_list_directors) - setattr(actor_action, argparse_completer.ACTION_ARG_CHOICES, query_actors) - - vid_movies_add_parser = vid_movies_commands_subparsers.add_parser('add') - vid_movies_add_parser.add_argument('title', help='Movie Title') - vid_movies_add_parser.add_argument('rating', help='Movie Rating', choices=TabCompleteExample.ratings_types) - - # save a reference to the action object - director_action = vid_movies_add_parser.add_argument('-d', '--director', help='Director', nargs=(1, 2), - required=True) - actor_action = vid_movies_add_parser.add_argument('actor', help='Actors', nargs='*') - - vid_movies_load_parser = vid_movies_commands_subparsers.add_parser('load') - vid_movie_file_action = vid_movies_load_parser.add_argument('movie_file', help='Movie database') - - vid_movies_read_parser = vid_movies_commands_subparsers.add_parser('read') - vid_movie_fread_action = vid_movies_read_parser.add_argument('movie_file', help='Movie database') - - # tag the action objects with completion providers. This can be a collection or a callable - setattr(director_action, argparse_completer.ACTION_ARG_CHOICES, TabCompleteExample.static_list_directors) - setattr(actor_action, argparse_completer.ACTION_ARG_CHOICES, 'instance_query_actors') - - # tag the file property with a custom completion function 'delimiter_complete' provided by cmd2. - setattr(vid_movie_file_action, argparse_completer.ACTION_ARG_CHOICES, - ('delimiter_complete', - {'delimiter': '/', - 'match_against': TabCompleteExample.file_list})) - setattr(vid_movie_fread_action, argparse_completer.ACTION_ARG_CHOICES, - ('path_complete',)) - - vid_movies_delete_parser = vid_movies_commands_subparsers.add_parser('delete') - vid_delete_movie_id = vid_movies_delete_parser.add_argument('movie_id', help='Movie ID') - setattr(vid_delete_movie_id, argparse_completer.ACTION_ARG_CHOICES, TabCompleteExample.instance_query_movie_ids) - setattr(vid_delete_movie_id, argparse_completer.ACTION_DESCRIPTIVE_COMPLETION_HEADER, 'Title') - - # Add the 'movies' parser as a parent of sub-parser - video_types_subparsers.add_parser('movies', parents=[vid_movies_parser], add_help=False) - - vid_shows_parser = argparse_completer.ACArgumentParser(prog='shows') - vid_shows_parser.set_defaults(func=TabCompleteExample._do_vid_media_shows) - - vid_shows_commands_subparsers = vid_shows_parser.add_subparsers(title='Commands', dest='command') - - vid_shows_commands_subparsers.add_parser('list') - - video_types_subparsers.add_parser('shows', parents=[vid_shows_parser], add_help=False) - - # For mocking a data source for the example commands - ratings_types = ['G', 'PG', 'PG-13', 'R', 'NC-17'] - show_ratings = ['TV-Y', 'TV-Y7', 'TV-G', 'TV-PG', 'TV-14', 'TV-MA'] - static_list_directors = ['J. J. Abrams', 'Irvin Kershner', 'George Lucas', 'Richard Marquand', - 'Rian Johnson', 'Gareth Edwards'] - USER_MOVIE_LIBRARY = ['ROGUE1', 'SW_EP04', 'SW_EP05'] - MOVIE_DATABASE_IDS = ['SW_EP1', 'SW_EP02', 'SW_EP03', 'ROGUE1', 'SW_EP04', - 'SW_EP05', 'SW_EP06', 'SW_EP07', 'SW_EP08', 'SW_EP09'] - MOVIE_DATABASE = {'SW_EP04': {'title': 'Star Wars: Episode IV - A New Hope', - 'rating': 'PG', - 'director': ['George Lucas'], - 'actor': ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', - 'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels'] - }, - 'SW_EP05': {'title': 'Star Wars: Episode V - The Empire Strikes Back', - 'rating': 'PG', - 'director': ['Irvin Kershner'], - 'actor': ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', - 'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels'] - }, - 'SW_EP06': {'title': 'Star Wars: Episode VI - Return of the Jedi', - 'rating': 'PG', - 'director': ['Richard Marquand'], - 'actor': ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', - 'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels'] - }, - 'SW_EP1': {'title': 'Star Wars: Episode I - The Phantom Menace', - 'rating': 'PG', - 'director': ['George Lucas'], - 'actor': ['Liam Neeson', 'Ewan McGregor', 'Natalie Portman', 'Jake Lloyd'] - }, - 'SW_EP02': {'title': 'Star Wars: Episode II - Attack of the Clones', - 'rating': 'PG', - 'director': ['George Lucas'], - 'actor': ['Liam Neeson', 'Ewan McGregor', 'Natalie Portman', - 'Hayden Christensen', 'Christopher Lee'] - }, - 'SW_EP03': {'title': 'Star Wars: Episode III - Revenge of the Sith', - 'rating': 'PG-13', - 'director': ['George Lucas'], - 'actor': ['Liam Neeson', 'Ewan McGregor', 'Natalie Portman', - 'Hayden Christensen'] - }, - - } - USER_SHOW_LIBRARY = {'SW_REB': ['S01E01', 'S02E02']} - SHOW_DATABASE_IDS = ['SW_CW', 'SW_TCW', 'SW_REB'] - SHOW_DATABASE = {'SW_CW': {'title': 'Star Wars: Clone Wars', - 'rating': 'TV-Y7', - 'seasons': {1: ['S01E01', 'S01E02', 'S01E03'], - 2: ['S02E01', 'S02E02', 'S02E03']} - }, - 'SW_TCW': {'title': 'Star Wars: The Clone Wars', - 'rating': 'TV-PG', - 'seasons': {1: ['S01E01', 'S01E02', 'S01E03'], - 2: ['S02E01', 'S02E02', 'S02E03']} - }, - 'SW_REB': {'title': 'Star Wars: Rebels', - 'rating': 'TV-Y7', - 'seasons': {1: ['S01E01', 'S01E02', 'S01E03'], - 2: ['S02E01', 'S02E02', 'S02E03']} - }, - } - - file_list = \ - [ - '/home/user/file.db', - '/home/user/file space.db', - '/home/user/another.db', - '/home/other user/maps.db', - '/home/other user/tests.db' - ] - - def instance_query_actors(self) -> List[str]: - """Simulating a function that queries and returns a completion values""" - return actors - - def instance_query_movie_ids(self) -> List[str]: - """Demonstrates showing tabular hinting of tab completion information""" - completions_with_desc = [] - - # Sort the movie id strings with a natural sort since they contain numbers - for movie_id in utils.natural_sort(self.MOVIE_DATABASE_IDS): - if movie_id in self.MOVIE_DATABASE: - movie_entry = self.MOVIE_DATABASE[movie_id] - completions_with_desc.append(argparse_completer.CompletionItem(movie_id, movie_entry['title'])) - - # Mark that we already sorted the matches - self.matches_sorted = True - return completions_with_desc - - ################################################################################### - # The media command demonstrates a completer with multiple layers of subcommands - # - This example demonstrates how to tag a completion attribute on each action, enabling argument - # completion without implementing a complete_COMMAND function - def _do_vid_media_movies(self, args) -> None: - if not args.command: - self.do_help('video movies') - elif args.command == 'list': - for movie_id in TabCompleteExample.MOVIE_DATABASE: - movie = TabCompleteExample.MOVIE_DATABASE[movie_id] - print('{}\n-----------------------------\n{} ID: {}\nDirector: {}\nCast:\n {}\n\n' - .format(movie['title'], movie['rating'], movie_id, - ', '.join(movie['director']), - '\n '.join(movie['actor']))) - - def _do_vid_media_shows(self, args) -> None: - if not args.command: - self.do_help('video shows') - - elif args.command == 'list': - for show_id in TabCompleteExample.SHOW_DATABASE: - show = TabCompleteExample.SHOW_DATABASE[show_id] - print('{}\n-----------------------------\n{} ID: {}' - .format(show['title'], show['rating'], show_id)) - for season in show['seasons']: - ep_list = show['seasons'][season] - print(' Season {}:\n {}' - .format(season, - '\n '.join(ep_list))) - print() - - video_parser = argparse_completer.ACArgumentParser(prog='video') - - @cmd2.with_category(CAT_AUTOCOMPLETE) - @cmd2.with_argparser(video_parser) - def do_video(self, args): - """Video management command demonstrates multiple layers of sub-commands being handled by AutoCompleter""" - func = getattr(args, 'func', None) - if func is not None: - # Call whatever subcommand function was selected - func(self, args) - else: - # No subcommand was provided, so call help - self.do_help('video') - - -if __name__ == '__main__': - import sys - app = TabCompleteExample() - sys.exit(app.cmdloop()) diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index 64bcbb652..4b13b5c38 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -5,7 +5,6 @@ """ import argparse import functools -import itertools from typing import List import cmd2 @@ -179,11 +178,6 @@ def do_orig_suggest(self, args) -> None: if not args.type: self.do_help('orig_suggest') - ################################################################################### - # The media command demonstrates a completer with multiple layers of subcommands - # - This example demonstrates how to tag a completion attribute on each action, enabling argument - # completion without implementing a complete_COMMAND function - def _do_vid_media_movies(self, args) -> None: if not args.command: self.do_help('media movies') @@ -268,259 +262,6 @@ def do_video(self, args): # No subcommand was provided, so call help self.do_help('video') - ################################################################################### - # The media command demonstrates a completer with multiple layers of subcommands - # - This example uses a flat completion lookup dictionary - - def _do_media_movies(self, args) -> None: - if not args.command: - self.do_help('media movies') - elif args.command == 'list': - for movie_id in TabCompleteExample.MOVIE_DATABASE: - movie = TabCompleteExample.MOVIE_DATABASE[movie_id] - print('{}\n-----------------------------\n{} ID: {}\nDirector: {}\nCast:\n {}\n\n' - .format(movie['title'], movie['rating'], movie_id, - ', '.join(movie['director']), - '\n '.join(movie['actor']))) - elif args.command == 'add': - print('Adding Movie\n----------------\nTitle: {}\nRating: {}\nDirectors: {}\nActors: {}\n\n' - .format(args.title, args.rating, ', '.join(args.director), ', '.join(args.actor))) - - def _do_media_shows(self, args) -> None: - if not args.command: - self.do_help('media shows') - - elif args.command == 'list': - for show_id in TabCompleteExample.SHOW_DATABASE: - show = TabCompleteExample.SHOW_DATABASE[show_id] - print('{}\n-----------------------------\n{} ID: {}' - .format(show['title'], show['rating'], show_id)) - for season in show['seasons']: - ep_list = show['seasons'][season] - print(' Season {}:\n {}' - .format(season, - '\n '.join(ep_list))) - print() - - media_parser = Cmd2ArgParser(prog='media') - - media_types_subparsers = media_parser.add_subparsers(title='Media Types', dest='type') - - movies_parser = media_types_subparsers.add_parser('movies') - movies_parser.set_defaults(func=_do_media_movies) - - movies_commands_subparsers = movies_parser.add_subparsers(title='Commands', dest='command') - - movies_list_parser = movies_commands_subparsers.add_parser('list') - - movies_list_parser.add_argument('-t', '--title', help='Title Filter') - movies_list_parser.add_argument('-r', '--rating', help='Rating Filter', nargs='+', - choices=ratings_types) - movies_list_parser.add_argument('-d', '--director', help='Director Filter') - movies_list_parser.add_argument('-a', '--actor', help='Actor Filter', action='append') - - movies_add_parser = movies_commands_subparsers.add_parser('add') - movies_add_parser.add_argument('title', help='Movie Title') - movies_add_parser.add_argument('rating', help='Movie Rating', choices=ratings_types) - movies_add_parser.add_argument('-d', '--director', help='Director', nargs=(1, 2), required=True) - movies_add_parser.add_argument('actor', help='Actors', nargs=argparse.REMAINDER) - - movies_delete_parser = movies_commands_subparsers.add_parser('delete') - movies_delete_parser.add_argument('movie_id', help='Movie ID', choices_method=instance_query_movie_ids, - descriptive_header='Title') - - movies_load_parser = movies_commands_subparsers.add_parser('load') - movie_file_action = movies_load_parser.add_argument('movie_file', help='Movie database') - - shows_parser = media_types_subparsers.add_parser('shows') - shows_parser.set_defaults(func=_do_media_shows) - - shows_commands_subparsers = shows_parser.add_subparsers(title='Commands', dest='command') - - shows_list_parser = shows_commands_subparsers.add_parser('list') - - @cmd2.with_category(CAT_AUTOCOMPLETE) - @cmd2.with_argparser(media_parser) - def do_media(self, args): - """Media management command demonstrates multiple layers of sub-commands being handled by AutoCompleter""" - func = getattr(args, 'func', None) - if func is not None: - # Call whatever subcommand function was selected - func(self, args) - else: - # No subcommand was provided, so call help - self.do_help('media') - - # This completer is implemented using a single dictionary to look up completion lists for all layers of - # subcommands. For each argument, AutoCompleter will search for completion values from the provided - # arg_choices dict. This requires careful naming of argparse arguments so that there are no unintentional - # name collisions. - def complete_media(self, text, line, begidx, endidx): - """ Adds tab completion to media""" - choices = {'actor': query_actors, # function - 'director': TabCompleteExample.static_list_directors, # static list - 'movie_file': (self.path_complete,) - } - completer = argparse_completer.AutoCompleter(TabCompleteExample.media_parser, - self, - arg_choices=choices) - - tokens, _ = self.tokens_for_completion(line, begidx, endidx) - results = completer.complete_command(tokens, text, line, begidx, endidx) - - return results - - ################################################################################### - # The library command demonstrates a completer with multiple layers of subcommands - # with different completion results per sub-command - # - This demonstrates how to build a tree of completion lookups to pass down - # - # Only use this method if you absolutely need to as it dramatically - # increases the complexity and decreases readability. - - def _do_library_movie(self, args): - if not args.type or not args.command: - self.do_help('library movie') - - def _do_library_show(self, args): - if not args.type: - self.do_help('library show') - - # noinspection PyMethodMayBeStatic - def _query_movie_database(self): - return list(set(TabCompleteExample.MOVIE_DATABASE_IDS).difference(set(TabCompleteExample.USER_MOVIE_LIBRARY))) - - # noinspection PyMethodMayBeStatic - def _query_movie_user_library(self): - return TabCompleteExample.USER_MOVIE_LIBRARY - - # noinspection PyMethodMayBeStatic, PyUnusedLocal - def _filter_library(self, text, line, begidx, endidx, full, exclude=()): - candidates = list(set(full).difference(set(exclude))) - return [entry for entry in candidates if entry.startswith(text)] - - library_parser = Cmd2ArgParser(prog='library') - - library_subcommands = library_parser.add_subparsers(title='Media Types', dest='type') - - library_movie_parser = library_subcommands.add_parser('movie') - library_movie_parser.set_defaults(func=_do_library_movie) - - library_movie_subcommands = library_movie_parser.add_subparsers(title='Command', dest='command') - - library_movie_add_parser = library_movie_subcommands.add_parser('add') - library_movie_add_parser.add_argument('movie_id', help='ID of movie to add', action='append') - library_movie_add_parser.add_argument('-b', '--borrowed', action='store_true') - - library_movie_remove_parser = library_movie_subcommands.add_parser('remove') - library_movie_remove_parser.add_argument('movie_id', help='ID of movie to remove', action='append') - - library_show_parser = library_subcommands.add_parser('show') - library_show_parser.set_defaults(func=_do_library_show) - - library_show_subcommands = library_show_parser.add_subparsers(title='Command', dest='command') - - library_show_add_parser = library_show_subcommands.add_parser('add') - library_show_add_parser.add_argument('show_id', help='Show IDs to add') - library_show_add_parser.add_argument('episode_id', nargs='*', help='Show IDs to add') - - library_show_rmv_parser = library_show_subcommands.add_parser('remove') - - # Demonstrates a custom completion function that does more with the command line than is - # allowed by the standard completion functions - def _filter_episodes(self, text, line, begidx, endidx, show_db, user_lib): - tokens, _ = self.tokens_for_completion(line, begidx, endidx) - show_id = tokens[3] - if show_id: - if show_id in show_db: - show = show_db[show_id] - all_episodes = itertools.chain(*(show['seasons'].values())) - - if show_id in user_lib: - user_eps = user_lib[show_id] - else: - user_eps = [] - - return self._filter_library(text, line, begidx, endidx, all_episodes, user_eps) - return [] - - @cmd2.with_category(CAT_AUTOCOMPLETE) - @cmd2.with_argparser(library_parser) - def do_library(self, args): - """Media management command demonstrates multiple layers of sub-commands being handled by AutoCompleter""" - func = getattr(args, 'func', None) - if func is not None: - # Call whatever subcommand function was selected - func(self, args) - else: - # No subcommand was provided, so call help - self.do_help('library') - - def complete_library(self, text, line, begidx, endidx): - - # this demonstrates the much more complicated scenario of having - # unique completion parameters per sub-command that use the same - # argument name. To do this we build a multi-layer nested tree - # of lookups far AutoCompleter to traverse. This nested tree must - # match the structure of the argparse parser - # - - movie_add_choices = {'movie_id': self._query_movie_database} - movie_remove_choices = {'movie_id': self._query_movie_user_library} - - # This demonstrates the ability to mix custom completion functions with argparse completion. - # By specifying a tuple for a completer, AutoCompleter expects a custom completion function - # with optional index-based as well as keyword based arguments. This is an alternative to using - # a partial function. - - show_add_choices = {'show_id': (self._filter_library, # This is a custom completion function - # This tuple represents index-based args to append to the function call - (list(TabCompleteExample.SHOW_DATABASE.keys()),) - ), - 'episode_id': (self._filter_episodes, # this is a custom completion function - # this list represents index-based args to append to the function call - [TabCompleteExample.SHOW_DATABASE], - # this dict contains keyword-based args to append to the function call - {'user_lib': TabCompleteExample.USER_SHOW_LIBRARY})} - show_remove_choices = {} - - # The library movie sub-parser group 'command' has 2 sub-parsers: - # 'add' and 'remove' - library_movie_command_params = \ - {'add': (movie_add_choices, None), - 'remove': (movie_remove_choices, None)} - - library_show_command_params = \ - {'add': (show_add_choices, None), - 'remove': (show_remove_choices, None)} - - # The 'library movie' command has a sub-parser group called 'command' - library_movie_subcommand_groups = {'command': library_movie_command_params} - library_show_subcommand_groups = {'command': library_show_command_params} - - # Mapping of a specific sub-parser of the 'type' group to a tuple. Each - # tuple has 2 values corresponding what's passed to the constructor - # parameters (arg_choices,subcmd_args_lookup) of the nested - # instance of AutoCompleter - library_type_params = {'movie': (None, library_movie_subcommand_groups), - 'show': (None, library_show_subcommand_groups)} - - # maps the a subcommand group to a dictionary mapping a specific - # sub-command to a tuple of (arg_choices, subcmd_args_lookup) - # - # In this example, 'library_parser' has a sub-parser group called 'type' - # under the type sub-parser group, there are 2 sub-parsers: 'movie', 'show' - library_subcommand_groups = {'type': library_type_params} - - completer = argparse_completer.AutoCompleter(TabCompleteExample.library_parser, - self, - subcmd_args_lookup=library_subcommand_groups) - - tokens, _ = self.tokens_for_completion(line, begidx, endidx) - results = completer.complete_command(tokens, text, line, begidx, endidx) - - return results - if __name__ == '__main__': import sys diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py index 4e1ceff05..220476571 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -32,22 +32,6 @@ def cmd2_app(): single value - maximum duration [a, b] - duration range''' -MEDIA_MOVIES_ADD_HELP = '''Usage: media movies add -d DIRECTOR{1..2} - [-h] - title {G, PG, PG-13, R, NC-17} ... - -positional arguments: - title Movie Title - {G, PG, PG-13, R, NC-17} - Movie Rating - actor Actors - -required arguments: - -d, --director DIRECTOR{1..2} - Director - -optional arguments: - -h, --help show this help message and exit''' def test_help_required_group(cmd2_app): out1, err1 = run_cmd(cmd2_app, 'suggest -h') @@ -60,15 +44,6 @@ def test_help_required_group(cmd2_app): assert out1 == normalize(SUGGEST_HELP) -def test_help_required_group_long(cmd2_app): - out1, err1 = run_cmd(cmd2_app, 'media movies add -h') - out2, err2 = run_cmd(cmd2_app, 'help media movies add') - - assert out1 == out2 - assert out1[0].startswith('Usage: media movies add') - assert out1 == normalize(MEDIA_MOVIES_ADD_HELP) - - def test_autocomp_flags(cmd2_app): text = '-' line = 'suggest {}'.format(text) @@ -79,6 +54,7 @@ def test_autocomp_flags(cmd2_app): assert first_match is not None and \ cmd2_app.completion_matches == ['--duration', '--help', '--type', '-d', '-h', '-t'] + def test_autcomp_hint(cmd2_app, capsys): text = '' line = 'suggest -d {}'.format(text) @@ -152,59 +128,6 @@ def test_autcomp_narg_beyond_max(cmd2_app): assert 'Error: unrecognized arguments: 5' in err[1] -def test_autocomp_subcmd_nested(cmd2_app): - text = '' - line = 'media movies {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None and \ - cmd2_app.completion_matches == ['add', 'delete', 'list', 'load'] - - -def test_autocomp_subcmd_flag_choices_append(cmd2_app): - text = '' - line = 'media movies list -r {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None and \ - cmd2_app.completion_matches == ['G', 'NC-17', 'PG', 'PG-13', 'R'] - -def test_autocomp_subcmd_flag_choices_append_exclude(cmd2_app): - text = '' - line = 'media movies list -r PG PG-13 {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None and \ - cmd2_app.completion_matches == ['G', 'NC-17', 'R'] - - -def test_autocomp_subcmd_flag_comp_func(cmd2_app): - text = 'A' - line = 'media movies list -a "{}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None and \ - cmd2_app.completion_matches == ['Adam Driver', 'Alec Guinness', 'Andy Serkis', 'Anthony Daniels'] - - -def test_autocomp_subcmd_flag_comp_list(cmd2_app): - text = 'G' - line = 'media movies list -d {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None and first_match == '"Gareth Edwards' - - def test_autocomp_subcmd_flag_comp_func_attr(cmd2_app): text = 'A' line = 'video movies list -a "{}'.format(text) @@ -247,28 +170,6 @@ def test_autocomp_pos_after_flag(cmd2_app): cmd2_app.completion_matches == ['John Boyega" '] -def test_autocomp_custom_func_list_arg(cmd2_app): - text = 'SW_' - line = 'library show add {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None and \ - cmd2_app.completion_matches == ['SW_CW', 'SW_REB', 'SW_TCW'] - - -def test_autocomp_custom_func_list_and_dict_arg(cmd2_app): - text = '' - line = 'library show add SW_REB {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None and \ - cmd2_app.completion_matches == ['S01E02', 'S01E03', 'S02E01', 'S02E03'] - - def test_autocomp_custom_func_dict_arg(cmd2_app): text = '/home/user/' line = 'video movies load {}'.format(text) From 1d560965bf5e03d82c4c353899ee9c7a6bf70a14 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 5 Jul 2019 16:50:30 -0400 Subject: [PATCH 25/88] Simplifying unit tests for AutoCompleter --- examples/tab_completion.py | 4 +- tests/test_autocompletion.py | 427 +++++++++++++++++++---------------- 2 files changed, 231 insertions(+), 200 deletions(-) diff --git a/examples/tab_completion.py b/examples/tab_completion.py index 48d7cb050..2830b0028 100755 --- a/examples/tab_completion.py +++ b/examples/tab_completion.py @@ -1,6 +1,8 @@ #!/usr/bin/env python # coding=utf-8 -"""A simple example demonstrating how to use flag and index based tab-completion functions +""" +A simple example demonstrating how to use flag and index based tab-completion functions +For argparse-based tab completion, see tab_autocompletion.py """ import argparse diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py index 220476571..30cb5dad3 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -3,244 +3,273 @@ """ Unit/functional testing for argparse completer in cmd2 """ +import argparse +from typing import List + import pytest +import cmd2 +from cmd2 import with_argparser +from cmd2.argparse_custom import Cmd2ArgParser from cmd2.utils import StdSim -from .conftest import run_cmd, normalize, complete_tester - -from examples.tab_autocompletion import TabCompleteExample - -@pytest.fixture -def cmd2_app(): - app = TabCompleteExample() - app.stdout = StdSim(app.stdout) - return app +from .conftest import run_cmd, complete_tester +# Lists used in our tests +static_choices_list = ['static', 'choices'] +choices_from_function = ['choices', 'function'] +choices_from_method = ['choices', 'method'] -SUGGEST_HELP = '''Usage: suggest -t {movie, show} [-h] [-d DURATION{1..2}] -Suggest command demonstrates argparse customizations. -See hybrid_suggest and orig_suggest to compare the help output. +def choices_function() -> List[str]: + """Function that provides choices""" + return choices_from_function -required arguments: - -t, --type {movie, show} -optional arguments: - -h, --help show this help message and exit - -d, --duration DURATION{1..2} - Duration constraint in minutes. - single value - maximum duration - [a, b] - duration range''' +class AutoCompleteTester(cmd2.Cmd): + """Cmd2 app that exercises AutoCompleter class""" + def __init__(self): + super().__init__() + def choices_method(self) -> List[str]: + """Method that provides choices""" + return choices_from_method -def test_help_required_group(cmd2_app): - out1, err1 = run_cmd(cmd2_app, 'suggest -h') - out2, err2 = run_cmd(cmd2_app, 'help suggest') + # Basic command with no subcommands that exercises tab completing choices from various sources + basic_parser = Cmd2ArgParser() + basic_parser.add_argument("-n", "--no_choices", help="a flag with no choices") + basic_parser.add_argument("-l", "--choices_list", help="a flag populated with a choices list", + choices=static_choices_list) + basic_parser.add_argument("-f", "--choices_function", help="a flag populated with a choices function", + choices_function=choices_function) + basic_parser.add_argument("-m", "--choices_method", help="a flag populated with a choices method", + choices_method=choices_method) - assert out1 == out2 - assert out1[0].startswith('Usage: suggest') - assert out1[1] == '' - assert out1[2].startswith('Suggest command demonstrates argparse customizations.') - assert out1 == normalize(SUGGEST_HELP) - - -def test_autocomp_flags(cmd2_app): - text = '-' - line = 'suggest {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None and \ - cmd2_app.completion_matches == ['--duration', '--help', '--type', '-d', '-h', '-t'] - - -def test_autcomp_hint(cmd2_app, capsys): - text = '' - line = 'suggest -d {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - out, err = capsys.readouterr() + basic_parser.add_argument("no_choice_pos", help="a positional with no choices") + basic_parser.add_argument("choices_list_pos", help="a positional populated with a choices list", + choices=static_choices_list) + basic_parser.add_argument("choices_function_pos", help="a positional populated with a choices function", + choices_function=choices_function) + basic_parser.add_argument("choices_method_pos", help="a positional populated with a choices method", + choices_method=choices_method) - assert out == ''' -Hint: - -d, --duration DURATION Duration constraint in minutes. - single value - maximum duration - [a, b] - duration range + @with_argparser(basic_parser) + def do_basic(self, args: argparse.Namespace) -> None: + pass -''' -def test_autcomp_flag_comp(cmd2_app, capsys): - text = '--d' - line = 'suggest {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) +@pytest.fixture +def ac_app(): + app = AutoCompleteTester() + app.stdout = StdSim(app.stdout) + return app - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - out, err = capsys.readouterr() - assert first_match is not None and \ - cmd2_app.completion_matches == ['--duration '] +def test_help_basic(ac_app): + out1, err1 = run_cmd(ac_app, 'basic -h') + out2, err2 = run_cmd(ac_app, 'help basic') + assert out1 == out2 -def test_autocomp_flags_choices(cmd2_app): - text = '' - line = 'suggest -t {}'.format(text) +def test_autocomp_flags(ac_app): + text = '-' + line = 'basic {}'.format(text) endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + first_match = complete_tester(text, line, begidx, endidx, ac_app) assert first_match is not None and \ - cmd2_app.completion_matches == ['movie', 'show'] + ac_app.completion_matches == ['--choices_function', '--choices_list', '--choices_method', '--help', + '--no_choices', '-f', '-h', '-l', '-m', '-n'] -def test_autcomp_hint_in_narg_range(cmd2_app, capsys): +def test_autcomp_hint(ac_app, capsys): text = '' - line = 'suggest -d 2 {}'.format(text) + line = 'basic -n {}'.format(text) endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + first_match = complete_tester(text, line, begidx, endidx, ac_app) out, err = capsys.readouterr() - assert out == ''' -Hint: - -d, --duration DURATION Duration constraint in minutes. - single value - maximum duration - [a, b] - duration range - -''' - -def test_autocomp_flags_narg_max(cmd2_app): - text = '' - line = 'suggest d 2 3 {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) assert first_match is None + assert 'a flag with no choices' in out -def test_autcomp_narg_beyond_max(cmd2_app): - out, err = run_cmd(cmd2_app, 'suggest -t movie -d 3 4 5') - assert 'Error: unrecognized arguments: 5' in err[1] - - -def test_autocomp_subcmd_flag_comp_func_attr(cmd2_app): - text = 'A' - line = 'video movies list -a "{}'.format(text) +def test_autcomp_flag_comp(ac_app): + text = '--ch' + line = 'basic {}'.format(text) endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + first_match = complete_tester(text, line, begidx, endidx, ac_app) assert first_match is not None and \ - cmd2_app.completion_matches == ['Adam Driver', 'Alec Guinness', 'Andy Serkis', 'Anthony Daniels'] - - -def test_autocomp_subcmd_flag_comp_list_attr(cmd2_app): - text = 'G' - line = 'video movies list -d {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None and first_match == '"Gareth Edwards' - - -def test_autocomp_pos_consumed(cmd2_app): + ac_app.completion_matches == ['--choices_function', '--choices_list', '--choices_method'] + + +@pytest.mark.parametrize('flag, completions', [ + ('-l', static_choices_list), + ('--choices_list', static_choices_list), + ('-f', choices_from_function), + ('--choices_function', choices_from_function), + ('-m', choices_from_method), + ('--choices_method', choices_from_method), +]) +def test_autocomp_flags_choices(ac_app, flag, completions): text = '' - line = 'library movie add SW_EP01 {}'.format(text) + line = 'basic {} {}'.format(flag, text) endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is None - - -def test_autocomp_pos_after_flag(cmd2_app): - text = 'Joh' - line = 'video movies add -d "George Lucas" -- "Han Solo" PG "Emilia Clarke" "{}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + first_match = complete_tester(text, line, begidx, endidx, ac_app) assert first_match is not None and \ - cmd2_app.completion_matches == ['John Boyega" '] - - -def test_autocomp_custom_func_dict_arg(cmd2_app): - text = '/home/user/' - line = 'video movies load {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None and \ - cmd2_app.completion_matches == ['/home/user/another.db', '/home/user/file space.db', '/home/user/file.db'] - - -def test_argparse_remainder_flag_completion(cmd2_app): - import cmd2 - import argparse - - # Test flag completion as first arg of positional with nargs=argparse.REMAINDER - text = '--h' - line = 'help command {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - # --h should not complete into --help because we are in the argparse.REMAINDER section - assert complete_tester(text, line, begidx, endidx, cmd2_app) is None - - # Test flag completion within an already started positional with nargs=argparse.REMAINDER - text = '--h' - line = 'help command subcommand {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - # --h should not complete into --help because we are in the argparse.REMAINDER section - assert complete_tester(text, line, begidx, endidx, cmd2_app) is None - - # Test a flag with nargs=argparse.REMAINDER - parser = argparse.ArgumentParser() - parser.add_argument('-f', nargs=argparse.REMAINDER) - - # Overwrite eof's parser for this test - cmd2.Cmd.do_eof.argparser = parser - - text = '--h' - line = 'eof -f {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - # --h should not complete into --help because we are in the argparse.REMAINDER section - assert complete_tester(text, line, begidx, endidx, cmd2_app) is None - - -def test_completion_after_double_dash(cmd2_app): - """ - Test completion after --, which argparse says (all args after -- are non-options) - All of these tests occur outside of an argparse.REMAINDER section since those tests - are handled in test_argparse_remainder_flag_completion - """ - - # Test -- as the last token - text = '--' - line = 'help {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - # Since -- is the last token, then it should show flag choices - first_match = complete_tester(text, line, begidx, endidx, cmd2_app) - assert first_match is not None and '--help' in cmd2_app.completion_matches - - # Test -- to end all flag completion - text = '--' - line = 'help -- {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - # Since -- appeared before the -- being completed, nothing should be completed - assert complete_tester(text, line, begidx, endidx, cmd2_app) is None + ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) + + +# def test_autcomp_hint_in_narg_range(cmd2_app, capsys): +# text = '' +# line = 'suggest -d 2 {}'.format(text) +# endidx = len(line) +# begidx = endidx - len(text) +# +# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) +# out, err = capsys.readouterr() +# +# assert out == ''' +# Hint: +# -d, --duration DURATION Duration constraint in minutes. +# single value - maximum duration +# [a, b] - duration range +# +# ''' +# +# def test_autocomp_flags_narg_max(cmd2_app): +# text = '' +# line = 'suggest d 2 3 {}'.format(text) +# endidx = len(line) +# begidx = endidx - len(text) +# +# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) +# assert first_match is None +# +# +# def test_autcomp_narg_beyond_max(cmd2_app): +# out, err = run_cmd(cmd2_app, 'suggest -t movie -d 3 4 5') +# assert 'Error: unrecognized arguments: 5' in err[1] +# +# +# def test_autocomp_subcmd_flag_comp_func_attr(cmd2_app): +# text = 'A' +# line = 'video movies list -a "{}'.format(text) +# endidx = len(line) +# begidx = endidx - len(text) +# +# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) +# assert first_match is not None and \ +# cmd2_app.completion_matches == ['Adam Driver', 'Alec Guinness', 'Andy Serkis', 'Anthony Daniels'] +# +# +# def test_autocomp_subcmd_flag_comp_list_attr(cmd2_app): +# text = 'G' +# line = 'video movies list -d {}'.format(text) +# endidx = len(line) +# begidx = endidx - len(text) +# +# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) +# assert first_match is not None and first_match == '"Gareth Edwards' +# +# +# def test_autocomp_pos_consumed(cmd2_app): +# text = '' +# line = 'library movie add SW_EP01 {}'.format(text) +# endidx = len(line) +# begidx = endidx - len(text) +# +# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) +# assert first_match is None +# +# +# def test_autocomp_pos_after_flag(cmd2_app): +# text = 'Joh' +# line = 'video movies add -d "George Lucas" -- "Han Solo" PG "Emilia Clarke" "{}'.format(text) +# endidx = len(line) +# begidx = endidx - len(text) +# +# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) +# assert first_match is not None and \ +# cmd2_app.completion_matches == ['John Boyega" '] +# +# +# def test_autocomp_custom_func_dict_arg(cmd2_app): +# text = '/home/user/' +# line = 'video movies load {}'.format(text) +# endidx = len(line) +# begidx = endidx - len(text) +# +# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) +# assert first_match is not None and \ +# cmd2_app.completion_matches == ['/home/user/another.db', '/home/user/file space.db', '/home/user/file.db'] +# +# +# def test_argparse_remainder_flag_completion(cmd2_app): +# import cmd2 +# import argparse +# +# # Test flag completion as first arg of positional with nargs=argparse.REMAINDER +# text = '--h' +# line = 'help command {}'.format(text) +# endidx = len(line) +# begidx = endidx - len(text) +# +# # --h should not complete into --help because we are in the argparse.REMAINDER section +# assert complete_tester(text, line, begidx, endidx, cmd2_app) is None +# +# # Test flag completion within an already started positional with nargs=argparse.REMAINDER +# text = '--h' +# line = 'help command subcommand {}'.format(text) +# endidx = len(line) +# begidx = endidx - len(text) +# +# # --h should not complete into --help because we are in the argparse.REMAINDER section +# assert complete_tester(text, line, begidx, endidx, cmd2_app) is None +# +# # Test a flag with nargs=argparse.REMAINDER +# parser = argparse.ArgumentParser() +# parser.add_argument('-f', nargs=argparse.REMAINDER) +# +# # Overwrite eof's parser for this test +# cmd2.Cmd.do_eof.argparser = parser +# +# text = '--h' +# line = 'eof -f {}'.format(text) +# endidx = len(line) +# begidx = endidx - len(text) +# +# # --h should not complete into --help because we are in the argparse.REMAINDER section +# assert complete_tester(text, line, begidx, endidx, cmd2_app) is None +# +# +# def test_completion_after_double_dash(cmd2_app): +# """ +# Test completion after --, which argparse says (all args after -- are non-options) +# All of these tests occur outside of an argparse.REMAINDER section since those tests +# are handled in test_argparse_remainder_flag_completion +# """ +# +# # Test -- as the last token +# text = '--' +# line = 'help {}'.format(text) +# endidx = len(line) +# begidx = endidx - len(text) +# +# # Since -- is the last token, then it should show flag choices +# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) +# assert first_match is not None and '--help' in cmd2_app.completion_matches +# +# # Test -- to end all flag completion +# text = '--' +# line = 'help -- {}'.format(text) +# endidx = len(line) +# begidx = endidx - len(text) +# +# # Since -- appeared before the -- being completed, nothing should be completed +# assert complete_tester(text, line, begidx, endidx, cmd2_app) is None From 655243cb6f586e33c68928f838fcd7d921da1101 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 5 Jul 2019 16:58:35 -0400 Subject: [PATCH 26/88] Reorganized argparse completion and custom unit tests --- ...ocompletion.py => test_argparse_completer.py} | 15 +++++++++++++++ ...est_acargparse.py => test_argparse_custom.py} | 16 ---------------- 2 files changed, 15 insertions(+), 16 deletions(-) rename tests/{test_autocompletion.py => test_argparse_completer.py} (95%) rename tests/{test_acargparse.py => test_argparse_custom.py} (78%) diff --git a/tests/test_autocompletion.py b/tests/test_argparse_completer.py similarity index 95% rename from tests/test_autocompletion.py rename to tests/test_argparse_completer.py index 30cb5dad3..4f1ed44a7 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_argparse_completer.py @@ -10,6 +10,7 @@ import cmd2 from cmd2 import with_argparser +from cmd2.argparse_completer import is_potential_flag from cmd2.argparse_custom import Cmd2ArgParser from cmd2.utils import StdSim from .conftest import run_cmd, complete_tester @@ -273,3 +274,17 @@ def test_autocomp_flags_choices(ac_app, flag, completions): # # # Since -- appeared before the -- being completed, nothing should be completed # assert complete_tester(text, line, begidx, endidx, cmd2_app) is None + +def test_is_potential_flag(): + parser = Cmd2ArgParser() + + # Not valid flags + assert not is_potential_flag('', parser) + assert not is_potential_flag('non-flag', parser) + assert not is_potential_flag('-', parser) + assert not is_potential_flag('--has space', parser) + assert not is_potential_flag('-2', parser) + + # Valid flags + assert is_potential_flag('-flag', parser) + assert is_potential_flag('--flag', parser) \ No newline at end of file diff --git a/tests/test_acargparse.py b/tests/test_argparse_custom.py similarity index 78% rename from tests/test_acargparse.py rename to tests/test_argparse_custom.py index c8f09f76c..85587d498 100644 --- a/tests/test_acargparse.py +++ b/tests/test_argparse_custom.py @@ -4,7 +4,6 @@ """ import pytest from cmd2.argparse_custom import Cmd2ArgParser -from cmd2.argparse_completer import is_potential_flag def test_acarg_narg_empty_tuple(): @@ -50,18 +49,3 @@ def test_acarg_narg_tuple_zero_base(): def test_acarg_narg_tuple_zero_to_one(): parser = Cmd2ArgParser(prog='test') parser.add_argument('tuple', nargs=(0, 1)) - - -def test_is_potential_flag(): - parser = Cmd2ArgParser() - - # Not valid flags - assert not is_potential_flag('', parser) - assert not is_potential_flag('non-flag', parser) - assert not is_potential_flag('-', parser) - assert not is_potential_flag('--has space', parser) - assert not is_potential_flag('-2', parser) - - # Valid flags - assert is_potential_flag('-flag', parser) - assert is_potential_flag('--flag', parser) From fa564a6f1b41f7a437b4b69b7d3f3bd5c0cfa3d7 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 5 Jul 2019 19:18:01 -0400 Subject: [PATCH 27/88] Refactoring and more unit tests --- cmd2/argparse_completer.py | 30 +++++------ tests/test_argparse_completer.py | 88 +++++++++++++++++++++----------- 2 files changed, 73 insertions(+), 45 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 4300365c0..9fa502dbc 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -60,7 +60,6 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str import argparse import os -from argparse import SUPPRESS from typing import List, Union from . import utils @@ -470,7 +469,7 @@ def process_action_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ if flag_action.dest in consumed_arg_values else [] completion_results = self._complete_for_arg(flag_action, text, line, begidx, endidx, consumed) if not completion_results: - self._print_action_help(flag_action) + self._print_arg_hint(flag_action) elif len(completion_results) > 1: completion_results = self._format_completions(flag_action, completion_results) @@ -481,7 +480,7 @@ def process_action_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ 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) + self._print_arg_hint(pos_action) elif len(completion_results) > 1: completion_results = self._format_completions(pos_action, completion_results) @@ -611,33 +610,34 @@ def _resolve_choices_for_arg(self, arg: argparse.Action, used_values=()) -> List return [] - def _print_action_help(self, action: argparse.Action) -> None: + def _print_arg_hint(self, arg: argparse.Action) -> None: + """Print argument hint to the terminal when tab completion results in no results""" # is parameter hinting disabled globally? if not self._tab_for_arg_help: return # is parameter hinting disabled for this parameter? - suppress_hint = getattr(action, ATTR_SUPPRESS_TAB_HINT, False) + suppress_hint = getattr(arg, ATTR_SUPPRESS_TAB_HINT, False) if suppress_hint: return - if action.option_strings: - flags = ', '.join(action.option_strings) + # Check if this is a flag + if arg.option_strings: + flags = ', '.join(arg.option_strings) param = '' - if action.nargs is None or action.nargs != 0: - param += ' ' + str(action.dest).upper() + if arg.nargs is None or arg.nargs != 0: + param += ' ' + str(arg.dest).upper() prefix = '{}{}'.format(flags, param) + + # Otherwise this is a positional else: - if action.dest != SUPPRESS: - prefix = '{}'.format(str(action.dest).upper()) - else: - prefix = '' + prefix = '{}'.format(str(arg.dest).upper()) - if action.help is None: + if not arg.help or arg.help == argparse.SUPPRESS: help_text = '' else: - help_text = action.help + help_text = arg.help # is there anything to print for this parameter? if not prefix and not help_text: diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 4f1ed44a7..f2aa40a3e 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -31,30 +31,42 @@ class AutoCompleteTester(cmd2.Cmd): def __init__(self): super().__init__() + ############################################################################################################ + # Begin code related to testing help and subcommand completion + ############################################################################################################ + basic_parser = Cmd2ArgParser(prog='basic') + basic_subparsers = basic_parser.add_subparsers() + + + ############################################################################################################ + # Begin code related to testing choices, choices_function, and choices_method parameters + ############################################################################################################ def choices_method(self) -> List[str]: """Method that provides choices""" return choices_from_method - # Basic command with no subcommands that exercises tab completing choices from various sources - basic_parser = Cmd2ArgParser() - basic_parser.add_argument("-n", "--no_choices", help="a flag with no choices") - basic_parser.add_argument("-l", "--choices_list", help="a flag populated with a choices list", - choices=static_choices_list) - basic_parser.add_argument("-f", "--choices_function", help="a flag populated with a choices function", - choices_function=choices_function) - basic_parser.add_argument("-m", "--choices_method", help="a flag populated with a choices method", - choices_method=choices_method) - - basic_parser.add_argument("no_choice_pos", help="a positional with no choices") - basic_parser.add_argument("choices_list_pos", help="a positional populated with a choices list", - choices=static_choices_list) - basic_parser.add_argument("choices_function_pos", help="a positional populated with a choices function", - choices_function=choices_function) - basic_parser.add_argument("choices_method_pos", help="a positional populated with a choices method", - choices_method=choices_method) - - @with_argparser(basic_parser) - def do_basic(self, args: argparse.Namespace) -> None: + choices_parser = Cmd2ArgParser() + + # Flags args for choices command + choices_parser.add_argument("-n", "--no_choices", help="a flag with no choices") + choices_parser.add_argument("-l", "--choices_list", help="a flag populated with a choices list", + choices=static_choices_list) + choices_parser.add_argument("-f", "--choices_function", help="a flag populated with a choices function", + choices_function=choices_function) + choices_parser.add_argument("-m", "--choices_method", help="a flag populated with a choices method", + choices_method=choices_method) + + # Positional args for choices command + choices_parser.add_argument("no_choice_pos", help="a positional with no choices") + choices_parser.add_argument("choices_list_pos", help="a positional populated with a choices list", + choices=static_choices_list) + choices_parser.add_argument("choices_function_pos", help="a positional populated with a choices function", + choices_function=choices_function) + choices_parser.add_argument("choices_method_pos", help="a positional populated with a choices method", + choices_method=choices_method) + + @with_argparser(choices_parser) + def do_choices(self, args: argparse.Namespace) -> None: pass @@ -66,14 +78,14 @@ def ac_app(): def test_help_basic(ac_app): - out1, err1 = run_cmd(ac_app, 'basic -h') - out2, err2 = run_cmd(ac_app, 'help basic') + out1, err1 = run_cmd(ac_app, 'choices -h') + out2, err2 = run_cmd(ac_app, 'help choices') assert out1 == out2 def test_autocomp_flags(ac_app): text = '-' - line = 'basic {}'.format(text) + line = 'choices {}'.format(text) endidx = len(line) begidx = endidx - len(text) @@ -83,9 +95,9 @@ def test_autocomp_flags(ac_app): '--no_choices', '-f', '-h', '-l', '-m', '-n'] -def test_autcomp_hint(ac_app, capsys): +def test_autcomp_flag_hint(ac_app, capsys): text = '' - line = 'basic -n {}'.format(text) + line = 'choices -n {}'.format(text) endidx = len(line) begidx = endidx - len(text) @@ -96,9 +108,9 @@ def test_autcomp_hint(ac_app, capsys): assert 'a flag with no choices' in out -def test_autcomp_flag_comp(ac_app): +def test_autcomp_flag_completion(ac_app): text = '--ch' - line = 'basic {}'.format(text) + line = 'choices {}'.format(text) endidx = len(line) begidx = endidx - len(text) @@ -106,7 +118,6 @@ def test_autcomp_flag_comp(ac_app): assert first_match is not None and \ ac_app.completion_matches == ['--choices_function', '--choices_list', '--choices_method'] - @pytest.mark.parametrize('flag, completions', [ ('-l', static_choices_list), ('--choices_list', static_choices_list), @@ -115,9 +126,26 @@ def test_autcomp_flag_comp(ac_app): ('-m', choices_from_method), ('--choices_method', choices_from_method), ]) -def test_autocomp_flags_choices(ac_app, flag, completions): +def test_autocomp_flag_choices_completion(ac_app, flag, completions): + text = '' + line = 'choices {} {}'.format(flag, text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + assert first_match is not None and \ + ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) + + +@pytest.mark.parametrize('pos, completions', [ + (2, static_choices_list), # choices_list_pos + (3, choices_from_function), # choices_function_pos + (4, choices_from_method), # choices_method_pos +]) +def test_autocomp_positional_choices_completion(ac_app, pos, completions): + # Test completions of positional arguments by generating a line were preceding positionals are already filled text = '' - line = 'basic {} {}'.format(flag, text) + line = 'choices {} {}'.format('foo ' * (pos - 1), text) endidx = len(line) begidx = endidx - len(text) From 5ef4267360e87d8c2af13d0b7f6a6cd8d80fd016 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 6 Jul 2019 10:39:56 -0400 Subject: [PATCH 28/88] Made alias and macro tab completion lookup results use CompletionItems --- cmd2/cmd2.py | 32 ++++++++++++++++---------------- tests/test_cmd2.py | 21 +++++++++++++++++---- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index eaae5de7c..364f18f9c 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -46,7 +46,7 @@ from . import constants from . import plugin from . import utils -from .argparse_completer import AutoCompleter +from .argparse_completer import AutoCompleter, CompletionItem from .argparse_custom import Cmd2ArgParser from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .history import History, HistoryItem @@ -1605,13 +1605,13 @@ def get_visible_commands(self) -> List[str]: return commands - def _get_alias_names(self) -> List[str]: + def _get_alias_completion_items(self) -> List[CompletionItem]: """Return list of current alias names""" - return list(self.aliases) + 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 alias names""" + 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""" @@ -1620,8 +1620,8 @@ def _get_settable_names(self) -> List[str]: 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]: @@ -2370,8 +2370,8 @@ def _alias_list(self, args: argparse.Namespace) -> None: 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) - alias_delete_parser.add_argument('name', nargs=argparse.ZERO_OR_MORE, - help='alias to delete', choices_method=_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) @@ -2384,8 +2384,8 @@ def _alias_list(self, args: argparse.Namespace) -> None: alias_list_parser = alias_subparsers.add_parser('list', help=alias_list_help, description=alias_list_description) - alias_list_parser.add_argument('name', nargs=argparse.ZERO_OR_MORE, - help='alias to list', choices_method=_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 @@ -2574,8 +2574,8 @@ def _macro_list(self, args: argparse.Namespace) -> None: 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) - macro_delete_parser.add_argument('name', nargs=argparse.ZERO_OR_MORE, - help='macro to delete', choices_method=_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) @@ -2587,8 +2587,8 @@ def _macro_list(self, args: argparse.Namespace) -> None: "Without arguments, all macros will be listed.") macro_list_parser = macro_subparsers.add_parser('list', help=macro_list_help, description=macro_list_description) - macro_list_parser.add_argument('name', nargs=argparse.ZERO_OR_MORE, - help='macro to list', choices_method=_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 diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index c9a41033c..6353d884f 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -21,6 +21,7 @@ import cmd2 from cmd2 import ansi, clipboard, constants, utils +from cmd2.argparse_completer import CompletionItem from .conftest import run_cmd, normalize, verify_help_text, HELP_HISTORY, SHORTCUTS_TXT, SHOW_TXT, SHOW_LONG def CreateOutsimApp(): @@ -1504,19 +1505,31 @@ def test_poutput_ansi_never(outsim_app): 'noembedded"quotes', ] -def test_get_alias_names(base_app): +def test_get_alias_completion_items(base_app): assert len(base_app.aliases) == 0 run_cmd(base_app, 'alias create fake run_pyscript') run_cmd(base_app, 'alias create ls !ls -hal') assert len(base_app.aliases) == 2 - assert sorted(base_app._get_alias_names()) == ['fake', 'ls'] -def test_get_macro_names(base_app): + expected = sorted([CompletionItem('fake', 'run_pyscript'), CompletionItem('ls', '!ls -hal')]) + results = sorted(base_app._get_alias_completion_items()) + + for index, cur_res in enumerate(results): + assert cur_res == expected[index] + assert cur_res.description == expected[index].description + +def test_get_macro_completion_items(base_app): assert len(base_app.macros) == 0 run_cmd(base_app, 'macro create foo !echo foo') run_cmd(base_app, 'macro create bar !echo bar') assert len(base_app.macros) == 2 - assert sorted(base_app._get_macro_names()) == ['bar', 'foo'] + + expected = sorted([CompletionItem('foo', '!echo foo'), CompletionItem('bar', '!echo bar')]) + results = sorted(base_app._get_macro_completion_items()) + + for index, cur_res in enumerate(results): + assert cur_res == expected[index] + assert cur_res.description == expected[index].description def test_get_settable_names(base_app): assert sorted(base_app._get_settable_names()) == sorted(base_app.settable.keys()) From 67445d49af8db72f9e27a8d47449d0b5ed1e6b9c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 6 Jul 2019 11:28:05 -0400 Subject: [PATCH 29/88] Display set command tab-completion results as CompletionItems --- cmd2/cmd2.py | 12 ++++++------ tests/test_cmd2.py | 30 ++++++++++++++---------------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 364f18f9c..46d996ea3 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1606,16 +1606,16 @@ def get_visible_commands(self) -> List[str]: return commands def _get_alias_completion_items(self) -> List[CompletionItem]: - """Return list of current alias names""" + """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_completion_items(self) -> List[CompletionItem]: - """Return list of current alias names""" + """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""" @@ -2920,7 +2920,7 @@ def _show(self, args: argparse.Namespace, parameter: str = '') -> None: 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') set_parser.add_argument('param', nargs=argparse.OPTIONAL, help='parameter to set or view', - choices_method=_get_settable_names) + 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) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 6353d884f..4e05283e7 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -21,7 +21,6 @@ import cmd2 from cmd2 import ansi, clipboard, constants, utils -from cmd2.argparse_completer import CompletionItem from .conftest import run_cmd, normalize, verify_help_text, HELP_HISTORY, SHORTCUTS_TXT, SHOW_TXT, SHOW_LONG def CreateOutsimApp(): @@ -1506,33 +1505,32 @@ def test_poutput_ansi_never(outsim_app): ] def test_get_alias_completion_items(base_app): - assert len(base_app.aliases) == 0 run_cmd(base_app, 'alias create fake run_pyscript') run_cmd(base_app, 'alias create ls !ls -hal') - assert len(base_app.aliases) == 2 - expected = sorted([CompletionItem('fake', 'run_pyscript'), CompletionItem('ls', '!ls -hal')]) - results = sorted(base_app._get_alias_completion_items()) + results = base_app._get_alias_completion_items() + assert len(results) == len(base_app.aliases) - for index, cur_res in enumerate(results): - assert cur_res == expected[index] - assert cur_res.description == expected[index].description + for cur_res in results: + assert cur_res in base_app.aliases + assert cur_res.description == base_app.aliases[cur_res] def test_get_macro_completion_items(base_app): - assert len(base_app.macros) == 0 run_cmd(base_app, 'macro create foo !echo foo') run_cmd(base_app, 'macro create bar !echo bar') - assert len(base_app.macros) == 2 - expected = sorted([CompletionItem('foo', '!echo foo'), CompletionItem('bar', '!echo bar')]) - results = sorted(base_app._get_macro_completion_items()) + results = base_app._get_macro_completion_items() + assert len(results) == len(base_app.macros) - for index, cur_res in enumerate(results): - assert cur_res == expected[index] - assert cur_res.description == expected[index].description + for cur_res in results: + assert cur_res in base_app.macros + assert cur_res.description == base_app.macros[cur_res].value def test_get_settable_names(base_app): - assert sorted(base_app._get_settable_names()) == sorted(base_app.settable.keys()) + results = base_app._get_settable_completion_items() + for cur_res in results: + assert cur_res in base_app.settable + assert cur_res.description == base_app.settable[cur_res] def test_alias_no_subcommand(base_app): out, err = run_cmd(base_app, 'alias') From 53f6c07559f2c15656423dbbb92471758e8c6d20 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 6 Jul 2019 13:22:41 -0400 Subject: [PATCH 30/88] Added ability to limit how many CompletionItems display at a time --- cmd2/argparse_completer.py | 4 +++- cmd2/cmd2.py | 5 +++++ tests/test_cmd2.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 9fa502dbc..0e9321dbf 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -487,7 +487,9 @@ def process_action_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ return completion_results 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: diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 46d996ea3..e6d4ae476 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -468,6 +468,11 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, # Otherwise it can be set to any custom key to meet your needs. self.matches_sort_key = ALPHABETICAL_SORT_KEY + # The maximum number of CompletionItems to display during tab completion. If the number of possible + # completions exceeds this number, suggestions will be displayed in the typical columnized format and + # will not include the description value of the CompletionItems. + self.max_completion_items = 50 + ############################################################################################################ # The following variables are used by tab-completion functions. They are reset each time complete() is run # in reset_completion_defaults() and it is up to completer functions to set them before returning results. diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 4e05283e7..f1d366f7d 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1526,7 +1526,7 @@ def test_get_macro_completion_items(base_app): assert cur_res in base_app.macros assert cur_res.description == base_app.macros[cur_res].value -def test_get_settable_names(base_app): +def test_get_settable_completion_items(base_app): results = base_app._get_settable_completion_items() for cur_res in results: assert cur_res in base_app.settable From 748a3e4e2a99b8067bc2738f85de667870d7f1d1 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 6 Jul 2019 16:58:31 -0400 Subject: [PATCH 31/88] Updating unit tests --- tests/test_argparse_completer.py | 156 +++++++++++++++++++------------ 1 file changed, 94 insertions(+), 62 deletions(-) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index f2aa40a3e..839cdf7ad 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -12,13 +12,16 @@ from cmd2 import with_argparser from cmd2.argparse_completer import is_potential_flag from cmd2.argparse_custom import Cmd2ArgParser -from cmd2.utils import StdSim +from cmd2.utils import StdSim, basic_complete from .conftest import run_cmd, complete_tester # Lists used in our tests -static_choices_list = ['static', 'choices'] -choices_from_function = ['choices', 'function'] -choices_from_method = ['choices', 'method'] +static_choices_list = ['static', 'choices', 'stop', 'here'] +choices_from_function = ['choices', 'function', 'chatty', 'smith'] +choices_from_method = ['choices', 'method', 'most', 'improved'] + +completions_from_function = ['completions', 'function', 'fairly', 'complete'] +completions_from_method = ['completions', 'method', 'missed', 'spot'] def choices_function() -> List[str]: @@ -26,18 +29,17 @@ def choices_function() -> List[str]: return choices_from_function +def completer_function(text: str, line: str, begidx: int, endidx: int) -> List[str]: + """Tab completion function""" + return basic_complete(text, line, begidx, endidx, completions_from_function) + + +# noinspection PyMethodMayBeStatic class AutoCompleteTester(cmd2.Cmd): """Cmd2 app that exercises AutoCompleter class""" def __init__(self): super().__init__() - ############################################################################################################ - # Begin code related to testing help and subcommand completion - ############################################################################################################ - basic_parser = Cmd2ArgParser(prog='basic') - basic_subparsers = basic_parser.add_subparsers() - - ############################################################################################################ # Begin code related to testing choices, choices_function, and choices_method parameters ############################################################################################################ @@ -48,27 +50,50 @@ def choices_method(self) -> List[str]: choices_parser = Cmd2ArgParser() # Flags args for choices command - choices_parser.add_argument("-n", "--no_choices", help="a flag with no choices") - choices_parser.add_argument("-l", "--choices_list", help="a flag populated with a choices list", + choices_parser.add_argument("-l", "--list", help="a flag populated with a choices list", choices=static_choices_list) - choices_parser.add_argument("-f", "--choices_function", help="a flag populated with a choices function", + choices_parser.add_argument("-f", "--function", help="a flag populated with a choices function", choices_function=choices_function) - choices_parser.add_argument("-m", "--choices_method", help="a flag populated with a choices method", + choices_parser.add_argument("-m", "--method", help="a flag populated with a choices method", choices_method=choices_method) # Positional args for choices command - choices_parser.add_argument("no_choice_pos", help="a positional with no choices") - choices_parser.add_argument("choices_list_pos", help="a positional populated with a choices list", + choices_parser.add_argument("list_pos", help="a positional populated with a choices list", choices=static_choices_list) - choices_parser.add_argument("choices_function_pos", help="a positional populated with a choices function", + choices_parser.add_argument("function_pos", help="a positional populated with a choices function", choices_function=choices_function) - choices_parser.add_argument("choices_method_pos", help="a positional populated with a choices method", + choices_parser.add_argument("method_pos", help="a positional populated with a choices method", choices_method=choices_method) @with_argparser(choices_parser) def do_choices(self, args: argparse.Namespace) -> None: pass + ############################################################################################################ + # Begin code related to testing completer_function and completer_method parameters + ############################################################################################################ + def completer_method(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """Tab completion method""" + return basic_complete(text, line, begidx, endidx, completions_from_method) + + completer_parser = Cmd2ArgParser() + + # Flags args for completer command + completer_parser.add_argument("-f", "--function", help="a flag populated with a choices function", + completer_function=completer_function) + completer_parser.add_argument("-m", "--method", help="a flag populated with a choices method", + completer_method=completer_method) + + # Positional args for completer command + completer_parser.add_argument("function_pos", help="a positional using a completer function", + completer_function=completer_function) + completer_parser.add_argument("method_pos", help="a positional using a completer method", + completer_method=completer_method) + + @with_argparser(completer_parser) + def do_completer(self, args: argparse.Namespace) -> None: + pass + @pytest.fixture def ac_app(): @@ -83,76 +108,83 @@ def test_help_basic(ac_app): assert out1 == out2 -def test_autocomp_flags(ac_app): - text = '-' +@pytest.mark.parametrize('text, completions', [ + ('-', ['--function', '--help', '--list', '--method', '-f', '-h', '-l', '-m']), + ('--', ['--function', '--help', '--list', '--method']), + ('-f', ['-f ']), + ('--f', ['--function ']), +]) +def test_autcomp_flag_completion(ac_app, text, completions): line = 'choices {}'.format(text) endidx = len(line) begidx = endidx - len(text) first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None and \ - ac_app.completion_matches == ['--choices_function', '--choices_list', '--choices_method', '--help', - '--no_choices', '-f', '-h', '-l', '-m', '-n'] + assert first_match is not None and ac_app.completion_matches == completions -def test_autcomp_flag_hint(ac_app, capsys): - text = '' - line = 'choices -n {}'.format(text) +@pytest.mark.parametrize('flag, text, completions', [ + ('-l', '', static_choices_list), + ('--list', 's', ['static', 'stop']), + ('-f', '', choices_from_function), + ('--function', 'ch', ['choices', 'chatty']), + ('-m', '', choices_from_method), + ('--method', 'm', ['method', 'most']), +]) +def test_autocomp_flag_choices_completion(ac_app, flag, text, completions): + line = 'choices {} {}'.format(flag, text) endidx = len(line) begidx = endidx - len(text) first_match = complete_tester(text, line, begidx, endidx, ac_app) - out, err = capsys.readouterr() - - assert first_match is None - assert 'a flag with no choices' in out + assert first_match is not None and ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) -def test_autcomp_flag_completion(ac_app): - text = '--ch' - line = 'choices {}'.format(text) +@pytest.mark.parametrize('pos, text, completions', [ + (1, '', static_choices_list), + (1, 's', ['static', 'stop']), + (2, '', choices_from_function), + (2, 'ch', ['choices', 'chatty']), + (3, '', choices_from_method), + (3, 'm', ['method', 'most']), +]) +def test_autocomp_positional_choices_completion(ac_app, pos, text, completions): + # Generate line were preceding positionals are already filled + line = 'choices {} {}'.format('foo ' * (pos - 1), text) endidx = len(line) begidx = endidx - len(text) first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None and \ - ac_app.completion_matches == ['--choices_function', '--choices_list', '--choices_method'] - -@pytest.mark.parametrize('flag, completions', [ - ('-l', static_choices_list), - ('--choices_list', static_choices_list), - ('-f', choices_from_function), - ('--choices_function', choices_from_function), - ('-m', choices_from_method), - ('--choices_method', choices_from_method), + assert first_match is not None and ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) + +@pytest.mark.parametrize('flag, text, completions', [ + ('-f', '', completions_from_function), + ('--function', 'f', ['function', 'fairly']), + ('-m', '', completions_from_method), + ('--method', 'm', ['method', 'missed']), ]) -def test_autocomp_flag_choices_completion(ac_app, flag, completions): - text = '' - line = 'choices {} {}'.format(flag, text) +def test_autocomp_flag_completers(ac_app, flag, text, completions): + line = 'completer {} {}'.format(flag, text) endidx = len(line) begidx = endidx - len(text) first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None and \ - ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) - + assert first_match is not None and ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) -@pytest.mark.parametrize('pos, completions', [ - (2, static_choices_list), # choices_list_pos - (3, choices_from_function), # choices_function_pos - (4, choices_from_method), # choices_method_pos +@pytest.mark.parametrize('pos, text, completions', [ + (1, '', completions_from_function), + (1, 'c', ['completions', 'complete']), + (2, '', completions_from_method), + (2, 'm', ['method', 'missed']), ]) -def test_autocomp_positional_choices_completion(ac_app, pos, completions): - # Test completions of positional arguments by generating a line were preceding positionals are already filled - text = '' - line = 'choices {} {}'.format('foo ' * (pos - 1), text) +def test_autocomp_positional_completers(ac_app, pos, text, completions): + # Generate line were preceding positionals are already filled + line = 'completer {} {}'.format('foo ' * (pos - 1), text) endidx = len(line) begidx = endidx - len(text) first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None and \ - ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) - + assert first_match is not None and ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) # def test_autcomp_hint_in_narg_range(cmd2_app, capsys): # text = '' @@ -315,4 +347,4 @@ def test_is_potential_flag(): # Valid flags assert is_potential_flag('-flag', parser) - assert is_potential_flag('--flag', parser) \ No newline at end of file + assert is_potential_flag('--flag', parser) From f8f06bff169dca1f0c6ee1dbb2d61c347490b3bb Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 6 Jul 2019 18:03:12 -0400 Subject: [PATCH 32/88] More unit tests --- cmd2/argparse_completer.py | 4 +- cmd2/cmd2.py | 6 +-- tests/test_argparse_completer.py | 65 +++++++++++++++++++++++++++++--- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 0e9321dbf..b6d22dce1 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -59,7 +59,7 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str """ import argparse -import os +import shutil from typing import List, Union from . import utils @@ -504,7 +504,7 @@ def _format_completions(self, action, completions: List[Union[str, CompletionIte 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, diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e6d4ae476..009b13593 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1584,18 +1584,18 @@ def complete(self, text: str, state: int) -> Optional[str]: 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""" completer = AutoCompleter(argparser, self) tokens, _ = self.tokens_for_completion(line, begidx, endidx) 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 diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 839cdf7ad..26bea7940 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -79,9 +79,9 @@ def completer_method(self, text: str, line: str, begidx: int, endidx: int) -> Li completer_parser = Cmd2ArgParser() # Flags args for completer command - completer_parser.add_argument("-f", "--function", help="a flag populated with a choices function", + completer_parser.add_argument("-f", "--function", help="a flag using a completer function", completer_function=completer_function) - completer_parser.add_argument("-m", "--method", help="a flag populated with a choices method", + completer_parser.add_argument("-m", "--method", help="a flag using a completer method", completer_method=completer_method) # Positional args for completer command @@ -102,12 +102,67 @@ def ac_app(): return app -def test_help_basic(ac_app): - out1, err1 = run_cmd(ac_app, 'choices -h') - out2, err2 = run_cmd(ac_app, 'help choices') +def test_help(ac_app): + out1, err1 = run_cmd(ac_app, 'alias -h') + out2, err2 = run_cmd(ac_app, 'help alias') assert out1 == out2 +def test_help_subcommand(ac_app): + out1, err1 = run_cmd(ac_app, 'alias create -h') + out2, err2 = run_cmd(ac_app, 'help alias create') + assert out1 == out2 + + +def test_complete_help(ac_app): + text = 'al' + line = 'help {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + assert first_match is not None and ac_app.completion_matches == ['alias '] + + +def test_complete_help_subcommand(ac_app): + text = 'cre' + line = 'help alias {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + assert first_match is not None and ac_app.completion_matches == ['create '] + + +@pytest.mark.parametrize('num_aliases, show_description', [ + # The number of completion results determines if the description field of CompletionItems gets displayed + # in the tab completions. The count must be greater than 1 and less than ac_app.max_completion_items, + # which defaults to 50. + (1, False), + (5, True), + (100, False), +]) +def test_completion_items(ac_app, num_aliases, show_description): + # Create aliases + for i in range(0, num_aliases): + run_cmd(ac_app, 'alias create fake{} help'.format(i)) + + assert len(ac_app.aliases) == num_aliases + + text = 'fake' + line = 'alias list {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + assert first_match is not None + assert len(ac_app.completion_matches) == num_aliases + assert len(ac_app.display_matches) == num_aliases + + # If show_description is True, the alias's value will be in the display text + assert ('help' in ac_app.display_matches[0]) == show_description + + @pytest.mark.parametrize('text, completions', [ ('-', ['--function', '--help', '--list', '--method', '-f', '-h', '-l', '-m']), ('--', ['--function', '--help', '--list', '--method']), From 901aa9ed176507071198e84978891c69cc6e9d3b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 6 Jul 2019 20:30:39 -0400 Subject: [PATCH 33/88] Fixed issue where default descriptive header wasn't set correctly --- cmd2/argparse_completer.py | 10 ++++++---- tests/test_argparse_completer.py | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index b6d22dce1..2bf08ce44 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -68,6 +68,9 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str from .argparse_custom import ChoicesCallable, ATTR_CHOICES_CALLABLE from .rl_utils import rl_force_redisplay +# If no descriptive header is supplied, then this will be used instead +DEFAULT_DESCRIPTIVE_HEADER = 'Description' + class CompletionItem(str): """ @@ -512,10 +515,9 @@ def _format_completions(self, action, completions: List[Union[str, CompletionIte fill_width=fill_width) completions_with_desc.append(entry) - try: - desc_header = getattr(action, ATTR_DESCRIPTIVE_COMPLETION_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 diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 26bea7940..3c4fca77c 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -49,7 +49,7 @@ def choices_method(self) -> List[str]: choices_parser = Cmd2ArgParser() - # Flags args for choices command + # Flag args for choices command choices_parser.add_argument("-l", "--list", help="a flag populated with a choices list", choices=static_choices_list) choices_parser.add_argument("-f", "--function", help="a flag populated with a choices function", @@ -78,7 +78,7 @@ def completer_method(self, text: str, line: str, begidx: int, endidx: int) -> Li completer_parser = Cmd2ArgParser() - # Flags args for completer command + # Flag args for completer command completer_parser.add_argument("-f", "--function", help="a flag using a completer function", completer_function=completer_function) completer_parser.add_argument("-m", "--method", help="a flag using a completer method", From 90fb61f195e8b541af931fc94350f4cf16ff244c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 6 Jul 2019 22:27:24 -0400 Subject: [PATCH 34/88] Removed AutoCompleter that globally disables tab hints --- cmd2/argparse_completer.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 2bf08ce44..0c4cafde4 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -172,20 +172,18 @@ def reset(self) -> None: self.variable = False def __init__(self, parser: argparse.ArgumentParser, cmd2_app, *, - tab_for_arg_help: bool = True, token_start_index: int = 1) -> None: + token_start_index: int = 1) -> None: """ Create an AutoCompleter :param parser: ArgumentParser instance :param cmd2_app: reference to the Cmd2 application that owns this AutoCompleter - :param tab_for_arg_help: If True, then argument help will display when there's no completion result :param token_start_index: index of the token to start parsing at """ self._parser = parser self._cmd2_app = cmd2_app 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 @@ -231,8 +229,7 @@ def __init__(self, parser: argparse.ArgumentParser, cmd2_app, *, subcmd_start = token_start_index + len(self._positional_actions) sub_completers[subcmd] = AutoCompleter(action.choices[subcmd], cmd2_app, - token_start_index=subcmd_start, - tab_for_arg_help=tab_for_arg_help) + token_start_index=subcmd_start) sub_commands.append(subcmd) self._positional_completers[action.dest] = sub_completers @@ -614,13 +611,10 @@ def _resolve_choices_for_arg(self, arg: argparse.Action, used_values=()) -> List return [] - def _print_arg_hint(self, arg: argparse.Action) -> None: + @staticmethod + def _print_arg_hint(arg: argparse.Action) -> None: """Print argument hint to the terminal when tab completion results in no results""" - # is parameter hinting disabled globally? - if not self._tab_for_arg_help: - return - - # is parameter hinting disabled for this parameter? + # is hinting disabled for this argument? suppress_hint = getattr(arg, ATTR_SUPPRESS_TAB_HINT, False) if suppress_hint: return From 47287f42c4b36dcce9f99ed09bbb8ec579439273 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 6 Jul 2019 22:31:14 -0400 Subject: [PATCH 35/88] More unit tests --- tests/test_argparse_completer.py | 86 ++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 3c4fca77c..c73290e0e 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -10,7 +10,7 @@ import cmd2 from cmd2 import with_argparser -from cmd2.argparse_completer import is_potential_flag +from cmd2.argparse_completer import CompletionItem, is_potential_flag, DEFAULT_DESCRIPTIVE_HEADER from cmd2.argparse_custom import Cmd2ArgParser from cmd2.utils import StdSim, basic_complete from .conftest import run_cmd, complete_tester @@ -47,6 +47,14 @@ def choices_method(self) -> List[str]: """Method that provides choices""" return choices_from_method + def completion_item_method(self) -> List[CompletionItem]: + """Choices method that returns CompletionItems""" + items = [] + for i in range(0, 10): + main_str = 'main_str{}'.format(i) + items.append(CompletionItem(main_str, desc='blah blah')) + return items + choices_parser = Cmd2ArgParser() # Flag args for choices command @@ -56,6 +64,8 @@ def choices_method(self) -> List[str]: choices_function=choices_function) choices_parser.add_argument("-m", "--method", help="a flag populated with a choices method", choices_method=choices_method) + choices_parser.add_argument('-n', "--no_header", help='this arg has a no descriptive header', + choices_method=completion_item_method) # Positional args for choices command choices_parser.add_argument("list_pos", help="a positional populated with a choices list", @@ -134,38 +144,9 @@ def test_complete_help_subcommand(ac_app): assert first_match is not None and ac_app.completion_matches == ['create '] -@pytest.mark.parametrize('num_aliases, show_description', [ - # The number of completion results determines if the description field of CompletionItems gets displayed - # in the tab completions. The count must be greater than 1 and less than ac_app.max_completion_items, - # which defaults to 50. - (1, False), - (5, True), - (100, False), -]) -def test_completion_items(ac_app, num_aliases, show_description): - # Create aliases - for i in range(0, num_aliases): - run_cmd(ac_app, 'alias create fake{} help'.format(i)) - - assert len(ac_app.aliases) == num_aliases - - text = 'fake' - line = 'alias list {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None - assert len(ac_app.completion_matches) == num_aliases - assert len(ac_app.display_matches) == num_aliases - - # If show_description is True, the alias's value will be in the display text - assert ('help' in ac_app.display_matches[0]) == show_description - - @pytest.mark.parametrize('text, completions', [ - ('-', ['--function', '--help', '--list', '--method', '-f', '-h', '-l', '-m']), - ('--', ['--function', '--help', '--list', '--method']), + ('-', ['--function', '--help', '--list', '--method', '--no_header', '-f', '-h', '-l', '-m', '-n']), + ('--', ['--function', '--help', '--list', '--method', '--no_header']), ('-f', ['-f ']), ('--f', ['--function ']), ]) @@ -241,6 +222,47 @@ def test_autocomp_positional_completers(ac_app, pos, text, completions): first_match = complete_tester(text, line, begidx, endidx, ac_app) assert first_match is not None and ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) + +@pytest.mark.parametrize('num_aliases, show_description', [ + # The number of completion results determines if the description field of CompletionItems gets displayed + # in the tab completions. The count must be greater than 1 and less than ac_app.max_completion_items, + # which defaults to 50. + (1, False), + (5, True), + (100, False), +]) +def test_completion_items(ac_app, num_aliases, show_description): + # Create aliases + for i in range(0, num_aliases): + run_cmd(ac_app, 'alias create fake{} help'.format(i)) + + assert len(ac_app.aliases) == num_aliases + + text = 'fake' + line = 'alias list {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + assert first_match is not None + assert len(ac_app.completion_matches) == num_aliases + assert len(ac_app.display_matches) == num_aliases + + # If show_description is True, the alias's value will be in the display text + assert ('help' in ac_app.display_matches[0]) == show_description + + +def test_completion_items_default_header(ac_app): + text = '' + line = 'choices -n {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + # This positional argument did not provide a descriptive header, so it should be DEFAULT_DESCRIPTIVE_HEADER + complete_tester(text, line, begidx, endidx, ac_app) + assert DEFAULT_DESCRIPTIVE_HEADER in ac_app.completion_header + + # def test_autcomp_hint_in_narg_range(cmd2_app, capsys): # text = '' # line = 'suggest -d 2 {}'.format(text) From f8db8b766540920de6c85a26a6740170455b9354 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sun, 7 Jul 2019 00:18:07 -0400 Subject: [PATCH 36/88] Hiding flags that have help value of SUPRESSED in tab completion results Added more unit tests for argparse completer --- cmd2/argparse_completer.py | 47 +++++++++-------- cmd2/argparse_custom.py | 8 +-- tests/test_argparse_completer.py | 91 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 26 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 0c4cafde4..1a8dd473c 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -454,16 +454,28 @@ def process_action_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ # Here we're done parsing all of the prior arguments. We know what the next argument is. - 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 utils.basic_complete(text, line, begidx, endidx, - [flag for flag in self._flags if flag not in matched_flags]) + if not flag_arg.needed and len(tokens[-1]) > 0 and \ + tokens[-1][0] in self._parser.prefix_chars and not skip_remaining_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) + + completion_results = [] + # we're not at a positional argument, see if we're in a flag argument - elif not current_is_positional: + if 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 [] @@ -614,35 +626,26 @@ def _resolve_choices_for_arg(self, arg: argparse.Action, used_values=()) -> List @staticmethod def _print_arg_hint(arg: argparse.Action) -> None: """Print argument hint to the terminal when tab completion results in no results""" - # is hinting disabled for this argument? + + # Check if hinting is disabled suppress_hint = getattr(arg, ATTR_SUPPRESS_TAB_HINT, False) - if suppress_hint: + 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 = '' - if arg.nargs is None or arg.nargs != 0: - param += ' ' + str(arg.dest).upper() - + param = ' ' + str(arg.dest).upper() prefix = '{}{}'.format(flags, param) # Otherwise this is a positional else: prefix = '{}'.format(str(arg.dest).upper()) - if not arg.help or arg.help == argparse.SUPPRESS: - help_text = '' - else: - help_text = arg.help - - # is there anything to print for this parameter? - if not prefix and not help_text: - return - 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: diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 6361bdb9c..b05ca6edd 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -59,7 +59,7 @@ def _add_argument_wrapper(self, *args, 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_hint: bool = False, + suppress_tab_hint: bool = False, descriptive_header: Optional[str] = None, **kwargs) -> argparse.Action: """ @@ -77,8 +77,8 @@ def _add_argument_wrapper(self, *args, :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_hint: when AutoCompleter has no choices to show during tab completion, it displays the current - argument's help text as a hint. Set this to True to suppress the hint. Defaults to False. + :param suppress_tab_hint: when AutoCompleter has no choices to show during tab completion, it displays the current + argument's help text as a hint. Set this to True to suppress the hint. Defaults to False. :param descriptive_header: if the provided choices are CompletionItems, then this header will display during tab completion. Defaults to None. @@ -152,7 +152,7 @@ def _add_argument_wrapper(self, *args, 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_hint) + setattr(new_arg, ATTR_SUPPRESS_TAB_HINT, suppress_tab_hint) setattr(new_arg, ATTR_DESCRIPTIVE_COMPLETION_HEADER, descriptive_header) return new_arg diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index c73290e0e..a3fa6a593 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -104,6 +104,21 @@ def completer_method(self, text: str, line: str, begidx: int, endidx: int) -> Li def do_completer(self, args: argparse.Namespace) -> None: pass + ############################################################################################################ + # Begin code related to testing tab hints + ############################################################################################################ + hint_parser = Cmd2ArgParser() + hint_parser.add_argument('-f', '--flag', help='a flag arg') + hint_parser.add_argument('-s', '--suppressed_help', help=argparse.SUPPRESS) + hint_parser.add_argument('-t', '--suppressed_hint', help='a flag arg', suppress_tab_hint=True) + + hint_parser.add_argument('hint_pos', help='here is a hint\nwith new lines') + hint_parser.add_argument('no_help_pos') + + @with_argparser(hint_parser) + def do_hint(self, args: argparse.Namespace) -> None: + pass + @pytest.fixture def ac_app(): @@ -263,6 +278,82 @@ def test_completion_items_default_header(ac_app): assert DEFAULT_DESCRIPTIVE_HEADER in ac_app.completion_header +def test_autocomp_hint_flag(ac_app, capsys): + text = '' + line = 'hint --flag {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + out, err = capsys.readouterr() + + assert first_match is None + assert out == ''' +Hint: + -f, --flag FLAG a flag arg + +''' + + +def test_autocomp_hint_suppressed_help(ac_app, capsys): + text = '' + line = 'hint --suppressed_help {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + out, err = capsys.readouterr() + + assert first_match is None + assert not out + + +def test_autocomp_hint_suppressed_hint(ac_app, capsys): + text = '' + line = 'hint --suppressed_hint {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + out, err = capsys.readouterr() + + assert first_match is None + assert not out + + +def test_autocomp_hint_pos(ac_app, capsys): + text = '' + line = 'hint {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + out, err = capsys.readouterr() + + assert first_match is None + assert out == ''' +Hint: + HINT_POS here is a hint + with new lines + +''' + +def test_autocomp_hint_no_help(ac_app, capsys): + text = '' + line = 'hint foo {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + out, err = capsys.readouterr() + + assert first_match is None + assert not out == ''' +Hint: + NO_HELP_POS + +''' + # def test_autcomp_hint_in_narg_range(cmd2_app, capsys): # text = '' # line = 'suggest -d 2 {}'.format(text) From bb2dd69bd04f5dccff9474c018eb6b6eea74c6af Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sun, 7 Jul 2019 21:45:52 -0400 Subject: [PATCH 37/88] Moved all custom argparse classes intended for normal development to argparse_custom.py. Lazy loading AutoCompleter in cmd2 instance methods to allow argparse_completer.py to import cmd2.Cmd class. This Architecture makes more sense because AutoCompleter depends on cmd2.Cmd. --- cmd2/__init__.py | 1 + cmd2/argparse_completer.py | 55 ++----------------------------- cmd2/argparse_custom.py | 56 ++++++++++++++++++++++++++++++-- cmd2/cmd2.py | 6 ++-- examples/tab_autocompletion.py | 5 ++- tests/test_argparse_completer.py | 9 +++-- 6 files changed, 70 insertions(+), 62 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index d3c92636c..3b1496018 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -11,6 +11,7 @@ pass from .ansi import style +from .argparse_custom import Cmd2ArgParser, 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 1a8dd473c..1a156f8f9 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -62,66 +62,17 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str import shutil from typing import List, Union +from . import cmd2 from . import utils from .ansi import ansi_safe_wcswidth from .argparse_custom import ATTR_SUPPRESS_TAB_HINT, ATTR_DESCRIPTIVE_COMPLETION_HEADER, ATTR_NARGS_RANGE -from .argparse_custom import ChoicesCallable, ATTR_CHOICES_CALLABLE +from .argparse_custom import ChoicesCallable, CompletionItem, ATTR_CHOICES_CALLABLE from .rl_utils import rl_force_redisplay # If no descriptive header is supplied, then this will be used instead DEFAULT_DESCRIPTIVE_HEADER = 'Description' -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. - - 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 - - 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 - - Example: - token = 1 - token_description = "My Item" - completion_item = CompletionItem(token, token_description) - """ - 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 - - # noinspection PyProtectedMember def is_potential_flag(token: str, parser: argparse.ArgumentParser) -> bool: """Determine if a token looks like a potential flag. Based on argparse._parse_optional().""" @@ -171,7 +122,7 @@ def reset(self) -> None: self.needed = False self.variable = False - def __init__(self, parser: argparse.ArgumentParser, cmd2_app, *, + def __init__(self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *, token_start_index: int = 1) -> None: """ Create an AutoCompleter diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index b05ca6edd..5e3ed7f5d 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -26,6 +26,56 @@ ATTR_DESCRIPTIVE_COMPLETION_HEADER = 'desc_completion_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. + + 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 + + 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 + + Example: + token = 1 + token_description = "My Item" + completion_item = CompletionItem(token, token_description) + """ + 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. @@ -77,8 +127,10 @@ def _add_argument_wrapper(self, *args, :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 choices to show during tab completion, it displays the current - argument's help text as a hint. Set this to True to suppress the hint. Defaults to False. + :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. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index bfda6ae25..0d14d5ad4 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -46,8 +46,7 @@ from . import constants from . import plugin from . import utils -from .argparse_completer import AutoCompleter, CompletionItem -from .argparse_custom import Cmd2ArgParser +from .argparse_custom import Cmd2ArgParser, CompletionItem 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 @@ -1582,6 +1581,7 @@ def complete(self, text: str, state: int) -> Optional[str]: def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int, argparser: argparse.ArgumentParser) -> List[str]: """Default completion function for argparse commands""" + from .argparse_completer import AutoCompleter completer = AutoCompleter(argparser, self) tokens, _ = self.tokens_for_completion(line, begidx, endidx) return completer.complete_command(tokens, text, line, begidx, endidx) @@ -2643,6 +2643,7 @@ def complete_help_subcommand(self, text: str, line: str, begidx: int, endidx: in # 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) @@ -2673,6 +2674,7 @@ def do_help(self, args: argparse.Namespace) -> None: # 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)) diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index 4b13b5c38..a6d5487d7 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -8,8 +8,7 @@ from typing import List import cmd2 -from cmd2 import argparse_completer, utils -from cmd2.argparse_custom import Cmd2ArgParser +from cmd2 import utils, Cmd2ArgParser, CompletionItem actors = ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', 'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels', 'Adam Driver', 'Daisy Ridley', 'John Boyega', 'Oscar Isaac', @@ -116,7 +115,7 @@ def instance_query_movie_ids(self) -> List[str]: for movie_id in utils.natural_sort(self.MOVIE_DATABASE_IDS): if movie_id in self.MOVIE_DATABASE: movie_entry = self.MOVIE_DATABASE[movie_id] - completions_with_desc.append(argparse_completer.CompletionItem(movie_id, movie_entry['title'])) + completions_with_desc.append(CompletionItem(movie_id, movie_entry['title'])) # Mark that we already sorted the matches self.matches_sorted = True diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index a3fa6a593..6e0926192 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -9,9 +9,8 @@ import pytest import cmd2 -from cmd2 import with_argparser -from cmd2.argparse_completer import CompletionItem, is_potential_flag, DEFAULT_DESCRIPTIVE_HEADER -from cmd2.argparse_custom import Cmd2ArgParser +from cmd2 import with_argparser, Cmd2ArgParser, CompletionItem +from cmd2.argparse_completer import is_potential_flag, DEFAULT_DESCRIPTIVE_HEADER from cmd2.utils import StdSim, basic_complete from .conftest import run_cmd, complete_tester @@ -208,6 +207,7 @@ def test_autocomp_positional_choices_completion(ac_app, pos, text, completions): first_match = complete_tester(text, line, begidx, endidx, ac_app) assert first_match is not None and ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) + @pytest.mark.parametrize('flag, text, completions', [ ('-f', '', completions_from_function), ('--function', 'f', ['function', 'fairly']), @@ -222,6 +222,7 @@ def test_autocomp_flag_completers(ac_app, flag, text, completions): first_match = complete_tester(text, line, begidx, endidx, ac_app) assert first_match is not None and ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) + @pytest.mark.parametrize('pos, text, completions', [ (1, '', completions_from_function), (1, 'c', ['completions', 'complete']), @@ -338,6 +339,7 @@ def test_autocomp_hint_pos(ac_app, capsys): ''' + def test_autocomp_hint_no_help(ac_app, capsys): text = '' line = 'hint foo {}'.format(text) @@ -503,6 +505,7 @@ def test_autocomp_hint_no_help(ac_app, capsys): # # Since -- appeared before the -- being completed, nothing should be completed # assert complete_tester(text, line, begidx, endidx, cmd2_app) is None + def test_is_potential_flag(): parser = Cmd2ArgParser() From 8d3d59801ca690f718ed9814c9e124e27040c141 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sun, 7 Jul 2019 22:44:33 -0400 Subject: [PATCH 38/88] More argparse completer unit tests --- examples/tab_autocompletion.py | 12 ++--- tests/test_argparse_completer.py | 89 +++++++++++++++++++++++--------- 2 files changed, 71 insertions(+), 30 deletions(-) diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index a6d5487d7..c4fc62181 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -177,9 +177,9 @@ def do_orig_suggest(self, args) -> None: if not args.type: self.do_help('orig_suggest') - def _do_vid_media_movies(self, args) -> None: + def _do_vid_movies(self, args) -> None: if not args.command: - self.do_help('media movies') + self.do_help('video movies') elif args.command == 'list': for movie_id in TabCompleteExample.MOVIE_DATABASE: movie = TabCompleteExample.MOVIE_DATABASE[movie_id] @@ -188,9 +188,9 @@ def _do_vid_media_movies(self, args) -> None: ', '.join(movie['director']), '\n '.join(movie['actor']))) - def _do_vid_media_shows(self, args) -> None: + def _do_vid_shows(self, args) -> None: if not args.command: - self.do_help('media shows') + self.do_help('video shows') elif args.command == 'list': for show_id in TabCompleteExample.SHOW_DATABASE: @@ -209,7 +209,7 @@ def _do_vid_media_shows(self, args) -> None: video_types_subparsers = video_parser.add_subparsers(title='Media Types', dest='type') vid_movies_parser = video_types_subparsers.add_parser('movies') - vid_movies_parser.set_defaults(func=_do_vid_media_movies) + vid_movies_parser.set_defaults(func=_do_vid_movies) vid_movies_commands_subparsers = vid_movies_parser.add_subparsers(title='Commands', dest='command') @@ -243,7 +243,7 @@ def _do_vid_media_shows(self, args) -> None: descriptive_header='Title') vid_shows_parser = video_types_subparsers.add_parser('shows') - vid_shows_parser.set_defaults(func=_do_vid_media_shows) + vid_shows_parser.set_defaults(func=_do_vid_shows) vid_shows_commands_subparsers = vid_shows_parser.add_subparsers(title='Commands', dest='command') diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 6e0926192..fd43c91c0 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -33,12 +33,59 @@ def completer_function(text: str, line: str, begidx: int, endidx: int) -> List[s return basic_complete(text, line, begidx, endidx, completions_from_function) -# noinspection PyMethodMayBeStatic +# noinspection PyMethodMayBeStatic,PyUnusedLocal class AutoCompleteTester(cmd2.Cmd): """Cmd2 app that exercises AutoCompleter class""" def __init__(self): super().__init__() + ############################################################################################################ + # Begin code related to help and command name completion + ############################################################################################################ + def _music_create(self, args: argparse.Namespace) -> None: + """Implements the 'music create' command""" + self.poutput('music create') + + def _music_create_jazz(self, args: argparse.Namespace) -> None: + """Implements the 'music create jazz' command""" + self.poutput('music create jazz') + + def _music_create_rock(self, args: argparse.Namespace) -> None: + """Implements the 'music create rock' command""" + self.poutput('music create rock') + + # Top level parser for music command + music_parser = Cmd2ArgParser(description='Manage music', prog='music') + + # Add sub-commands to music + music_subparsers = music_parser.add_subparsers() + + # music -> create + music_create_parser = music_subparsers.add_parser('create', help='Create music') + music_create_parser.set_defaults(func=_music_create) + + # Add sub-commands to music -> create + music_create_subparsers = music_create_parser.add_subparsers() + + # music -> create -> jazz + music_create_jazz_parser = music_create_subparsers.add_parser('jazz', help='Create jazz') + music_create_jazz_parser.set_defaults(func=_music_create_jazz) + + # music -> create -> rock + music_create_rock_parser = music_create_subparsers.add_parser('rock', help='Create rocks') + music_create_rock_parser.set_defaults(func=_music_create_rock) + + @with_argparser(music_parser) + def do_music(self, args: argparse.Namespace) -> None: + """Music command""" + func = getattr(args, 'func', None) + if func is not None: + # Call whatever sub-command function was selected + func(self, args) + else: + # No sub-command was provided, so call help + self.do_help('music') + ############################################################################################################ # Begin code related to testing choices, choices_function, and choices_method parameters ############################################################################################################ @@ -126,36 +173,30 @@ def ac_app(): return app -def test_help(ac_app): - out1, err1 = run_cmd(ac_app, 'alias -h') - out2, err2 = run_cmd(ac_app, 'help alias') - assert out1 == out2 - - -def test_help_subcommand(ac_app): - out1, err1 = run_cmd(ac_app, 'alias create -h') - out2, err2 = run_cmd(ac_app, 'help alias create') +@pytest.mark.parametrize('command', [ + 'music', + 'music create', + 'music create rock', + 'music create jazz' +]) +def test_help(ac_app, command): + out1, err1 = run_cmd(ac_app, '{} -h'.format(command)) + out2, err2 = run_cmd(ac_app, 'help {}'.format(command)) assert out1 == out2 -def test_complete_help(ac_app): - text = 'al' - line = 'help {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None and ac_app.completion_matches == ['alias '] - - -def test_complete_help_subcommand(ac_app): - text = 'cre' - line = 'help alias {}'.format(text) +@pytest.mark.parametrize('command, text, completions', [ + ('', 'mu', ['music ']), + ('music', 'cre', ['create ']), + ('music create', '', ['jazz', 'rock']) +]) +def test_complete_help(ac_app, command, text, completions): + line = 'help {} {}'.format(command, text) endidx = len(line) begidx = endidx - len(text) first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None and ac_app.completion_matches == ['create '] + assert first_match is not None and ac_app.completion_matches == completions @pytest.mark.parametrize('text, completions', [ From d9a48462c90807a4a8e8c5d646a62d1bd883529f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sun, 7 Jul 2019 23:02:00 -0400 Subject: [PATCH 39/88] More unit tests --- tests/test_argparse_completer.py | 39 +++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index fd43c91c0..1b6d1f4bc 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -86,6 +86,18 @@ def do_music(self, args: argparse.Namespace) -> None: # No sub-command was provided, so call help self.do_help('music') + ############################################################################################################ + # Begin code related to flag completion + ############################################################################################################ + flag_parser = Cmd2ArgParser() + flag_parser.add_argument('-n', '--normal_flag', help='A normal flag', action='store_true') + flag_parser.add_argument('-o', '--other_normal_flag', help='The other normal flag', action='store_true') + flag_parser.add_argument('-s', '--suppressed_flag', help=argparse.SUPPRESS, action='store_true') + + @with_argparser(flag_parser) + def do_flag(self, args: argparse.Namespace) -> None: + pass + ############################################################################################################ # Begin code related to testing choices, choices_function, and choices_method parameters ############################################################################################################ @@ -199,19 +211,30 @@ def test_complete_help(ac_app, command, text, completions): assert first_match is not None and ac_app.completion_matches == completions -@pytest.mark.parametrize('text, completions', [ - ('-', ['--function', '--help', '--list', '--method', '--no_header', '-f', '-h', '-l', '-m', '-n']), - ('--', ['--function', '--help', '--list', '--method', '--no_header']), - ('-f', ['-f ']), - ('--f', ['--function ']), +@pytest.mark.parametrize('used_flags, text, completions', [ + ('', '-', ['--help', '--normal_flag', '--other_normal_flag', '-h', '-n', '-o']), + ('', '--', ['--help', '--normal_flag', '--other_normal_flag']), + ('', '-n', ['-n ']), + ('', '--n', ['--normal_flag ']), + ('', '-s', []), + ('', '--s', []), + ('-h', '-', ['--normal_flag', '--other_normal_flag', '-n', '-o']), + ('-h --normal_flag', '-', ['--other_normal_flag', '-o']), + ('-h --normal_flag', '--', ['--other_normal_flag ']), + ('-h --normal_flag -o', '-', []), ]) -def test_autcomp_flag_completion(ac_app, text, completions): - line = 'choices {}'.format(text) +def test_autcomp_flag_completion(ac_app, used_flags, text, completions): + line = 'flag {} {}'.format(used_flags, text) endidx = len(line) begidx = endidx - len(text) first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None and ac_app.completion_matches == completions + if completions: + assert first_match is not None + else: + assert first_match is None + + assert ac_app.completion_matches == completions @pytest.mark.parametrize('flag, text, completions', [ From 3140cd47d997d3e23789774f37ec601be408177b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sun, 7 Jul 2019 23:12:09 -0400 Subject: [PATCH 40/88] More unit tests --- tests/test_argparse_completer.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 1b6d1f4bc..08b829f78 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -15,6 +15,7 @@ from .conftest import run_cmd, complete_tester # Lists used in our tests +static_int_choices_list = [1, 2, 3, 4, 5] static_choices_list = ['static', 'choices', 'stop', 'here'] choices_from_function = ['choices', 'function', 'chatty', 'smith'] choices_from_method = ['choices', 'method', 'most', 'improved'] @@ -115,7 +116,7 @@ def completion_item_method(self) -> List[CompletionItem]: choices_parser = Cmd2ArgParser() - # Flag args for choices command + # Flag args for choices command. Include string and non-string arg types. choices_parser.add_argument("-l", "--list", help="a flag populated with a choices list", choices=static_choices_list) choices_parser.add_argument("-f", "--function", help="a flag populated with a choices function", @@ -124,6 +125,8 @@ def completion_item_method(self) -> List[CompletionItem]: choices_method=choices_method) choices_parser.add_argument('-n', "--no_header", help='this arg has a no descriptive header', choices_method=completion_item_method) + choices_parser.add_argument('-i', '--int', type=int, help='a flag with an int type', + choices=static_int_choices_list) # Positional args for choices command choices_parser.add_argument("list_pos", help="a positional populated with a choices list", @@ -221,7 +224,7 @@ def test_complete_help(ac_app, command, text, completions): ('-h', '-', ['--normal_flag', '--other_normal_flag', '-n', '-o']), ('-h --normal_flag', '-', ['--other_normal_flag', '-o']), ('-h --normal_flag', '--', ['--other_normal_flag ']), - ('-h --normal_flag -o', '-', []), + ('-h --normal_flag -o', '-', []) ]) def test_autcomp_flag_completion(ac_app, used_flags, text, completions): line = 'flag {} {}'.format(used_flags, text) @@ -244,6 +247,8 @@ def test_autcomp_flag_completion(ac_app, used_flags, text, completions): ('--function', 'ch', ['choices', 'chatty']), ('-m', '', choices_from_method), ('--method', 'm', ['method', 'most']), + ('-i', '', [str(i) for i in static_int_choices_list]), + ('--int', '1', ['1 ']) ]) def test_autocomp_flag_choices_completion(ac_app, flag, text, completions): line = 'choices {} {}'.format(flag, text) @@ -260,7 +265,7 @@ def test_autocomp_flag_choices_completion(ac_app, flag, text, completions): (2, '', choices_from_function), (2, 'ch', ['choices', 'chatty']), (3, '', choices_from_method), - (3, 'm', ['method', 'most']), + (3, 'm', ['method', 'most']) ]) def test_autocomp_positional_choices_completion(ac_app, pos, text, completions): # Generate line were preceding positionals are already filled @@ -276,7 +281,7 @@ def test_autocomp_positional_choices_completion(ac_app, pos, text, completions): ('-f', '', completions_from_function), ('--function', 'f', ['function', 'fairly']), ('-m', '', completions_from_method), - ('--method', 'm', ['method', 'missed']), + ('--method', 'm', ['method', 'missed']) ]) def test_autocomp_flag_completers(ac_app, flag, text, completions): line = 'completer {} {}'.format(flag, text) @@ -291,7 +296,7 @@ def test_autocomp_flag_completers(ac_app, flag, text, completions): (1, '', completions_from_function), (1, 'c', ['completions', 'complete']), (2, '', completions_from_method), - (2, 'm', ['method', 'missed']), + (2, 'm', ['method', 'missed']) ]) def test_autocomp_positional_completers(ac_app, pos, text, completions): # Generate line were preceding positionals are already filled @@ -309,7 +314,7 @@ def test_autocomp_positional_completers(ac_app, pos, text, completions): # which defaults to 50. (1, False), (5, True), - (100, False), + (100, False) ]) def test_completion_items(ac_app, num_aliases, show_description): # Create aliases From 4a5b23284be04258735ba421f62825520651e14e Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 8 Jul 2019 11:50:17 -0400 Subject: [PATCH 41/88] More unit tests --- tests/test_argparse_completer.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 08b829f78..82a825cd3 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -10,7 +10,6 @@ import cmd2 from cmd2 import with_argparser, Cmd2ArgParser, CompletionItem -from cmd2.argparse_completer import is_potential_flag, DEFAULT_DESCRIPTIVE_HEADER from cmd2.utils import StdSim, basic_complete from .conftest import run_cmd, complete_tester @@ -85,6 +84,7 @@ def do_music(self, args: argparse.Namespace) -> None: func(self, args) else: # No sub-command was provided, so call help + # noinspection PyTypeChecker self.do_help('music') ############################################################################################################ @@ -338,6 +338,8 @@ def test_completion_items(ac_app, num_aliases, show_description): def test_completion_items_default_header(ac_app): + from cmd2.argparse_completer import DEFAULT_DESCRIPTIVE_HEADER + text = '' line = 'choices -n {}'.format(text) endidx = len(line) @@ -576,6 +578,7 @@ def test_autocomp_hint_no_help(ac_app, capsys): def test_is_potential_flag(): + from cmd2.argparse_completer import is_potential_flag parser = Cmd2ArgParser() # Not valid flags @@ -588,3 +591,23 @@ def test_is_potential_flag(): # Valid flags assert is_potential_flag('-flag', parser) assert is_potential_flag('--flag', parser) + + +def test_complete_command_no_tokens(ac_app): + from cmd2.argparse_completer import AutoCompleter + + parser = Cmd2ArgParser() + ac = AutoCompleter(parser, ac_app) + + completions = ac.complete_command(tokens=[], text='', line='', begidx=0, endidx=0) + assert not completions + + +def test_complete_command_help_no_tokens(ac_app): + from cmd2.argparse_completer import AutoCompleter + + parser = Cmd2ArgParser() + ac = AutoCompleter(parser, ac_app) + + completions = ac.complete_command_help(tokens=[], text='', line='', begidx=0, endidx=0) + assert not completions From bdcc179661a16f9bf648f230e5713a81e391f083 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 8 Jul 2019 13:23:01 -0400 Subject: [PATCH 42/88] Fixed issue where -- was not handled properly in AutoCompleter when the parser's prefix characters did not include - --- cmd2/argparse_completer.py | 18 +++---- tests/test_argparse_completer.py | 82 +++++++++++++++++--------------- 2 files changed, 53 insertions(+), 47 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 1a156f8f9..4a33fba3d 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -321,21 +321,21 @@ def process_action_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER: skip_remaining_flags = True + # Handle '--' which tells argparse all remaining arguments are non-flags + if token == '--' and not skip_remaining_flags: + if is_last_token: + # Exit loop and see if -- can be completed into a flag + break + else: + skip_remaining_flags = True + # At this point we're no longer consuming flag arguments. Is the current argument a potential flag? - if is_potential_flag(token, self._parser) and not skip_remaining_flags: + elif 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] diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 82a825cd3..ad37629f7 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -90,6 +90,8 @@ def do_music(self, args: argparse.Namespace) -> None: ############################################################################################################ # Begin code related to flag completion ############################################################################################################ + + # Uses default flag prefix value (-) flag_parser = Cmd2ArgParser() flag_parser.add_argument('-n', '--normal_flag', help='A normal flag', action='store_true') flag_parser.add_argument('-o', '--other_normal_flag', help='The other normal flag', action='store_true') @@ -99,6 +101,16 @@ def do_music(self, args: argparse.Namespace) -> None: def do_flag(self, args: argparse.Namespace) -> None: pass + # Uses non-default flag prefix value (+) + plus_flag_parser = Cmd2ArgParser(prefix_chars='+') + plus_flag_parser.add_argument('+n', '++normal_flag', help='A normal flag', action='store_true') + plus_flag_parser.add_argument('+o', '++other_normal_flag', help='The other normal flag', action='store_true') + plus_flag_parser.add_argument('+s', '++suppressed_flag', help=argparse.SUPPRESS, action='store_true') + + @with_argparser(plus_flag_parser) + def do_plus_flag(self, args: argparse.Namespace) -> None: + pass + ############################################################################################################ # Begin code related to testing choices, choices_function, and choices_method parameters ############################################################################################################ @@ -214,20 +226,39 @@ def test_complete_help(ac_app, command, text, completions): assert first_match is not None and ac_app.completion_matches == completions -@pytest.mark.parametrize('used_flags, text, completions', [ - ('', '-', ['--help', '--normal_flag', '--other_normal_flag', '-h', '-n', '-o']), - ('', '--', ['--help', '--normal_flag', '--other_normal_flag']), - ('', '-n', ['-n ']), - ('', '--n', ['--normal_flag ']), - ('', '-s', []), - ('', '--s', []), - ('-h', '-', ['--normal_flag', '--other_normal_flag', '-n', '-o']), - ('-h --normal_flag', '-', ['--other_normal_flag', '-o']), - ('-h --normal_flag', '--', ['--other_normal_flag ']), - ('-h --normal_flag -o', '-', []) +@pytest.mark.parametrize('command_and_args, text, completions', [ + # Default flag prefix character (-) + ('flag', '-', ['--help', '--normal_flag', '--other_normal_flag', '-h', '-n', '-o']), + ('flag', '--', ['--help', '--normal_flag', '--other_normal_flag']), + ('flag', '-n', ['-n ']), + ('flag', '--n', ['--normal_flag ']), + ('flag', '-s', []), + ('flag', '--s', []), + ('flag -h', '-', ['--normal_flag', '--other_normal_flag', '-n', '-o']), + ('flag -h --normal_flag', '-', ['--other_normal_flag', '-o']), + ('flag -h --normal_flag', '--', ['--other_normal_flag ']), + ('flag -h --normal_flag -o', '-', []), + + # Non-default flag prefix character (+) + ('plus_flag', '+', ['++help', '++normal_flag', '++other_normal_flag', '+h', '+n', '+o']), + ('plus_flag', '++', ['++help', '++normal_flag', '++other_normal_flag']), + ('plus_flag', '+n', ['+n ']), + ('plus_flag', '++n', ['++normal_flag ']), + ('plus_flag', '+s', []), + ('plus_flag', '++s', []), + ('plus_flag +h', '+', ['++normal_flag', '++other_normal_flag', '+n', '+o']), + ('plus_flag +h ++normal_flag', '+', ['++other_normal_flag', '+o']), + ('plus_flag +h ++normal_flag', '++', ['++other_normal_flag ']), + ('plus_flag +h ++normal_flag +o', '+', []), + + # Flag completion should not occur after '--' since that tells argparse the all remaining arguments of non-flags + ('flag --', '--', []), + ('flag --help --', '--', []), + ('plus_flag --', '++', []), + ('plus_flag ++help --', '++', []) ]) -def test_autcomp_flag_completion(ac_app, used_flags, text, completions): - line = 'flag {} {}'.format(used_flags, text) +def test_autcomp_flag_completion(ac_app, command_and_args, text, completions): + line = '{} {}'.format(command_and_args, text) endidx = len(line) begidx = endidx - len(text) @@ -550,31 +581,6 @@ def test_autocomp_hint_no_help(ac_app, capsys): # assert complete_tester(text, line, begidx, endidx, cmd2_app) is None # # -# def test_completion_after_double_dash(cmd2_app): -# """ -# Test completion after --, which argparse says (all args after -- are non-options) -# All of these tests occur outside of an argparse.REMAINDER section since those tests -# are handled in test_argparse_remainder_flag_completion -# """ -# -# # Test -- as the last token -# text = '--' -# line = 'help {}'.format(text) -# endidx = len(line) -# begidx = endidx - len(text) -# -# # Since -- is the last token, then it should show flag choices -# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) -# assert first_match is not None and '--help' in cmd2_app.completion_matches -# -# # Test -- to end all flag completion -# text = '--' -# line = 'help -- {}'.format(text) -# endidx = len(line) -# begidx = endidx - len(text) -# -# # Since -- appeared before the -- being completed, nothing should be completed -# assert complete_tester(text, line, begidx, endidx, cmd2_app) is None def test_is_potential_flag(): From 36209ae6d9563054d57c630729be3c9002453729 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 8 Jul 2019 14:49:53 -0400 Subject: [PATCH 43/88] Added code to handle flags with action set to append, append_const, and count in AutoCompleter --- cmd2/argparse_completer.py | 8 +++++- tests/test_argparse_completer.py | 44 ++++++++++++++++---------------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 4a33fba3d..6fef6f802 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -347,7 +347,13 @@ def process_action_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ 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): + + # Keep track of what flags have already been used + # Flags with action set to append, append_const, and count can be reused + if not is_last_token and \ + not isinstance(flag_action, argparse._AppendAction) and \ + not isinstance(flag_action, argparse._AppendConstAction) and \ + not isinstance(flag_action, argparse._CountAction): matched_flags.extend(flag_action.option_strings) # current token isn't a potential flag diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index ad37629f7..b70ab29dc 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -94,7 +94,9 @@ def do_music(self, args: argparse.Namespace) -> None: # Uses default flag prefix value (-) flag_parser = Cmd2ArgParser() flag_parser.add_argument('-n', '--normal_flag', help='A normal flag', action='store_true') - flag_parser.add_argument('-o', '--other_normal_flag', help='The other normal flag', action='store_true') + flag_parser.add_argument('-a', '--append_flag', help='Append flag', action='append') + flag_parser.add_argument('-o', '--append_const_flag', help='Append const flag', action='append_const', const=True) + flag_parser.add_argument('-c', '--count_flag', help='Count flag', action='count') flag_parser.add_argument('-s', '--suppressed_flag', help=argparse.SUPPRESS, action='store_true') @with_argparser(flag_parser) @@ -104,8 +106,6 @@ def do_flag(self, args: argparse.Namespace) -> None: # Uses non-default flag prefix value (+) plus_flag_parser = Cmd2ArgParser(prefix_chars='+') plus_flag_parser.add_argument('+n', '++normal_flag', help='A normal flag', action='store_true') - plus_flag_parser.add_argument('+o', '++other_normal_flag', help='The other normal flag', action='store_true') - plus_flag_parser.add_argument('+s', '++suppressed_flag', help=argparse.SUPPRESS, action='store_true') @with_argparser(plus_flag_parser) def do_plus_flag(self, args: argparse.Namespace) -> None: @@ -227,31 +227,31 @@ def test_complete_help(ac_app, command, text, completions): @pytest.mark.parametrize('command_and_args, text, completions', [ - # Default flag prefix character (-) - ('flag', '-', ['--help', '--normal_flag', '--other_normal_flag', '-h', '-n', '-o']), - ('flag', '--', ['--help', '--normal_flag', '--other_normal_flag']), + # Complete all flags (suppressed will not show) + ('flag', '-', ['--append_const_flag', '--append_flag', '--count_flag', '--help', + '--normal_flag', '-a', '-c', '-h', '-n', '-o']), + ('flag', '--', ['--append_const_flag', '--append_flag', '--count_flag', '--help', '--normal_flag']), + + # Complete individual flag ('flag', '-n', ['-n ']), ('flag', '--n', ['--normal_flag ']), + + # Suppressed flag should not complete ('flag', '-s', []), ('flag', '--s', []), - ('flag -h', '-', ['--normal_flag', '--other_normal_flag', '-n', '-o']), - ('flag -h --normal_flag', '-', ['--other_normal_flag', '-o']), - ('flag -h --normal_flag', '--', ['--other_normal_flag ']), - ('flag -h --normal_flag -o', '-', []), + + # A used flag should not show in completions + ('flag -n', '--', ['--append_const_flag', '--append_flag', '--count_flag', '--help']), + + # Flags with actions set to append, append_const, and count will always show even if they've been used + ('flag --append_const_flag -c --append_flag value', '--', ['--append_const_flag', '--append_flag', '--count_flag', + '--help', '--normal_flag']), # Non-default flag prefix character (+) - ('plus_flag', '+', ['++help', '++normal_flag', '++other_normal_flag', '+h', '+n', '+o']), - ('plus_flag', '++', ['++help', '++normal_flag', '++other_normal_flag']), - ('plus_flag', '+n', ['+n ']), - ('plus_flag', '++n', ['++normal_flag ']), - ('plus_flag', '+s', []), - ('plus_flag', '++s', []), - ('plus_flag +h', '+', ['++normal_flag', '++other_normal_flag', '+n', '+o']), - ('plus_flag +h ++normal_flag', '+', ['++other_normal_flag', '+o']), - ('plus_flag +h ++normal_flag', '++', ['++other_normal_flag ']), - ('plus_flag +h ++normal_flag +o', '+', []), - - # Flag completion should not occur after '--' since that tells argparse the all remaining arguments of non-flags + ('plus_flag', '+', ['++help', '++normal_flag', '+h', '+n']), + ('plus_flag', '++', ['++help', '++normal_flag']), + + # Flag completion should not occur after '--' since that tells argparse all remaining arguments are non-flags ('flag --', '--', []), ('flag --help --', '--', []), ('plus_flag --', '++', []), From 9e49b7eb70b715f8adc06748a73caa5a0fafd065 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 8 Jul 2019 16:16:32 -0400 Subject: [PATCH 44/88] Resetting consumed arguments list each time a flag is used Started adding AutoCompleter unit tests for nargs. --- cmd2/argparse_completer.py | 72 +++++++++++++----------- tests/test_argparse_completer.py | 95 ++++++++++++++++++++++++++------ 2 files changed, 119 insertions(+), 48 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 6fef6f802..9f497ca81 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -227,6 +227,7 @@ def consume_flag_argument() -> None: # does this complete an option item for the flag arg_choices = self._resolve_choices_for_arg(flag_action) + # if the current token matches the current flag'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: @@ -239,42 +240,45 @@ def consume_positional_argument() -> None: # does this complete an option item for the positional 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(arg_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""" + def process_argument_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ArgumentState) -> None: + """Use argument's nargs value to set related values in its ArgumentState object""" + + # Check if nargs is a range nargs_range = getattr(arg_action, ATTR_NARGS_RANGE, None) if nargs_range is not None: arg_state.min = nargs_range[0] arg_state.max = nargs_range[1] arg_state.variable = True - if arg_state.min is None or arg_state.max is None: - if arg_action.nargs is None: - arg_state.min = 1 - arg_state.max = 1 - elif arg_action.nargs == argparse.ONE_OR_MORE: - arg_state.min = 1 - arg_state.max = float('inf') - arg_state.variable = True - elif arg_action.nargs == argparse.ZERO_OR_MORE or arg_action.nargs == argparse.REMAINDER: - arg_state.min = 0 - arg_state.max = float('inf') - arg_state.variable = True - if arg_action.nargs == argparse.REMAINDER: - remainder['action'] = arg_action - remainder['arg'] = arg_state - elif arg_action.nargs == argparse.OPTIONAL: - arg_state.min = 0 - arg_state.max = 1 - arg_state.variable = True - else: - arg_state.min = arg_action.nargs - arg_state.max = arg_action.nargs + + # Otherwise check against argparse types + elif arg_action.nargs is None: + arg_state.min = 1 + arg_state.max = 1 + elif arg_action.nargs == argparse.ONE_OR_MORE: + arg_state.min = 1 + arg_state.max = float('inf') + arg_state.variable = True + elif arg_action.nargs == argparse.ZERO_OR_MORE or arg_action.nargs == argparse.REMAINDER: + arg_state.min = 0 + arg_state.max = float('inf') + arg_state.variable = True + if arg_action.nargs == argparse.REMAINDER: + remainder['action'] = arg_action + remainder['arg'] = arg_state + elif arg_action.nargs == argparse.OPTIONAL: + arg_state.min = 0 + arg_state.max = 1 + arg_state.variable = True + else: + arg_state.min = arg_action.nargs + arg_state.max = arg_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 position should be @@ -333,6 +337,7 @@ def process_action_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ elif 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 @@ -345,8 +350,11 @@ def process_action_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ 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) + process_argument_nargs(flag_action, flag_arg) + + # It's possible we already have consumed values for this flag if it was used earlier + # in the command line. Reset them now for this use of the flag. + consumed_arg_values[flag_action.dest] = [] # Keep track of what flags have already been used # Flags with action set to append, append_const, and count can be reused @@ -389,7 +397,7 @@ def process_action_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ return sub_completers[token].complete_command(tokens, text, line, begidx, endidx) pos_action = action - process_action_nargs(pos_action, pos_arg) + process_argument_nargs(pos_action, pos_arg) consume_positional_argument() elif not is_last_token and pos_arg.max is not None: @@ -434,8 +442,8 @@ def process_action_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ # we're not at a positional argument, see if we're in a flag argument if 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 [] + consumed = consumed_arg_values[flag_action.dest] if flag_action.dest in consumed_arg_values else [] + completion_results = self._complete_for_arg(flag_action, text, line, begidx, endidx, consumed) if not completion_results: self._print_arg_hint(flag_action) @@ -445,8 +453,8 @@ def process_action_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ # 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 [] + consumed = consumed_arg_values[pos_action.dest] if pos_action.dest in consumed_arg_values else [] + completion_results = self._complete_for_arg(pos_action, text, line, begidx, endidx, consumed) if not completion_results: self._print_arg_hint(pos_action) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index b70ab29dc..8d939e05b 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -177,6 +177,21 @@ def completer_method(self, text: str, line: str, begidx: int, endidx: int) -> Li def do_completer(self, args: argparse.Namespace) -> None: pass + ############################################################################################################ + # Begin code related to nargs + ############################################################################################################ + nargs_parser = Cmd2ArgParser() + + # Flag args for nargs command + nargs_parser.add_argument("--set_value", help="a flag with a set value for nargs", nargs=2, + choices=static_choices_list) + nargs_parser.add_argument("--one_or_more", help="a flag with nargs", nargs=argparse.ONE_OR_MORE, + choices=static_choices_list) + + @with_argparser(nargs_parser) + def do_nargs(self, args: argparse.Namespace) -> None: + pass + ############################################################################################################ # Begin code related to testing tab hints ############################################################################################################ @@ -200,6 +215,39 @@ def ac_app(): return app +@pytest.mark.parametrize('args, completions', [ + # nargs = 2 + ('--set_value', static_choices_list), + ('--set_value static', ['choices', 'stop', 'here']), + ('--set_value static choices', []), + + # Using the flag again will reset the choices available + ('--set_value static choices --set_value', static_choices_list), + + # nargs = ONE_OR_MORE + ('--one_or_more', static_choices_list), + ('--one_or_more static', ['choices', 'stop', 'here']), + ('--one_or_more static choices', ['stop', 'here']), + + # No more flags after a double dash + ('-- --one_or_more static choices', []), + +]) +def test_autcomp_nargs(ac_app, args, completions): + text = '' + line = 'nargs {} {}'.format(args, text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + if completions: + assert first_match is not None + else: + assert first_match is None + + assert ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) + + @pytest.mark.parametrize('command', [ 'music', 'music create', @@ -223,7 +271,12 @@ def test_complete_help(ac_app, command, text, completions): begidx = endidx - len(text) first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None and ac_app.completion_matches == completions + if completions: + assert first_match is not None + else: + assert first_match is None + + assert ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) @pytest.mark.parametrize('command_and_args, text, completions', [ @@ -268,7 +321,7 @@ def test_autcomp_flag_completion(ac_app, command_and_args, text, completions): else: assert first_match is None - assert ac_app.completion_matches == completions + assert ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) @pytest.mark.parametrize('flag, text, completions', [ @@ -287,7 +340,12 @@ def test_autocomp_flag_choices_completion(ac_app, flag, text, completions): begidx = endidx - len(text) first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None and ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) + if completions: + assert first_match is not None + else: + assert first_match is None + + assert ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) @pytest.mark.parametrize('pos, text, completions', [ @@ -305,7 +363,12 @@ def test_autocomp_positional_choices_completion(ac_app, pos, text, completions): begidx = endidx - len(text) first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None and ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) + if completions: + assert first_match is not None + else: + assert first_match is None + + assert ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) @pytest.mark.parametrize('flag, text, completions', [ @@ -320,7 +383,12 @@ def test_autocomp_flag_completers(ac_app, flag, text, completions): begidx = endidx - len(text) first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None and ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) + if completions: + assert first_match is not None + else: + assert first_match is None + + assert ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) @pytest.mark.parametrize('pos, text, completions', [ @@ -336,7 +404,12 @@ def test_autocomp_positional_completers(ac_app, pos, text, completions): begidx = endidx - len(text) first_match = complete_tester(text, line, begidx, endidx, ac_app) - assert first_match is not None and ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) + if completions: + assert first_match is not None + else: + assert first_match is None + + assert ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) @pytest.mark.parametrize('num_aliases, show_description', [ @@ -532,16 +605,6 @@ def test_autocomp_hint_no_help(ac_app, capsys): # cmd2_app.completion_matches == ['John Boyega" '] # # -# def test_autocomp_custom_func_dict_arg(cmd2_app): -# text = '/home/user/' -# line = 'video movies load {}'.format(text) -# endidx = len(line) -# begidx = endidx - len(text) -# -# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) -# assert first_match is not None and \ -# cmd2_app.completion_matches == ['/home/user/another.db', '/home/user/file space.db', '/home/user/file.db'] -# # # def test_argparse_remainder_flag_completion(cmd2_app): # import cmd2 From 3f70df68900395a9ba3494eadc9f3fbc97e16b0f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 10 Jul 2019 08:40:46 -0400 Subject: [PATCH 45/88] Fixed issue where names of remainder flags were not completing --- cmd2/argparse_completer.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 9f497ca81..2867a5531 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -228,8 +228,8 @@ def consume_flag_argument() -> None: # does this complete an option item for the flag arg_choices = self._resolve_choices_for_arg(flag_action) - # if the current token matches the current flag's autocomplete argument list, - # track that we've used it already. Unless this is the current token, then keep it. + # If the current token isn't the one being completed and it's in the flag + # argument's autocomplete list, then track that we've used it already. if not is_last_token and token in arg_choices: consumed_arg_values.setdefault(flag_action.dest, []) consumed_arg_values[flag_action.dest].append(token) @@ -241,8 +241,8 @@ def consume_positional_argument() -> None: # does this complete an option item for the positional 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 the current token isn't the one being completed and it's in the positional + # argument's autocomplete list, then track that we've used it already. if not is_last_token and token in arg_choices: consumed_arg_values.setdefault(pos_action.dest, []) consumed_arg_values[pos_action.dest].append(token) @@ -410,12 +410,12 @@ def process_argument_nargs(arg_action: argparse.Action, arg_state: AutoCompleter else: consume_flag_argument() - if remainder['arg'] is not None: - 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 + # To allow completion of the final token, we only do the following on preceding tokens + if not is_last_token: + if remainder['arg'] is not None: + skip_remaining_flags = True + elif flag_arg.min is not None: + flag_arg.needed = flag_arg.count < flag_arg.min # Here we're done parsing all of the prior arguments. We know what the next argument is. From b188531a0c7277b7e3d70d6c096e00f5653415c3 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 10 Jul 2019 09:05:40 -0400 Subject: [PATCH 46/88] Added some tests for REMAINDER flags --- tests/test_argparse_completer.py | 37 +++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 8d939e05b..6297dfb34 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -98,6 +98,7 @@ def do_music(self, args: argparse.Namespace) -> None: flag_parser.add_argument('-o', '--append_const_flag', help='Append const flag', action='append_const', const=True) flag_parser.add_argument('-c', '--count_flag', help='Count flag', action='count') flag_parser.add_argument('-s', '--suppressed_flag', help=argparse.SUPPRESS, action='store_true') + flag_parser.add_argument('-r', '--remainder_flag', nargs=argparse.REMAINDER, help='a remainder flag') @with_argparser(flag_parser) def do_flag(self, args: argparse.Namespace) -> None: @@ -185,7 +186,9 @@ def do_completer(self, args: argparse.Namespace) -> None: # Flag args for nargs command nargs_parser.add_argument("--set_value", help="a flag with a set value for nargs", nargs=2, choices=static_choices_list) - nargs_parser.add_argument("--one_or_more", help="a flag with nargs", nargs=argparse.ONE_OR_MORE, + nargs_parser.add_argument("--one_or_more", help="a flag wanting one or more args", nargs=argparse.ONE_OR_MORE, + choices=static_choices_list) + nargs_parser.add_argument("--remainder", help="a flag wanting remaining", nargs=argparse.REMAINDER, choices=static_choices_list) @with_argparser(nargs_parser) @@ -216,7 +219,7 @@ def ac_app(): @pytest.mark.parametrize('args, completions', [ - # nargs = 2 + # Flag with nargs = 2 ('--set_value', static_choices_list), ('--set_value static', ['choices', 'stop', 'here']), ('--set_value static choices', []), @@ -224,7 +227,7 @@ def ac_app(): # Using the flag again will reset the choices available ('--set_value static choices --set_value', static_choices_list), - # nargs = ONE_OR_MORE + # Flag with nargs = ONE_OR_MORE ('--one_or_more', static_choices_list), ('--one_or_more static', ['choices', 'stop', 'here']), ('--one_or_more static choices', ['stop', 'here']), @@ -232,6 +235,12 @@ def ac_app(): # No more flags after a double dash ('-- --one_or_more static choices', []), + # Flag with nargs = REMAINDER + ('--remainder', static_choices_list), + ('--remainder static ', ['choices', 'stop', 'here']), + + # No more flags can appear after a REMAINDER flag) + ('--remainder static --set_value', ['choices', 'stop', 'here']) ]) def test_autcomp_nargs(ac_app, args, completions): text = '' @@ -281,24 +290,36 @@ def test_complete_help(ac_app, command, text, completions): @pytest.mark.parametrize('command_and_args, text, completions', [ # Complete all flags (suppressed will not show) - ('flag', '-', ['--append_const_flag', '--append_flag', '--count_flag', '--help', - '--normal_flag', '-a', '-c', '-h', '-n', '-o']), - ('flag', '--', ['--append_const_flag', '--append_flag', '--count_flag', '--help', '--normal_flag']), + ('flag', '-', ['--append_const_flag', '--append_flag', '--count_flag', '--help', '--normal_flag', + '--remainder_flag', '-a', '-c', '-h', '-n', '-o', '-r']), + ('flag', '--', ['--append_const_flag', '--append_flag', '--count_flag', '--help', + '--normal_flag', '--remainder_flag']), # Complete individual flag ('flag', '-n', ['-n ']), ('flag', '--n', ['--normal_flag ']), + # No flags should complete until current flag has its args + ('flag --append_flag', '-', []), + + # Complete REMAINDER flag name + ('flag', '-r', ['-r ']), + ('flag', '--r', ['--remainder_flag ']), + + # No flags after a REMAINDER should complete + ('flag -r value', '-', []), + ('flag --remainder_flag value', '--', []), + # Suppressed flag should not complete ('flag', '-s', []), ('flag', '--s', []), # A used flag should not show in completions - ('flag -n', '--', ['--append_const_flag', '--append_flag', '--count_flag', '--help']), + ('flag -n', '--', ['--append_const_flag', '--append_flag', '--count_flag', '--help', '--remainder_flag']), # Flags with actions set to append, append_const, and count will always show even if they've been used ('flag --append_const_flag -c --append_flag value', '--', ['--append_const_flag', '--append_flag', '--count_flag', - '--help', '--normal_flag']), + '--help', '--normal_flag', '--remainder_flag']), # Non-default flag prefix character (+) ('plus_flag', '+', ['++help', '++normal_flag', '+h', '+n']), From 1c2dc43a982eada206975f97e8e49c5ea95d9dd5 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 10 Jul 2019 11:51:06 -0400 Subject: [PATCH 47/88] Small refactoring to simplify code --- cmd2/argparse_completer.py | 264 ++++++++++++++++++------------------- cmd2/cmd2.py | 4 +- 2 files changed, 132 insertions(+), 136 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 2867a5531..44982f285 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -188,7 +188,7 @@ def __init__(self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *, 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 not tokens: + 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 @@ -291,131 +291,131 @@ def process_argument_nargs(arg_action: argparse.Action, arg_state: AutoCompleter # 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 + # Notes when the token being completed has been reached is_last_token = False - for idx, token in enumerate(tokens): - is_last_token = idx >= len(tokens) - 1 + # Enumerate over the sliced list + for loop_index, token in enumerate(tokens[self._token_start_index:]): + token_index = loop_index + self._token_start_index + if token_index >= len(tokens) - 1: + is_last_token = True + + # 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 - # Only start at the start token index - if idx >= self._token_start_index: + # Handle '--' which tells argparse all remaining arguments are non-flags + if token == '--' and not skip_remaining_flags: + if is_last_token: + # Exit loop and see if -- can be completed into a flag + break + else: + skip_remaining_flags = True - # 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: + # At this point we're no longer consuming flag arguments. Is the current argument a potential flag? + elif 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 + + # 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: + process_argument_nargs(flag_action, flag_arg) + + # It's possible we already have consumed values for this flag if it was used earlier + # in the command line. Reset them now for this use of the flag. + consumed_arg_values[flag_action.dest] = [] + + # Keep track of what flags have already been used + # Flags with action set to append, append_const, and count can be reused + if not is_last_token and \ + not isinstance(flag_action, argparse._AppendAction) and \ + not isinstance(flag_action, argparse._AppendConstAction) and \ + not isinstance(flag_action, argparse._CountAction): + 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() - 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 - - # Handle '--' which tells argparse all remaining arguments are non-flags - if token == '--' and not skip_remaining_flags: - if is_last_token: - # Exit loop and see if -- can be completed into a flag - break - else: - skip_remaining_flags = True - - # At this point we're no longer consuming flag arguments. Is the current argument a potential flag? - elif 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 - - # 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: - process_argument_nargs(flag_action, flag_arg) - - # It's possible we already have consumed values for this flag if it was used earlier - # in the command line. Reset them now for this use of the flag. - consumed_arg_values[flag_action.dest] = [] - - # Keep track of what flags have already been used - # Flags with action set to append, append_const, and count can be reused - if not is_last_token and \ - not isinstance(flag_action, argparse._AppendAction) and \ - not isinstance(flag_action, argparse._AppendConstAction) and \ - not isinstance(flag_action, argparse._CountAction): - 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. + 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_argument_nargs(pos_action, pos_arg) 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_argument_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() + elif not is_last_token and pos_arg.max is not None: + pos_action = None + pos_arg.reset() else: consume_flag_argument() - # To allow completion of the final token, we only do the following on preceding tokens - if not is_last_token: - if remainder['arg'] is not None: - skip_remaining_flags = True - elif flag_arg.min is not None: - flag_arg.needed = flag_arg.count < flag_arg.min + else: + consume_flag_argument() + + # To allow completion of the final token, we only do the following on preceding tokens + if not is_last_token: + if remainder['arg'] is not None: + skip_remaining_flags = True + elif flag_arg.min is not None: + flag_arg.needed = flag_arg.count < flag_arg.min # Here we're done parsing all of the prior arguments. We know what the next argument is. @@ -509,16 +509,15 @@ def complete_command_help(self, tokens: List[str], text: str, line: str, begidx: :param endidx: the ending index of the prefix text :return: List of subcommand completions """ - 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 utils.basic_complete(text, line, begidx, endidx, completers.keys()) + 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: @@ -527,14 +526,13 @@ def format_help(self, tokens: List[str]) -> str: :param tokens: command line tokens :return: help text of the subcommand being queried """ - 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) + 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, arg: argparse.Action, diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 0d14d5ad4..4ac63a079 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1926,8 +1926,6 @@ def _resolve_macro(self, statement: Statement) -> Optional[str]: :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)) @@ -1960,7 +1958,7 @@ def _resolve_macro(self, statement: Statement) -> Optional[str]: 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. From 2ebf63617990d1ee0ebe08f7c3938234c7c5fd24 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 10 Jul 2019 14:49:04 -0400 Subject: [PATCH 48/88] Fixed bug where -- wasn't stopping a REMAINDER flag and did a lot of refactoring --- cmd2/argparse_completer.py | 217 +++++++++++++++---------------- tests/test_argparse_completer.py | 12 +- 2 files changed, 113 insertions(+), 116 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 44982f285..d87229a7b 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -84,10 +84,6 @@ def is_potential_flag(token: str, parser: argparse.ArgumentParser) -> bool: 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 parser._negative_number_matcher.match(token): @@ -107,20 +103,43 @@ class AutoCompleter(object): """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 + 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] + self.variable = True + + # Otherwise check against argparse types + elif self.action.nargs is None: + self.min = 1 + self.max = 1 + elif self.action.nargs == argparse.ONE_OR_MORE: + self.min = 1 + self.max = float('inf') + self.variable = True + elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER: + self.min = 0 + self.max = float('inf') + self.variable = True + elif self.action.nargs == argparse.OPTIONAL: + self.min = 0 + self.max = 1 + self.variable = True + 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: @@ -199,11 +218,11 @@ def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, # 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 + # _ArgumentState of the current flag + flag_arg_state = None # dict is used because object wrapper is necessary to allow inner functions to modify outer variables remainder = {'arg': None, 'action': None} @@ -220,65 +239,37 @@ def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, def consume_flag_argument() -> None: """Consuming token as a flag argument""" - # we're consuming flag arguments + if flag_arg_state is None: + return + # 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 + if not is_potential_flag(token, self._parser): + flag_arg_state.count += 1 # does this complete an option item for the flag - arg_choices = self._resolve_choices_for_arg(flag_action) + arg_choices = self._resolve_choices_for_arg(flag_arg_state.action) # If the current token isn't the one being completed and it's in the flag # argument's autocomplete list, then track that we've used it already. if not is_last_token and token in arg_choices: - consumed_arg_values.setdefault(flag_action.dest, []) - consumed_arg_values[flag_action.dest].append(token) + consumed_arg_values.setdefault(flag_arg_state.action.dest, []) + consumed_arg_values[flag_arg_state.action.dest].append(token) def consume_positional_argument() -> None: """Consuming token as positional argument""" - pos_arg.count += 1 + if pos_arg_state is None: + return + + pos_arg_state.count += 1 # does this complete an option item for the positional - arg_choices = self._resolve_choices_for_arg(pos_action) + arg_choices = self._resolve_choices_for_arg(pos_arg_state.action) # If the current token isn't the one being completed and it's in the positional # argument's autocomplete list, then track that we've used it already. if not is_last_token and token in arg_choices: - consumed_arg_values.setdefault(pos_action.dest, []) - consumed_arg_values[pos_action.dest].append(token) - - def process_argument_nargs(arg_action: argparse.Action, arg_state: AutoCompleter._ArgumentState) -> None: - """Use argument's nargs value to set related values in its ArgumentState object""" - - # Check if nargs is a range - nargs_range = getattr(arg_action, ATTR_NARGS_RANGE, None) - if nargs_range is not None: - arg_state.min = nargs_range[0] - arg_state.max = nargs_range[1] - arg_state.variable = True - - # Otherwise check against argparse types - elif arg_action.nargs is None: - arg_state.min = 1 - arg_state.max = 1 - elif arg_action.nargs == argparse.ONE_OR_MORE: - arg_state.min = 1 - arg_state.max = float('inf') - arg_state.variable = True - elif arg_action.nargs == argparse.ZERO_OR_MORE or arg_action.nargs == argparse.REMAINDER: - arg_state.min = 0 - arg_state.max = float('inf') - arg_state.variable = True - if arg_action.nargs == argparse.REMAINDER: - remainder['action'] = arg_action - remainder['arg'] = arg_state - elif arg_action.nargs == argparse.OPTIONAL: - arg_state.min = 0 - arg_state.max = 1 - arg_state.variable = True - else: - arg_state.min = arg_action.nargs - arg_state.max = arg_action.nargs + consumed_arg_values.setdefault(pos_arg_state.action.dest, []) + consumed_arg_values[pos_arg_state.action.dest].append(token) # This next block of processing tries to parse all parameters before the last parameter. # We're trying to determine what specific argument the current cursor position should be @@ -301,18 +292,24 @@ def process_argument_nargs(arg_action: argparse.Action, arg_state: AutoCompleter is_last_token = True # 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: + if pos_arg_state is not None and pos_arg_state.is_remainder: + consume_positional_argument() + continue + elif flag_arg_state is not None and flag_arg_state.is_remainder: + skip_remaining_flags = True + if token == '--': + # End this flag and don't allow any more flags + flag_arg_state = None + else: consume_flag_argument() - continue + continue current_is_positional = False - # Are we consuming flag arguments? - if not flag_arg.needed: + # Are we consuming flag arguments? + if flag_arg_state is not None and flag_arg_state.needed: + consume_flag_argument() + else: if not skip_remaining_flags: # Special case when each of the following is true: # - We're not in the middle of consuming flag arguments @@ -320,7 +317,7 @@ def process_argument_nargs(arg_action: argparse.Action, arg_state: AutoCompleter # - 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 \ + if pos_arg_state is not None and pos_arg_state.count >= pos_arg_state.max and \ next_pos_arg_index < len(self._positional_actions) and \ self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER: skip_remaining_flags = True @@ -335,57 +332,56 @@ def process_argument_nargs(arg_action: argparse.Action, arg_state: AutoCompleter # At this point we're no longer consuming flag arguments. Is the current argument a potential flag? elif 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 + # Reset flag arg state but not positional tracking because flags can be + # interspersed anywhere between positionals + flag_arg_state = None + action = None # does the token fully match a known flag? if token in self._flag_to_action: - flag_action = self._flag_to_action[token] + 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]] + action = self._flag_to_action[candidates_flags[0]] - if flag_action is not None: - process_argument_nargs(flag_action, flag_arg) + if action is not None: + flag_arg_state = AutoCompleter._ArgumentState(action) # It's possible we already have consumed values for this flag if it was used earlier # in the command line. Reset them now for this use of the flag. - consumed_arg_values[flag_action.dest] = [] + consumed_arg_values[flag_arg_state.action.dest] = [] # Keep track of what flags have already been used # Flags with action set to append, append_const, and count can be reused if not is_last_token and \ - not isinstance(flag_action, argparse._AppendAction) and \ - not isinstance(flag_action, argparse._AppendConstAction) and \ - not isinstance(flag_action, argparse._CountAction): - matched_flags.extend(flag_action.option_strings) + not isinstance(flag_arg_state.action, argparse._AppendAction) and \ + not isinstance(flag_arg_state.action, argparse._AppendConstAction) and \ + not isinstance(flag_arg_state.action, argparse._CountAction): + matched_flags.extend(flag_arg_state.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: + elif flag_arg_state is None or \ + not flag_arg_state.variable or \ + flag_arg_state.count >= flag_arg_state.max: # previous flag doesn't accept variable arguments, count this as a positional argument # reset flag tracking variables - flag_arg.reset() - flag_action = None + flag_arg_state = None current_is_positional = True - if len(token) > 0 and pos_action is not None and pos_arg.count < pos_arg.max: + if len(token) > 0 and pos_arg_state is not None and pos_arg_state.count < pos_arg_state.max: # we have positional action match and we haven't reached the max arg count, consume # the positional argument and move on. consume_positional_argument() - elif pos_action is None or pos_arg.count >= pos_arg.max: + elif pos_arg_state is None or pos_arg_state.count >= pos_arg_state.max: # if we don't have a current positional action or we've reached the max count for the action # close out the current positional argument state and set up for the next one pos_index = next_pos_arg_index next_pos_arg_index += 1 - pos_arg.reset() - pos_action = None + pos_arg_state = None # are we at a sub-command? If so, forward to the matching completer if pos_index < len(self._positional_actions): @@ -396,33 +392,29 @@ def process_argument_nargs(arg_action: argparse.Action, arg_state: AutoCompleter if token in sub_completers: return sub_completers[token].complete_command(tokens, text, line, begidx, endidx) - pos_action = action - process_argument_nargs(pos_action, pos_arg) + + pos_arg_state = AutoCompleter._ArgumentState(action) consume_positional_argument() - elif not is_last_token and pos_arg.max is not None: - pos_action = None - pos_arg.reset() + elif not is_last_token and pos_arg_state is not None: + pos_arg_state = None else: consume_flag_argument() - else: - consume_flag_argument() - # To allow completion of the final token, we only do the following on preceding tokens if not is_last_token: if remainder['arg'] is not None: skip_remaining_flags = True - elif flag_arg.min is not None: - flag_arg.needed = flag_arg.count < flag_arg.min + elif flag_arg_state is not None and flag_arg_state.min is not None: + flag_arg_state.needed = flag_arg_state.count < flag_arg_state.min # Here we're done parsing all of the prior arguments. We know what the next argument is. # 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: + if (flag_arg_state is None or not flag_arg_state.needed) and \ + is_potential_flag(tokens[-1], self._parser) and not skip_remaining_flags: # Build a list of flags that can be tab completed match_against = [] @@ -441,25 +433,26 @@ def process_argument_nargs(arg_action: argparse.Action, arg_state: AutoCompleter # we're not at a positional argument, see if we're in a flag argument if 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 [] - - completion_results = self._complete_for_arg(flag_action, text, line, begidx, endidx, consumed) + if flag_arg_state is not None: + consumed = consumed_arg_values.get(flag_arg_state.action.dest, []) + completion_results = self._complete_for_arg(flag_arg_state.action, text, line, + begidx, endidx, consumed) if not completion_results: - self._print_arg_hint(flag_action) + self._print_arg_hint(flag_arg_state.action) elif len(completion_results) > 1: - completion_results = self._format_completions(flag_action, completion_results) + completion_results = self._format_completions(flag_arg_state.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: - consumed = consumed_arg_values[pos_action.dest] if pos_action.dest in consumed_arg_values else [] + if pos_arg_state is not None: + consumed = consumed_arg_values.get(pos_arg_state.action.dest, []) + completion_results = self._complete_for_arg(pos_arg_state.action, text, line, + begidx, endidx, consumed) - completion_results = self._complete_for_arg(pos_action, text, line, begidx, endidx, consumed) if not completion_results: - self._print_arg_hint(pos_action) + self._print_arg_hint(pos_arg_state.action) elif len(completion_results) > 1: - completion_results = self._format_completions(pos_action, completion_results) + completion_results = self._format_completions(pos_arg_state.action, completion_results) return completion_results diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 6297dfb34..404ba10b2 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -240,7 +240,10 @@ def ac_app(): ('--remainder static ', ['choices', 'stop', 'here']), # No more flags can appear after a REMAINDER flag) - ('--remainder static --set_value', ['choices', 'stop', 'here']) + ('--remainder static --set_value', ['choices', 'stop', 'here']), + + # Double dash ends a remainder flag + ('--remainder static --', []) ]) def test_autcomp_nargs(ac_app, args, completions): text = '' @@ -671,14 +674,15 @@ def test_is_potential_flag(): from cmd2.argparse_completer import is_potential_flag parser = Cmd2ArgParser() - # Not valid flags + # Not potential flags assert not is_potential_flag('', parser) assert not is_potential_flag('non-flag', parser) - assert not is_potential_flag('-', parser) assert not is_potential_flag('--has space', parser) assert not is_potential_flag('-2', parser) - # Valid flags + # Potential flags + assert is_potential_flag('-', parser) + assert is_potential_flag('--', parser) assert is_potential_flag('-flag', parser) assert is_potential_flag('--flag', parser) From 60e294ee645e092b8713d5cf60f8797d3685416a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 10 Jul 2019 14:55:12 -0400 Subject: [PATCH 49/88] Removed unused variable --- cmd2/argparse_completer.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index d87229a7b..27e7df254 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -224,9 +224,6 @@ def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, # _ArgumentState of the current flag flag_arg_state = None - # dict is used because object wrapper is necessary to allow inner functions to modify outer variables - remainder = {'arg': None, 'action': None} - matched_flags = [] current_is_positional = False consumed_arg_values = {} # dict(arg_name -> [values, ...]) @@ -404,9 +401,7 @@ def consume_positional_argument() -> None: # To allow completion of the final token, we only do the following on preceding tokens if not is_last_token: - if remainder['arg'] is not None: - skip_remaining_flags = True - elif flag_arg_state is not None and flag_arg_state.min is not None: + if flag_arg_state is not None and flag_arg_state.min is not None: flag_arg_state.needed = flag_arg_state.count < flag_arg_state.min # Here we're done parsing all of the prior arguments. We know what the next argument is. From 782bab855b0ec1d1b9728a322b932f99e6fb3849 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 10 Jul 2019 16:22:47 -0400 Subject: [PATCH 50/88] Fixed some double-dash handling logic added unit tests --- cmd2/argparse_completer.py | 26 ++++---- tests/test_argparse_completer.py | 102 +++++++++++++++++-------------- 2 files changed, 72 insertions(+), 56 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 27e7df254..d7ffc1804 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -288,19 +288,31 @@ def consume_positional_argument() -> None: if token_index >= len(tokens) - 1: is_last_token = True - # If a remainder action is found, force all future tokens to go to that + # If we're in a positional REMAINDER arg, force all future tokens to go to that if pos_arg_state is not None and pos_arg_state.is_remainder: consume_positional_argument() 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: skip_remaining_flags = True if token == '--': - # End this flag and don't allow any more flags flag_arg_state = None else: consume_flag_argument() continue + # Handle '--' which tells argparse all remaining arguments are non-flags + elif token == '--' and not skip_remaining_flags: + if is_last_token: + # Exit loop and see if -- can be completed into a flag + break + else: + # End the current flag + flag_arg_state = None + skip_remaining_flags = True + continue + current_is_positional = False # Are we consuming flag arguments? @@ -319,16 +331,8 @@ def consume_positional_argument() -> None: self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER: skip_remaining_flags = True - # Handle '--' which tells argparse all remaining arguments are non-flags - if token == '--' and not skip_remaining_flags: - if is_last_token: - # Exit loop and see if -- can be completed into a flag - break - else: - skip_remaining_flags = True - # At this point we're no longer consuming flag arguments. Is the current argument a potential flag? - elif is_potential_flag(token, self._parser) and not skip_remaining_flags: + if is_potential_flag(token, self._parser) and not skip_remaining_flags: # Reset flag arg state but not positional tracking because flags can be # interspersed anywhere between positionals flag_arg_state = None diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 404ba10b2..cf4ac7b32 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -19,6 +19,11 @@ choices_from_function = ['choices', 'function', 'chatty', 'smith'] choices_from_method = ['choices', 'method', 'most', 'improved'] +set_value_choices = ['set', 'value', 'choices'] +one_or_more_choices = ['one', 'or', 'more', 'choices'] +optional_choices = ['optional', 'choices'] +remainder_choices = ['remainder', 'choices'] + completions_from_function = ['completions', 'function', 'fairly', 'complete'] completions_from_method = ['completions', 'method', 'missed', 'spot'] @@ -185,11 +190,13 @@ def do_completer(self, args: argparse.Namespace) -> None: # Flag args for nargs command nargs_parser.add_argument("--set_value", help="a flag with a set value for nargs", nargs=2, - choices=static_choices_list) + choices=set_value_choices) nargs_parser.add_argument("--one_or_more", help="a flag wanting one or more args", nargs=argparse.ONE_OR_MORE, - choices=static_choices_list) + choices=one_or_more_choices) + nargs_parser.add_argument("--optional", help="a flag with an optional value", nargs=argparse.OPTIONAL, + choices=optional_choices) nargs_parser.add_argument("--remainder", help="a flag wanting remaining", nargs=argparse.REMAINDER, - choices=static_choices_list) + choices=remainder_choices) @with_argparser(nargs_parser) def do_nargs(self, args: argparse.Namespace) -> None: @@ -218,48 +225,6 @@ def ac_app(): return app -@pytest.mark.parametrize('args, completions', [ - # Flag with nargs = 2 - ('--set_value', static_choices_list), - ('--set_value static', ['choices', 'stop', 'here']), - ('--set_value static choices', []), - - # Using the flag again will reset the choices available - ('--set_value static choices --set_value', static_choices_list), - - # Flag with nargs = ONE_OR_MORE - ('--one_or_more', static_choices_list), - ('--one_or_more static', ['choices', 'stop', 'here']), - ('--one_or_more static choices', ['stop', 'here']), - - # No more flags after a double dash - ('-- --one_or_more static choices', []), - - # Flag with nargs = REMAINDER - ('--remainder', static_choices_list), - ('--remainder static ', ['choices', 'stop', 'here']), - - # No more flags can appear after a REMAINDER flag) - ('--remainder static --set_value', ['choices', 'stop', 'here']), - - # Double dash ends a remainder flag - ('--remainder static --', []) -]) -def test_autcomp_nargs(ac_app, args, completions): - text = '' - line = 'nargs {} {}'.format(args, text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, ac_app) - if completions: - assert first_match is not None - else: - assert first_match is None - - assert ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) - - @pytest.mark.parametrize('command', [ 'music', 'music create', @@ -465,6 +430,53 @@ def test_completion_items(ac_app, num_aliases, show_description): assert ('help' in ac_app.display_matches[0]) == show_description +@pytest.mark.parametrize('args, completions', [ + # Flag with nargs = 2 + ('--set_value', set_value_choices), + ('--set_value set', ['value', 'choices']), + ('--set_value set value choices', []), + + # Another flag can't start until all expected args are filled out + ('--set_value --one_or_more', set_value_choices), + + # Using the flag again will reset the choices available + ('--set_value set value --set_value', set_value_choices), + + # Flag with nargs = ONE_OR_MORE + ('--one_or_more', one_or_more_choices), + ('--one_or_more one', ['or', 'more', 'choices']), + + # Flag with nargs = REMAINDER + ('--remainder', remainder_choices), + ('--remainder remainder ', ['choices ']), + + # No more flags can appear after a REMAINDER flag) + ('--remainder choices --set_value', ['remainder ']), + + # Double dash ends the current flag (even if all expected args aren't entered) + ('--set_value --', []), + + # Double dash ends a REMAINDER flag + ('--remainder remainder --', []), + + # No more flags after a double dash + ('-- --one_or_more ', []), +]) +def test_autcomp_nargs(ac_app, args, completions): + text = '' + line = 'nargs {} {}'.format(args, text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, ac_app) + if completions: + assert first_match is not None + else: + assert first_match is None + + assert ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) + + def test_completion_items_default_header(ac_app): from cmd2.argparse_completer import DEFAULT_DESCRIPTIVE_HEADER From 734fee811025a8f1a68d985907649937ac42a629 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 10 Jul 2019 16:37:26 -0400 Subject: [PATCH 51/88] Small refactors and doc updates --- cmd2/argparse_completer.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index d7ffc1804..a4b25ea5c 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -125,17 +125,17 @@ def __init__(self, arg_action: argparse.Action) -> None: elif self.action.nargs is None: self.min = 1 self.max = 1 - elif self.action.nargs == argparse.ONE_OR_MORE: - self.min = 1 - self.max = float('inf') + elif self.action.nargs == argparse.OPTIONAL: + self.min = 0 + self.max = 1 self.variable = True elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER: self.min = 0 self.max = float('inf') self.variable = True - elif self.action.nargs == argparse.OPTIONAL: - self.min = 0 - self.max = 1 + elif self.action.nargs == argparse.ONE_OR_MORE: + self.min = 1 + self.max = float('inf') self.variable = True else: self.min = self.action.nargs @@ -404,9 +404,8 @@ def consume_positional_argument() -> None: consume_flag_argument() # To allow completion of the final token, we only do the following on preceding tokens - if not is_last_token: - if flag_arg_state is not None and flag_arg_state.min is not None: - flag_arg_state.needed = flag_arg_state.count < flag_arg_state.min + if not is_last_token and flag_arg_state is not None: + flag_arg_state.needed = flag_arg_state.count < flag_arg_state.min # Here we're done parsing all of the prior arguments. We know what the next argument is. @@ -493,7 +492,7 @@ def _format_completions(self, action, completions: List[Union[str, CompletionIte def complete_command_help(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]: """ - Supports the completion of sub-command names + 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 From 2ef1f27bdd599a47f34f186fddb3b5550352e04b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 10 Jul 2019 16:46:36 -0400 Subject: [PATCH 52/88] Added unit tests --- tests/test_argparse_completer.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index cf4ac7b32..5c6b750fe 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -21,7 +21,8 @@ set_value_choices = ['set', 'value', 'choices'] one_or_more_choices = ['one', 'or', 'more', 'choices'] -optional_choices = ['optional', 'choices'] +optional_choices = ['a', 'few', 'optional', 'choices'] +range_choices = ['some', 'range', 'choices'] remainder_choices = ['remainder', 'choices'] completions_from_function = ['completions', 'function', 'fairly', 'complete'] @@ -195,6 +196,8 @@ def do_completer(self, args: argparse.Namespace) -> None: choices=one_or_more_choices) nargs_parser.add_argument("--optional", help="a flag with an optional value", nargs=argparse.OPTIONAL, choices=optional_choices) + nargs_parser.add_argument("--range", help="a flag with nargs range", nargs=(1, 2), + choices=range_choices) nargs_parser.add_argument("--remainder", help="a flag wanting remaining", nargs=argparse.REMAINDER, choices=remainder_choices) @@ -446,6 +449,19 @@ def test_completion_items(ac_app, num_aliases, show_description): ('--one_or_more', one_or_more_choices), ('--one_or_more one', ['or', 'more', 'choices']), + # Flag with nargs = OPTIONAL + ('--optional', optional_choices), + + # Only one arg allowed for an OPTIONAL to completions are now empty + ('--optional optional', []), + + # Flag with nargs range (1, 2) + ('--range', range_choices), + ('--range some', ['range', 'choices']), + + # Already used 2 args so no more completions + ('--range some range', []), + # Flag with nargs = REMAINDER ('--remainder', remainder_choices), ('--remainder remainder ', ['choices ']), From 50e143c34147ada3f693cdf1b11e4e88fedb3104 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 10 Jul 2019 18:57:14 -0400 Subject: [PATCH 53/88] Added unit tests for argparse_custom.py --- tests/test_argparse_custom.py | 108 +++++++++++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 7 deletions(-) diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 85587d498..35d97974a 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -2,50 +2,144 @@ """ Unit/functional testing for argparse customizations in cmd2 """ +import argparse + import pytest + +import cmd2 from cmd2.argparse_custom import Cmd2ArgParser +from .conftest import run_cmd + + +class ApCustomTestApp(cmd2.Cmd): + """Test app for cmd2's argparse customization""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + range_parser = Cmd2ArgParser() + range_parser.add_argument('--arg1', nargs=(2, 3)) + range_parser.add_argument('--arg2', nargs=argparse.ZERO_OR_MORE) + range_parser.add_argument('--arg3', nargs=argparse.ONE_OR_MORE) + + @cmd2.with_argparser(range_parser) + def do_range(self, _): + pass + + +@pytest.fixture +def cust_app(): + return ApCustomTestApp() + + +def fake_func(): + pass + + +@pytest.mark.parametrize('args, is_valid', [ + ({'choices': []}, True), + ({'choices_function': fake_func}, True), + ({'choices_method': fake_func}, True), + ({'completer_function': fake_func}, True), + ({'completer_method': fake_func}, True), + ({'choices': [], 'choices_function': fake_func}, False), + ({'choices': [], 'choices_method': fake_func}, False), + ({'choices_method': fake_func, 'completer_function': fake_func}, False), + ({'choices_method': fake_func, 'completer_method': fake_func}, False), +]) +def test_apcustom_invalid_args(args, is_valid): + parser = Cmd2ArgParser(prog='test') + try: + parser.add_argument('name', **args) + assert is_valid + except ValueError as ex: + assert not is_valid + assert 'Only one of the following may be used' in str(ex) + + +def test_apcustom_usage(): + usage = "A custom usage statement" + parser = Cmd2ArgParser(usage=usage) + help = parser.format_help() + assert usage in help -def test_acarg_narg_empty_tuple(): +def test_apcustom_nargs_help_format(cust_app): + out, err = run_cmd(cust_app, 'help range') + assert 'Usage: range [-h] [--arg1 ARG1{2..3}] [--arg2 [ARG2 [...]]]' in out[0] + assert ' [--arg3 ARG3 [...]]' in out[1] + + +def test_apcustom_nargs_not_enough(cust_app): + out, err = run_cmd(cust_app, 'range --arg1 one') + assert 'Error: argument --arg1: Expected between 2 and 3 arguments' in err[2] + + +def test_apcustom_narg_empty_tuple(): with pytest.raises(ValueError) as excinfo: parser = Cmd2ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=()) assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value) -def test_acarg_narg_single_tuple(): +def test_apcustom_narg_single_tuple(): with pytest.raises(ValueError) as excinfo: parser = Cmd2ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=(1,)) assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value) -def test_acarg_narg_tuple_triple(): +def test_apcustom_narg_tuple_triple(): with pytest.raises(ValueError) as excinfo: parser = Cmd2ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=(1, 2, 3)) assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value) -def test_acarg_narg_tuple_order(): +def test_apcustom_narg_tuple_order(): with pytest.raises(ValueError) as excinfo: parser = Cmd2ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=(2, 1)) assert 'Invalid nargs range. The first value must be less than the second' in str(excinfo.value) -def test_acarg_narg_tuple_negative(): +def test_apcustom_narg_tuple_negative(): with pytest.raises(ValueError) as excinfo: parser = Cmd2ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=(-1, 1)) assert 'Negative numbers are invalid for nargs range' in str(excinfo.value) -def test_acarg_narg_tuple_zero_base(): +def test_apcustom_narg_tuple_zero_base(): parser = Cmd2ArgParser(prog='test') parser.add_argument('tuple', nargs=(0, 3)) -def test_acarg_narg_tuple_zero_to_one(): +def test_apcustom_narg_tuple_zero_to_one(): parser = Cmd2ArgParser(prog='test') parser.add_argument('tuple', nargs=(0, 1)) + + +def test_apcustom_print_message(capsys): + import sys + test_message = 'The test message' + + # Specify the file + parser = Cmd2ArgParser(prog='test') + parser._print_message(test_message, file=sys.stdout) + out, err = capsys.readouterr() + assert test_message in out + + # Make sure file defaults to sys.stderr + parser = Cmd2ArgParser(prog='test') + parser._print_message(test_message) + out, err = capsys.readouterr() + assert test_message in err + + +def test_apcustom_required_options(): + # Make sure a 'required arguments' section shows when a flag is marked required + parser = Cmd2ArgParser(prog='test') + parser.add_argument('--required_flag', required=True) + help = parser.format_help() + + assert 'required arguments' in help From bc80c994abece4ac1a0540beecd93624d96514b7 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 10 Jul 2019 18:58:28 -0400 Subject: [PATCH 54/88] Small refactoring --- cmd2/argparse_completer.py | 27 +++++++++++---------------- tests/test_argparse_completer.py | 4 ++-- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index a4b25ea5c..1393db0e7 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -236,14 +236,11 @@ def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, def consume_flag_argument() -> None: """Consuming token as a flag argument""" - if flag_arg_state is None: - return - # if the token does not look like a new flag, then count towards flag arguments - if not is_potential_flag(token, self._parser): + if flag_arg_state is not None and not is_potential_flag(token, self._parser): flag_arg_state.count += 1 - # does this complete an option item for the flag + # Does this complete an option item for the flag? arg_choices = self._resolve_choices_for_arg(flag_arg_state.action) # If the current token isn't the one being completed and it's in the flag @@ -254,19 +251,17 @@ def consume_flag_argument() -> None: def consume_positional_argument() -> None: """Consuming token as positional argument""" - if pos_arg_state is None: - return - - pos_arg_state.count += 1 + if pos_arg_state is not None: + pos_arg_state.count += 1 - # does this complete an option item for the positional - arg_choices = self._resolve_choices_for_arg(pos_arg_state.action) + # Does this complete an option item for the positional? + arg_choices = self._resolve_choices_for_arg(pos_arg_state.action) - # If the current token isn't the one being completed and it's in the positional - # argument's autocomplete list, then track that we've used it already. - if not is_last_token and token in arg_choices: - consumed_arg_values.setdefault(pos_arg_state.action.dest, []) - consumed_arg_values[pos_arg_state.action.dest].append(token) + # If the current token isn't the one being completed and it's in the positional + # argument's autocomplete list, then track that we've used it already. + if not is_last_token and token in arg_choices: + consumed_arg_values.setdefault(pos_arg_state.action.dest, []) + consumed_arg_values[pos_arg_state.action.dest].append(token) # This next block of processing tries to parse all parameters before the last parameter. # We're trying to determine what specific argument the current cursor position should be diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 5c6b750fe..7a7559f4c 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -42,8 +42,8 @@ def completer_function(text: str, line: str, begidx: int, endidx: int) -> List[s # noinspection PyMethodMayBeStatic,PyUnusedLocal class AutoCompleteTester(cmd2.Cmd): """Cmd2 app that exercises AutoCompleter class""" - def __init__(self): - super().__init__() + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) ############################################################################################################ # Begin code related to help and command name completion From ca89266546d93b993cc3e48935b62de08332c3a0 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 10 Jul 2019 19:29:45 -0400 Subject: [PATCH 55/88] More unit tests --- tests/test_argparse_completer.py | 159 +++++++------------------------ 1 file changed, 36 insertions(+), 123 deletions(-) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 7a7559f4c..f1faa66a9 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -25,6 +25,8 @@ range_choices = ['some', 'range', 'choices'] remainder_choices = ['remainder', 'choices'] +positional_choices = ['the', 'positional', 'choices'] + completions_from_function = ['completions', 'function', 'fairly', 'complete'] completions_from_method = ['completions', 'method', 'missed', 'spot'] @@ -201,6 +203,11 @@ def do_completer(self, args: argparse.Namespace) -> None: nargs_parser.add_argument("--remainder", help="a flag wanting remaining", nargs=argparse.REMAINDER, choices=remainder_choices) + nargs_parser.add_argument("normal_pos", help="a remainder positional", nargs=2, + choices=positional_choices) + nargs_parser.add_argument("remainder_pos", help="a remainder positional", nargs=argparse.REMAINDER, + choices=remainder_choices) + @with_argparser(nargs_parser) def do_nargs(self, args: argparse.Namespace) -> None: pass @@ -437,7 +444,9 @@ def test_completion_items(ac_app, num_aliases, show_description): # Flag with nargs = 2 ('--set_value', set_value_choices), ('--set_value set', ['value', 'choices']), - ('--set_value set value choices', []), + + # Both args are filled. At positional arg now. + ('--set_value set value', positional_choices), # Another flag can't start until all expected args are filled out ('--set_value --one_or_more', set_value_choices), @@ -452,15 +461,15 @@ def test_completion_items(ac_app, num_aliases, show_description): # Flag with nargs = OPTIONAL ('--optional', optional_choices), - # Only one arg allowed for an OPTIONAL to completions are now empty - ('--optional optional', []), + # Only one arg allowed for an OPTIONAL. At positional now. + ('--optional optional', positional_choices), # Flag with nargs range (1, 2) ('--range', range_choices), ('--range some', ['range', 'choices']), - # Already used 2 args so no more completions - ('--range some range', []), + # Already used 2 args so at positional + ('--range some range', positional_choices), # Flag with nargs = REMAINDER ('--remainder', remainder_choices), @@ -470,13 +479,32 @@ def test_completion_items(ac_app, num_aliases, show_description): ('--remainder choices --set_value', ['remainder ']), # Double dash ends the current flag (even if all expected args aren't entered) - ('--set_value --', []), + ('--set_value --', positional_choices), # Double dash ends a REMAINDER flag - ('--remainder remainder --', []), + ('--remainder remainder --', positional_choices), # No more flags after a double dash - ('-- --one_or_more ', []), + ('-- --one_or_more ', positional_choices), + + # Consume positional + ('', positional_choices), + ('positional', ['the', 'choices']), + + # Intermixed flag and positional + ('positional --set_value', set_value_choices), + ('positional --set_value set', ['value', 'choices']), + + # Intermixed flag and positional with flag finishing + ('positional --set_value set value', ['the', 'choices']), + ('positional --set_value set --', ['the', 'choices']), + + # REMAINDER positional + ('the positional', remainder_choices), + ('the positional remainder', ['choices ']), + + # REMAINDER positional. Flags don't work in REMAINDER + ('the positional --set_value', remainder_choices), ]) def test_autcomp_nargs(ac_app, args, completions): text = '' @@ -583,121 +611,6 @@ def test_autocomp_hint_no_help(ac_app, capsys): ''' -# def test_autcomp_hint_in_narg_range(cmd2_app, capsys): -# text = '' -# line = 'suggest -d 2 {}'.format(text) -# endidx = len(line) -# begidx = endidx - len(text) -# -# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) -# out, err = capsys.readouterr() -# -# assert out == ''' -# Hint: -# -d, --duration DURATION Duration constraint in minutes. -# single value - maximum duration -# [a, b] - duration range -# -# ''' -# -# def test_autocomp_flags_narg_max(cmd2_app): -# text = '' -# line = 'suggest d 2 3 {}'.format(text) -# endidx = len(line) -# begidx = endidx - len(text) -# -# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) -# assert first_match is None -# -# -# def test_autcomp_narg_beyond_max(cmd2_app): -# out, err = run_cmd(cmd2_app, 'suggest -t movie -d 3 4 5') -# assert 'Error: unrecognized arguments: 5' in err[1] -# -# -# def test_autocomp_subcmd_flag_comp_func_attr(cmd2_app): -# text = 'A' -# line = 'video movies list -a "{}'.format(text) -# endidx = len(line) -# begidx = endidx - len(text) -# -# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) -# assert first_match is not None and \ -# cmd2_app.completion_matches == ['Adam Driver', 'Alec Guinness', 'Andy Serkis', 'Anthony Daniels'] -# -# -# def test_autocomp_subcmd_flag_comp_list_attr(cmd2_app): -# text = 'G' -# line = 'video movies list -d {}'.format(text) -# endidx = len(line) -# begidx = endidx - len(text) -# -# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) -# assert first_match is not None and first_match == '"Gareth Edwards' -# -# -# def test_autocomp_pos_consumed(cmd2_app): -# text = '' -# line = 'library movie add SW_EP01 {}'.format(text) -# endidx = len(line) -# begidx = endidx - len(text) -# -# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) -# assert first_match is None -# -# -# def test_autocomp_pos_after_flag(cmd2_app): -# text = 'Joh' -# line = 'video movies add -d "George Lucas" -- "Han Solo" PG "Emilia Clarke" "{}'.format(text) -# endidx = len(line) -# begidx = endidx - len(text) -# -# first_match = complete_tester(text, line, begidx, endidx, cmd2_app) -# assert first_match is not None and \ -# cmd2_app.completion_matches == ['John Boyega" '] -# -# -# -# def test_argparse_remainder_flag_completion(cmd2_app): -# import cmd2 -# import argparse -# -# # Test flag completion as first arg of positional with nargs=argparse.REMAINDER -# text = '--h' -# line = 'help command {}'.format(text) -# endidx = len(line) -# begidx = endidx - len(text) -# -# # --h should not complete into --help because we are in the argparse.REMAINDER section -# assert complete_tester(text, line, begidx, endidx, cmd2_app) is None -# -# # Test flag completion within an already started positional with nargs=argparse.REMAINDER -# text = '--h' -# line = 'help command subcommand {}'.format(text) -# endidx = len(line) -# begidx = endidx - len(text) -# -# # --h should not complete into --help because we are in the argparse.REMAINDER section -# assert complete_tester(text, line, begidx, endidx, cmd2_app) is None -# -# # Test a flag with nargs=argparse.REMAINDER -# parser = argparse.ArgumentParser() -# parser.add_argument('-f', nargs=argparse.REMAINDER) -# -# # Overwrite eof's parser for this test -# cmd2.Cmd.do_eof.argparser = parser -# -# text = '--h' -# line = 'eof -f {}'.format(text) -# endidx = len(line) -# begidx = endidx - len(text) -# -# # --h should not complete into --help because we are in the argparse.REMAINDER section -# assert complete_tester(text, line, begidx, endidx, cmd2_app) is None -# -# - - def test_is_potential_flag(): from cmd2.argparse_completer import is_potential_flag parser = Cmd2ArgParser() From eb5b4fff8b1042d6a5b30a768bf35496acc4c593 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 11 Jul 2019 14:20:04 -0400 Subject: [PATCH 56/88] Added documentation --- cmd2/argparse_completer.py | 56 +-------------------- cmd2/argparse_custom.py | 99 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 54 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 1393db0e7..579d80299 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -2,60 +2,8 @@ # flake8: noqa C901 # NOTE: Ignoring flake8 cyclomatic complexity in this file """ -This module adds tab completion to argparse parsers within cmd2 apps. - -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 diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 5e3ed7f5d..342360aee 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -1,4 +1,103 @@ # coding=utf-8 +""" +This module adds capabilities to argparse by patching a few of its functions. It also defines a parser +class called Cmd2ArgParser 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 Cmd2ArgParser is used. However, the help output of Cmd2ArgParser is customized to notate nargs ranges +whereas any other parser class won't be as explicit in the usage statement. + +############################################################################################################ +# Added capabilities +############################################################################################################ + +Extends argparse nargs functionality by allowing tuples which specify a range (min, max) + Example: + The following command says the -f argument expects between 3 and 5 values (inclusive) + 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) + +############################################################################################################ +# 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 as _re import sys From c1312cadba42f273179a6bc08ec5a8d22ca23891 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 11 Jul 2019 15:16:38 -0400 Subject: [PATCH 57/88] Updated documentation --- cmd2/argparse_custom.py | 71 +++++++++++++++++++++++++---------------- cmd2/cmd2.py | 6 ++-- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 342360aee..dbe12d935 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -85,6 +85,47 @@ def my_completer_function(text, line, begidx, endidx): 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: ########################################################################################################### @@ -101,6 +142,7 @@ def my_completer_function(text, line, begidx, endidx): import argparse import re as _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 @@ -129,34 +171,7 @@ 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. - - 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 - - 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 - - Example: - token = 1 - token_description = "My Item" - completion_item = CompletionItem(token, token_description) + See header of this file for more information """ def __new__(cls, value: object, *args, **kwargs) -> str: return super().__new__(cls, value) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 14107f5b9..ee40d79d9 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -467,9 +467,9 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, # Otherwise it can be set to any custom key to meet your needs. self.matches_sort_key = ALPHABETICAL_SORT_KEY - # The maximum number of CompletionItems to display during tab completion. If the number of possible - # completions exceeds this number, suggestions will be displayed in the typical columnized format and - # will not include the description value of the CompletionItems. + # 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 ############################################################################################################ From 9ca1ecfe75e85b19c7cd16e0cf5ac133ee287aff Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 11 Jul 2019 15:19:48 -0400 Subject: [PATCH 58/88] Updated CHANGELOG --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7bc5c32..27cf4e671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ ## 0.9.15 (July TBD, 2019) +*# Enhancements + * Greatly simplified using argparse-based tab completion. The new interface is a complete overhaul that breaks + the previous way of specifying completion and choices functions. See header of argparse_completer.py for more + information. * **Renamed Commands Notice** * The following commands were renamed in the last release and have been removed in this release * `load` - replaced by `run_script` @@ -9,7 +13,7 @@ * Breaking Changes * Restored `cmd2.Cmd.statement_parser` to be a public attribute (no underscore) * Since it can be useful for creating [post-parsing hooks](https://cmd2.readthedocs.io/en/latest/features/hooks.html#postparsing-hooks) - + * Completely overhauled the interface for adding tab completion to argparse arguments. See enhancements for more details. ## 0.9.14 (June 29, 2019) * Enhancements From bd1eb859e8d0396bfe4d02f02b4fc0552e3fb931 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 11 Jul 2019 15:24:49 -0400 Subject: [PATCH 59/88] Updated CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27cf4e671..7885e936b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ * Restored `cmd2.Cmd.statement_parser` to be a public attribute (no underscore) * Since it can be useful for creating [post-parsing hooks](https://cmd2.readthedocs.io/en/latest/features/hooks.html#postparsing-hooks) * Completely overhauled the interface for adding tab completion to argparse arguments. See enhancements for more details. + * `ACArgumentParser` is now called `Cmd2ArgParser` ## 0.9.14 (June 29, 2019) * Enhancements From a28896f40177d66a717250cb890c5ac82eba179d Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 11 Jul 2019 16:14:47 -0400 Subject: [PATCH 60/88] Removed unused code and add unit test --- cmd2/argparse_completer.py | 21 ++++----------------- tests/test_argparse_completer.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 579d80299..d501a0cb8 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -59,7 +59,6 @@ def __init__(self, arg_action: argparse.Action) -> None: self.max = None self.count = 0 self.needed = False - self.variable = False self.is_remainder = (self.action.nargs == argparse.REMAINDER) # Check if nargs is a range @@ -67,7 +66,6 @@ def __init__(self, arg_action: argparse.Action) -> None: if nargs_range is not None: self.min = nargs_range[0] self.max = nargs_range[1] - self.variable = True # Otherwise check against argparse types elif self.action.nargs is None: @@ -76,15 +74,12 @@ def __init__(self, arg_action: argparse.Action) -> None: elif self.action.nargs == argparse.OPTIONAL: self.min = 0 self.max = 1 - self.variable = True elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER: self.min = 0 self.max = float('inf') - self.variable = True elif self.action.nargs == argparse.ONE_OR_MORE: self.min = 1 self.max = float('inf') - self.variable = True else: self.min = self.action.nargs self.max = self.action.nargs @@ -305,18 +300,13 @@ def consume_positional_argument() -> None: matched_flags.extend(flag_arg_state.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 flag_arg_state is None or \ - not flag_arg_state.variable or \ - flag_arg_state.count >= flag_arg_state.max: - # previous flag doesn't accept variable arguments, count this as a positional argument - - # reset flag tracking variables + # - Is there not a current flag or have we reached the max arg count for the flag? + elif flag_arg_state is None or flag_arg_state.count >= flag_arg_state.max: + # Count this as a positional argument flag_arg_state = None current_is_positional = True - if len(token) > 0 and pos_arg_state is not None and pos_arg_state.count < pos_arg_state.max: + if pos_arg_state is not None and pos_arg_state.count < pos_arg_state.max: # we have positional action match and we haven't reached the max arg count, consume # the positional argument and move on. consume_positional_argument() @@ -340,9 +330,6 @@ def consume_positional_argument() -> None: pos_arg_state = AutoCompleter._ArgumentState(action) consume_positional_argument() - elif not is_last_token and pos_arg_state is not None: - pos_arg_state = None - else: consume_flag_argument() diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index f1faa66a9..fa987503b 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -411,6 +411,35 @@ def test_autocomp_positional_completers(ac_app, pos, text, completions): assert ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) +def test_autocomp_blank_token(ac_app): + """Force a blank token to make sure AutoCompleter consumes them like argparse does""" + from cmd2.argparse_completer import AutoCompleter + + blank = '' + + # Blank flag arg + text = '' + line = 'completer -m {} {}'.format(blank, text) + endidx = len(line) + begidx = endidx - len(text) + + completer = AutoCompleter(ac_app.completer_parser, ac_app) + tokens = ['completer', '-f', blank, text] + completions = completer.complete_command(tokens, text, line, begidx, endidx) + assert completions == completions_from_function + + # Blank positional arg + text = '' + line = 'completer {} {}'.format(blank, text) + endidx = len(line) + begidx = endidx - len(text) + + completer = AutoCompleter(ac_app.completer_parser, ac_app) + tokens = ['completer', blank, text] + completions = completer.complete_command(tokens, text, line, begidx, endidx) + assert completions == completions_from_method + + @pytest.mark.parametrize('num_aliases, show_description', [ # The number of completion results determines if the description field of CompletionItems gets displayed # in the tab completions. The count must be greater than 1 and less than ac_app.max_completion_items, @@ -611,6 +640,7 @@ def test_autocomp_hint_no_help(ac_app, capsys): ''' + def test_is_potential_flag(): from cmd2.argparse_completer import is_potential_flag parser = Cmd2ArgParser() From 2a1f548f7d078d01028e022d13aef196dbe500ce Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 11 Jul 2019 17:58:59 -0400 Subject: [PATCH 61/88] Changes suggested in code review --- cmd2/argparse_completer.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index d501a0cb8..3ad50da0f 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -293,10 +293,9 @@ def consume_positional_argument() -> 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 is_last_token and \ - not isinstance(flag_arg_state.action, argparse._AppendAction) and \ - not isinstance(flag_arg_state.action, argparse._AppendConstAction) and \ - not isinstance(flag_arg_state.action, argparse._CountAction): + if not is_last_token and not isinstance(flag_arg_state.action, (argparse._AppendAction, + argparse._AppendConstAction, + argparse._CountAction)): matched_flags.extend(flag_arg_state.action.option_strings) # current token isn't a potential flag @@ -499,7 +498,7 @@ def _resolve_choices_for_arg(self, arg: argparse.Action, used_values=()) -> List # 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,): + if not isinstance(choice, str): arg_choices[index] = str(choice) # Filter out arguments we already used From 5546bd8d72d67c6a605f19e427deb137443097af Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 11 Jul 2019 23:20:53 -0400 Subject: [PATCH 62/88] Changed how re is being imported --- cmd2/argparse_custom.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index dbe12d935..430998233 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -140,7 +140,7 @@ def my_completer_function(text, line, begidx, endidx): """ import argparse -import re as _re +import re import sys # noinspection PyUnresolvedReferences,PyProtectedMember @@ -369,7 +369,7 @@ def _get_nargs_pattern_wrapper(self, action) -> str: 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) + match = re.match(nargs_pattern, arg_strings_pattern) # raise an exception if we weren't able to find a match if match is None: @@ -444,9 +444,9 @@ def _format_usage(self, usage, actions, groups, prefix) -> str: 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) + 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 From 73fad36d62671aa257d80affd5099ac9b46b3e03 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 11 Jul 2019 23:26:47 -0400 Subject: [PATCH 63/88] Refactored much of AutoCompleter.complete_command. Fixed issue where negative numbers did not tab complete. AutoCompleter now prints an error if flags are left unfinished before moving to next argument. --- cmd2/argparse_completer.py | 391 ++++++++++++++++--------------- tests/test_argparse_completer.py | 53 +++-- 2 files changed, 234 insertions(+), 210 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 3ad50da0f..7a099ed92 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -12,7 +12,7 @@ from . import cmd2 from . import utils -from .ansi import ansi_safe_wcswidth +from .ansi import ansi_safe_wcswidth, style_error from .argparse_custom import ATTR_SUPPRESS_TAB_HINT, ATTR_DESCRIPTIVE_COMPLETION_HEADER, ATTR_NARGS_RANGE from .argparse_custom import ChoicesCallable, CompletionItem, ATTR_CHOICES_CALLABLE from .rl_utils import rl_force_redisplay @@ -22,27 +22,30 @@ # noinspection PyProtectedMember -def is_potential_flag(token: str, parser: argparse.ArgumentParser) -> bool: - """Determine if a token looks like a potential flag. Based on argparse._parse_optional().""" - # if it's an empty string, it was meant to be a positional - if not token: +def starts_like_flag(token: str, parser: argparse.ArgumentParser) -> bool: + """ + Determine if a token starts like a flag. Unless an argument has nargs set to argparse.REMAINDER, + then anything that starts like a flag can't be consumed as a value for it. + Based on argparse._parse_optional(). + """ + # Flags have to be at least characters + if len(token) < 2: return False - # if it doesn't start with a prefix, it was meant to be positional + # Flags have to start with a prefix character if not token[0] in parser.prefix_chars: return False - # if it looks like a negative number, it was meant to be positional - # unless there are negative-number-like options + # If it looks like a negative number, it is not a flag unless there are negative-number-like flags if parser._negative_number_matcher.match(token): if not parser._has_negative_number_optionals: return False - # if it contains a space, it was meant to be a positional + # Flags can't have a space if ' ' in token: return False - # Looks like a flag + # Starts like a flag return True @@ -58,7 +61,6 @@ def __init__(self, arg_action: argparse.Action) -> None: self.min = None self.max = None self.count = 0 - self.needed = False self.is_remainder = (self.action.nargs == argparse.REMAINDER) # Check if nargs is a range @@ -99,7 +101,6 @@ def __init__(self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *, self._token_start_index = token_start_index self._flags = [] # all flags in this command - self._flags_without_args = [] # all flags that don't take arguments self._flag_to_action = {} # maps flags to the argparse action object self._positional_actions = [] # argument names for positional arguments (by position index) @@ -125,8 +126,6 @@ def __init__(self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *, for option in action.option_strings: self._flags.append(option) self._flag_to_action[option] = action - if action.nargs == 0: - self._flags_without_args.append(option) # Otherwise this is a positional parameter else: @@ -168,67 +167,27 @@ def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, flag_arg_state = None matched_flags = [] - current_is_positional = False consumed_arg_values = {} # dict(arg_name -> [values, ...]) - # the following are nested functions that have full access to all variables in the parent - # function including variables declared and updated after this function. Variable values - # are current at the point the nested functions are invoked (as in, they do not receive a - # snapshot of these values, they directly access the current state of variables in the - # parent function) - - def consume_flag_argument() -> None: - """Consuming token as a flag argument""" - # if the token does not look like a new flag, then count towards flag arguments - if flag_arg_state is not None and not is_potential_flag(token, self._parser): - flag_arg_state.count += 1 - - # Does this complete an option item for the flag? - arg_choices = self._resolve_choices_for_arg(flag_arg_state.action) - - # If the current token isn't the one being completed and it's in the flag - # argument's autocomplete list, then track that we've used it already. - if not is_last_token and token in arg_choices: - consumed_arg_values.setdefault(flag_arg_state.action.dest, []) - consumed_arg_values[flag_arg_state.action.dest].append(token) - - def consume_positional_argument() -> None: - """Consuming token as positional argument""" - if pos_arg_state is not None: - pos_arg_state.count += 1 - - # Does this complete an option item for the positional? - arg_choices = self._resolve_choices_for_arg(pos_arg_state.action) - - # If the current token isn't the one being completed and it's in the positional - # argument's autocomplete list, then track that we've used it already. - if not is_last_token and token in arg_choices: - consumed_arg_values.setdefault(pos_arg_state.action.dest, []) - consumed_arg_values[pos_arg_state.action.dest].append(token) - - # This next block of processing tries to parse all parameters before the last parameter. - # We're trying to determine what specific argument the current cursor position should be - # matched with. When we finish parsing all of the arguments, we can determine whether the - # last token is a positional or flag argument and which specific argument it is. - # - # We're also trying to save every flag that has been used as well as every value that - # has been used for a positional or flag parameter. By saving this information we can exclude - # it from the completion results we generate for the last token. For example, single-use flag - # arguments will be hidden from the list of available flags. Also, arguments with a - # defined list of possible values will exclude values that have already been used. - - # Notes when the token being completed has been reached - is_last_token = False - - # Enumerate over the sliced list - for loop_index, token in enumerate(tokens[self._token_start_index:]): - token_index = loop_index + self._token_start_index - if token_index >= len(tokens) - 1: - is_last_token = True + def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: + """Consuming token as an argument""" + arg_state.count += 1 + + # Does this complete an option item for the flag? + arg_choices = self._resolve_choices_for_arg(arg_state.action) + + # If the current token is in the flag argument's autocomplete list, + # then track that we've used it already. + if token in arg_choices: + consumed_arg_values.setdefault(arg_state.action.dest, []) + consumed_arg_values[arg_state.action.dest].append(token) + + # Enumerate over the sliced list up to the token being completed + for loop_index, token in enumerate(tokens[self._token_start_index:-1]): # If we're in a positional REMAINDER arg, force all future tokens to go to that if pos_arg_state is not None and pos_arg_state.is_remainder: - consume_positional_argument() + consume_argument(pos_arg_state) continue # If we're in a flag REMAINDER arg, force all future tokens to go to that until a double dash is hit @@ -237,152 +196,190 @@ def consume_positional_argument() -> None: if token == '--': flag_arg_state = None else: - consume_flag_argument() + consume_argument(flag_arg_state) continue # Handle '--' which tells argparse all remaining arguments are non-flags elif token == '--' and not skip_remaining_flags: - if is_last_token: - # Exit loop and see if -- can be completed into a flag - break + # Check if there is an unfinished flag + if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: + self._print_unfinished_flag_error(flag_arg_state) + return [] + + # Otherwise end the current flag else: - # End the current flag flag_arg_state = None skip_remaining_flags = True continue - current_is_positional = False + # Check the format of the current token to see if it can be an argument's value + if starts_like_flag(token, self._parser) and not skip_remaining_flags: - # Are we consuming flag arguments? - if flag_arg_state is not None and flag_arg_state.needed: - consume_flag_argument() - else: - if not skip_remaining_flags: - # Special case when each of the following is true: - # - We're not in the middle of consuming flag arguments - # - The current positional argument count has hit the max count - # - The next positional argument is a REMAINDER argument - # Argparse will now treat all future tokens as arguments to the positional including tokens that - # look like flags so the completer should skip any flag related processing once this happens - if pos_arg_state is not None and pos_arg_state.count >= pos_arg_state.max and \ - next_pos_arg_index < len(self._positional_actions) and \ - self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER: - skip_remaining_flags = True - - # At this point we're no longer consuming flag arguments. Is the current argument a potential flag? - if is_potential_flag(token, self._parser) and not skip_remaining_flags: - # Reset flag arg state but not positional tracking because flags can be - # interspersed anywhere between positionals - flag_arg_state = None - action = None - - # does the token fully match a known flag? - if token in self._flag_to_action: - action = self._flag_to_action[token] - elif hasattr(self._parser, 'allow_abbrev') and self._parser.allow_abbrev: - candidates_flags = [flag for flag in self._flag_to_action if flag.startswith(token)] - if len(candidates_flags) == 1: - action = self._flag_to_action[candidates_flags[0]] - - if action is not None: - flag_arg_state = AutoCompleter._ArgumentState(action) + # Check if there is an unfinished flag + if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: + self._print_unfinished_flag_error(flag_arg_state) + return [] - # It's possible we already have consumed values for this flag if it was used earlier - # in the command line. Reset them now for this use of the flag. + # Reset flag arg state but not positional tracking because flags can be + # interspersed anywhere between positionals + flag_arg_state = None + action = None + + # Does the token match a known flag? + if token in self._flag_to_action: + action = self._flag_to_action[token] + elif self._parser.allow_abbrev: + candidates_flags = [flag for flag in self._flag_to_action if flag.startswith(token)] + if len(candidates_flags) == 1: + action = self._flag_to_action[candidates_flags[0]] + + if action is not None: + # Keep track of what flags have already been used + # Flags with action set to append, append_const, and count can be reused + if not isinstance(action, (argparse._AppendAction, + argparse._AppendConstAction, + argparse._CountAction)): + matched_flags.extend(action.option_strings) + + new_arg_state = AutoCompleter._ArgumentState(action) + + # Keep track of this flag if it can receive arguments + if new_arg_state.max > 0: + flag_arg_state = new_arg_state + + # It's possible we already have consumed values for this flag if it was used + # earlier in the command line. Reset them now for this use of it. consumed_arg_values[flag_arg_state.action.dest] = [] - # Keep track of what flags have already been used - # Flags with action set to append, append_const, and count can be reused - if not is_last_token and not isinstance(flag_arg_state.action, (argparse._AppendAction, - argparse._AppendConstAction, - argparse._CountAction)): - matched_flags.extend(flag_arg_state.action.option_strings) - - # current token isn't a potential flag - # - Is there not a current flag or have we reached the max arg count for the flag? - elif flag_arg_state is None or flag_arg_state.count >= flag_arg_state.max: - # Count this as a positional argument + # Check if we are consuming a flag + elif flag_arg_state is not None: + consume_argument(flag_arg_state) + + # Check if we have finished with this flag + if flag_arg_state.count >= flag_arg_state.max: flag_arg_state = None - current_is_positional = True - - if pos_arg_state is not None and pos_arg_state.count < pos_arg_state.max: - # we have positional action match and we haven't reached the max arg count, consume - # the positional argument and move on. - consume_positional_argument() - elif pos_arg_state is None or pos_arg_state.count >= pos_arg_state.max: - # if we don't have a current positional action or we've reached the max count for the action - # close out the current positional argument state and set up for the next one - pos_index = next_pos_arg_index - next_pos_arg_index += 1 + + # Otherwise treat as a positional argument + else: + # If we aren't current tracking a positional, then get the next positional arg to handle this token + if pos_arg_state is None: + pos_index = next_pos_arg_index + next_pos_arg_index += 1 + + # Make sure we are still have positional arguments to fill + if pos_index < len(self._positional_actions): + action = self._positional_actions[pos_index] + pos_name = action.dest + + # Are we at a sub-command? If so, forward to the matching completer + if pos_name in self._positional_completers: + sub_completers = self._positional_completers[pos_name] + if token in sub_completers: + return sub_completers[token].complete_command(tokens, text, line, + begidx, endidx) + + # Keep track of the argument + pos_arg_state = AutoCompleter._ArgumentState(action) + + # Check if we have a positional to consume this token + if pos_arg_state is not None: + consume_argument(pos_arg_state) + + # Check if we have finished with this positional + if pos_arg_state.count >= pos_arg_state.max: pos_arg_state = None - # are we at a sub-command? If so, forward to the matching completer - if pos_index < len(self._positional_actions): - action = self._positional_actions[pos_index] - pos_name = action.dest - if pos_name in self._positional_completers: - sub_completers = self._positional_completers[pos_name] - if token in sub_completers: - return sub_completers[token].complete_command(tokens, text, line, - begidx, endidx) + # Check if this a case in which we've finished all positionals before one that has nargs + # set to argparse.REMAINDER. At this point argparse allows no more flags to be processed. + if next_pos_arg_index < len(self._positional_actions) and \ + self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER: + skip_remaining_flags = True - pos_arg_state = AutoCompleter._ArgumentState(action) - consume_positional_argument() + # Now try to complete the last token + last_token = tokens[-1] - else: - consume_flag_argument() + # Check if we are completing a flag name + if starts_like_flag(last_token, self._parser) and not skip_remaining_flags: + if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: + self._print_unfinished_flag_error(flag_arg_state) + return [] + + return self._complete_flags(text, line, begidx, endidx, matched_flags) - # To allow completion of the final token, we only do the following on preceding tokens - if not is_last_token and flag_arg_state is not None: - flag_arg_state.needed = flag_arg_state.count < flag_arg_state.min + # Check if we are completing a flag's argument + completion_results = [] - # Here we're done parsing all of the prior arguments. We know what the next argument is. + if flag_arg_state is not None: + consumed = consumed_arg_values.get(flag_arg_state.action.dest, []) + completion_results = self._complete_for_arg(flag_arg_state.action, text, line, + begidx, endidx, consumed) - # if we don't have a flag to populate with arguments and the last token starts with - # a flag prefix then we'll complete the list of flag options - if (flag_arg_state is None or not flag_arg_state.needed) and \ - is_potential_flag(tokens[-1], self._parser) and not skip_remaining_flags: + # If we have results, then return them + if completion_results: + return completion_results - # Build a list of flags that can be tab completed - match_against = [] + # Otherwise, if we haven't completed this flag, then print a hint + elif flag_arg_state.count < flag_arg_state.min: + self._print_arg_hint(flag_arg_state.action) + return [] - for flag in self._flags: - # Make sure this flag hasn't already been used - if flag not in matched_flags: - # Make sure this flag isn't considered hidden - action = self._flag_to_action[flag] - if action.help != argparse.SUPPRESS: - match_against.append(flag) + # Otherwise check if we are completing a positional's argument + else: + if pos_arg_state is None: + pos_index = next_pos_arg_index + next_pos_arg_index += 1 - return utils.basic_complete(text, line, begidx, endidx, match_against) + # Make sure we are still have positional arguments to fill + if pos_index < len(self._positional_actions): + action = self._positional_actions[pos_index] + pos_name = action.dest - completion_results = [] + # Are we at a sub-command? If so, forward to the matching completer + if pos_name in self._positional_completers: + sub_completers = self._positional_completers[pos_name] + if text in sub_completers: + return sub_completers[text].complete_command(tokens, text, line, + begidx, endidx) - # we're not at a positional argument, see if we're in a flag argument - if not current_is_positional: - if flag_arg_state is not None: - consumed = consumed_arg_values.get(flag_arg_state.action.dest, []) - completion_results = self._complete_for_arg(flag_arg_state.action, text, line, - begidx, endidx, consumed) - if not completion_results: - self._print_arg_hint(flag_arg_state.action) - elif len(completion_results) > 1: - completion_results = self._format_completions(flag_arg_state.action, completion_results) + # Keep track of the argument + pos_arg_state = AutoCompleter._ArgumentState(action) - # ok, we're not a flag, see if there's a positional argument to complete - else: if pos_arg_state is not None: consumed = consumed_arg_values.get(pos_arg_state.action.dest, []) completion_results = self._complete_for_arg(pos_arg_state.action, text, line, begidx, endidx, consumed) - if not completion_results: + # If we have results, then return them + if completion_results: + return completion_results + + # Otherwise, if we haven't completed this flag, then print a hint + elif pos_arg_state.count < pos_arg_state.min: self._print_arg_hint(pos_arg_state.action) - elif len(completion_results) > 1: - completion_results = self._format_completions(pos_arg_state.action, completion_results) + return [] + + # If we've gotten this far, then our text did not complete for a flag name or a + if last_token and not skip_remaining_flags: + return self._complete_flags(text, line, begidx, endidx, matched_flags) return completion_results + def _complete_flags(self, text: str, line: str, begidx: int, endidx: int, matched_flags: List[str]) -> List[str]: + """Tab completion routine for a parsers unused flags""" + + # Build a list of flags that can be tab completed + match_against = [] + + for flag in self._flags: + # Make sure this flag hasn't already been used + if flag not in matched_flags: + # Make sure this flag isn't considered hidden + action = self._flag_to_action[flag] + if action.help != argparse.SUPPRESS: + match_against.append(flag) + + return utils.basic_complete(text, line, begidx, endidx, match_against) + def _format_completions(self, action, completions: List[Union[str, CompletionItem]]) -> List[str]: # Check if the results are CompletionItems and that there aren't too many to display if 1 < len(completions) <= self._cmd2_app.max_completion_items and \ @@ -455,27 +452,29 @@ def format_help(self, tokens: List[str]) -> str: return completers[token].format_help(tokens) return self._parser.format_help() - def _complete_for_arg(self, arg: argparse.Action, + def _complete_for_arg(self, arg_action: argparse.Action, text: str, line: str, begidx: int, endidx: int, used_values=()) -> List[str]: """Tab completion routine for argparse arguments""" + results = [] + # Check the arg provides choices to the user - if arg.dest in self._arg_choices: - arg_choices = self._arg_choices[arg.dest] + if arg_action.dest in self._arg_choices: + arg_choices = self._arg_choices[arg_action.dest] # Check if the argument uses a specific tab completion function to provide its choices if isinstance(arg_choices, ChoicesCallable) and arg_choices.is_completer: if arg_choices.is_method: - return arg_choices.to_call(self._cmd2_app, text, line, begidx, endidx) + results = arg_choices.to_call(self._cmd2_app, text, line, begidx, endidx) else: - return arg_choices.to_call(text, line, begidx, endidx) + results = arg_choices.to_call(text, line, begidx, endidx) # Otherwise use basic_complete on the choices else: - return utils.basic_complete(text, line, begidx, endidx, - self._resolve_choices_for_arg(arg, used_values)) + results = utils.basic_complete(text, line, begidx, endidx, + self._resolve_choices_for_arg(arg_action, used_values)) - return [] + return self._format_completions(arg_action, results) def _resolve_choices_for_arg(self, arg: argparse.Action, used_values=()) -> List[str]: """Retrieve a list of choices that are available for a particular argument""" @@ -540,3 +539,19 @@ def _print_arg_hint(arg: argparse.Action) -> None: # Redraw prompt and input line rl_force_redisplay() + + @staticmethod + def _print_unfinished_flag_error(flag_arg_state: _ArgumentState) -> None: + """Print an error during tab completion when the user has not finished the current flag""" + + flags = ', '.join(flag_arg_state.action.option_strings) + param = ' ' + str(flag_arg_state.action.dest).upper() + prefix = '{}{}'.format(flags, param) + + prefix = ' {0: <{width}} '.format(prefix, width=20) + + out_str = "Only {} of the minimum {} arguments were provided".format(flag_arg_state.count, flag_arg_state.min) + print(style_error('\nError:\n{}{}\n'.format(prefix, out_str))) + + # Redraw prompt and input line + rl_force_redisplay() diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index fa987503b..2a30fc3bc 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -14,7 +14,7 @@ from .conftest import run_cmd, complete_tester # Lists used in our tests -static_int_choices_list = [1, 2, 3, 4, 5] +static_int_choices_list = [-12, -1, -2, 0, 1, 2] static_choices_list = ['static', 'choices', 'stop', 'here'] choices_from_function = ['choices', 'function', 'chatty', 'smith'] choices_from_method = ['choices', 'method', 'most', 'improved'] @@ -331,7 +331,9 @@ def test_autcomp_flag_completion(ac_app, command_and_args, text, completions): ('-m', '', choices_from_method), ('--method', 'm', ['method', 'most']), ('-i', '', [str(i) for i in static_int_choices_list]), - ('--int', '1', ['1 ']) + ('--int', '1', ['1 ']), + ('--int', '-', ['-12', '-1', '-2']), + ('--int', '-1', ['-12', '-1']) ]) def test_autocomp_flag_choices_completion(ac_app, flag, text, completions): line = 'choices {} {}'.format(flag, text) @@ -477,9 +479,6 @@ def test_completion_items(ac_app, num_aliases, show_description): # Both args are filled. At positional arg now. ('--set_value set value', positional_choices), - # Another flag can't start until all expected args are filled out - ('--set_value --one_or_more', set_value_choices), - # Using the flag again will reset the choices available ('--set_value set value --set_value', set_value_choices), @@ -507,8 +506,8 @@ def test_completion_items(ac_app, num_aliases, show_description): # No more flags can appear after a REMAINDER flag) ('--remainder choices --set_value', ['remainder ']), - # Double dash ends the current flag (even if all expected args aren't entered) - ('--set_value --', positional_choices), + # Double dash ends the current flag + ('--range choice --', positional_choices), # Double dash ends a REMAINDER flag ('--remainder remainder --', positional_choices), @@ -522,11 +521,11 @@ def test_completion_items(ac_app, num_aliases, show_description): # Intermixed flag and positional ('positional --set_value', set_value_choices), - ('positional --set_value set', ['value', 'choices']), + ('positional --set_value set', ['choices', 'value']), # Intermixed flag and positional with flag finishing ('positional --set_value set value', ['the', 'choices']), - ('positional --set_value set --', ['the', 'choices']), + ('positional --range choice --', ['the', 'choices']), # REMAINDER positional ('the positional', remainder_choices), @@ -550,6 +549,16 @@ def test_autcomp_nargs(ac_app, args, completions): assert ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) +""" +TODO: Add unit tests for unfinished flag errors + # Double dash ends the current flag (even if all expected args aren't entered) + ('--set_value --', positional_choices), + + # Another flag can't start until all expected args are filled out + ('--set_value --one_or_more', set_value_choices), +""" + + def test_completion_items_default_header(ac_app): from cmd2.argparse_completer import DEFAULT_DESCRIPTIVE_HEADER @@ -641,21 +650,21 @@ def test_autocomp_hint_no_help(ac_app, capsys): ''' -def test_is_potential_flag(): - from cmd2.argparse_completer import is_potential_flag +def test_starts_like_flag(): + from cmd2.argparse_completer import starts_like_flag parser = Cmd2ArgParser() - # Not potential flags - assert not is_potential_flag('', parser) - assert not is_potential_flag('non-flag', parser) - assert not is_potential_flag('--has space', parser) - assert not is_potential_flag('-2', parser) - - # Potential flags - assert is_potential_flag('-', parser) - assert is_potential_flag('--', parser) - assert is_potential_flag('-flag', parser) - assert is_potential_flag('--flag', parser) + # Does not start like a flag + assert not starts_like_flag('', parser) + assert not starts_like_flag('non-flag', parser) + assert not starts_like_flag('-', parser) + assert not starts_like_flag('--has space', parser) + assert not starts_like_flag('-2', parser) + + # Does start like a flag + assert starts_like_flag('--', parser) + assert starts_like_flag('-flag', parser) + assert starts_like_flag('--flag', parser) def test_complete_command_no_tokens(ac_app): From d857eac48c55cb56be5c28146e6f1c58c2af2c86 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 00:00:47 -0400 Subject: [PATCH 64/88] More refactoring --- cmd2/argparse_completer.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 7a099ed92..8c21d0d48 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -192,7 +192,6 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: # 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: - skip_remaining_flags = True if token == '--': flag_arg_state = None else: @@ -246,6 +245,7 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: # 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. @@ -285,8 +285,12 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: 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 - if pos_arg_state.count >= pos_arg_state.max: + 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 @@ -550,7 +554,25 @@ def _print_unfinished_flag_error(flag_arg_state: _ArgumentState) -> None: prefix = ' {0: <{width}} '.format(prefix, width=20) - out_str = "Only {} of the minimum {} arguments were provided".format(flag_arg_state.count, flag_arg_state.min) + out_str = "This flag expects " + if flag_arg_state.max == float('inf'): + out_str += "at least {} ".format(flag_arg_state.min) + + if flag_arg_state.min == 1: + out_str += "argument" + else: + out_str += "arguments" + else: + if flag_arg_state.min == flag_arg_state.max: + out_str += "{} ".format(flag_arg_state.min) + else: + out_str += "between {} and {} ".format(flag_arg_state.min, flag_arg_state.max) + + if flag_arg_state.max == 1: + out_str += "argument" + else: + out_str += "arguments" + print(style_error('\nError:\n{}{}\n'.format(prefix, out_str))) # Redraw prompt and input line From 4149be49d639bd7f4bedf08e4890e546af4bc1a5 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 00:30:11 -0400 Subject: [PATCH 65/88] Added comments --- cmd2/argparse_completer.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 8c21d0d48..3afa0774b 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -302,7 +302,10 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: # Now try to complete the last token last_token = tokens[-1] - # Check if we are completing a flag name + # 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 starts_like_flag(last_token, self._parser) and not skip_remaining_flags: if flag_arg_state is not None and flag_arg_state.count < flag_arg_state.min: self._print_unfinished_flag_error(flag_arg_state) @@ -310,9 +313,9 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: return self._complete_flags(text, line, begidx, endidx, matched_flags) - # Check if we are completing a flag's argument 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, @@ -362,7 +365,8 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: self._print_arg_hint(pos_arg_state.action) return [] - # If we've gotten this far, then our text did not complete for a flag name or a + # If we've gotten this far, then last_token did not complete for a flag name, flag value, or positional. + # It's possible last_token is a single flag-prefix character like '-'. Try completing it against flag names. if last_token and not skip_remaining_flags: return self._complete_flags(text, line, begidx, endidx, matched_flags) From 6a9e3d9b72118443253e41a4cd06b59363b70e7d Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 00:53:11 -0400 Subject: [PATCH 66/88] Fixed issue where flags did not always complete while in a positional --- cmd2/argparse_completer.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 3afa0774b..89cfeb0a4 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -299,14 +299,11 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER: skip_remaining_flags = True - # Now try to complete the last token - last_token = tokens[-1] - # 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 starts_like_flag(last_token, self._parser) and not skip_remaining_flags: + if starts_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 [] @@ -360,14 +357,14 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: if completion_results: return completion_results - # Otherwise, if we haven't completed this flag, then print a hint - elif pos_arg_state.count < pos_arg_state.min: + # Otherwise, print a hint if text isn't possibly the start of a flag + elif not (len(text) == 1 and text[0] in self._parser.prefix_chars) or skip_remaining_flags: self._print_arg_hint(pos_arg_state.action) return [] - # If we've gotten this far, then last_token did not complete for a flag name, flag value, or positional. - # It's possible last_token is a single flag-prefix character like '-'. Try completing it against flag names. - if last_token and not skip_remaining_flags: + # If we've gotten this far, then text did not complete for a flag name, flag value, or positional. + # If text is a single flag-prefix character like '-', try completing it against flag names. + if len(text) == 1 and text[0] in self._parser.prefix_chars and not skip_remaining_flags: return self._complete_flags(text, line, begidx, endidx, matched_flags) return completion_results From 938bb97c9abc921a0cdb6c8618bf8deb03d7c78f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 01:14:20 -0400 Subject: [PATCH 67/88] Removed unused code --- cmd2/argparse_completer.py | 50 ++++++++++++++------------------------ 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 89cfeb0a4..1e2a782a2 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -328,39 +328,26 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: return [] # Otherwise check if we are completing a positional's argument - else: + 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 - 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 text in sub_completers: - return sub_completers[text].complete_command(tokens, text, line, - begidx, endidx) - - # Keep track of the argument - pos_arg_state = AutoCompleter._ArgumentState(action) - - if pos_arg_state is not None: - consumed = consumed_arg_values.get(pos_arg_state.action.dest, []) - completion_results = self._complete_for_arg(pos_arg_state.action, text, line, - begidx, endidx, consumed) - - # If 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 (len(text) == 1 and text[0] in self._parser.prefix_chars) or skip_remaining_flags: - self._print_arg_hint(pos_arg_state.action) - return [] + 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 (len(text) == 1 and text[0] in self._parser.prefix_chars) or skip_remaining_flags: + self._print_arg_hint(pos_arg_state.action) + return [] # If we've gotten this far, then text did not complete for a flag name, flag value, or positional. # If text is a single flag-prefix character like '-', try completing it against flag names. @@ -548,7 +535,6 @@ def _print_arg_hint(arg: argparse.Action) -> None: @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) From 98dd8cf6a5e03e33a383c64bff4ff4b1bf2804cf Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 11:24:57 -0400 Subject: [PATCH 68/88] Updated docs --- CHANGELOG.md | 3 +++ cmd2/argparse_completer.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7885e936b..dfb0dfc2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ * Since it can be useful for creating [post-parsing hooks](https://cmd2.readthedocs.io/en/latest/features/hooks.html#postparsing-hooks) * Completely overhauled the interface for adding tab completion to argparse arguments. See enhancements for more details. * `ACArgumentParser` is now called `Cmd2ArgParser` + * Moved `basic_complete` to utils.py + * Made optional arguments on the following completer methods keyword-only: + `delimiter_complete`, `flag_based_complete`, `index_based_complete`. `path_complete`, `shell_cmd_complete` ## 0.9.14 (June 29, 2019) * Enhancements diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 1e2a782a2..9109f5181 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -327,7 +327,7 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: self._print_arg_hint(flag_arg_state.action) return [] - # Otherwise check if we are completing a positional's argument + # 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 From 9bb6b84608b6262d228c021c7115e1389eed33e3 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 12:42:04 -0400 Subject: [PATCH 69/88] Renamed Cmd2ArgParser to ArgParser --- CHANGELOG.md | 2 +- README.md | 4 ++-- cmd2/__init__.py | 2 +- cmd2/argparse_custom.py | 6 +++--- cmd2/cmd2.py | 32 ++++++++++++++++---------------- examples/tab_autocompletion.py | 6 +++--- examples/table_display.py | 5 ++--- tests/test_argparse_completer.py | 22 +++++++++++----------- tests/test_argparse_custom.py | 27 +++++++++++++-------------- 9 files changed, 52 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfb0dfc2d..1c5690b0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ * Restored `cmd2.Cmd.statement_parser` to be a public attribute (no underscore) * Since it can be useful for creating [post-parsing hooks](https://cmd2.readthedocs.io/en/latest/features/hooks.html#postparsing-hooks) * Completely overhauled the interface for adding tab completion to argparse arguments. See enhancements for more details. - * `ACArgumentParser` is now called `Cmd2ArgParser` + * `ACArgumentParser` is now called `ArgParser` * Moved `basic_complete` to utils.py * Made optional arguments on the following completer methods keyword-only: `delimiter_complete`, `flag_based_complete`, `index_based_complete`. `path_complete`, `shell_cmd_complete` diff --git a/README.md b/README.md index fa42e52c1..ea7420f30 100755 --- a/README.md +++ b/README.md @@ -150,8 +150,8 @@ Instructions for implementing each feature follow. See https://cmd2.readthedocs.io/en/latest/argument_processing.html for more details - NOTE: `cmd2` also provides the `Cmd2ArgParser` customization of `argparse.ArgumentParser` for prettier formatting - of help and RangeAction type + NOTE: `cmd2` also provides the `cmd2.ArgParser` customization of `argparse.ArgumentParser` for prettier formatting + of help and error messages. - `cmd2` applications function like a full-featured shell in many ways (and are cross-platform) - Run arbitrary shell commands by preceding them with `!` or `shell` diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 3b1496018..f05e29ec5 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -11,7 +11,7 @@ pass from .ansi import style -from .argparse_custom import Cmd2ArgParser, CompletionItem +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_custom.py b/cmd2/argparse_custom.py index 430998233..e902daf08 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -1,13 +1,13 @@ # coding=utf-8 """ This module adds capabilities to argparse by patching a few of its functions. It also defines a parser -class called Cmd2ArgParser which improves error and help output over normal argparse. All cmd2 code uses +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 Cmd2ArgParser is used. However, the help output of Cmd2ArgParser is customized to notate nargs ranges +not ArgParser is used. However, the help output of ArgParser is customized to notate nargs ranges whereas any other parser class won't be as explicit in the usage statement. ############################################################################################################ @@ -575,7 +575,7 @@ def _format_args(self, action, default_metavar) -> str: # noinspection PyCompatibility -class Cmd2ArgParser(argparse.ArgumentParser): +class ArgParser(argparse.ArgumentParser): """Custom ArgumentParser class that improves error and help output""" def __init__(self, *args, **kwargs) -> None: diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ee40d79d9..ee7cc4493 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -42,11 +42,11 @@ 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_custom import Cmd2ArgParser, CompletionItem 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 @@ -2334,7 +2334,7 @@ def _alias_list(self, args: argparse.Namespace) -> None: "An alias is a command that enables replacement of a word by another string.") alias_epilog = ("See also:\n" " macro") - alias_parser = Cmd2ArgParser(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() @@ -2515,7 +2515,7 @@ def _macro_list(self, args: argparse.Namespace) -> None: "A macro is similar to an alias, but it can contain argument placeholders.") macro_epilog = ("See also:\n" " alias") - macro_parser = Cmd2ArgParser(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() @@ -2647,7 +2647,7 @@ def complete_help_subcommand(self, text: str, line: str, begidx: int, endidx: in return matches - help_parser = Cmd2ArgParser() + 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", @@ -2811,19 +2811,19 @@ def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None: command = '' self.stdout.write("\n") - @with_argparser(Cmd2ArgParser()) + @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(Cmd2ArgParser(epilog=INTERNAL_COMMAND_EPILOG)) + @with_argparser(ArgParser(epilog=INTERNAL_COMMAND_EPILOG)) def do_eof(self, _: argparse.Namespace) -> bool: """Called when -D is pressed""" # Return True to stop the command loop return True - @with_argparser(Cmd2ArgParser()) + @with_argparser(ArgParser()) def do_quit(self, _: argparse.Namespace) -> bool: """Exit this application""" # Return True to stop the command loop @@ -2918,7 +2918,7 @@ def _show(self, args: argparse.Namespace, parameter: str = '') -> None: "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 = Cmd2ArgParser(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') set_parser.add_argument('param', nargs=argparse.OPTIONAL, help='parameter to set or view', @@ -2963,7 +2963,7 @@ def do_set(self, args: argparse.Namespace) -> None: if onchange_hook is not None: onchange_hook(old=orig_value, new=new_value) - shell_parser = Cmd2ArgParser() + 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) @@ -3026,7 +3026,7 @@ def _reset_py_display() -> None: "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 = Cmd2ArgParser(description=py_description) + 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") @@ -3212,7 +3212,7 @@ def py_quit(): return bridge.stop - run_pyscript_parser = Cmd2ArgParser() + 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) @@ -3245,7 +3245,7 @@ def do_run_pyscript(self, args: argparse.Namespace) -> bool: # Only include the do_ipy() method if IPython is available on the system if ipython_available: # pragma: no cover - @with_argparser(Cmd2ArgParser()) + @with_argparser(ArgParser()) def do_ipy(self, _: argparse.Namespace) -> None: """Enter an interactive IPython shell""" from .pyscript_bridge import PyscriptBridge @@ -3268,7 +3268,7 @@ def load_ipy(app): history_description = "View, run, edit, save, or clear previously entered commands" - history_parser = Cmd2ArgParser(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', @@ -3567,7 +3567,7 @@ def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcrip "\n" " set editor (program-name)") - edit_parser = Cmd2ArgParser(description=edit_description) + 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) @@ -3599,7 +3599,7 @@ def _current_script_dir(self) -> Optional[str]: "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") - run_script_parser = Cmd2ArgParser(description=run_script_description) + 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) @@ -3665,7 +3665,7 @@ def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: relative_run_script_epilog = ("Notes:\n" " This command is intended to only be used within text file scripts.") - relative_run_script_parser = Cmd2ArgParser(description=relative_run_script_description, + 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') diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index c4fc62181..2e611b70f 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -8,7 +8,7 @@ from typing import List import cmd2 -from cmd2 import utils, Cmd2ArgParser, CompletionItem +from cmd2 import utils, CompletionItem actors = ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher', 'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels', 'Adam Driver', 'Daisy Ridley', 'John Boyega', 'Oscar Isaac', @@ -128,7 +128,7 @@ def instance_query_movie_ids(self) -> List[str]: suggest_description = "Suggest command demonstrates argparse customizations.\n" suggest_description += "See hybrid_suggest and orig_suggest to compare the help output." - suggest_parser = Cmd2ArgParser(description=suggest_description) + suggest_parser = cmd2.ArgParser(description=suggest_description) suggest_parser.add_argument('-t', '--type', choices=['movie', 'show'], required=True) suggest_parser.add_argument('-d', '--duration', nargs=(1, 2), action='append', @@ -204,7 +204,7 @@ def _do_vid_shows(self, args) -> None: '\n '.join(ep_list))) print() - video_parser = Cmd2ArgParser(prog='media') + video_parser = cmd2.ArgParser(prog='media') video_types_subparsers = video_parser.add_subparsers(title='Media Types', dest='type') diff --git a/examples/table_display.py b/examples/table_display.py index 54d5b7a49..a5a5c8300 100755 --- a/examples/table_display.py +++ b/examples/table_display.py @@ -15,7 +15,6 @@ from typing import Tuple import cmd2 -from cmd2.argparse_custom import Cmd2ArgParser import tableformatter as tf # Configure colors for when users chooses the "-c" flag to enable color in the table output @@ -143,14 +142,14 @@ def high_density_objs(row_obj: CityInfo) -> dict: return opts -def make_table_parser() -> Cmd2ArgParser: +def make_table_parser() -> cmd2.ArgParser: """Create a unique instance of an argparse Argument parser for processing table arguments. NOTE: The two cmd2 argparse decorators require that each parser be unique, even if they are essentially a deep copy of each other. For cases like that, you can create a function to return a unique instance of a parser, which is what is being done here. """ - table_parser = Cmd2ArgParser() + table_parser = cmd2.ArgParser() table_item_group = table_parser.add_mutually_exclusive_group() table_item_group.add_argument('-c', '--color', action='store_true', help='Enable color') table_item_group.add_argument('-f', '--fancy', action='store_true', help='Fancy Grid') diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 2a30fc3bc..3274ad0c2 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -9,7 +9,7 @@ import pytest import cmd2 -from cmd2 import with_argparser, Cmd2ArgParser, CompletionItem +from cmd2 import with_argparser, CompletionItem from cmd2.utils import StdSim, basic_complete from .conftest import run_cmd, complete_tester @@ -63,7 +63,7 @@ def _music_create_rock(self, args: argparse.Namespace) -> None: self.poutput('music create rock') # Top level parser for music command - music_parser = Cmd2ArgParser(description='Manage music', prog='music') + music_parser = cmd2.ArgParser(description='Manage music', prog='music') # Add sub-commands to music music_subparsers = music_parser.add_subparsers() @@ -100,7 +100,7 @@ def do_music(self, args: argparse.Namespace) -> None: ############################################################################################################ # Uses default flag prefix value (-) - flag_parser = Cmd2ArgParser() + flag_parser = cmd2.ArgParser() flag_parser.add_argument('-n', '--normal_flag', help='A normal flag', action='store_true') flag_parser.add_argument('-a', '--append_flag', help='Append flag', action='append') flag_parser.add_argument('-o', '--append_const_flag', help='Append const flag', action='append_const', const=True) @@ -113,7 +113,7 @@ def do_flag(self, args: argparse.Namespace) -> None: pass # Uses non-default flag prefix value (+) - plus_flag_parser = Cmd2ArgParser(prefix_chars='+') + plus_flag_parser = cmd2.ArgParser(prefix_chars='+') plus_flag_parser.add_argument('+n', '++normal_flag', help='A normal flag', action='store_true') @with_argparser(plus_flag_parser) @@ -135,7 +135,7 @@ def completion_item_method(self) -> List[CompletionItem]: items.append(CompletionItem(main_str, desc='blah blah')) return items - choices_parser = Cmd2ArgParser() + choices_parser = cmd2.ArgParser() # Flag args for choices command. Include string and non-string arg types. choices_parser.add_argument("-l", "--list", help="a flag populated with a choices list", @@ -168,7 +168,7 @@ def completer_method(self, text: str, line: str, begidx: int, endidx: int) -> Li """Tab completion method""" return basic_complete(text, line, begidx, endidx, completions_from_method) - completer_parser = Cmd2ArgParser() + completer_parser = cmd2.ArgParser() # Flag args for completer command completer_parser.add_argument("-f", "--function", help="a flag using a completer function", @@ -189,7 +189,7 @@ def do_completer(self, args: argparse.Namespace) -> None: ############################################################################################################ # Begin code related to nargs ############################################################################################################ - nargs_parser = Cmd2ArgParser() + nargs_parser = cmd2.ArgParser() # Flag args for nargs command nargs_parser.add_argument("--set_value", help="a flag with a set value for nargs", nargs=2, @@ -215,7 +215,7 @@ def do_nargs(self, args: argparse.Namespace) -> None: ############################################################################################################ # Begin code related to testing tab hints ############################################################################################################ - hint_parser = Cmd2ArgParser() + hint_parser = cmd2.ArgParser() hint_parser.add_argument('-f', '--flag', help='a flag arg') hint_parser.add_argument('-s', '--suppressed_help', help=argparse.SUPPRESS) hint_parser.add_argument('-t', '--suppressed_hint', help='a flag arg', suppress_tab_hint=True) @@ -652,7 +652,7 @@ def test_autocomp_hint_no_help(ac_app, capsys): def test_starts_like_flag(): from cmd2.argparse_completer import starts_like_flag - parser = Cmd2ArgParser() + parser = cmd2.ArgParser() # Does not start like a flag assert not starts_like_flag('', parser) @@ -670,7 +670,7 @@ def test_starts_like_flag(): def test_complete_command_no_tokens(ac_app): from cmd2.argparse_completer import AutoCompleter - parser = Cmd2ArgParser() + parser = cmd2.ArgParser() ac = AutoCompleter(parser, ac_app) completions = ac.complete_command(tokens=[], text='', line='', begidx=0, endidx=0) @@ -680,7 +680,7 @@ def test_complete_command_no_tokens(ac_app): def test_complete_command_help_no_tokens(ac_app): from cmd2.argparse_completer import AutoCompleter - parser = Cmd2ArgParser() + parser = cmd2.ArgParser() ac = AutoCompleter(parser, ac_app) completions = ac.complete_command_help(tokens=[], text='', line='', begidx=0, endidx=0) diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 35d97974a..b738efa34 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -7,7 +7,6 @@ import pytest import cmd2 -from cmd2.argparse_custom import Cmd2ArgParser from .conftest import run_cmd @@ -16,7 +15,7 @@ class ApCustomTestApp(cmd2.Cmd): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - range_parser = Cmd2ArgParser() + range_parser = cmd2.ArgParser() range_parser.add_argument('--arg1', nargs=(2, 3)) range_parser.add_argument('--arg2', nargs=argparse.ZERO_OR_MORE) range_parser.add_argument('--arg3', nargs=argparse.ONE_OR_MORE) @@ -47,7 +46,7 @@ def fake_func(): ({'choices_method': fake_func, 'completer_method': fake_func}, False), ]) def test_apcustom_invalid_args(args, is_valid): - parser = Cmd2ArgParser(prog='test') + parser = cmd2.ArgParser(prog='test') try: parser.add_argument('name', **args) assert is_valid @@ -58,7 +57,7 @@ def test_apcustom_invalid_args(args, is_valid): def test_apcustom_usage(): usage = "A custom usage statement" - parser = Cmd2ArgParser(usage=usage) + parser = cmd2.ArgParser(usage=usage) help = parser.format_help() assert usage in help @@ -76,46 +75,46 @@ def test_apcustom_nargs_not_enough(cust_app): def test_apcustom_narg_empty_tuple(): with pytest.raises(ValueError) as excinfo: - parser = Cmd2ArgParser(prog='test') + parser = cmd2.ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=()) assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value) def test_apcustom_narg_single_tuple(): with pytest.raises(ValueError) as excinfo: - parser = Cmd2ArgParser(prog='test') + parser = cmd2.ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=(1,)) assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value) def test_apcustom_narg_tuple_triple(): with pytest.raises(ValueError) as excinfo: - parser = Cmd2ArgParser(prog='test') + parser = cmd2.ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=(1, 2, 3)) assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value) def test_apcustom_narg_tuple_order(): with pytest.raises(ValueError) as excinfo: - parser = Cmd2ArgParser(prog='test') + parser = cmd2.ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=(2, 1)) assert 'Invalid nargs range. The first value must be less than the second' in str(excinfo.value) def test_apcustom_narg_tuple_negative(): with pytest.raises(ValueError) as excinfo: - parser = Cmd2ArgParser(prog='test') + parser = cmd2.ArgParser(prog='test') parser.add_argument('invalid_tuple', nargs=(-1, 1)) assert 'Negative numbers are invalid for nargs range' in str(excinfo.value) def test_apcustom_narg_tuple_zero_base(): - parser = Cmd2ArgParser(prog='test') + parser = cmd2.ArgParser(prog='test') parser.add_argument('tuple', nargs=(0, 3)) def test_apcustom_narg_tuple_zero_to_one(): - parser = Cmd2ArgParser(prog='test') + parser = cmd2.ArgParser(prog='test') parser.add_argument('tuple', nargs=(0, 1)) @@ -124,13 +123,13 @@ def test_apcustom_print_message(capsys): test_message = 'The test message' # Specify the file - parser = Cmd2ArgParser(prog='test') + parser = cmd2.ArgParser(prog='test') parser._print_message(test_message, file=sys.stdout) out, err = capsys.readouterr() assert test_message in out # Make sure file defaults to sys.stderr - parser = Cmd2ArgParser(prog='test') + parser = cmd2.ArgParser(prog='test') parser._print_message(test_message) out, err = capsys.readouterr() assert test_message in err @@ -138,7 +137,7 @@ def test_apcustom_print_message(capsys): def test_apcustom_required_options(): # Make sure a 'required arguments' section shows when a flag is marked required - parser = Cmd2ArgParser(prog='test') + parser = cmd2.ArgParser(prog='test') parser.add_argument('--required_flag', required=True) help = parser.format_help() From ddf4c2ccc73caae2ec9f7d5a26afb556718a54d8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 14:35:51 -0400 Subject: [PATCH 70/88] Changed unfinished flag error format --- cmd2/argparse_completer.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 9109f5181..27f32198d 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -539,9 +539,10 @@ def _print_unfinished_flag_error(flag_arg_state: _ArgumentState) -> None: param = ' ' + str(flag_arg_state.action.dest).upper() prefix = '{}{}'.format(flags, param) - prefix = ' {0: <{width}} '.format(prefix, width=20) + out_str = "\nError:\n" + out_str += ' {0: <{width}} '.format(prefix, width=20) + out_str += "Flag requires " - out_str = "This flag expects " if flag_arg_state.max == float('inf'): out_str += "at least {} ".format(flag_arg_state.min) @@ -553,14 +554,15 @@ def _print_unfinished_flag_error(flag_arg_state: _ArgumentState) -> None: if flag_arg_state.min == flag_arg_state.max: out_str += "{} ".format(flag_arg_state.min) else: - out_str += "between {} and {} ".format(flag_arg_state.min, flag_arg_state.max) + out_str += "{} to {} ".format(flag_arg_state.min, flag_arg_state.max) if flag_arg_state.max == 1: out_str += "argument" else: out_str += "arguments" - print(style_error('\nError:\n{}{}\n'.format(prefix, out_str))) + out_str += ' ({} entered)'.format(flag_arg_state.count) + print(style_error('{}\n'.format(out_str))) # Redraw prompt and input line rl_force_redisplay() From fdbd9d5bd17f8b3de278ee867919a3103b523d91 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 15:00:00 -0400 Subject: [PATCH 71/88] Fixed flake8 warning --- cmd2/cmd2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ee7cc4493..6667911a5 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3666,7 +3666,7 @@ def do_run_script(self, args: argparse.Namespace) -> Optional[bool]: " This command is intended to only be used within text file scripts.") relative_run_script_parser = ArgParser(description=relative_run_script_description, - epilog=relative_run_script_epilog) + 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) From 6e826f2a4c2e69a838d1f985ab93d43374f3b92a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 15:28:54 -0400 Subject: [PATCH 72/88] Fixed issue where flag arg hints weren't always printing --- cmd2/argparse_completer.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 27f32198d..b957a8bf5 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -21,6 +21,11 @@ DEFAULT_DESCRIPTIVE_HEADER = 'Description' +def single_prefix_char(token: str, parser: argparse.ArgumentParser) -> bool: + """Returns if a token is just a single flag prefix character""" + return token and token[0] in parser.prefix_chars + + # noinspection PyProtectedMember def starts_like_flag(token: str, parser: argparse.ArgumentParser) -> bool: """ @@ -299,6 +304,7 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER: skip_remaining_flags = True + # We have now parsed all tokens up to the one being completed and have enough information to do so. # 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 @@ -322,8 +328,9 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: if completion_results: return completion_results - # Otherwise, if we haven't completed this flag, then print a hint - elif flag_arg_state.count < flag_arg_state.min: + # 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 [] @@ -345,13 +352,13 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: return completion_results # Otherwise, print a hint if text isn't possibly the start of a flag - elif not (len(text) == 1 and text[0] in self._parser.prefix_chars) or skip_remaining_flags: + elif not single_prefix_char(text, self._parser) or skip_remaining_flags: self._print_arg_hint(pos_arg_state.action) return [] - # If we've gotten this far, then text did not complete for a flag name, flag value, or positional. - # If text is a single flag-prefix character like '-', try completing it against flag names. - if len(text) == 1 and text[0] in self._parser.prefix_chars and not skip_remaining_flags: + # 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 From dc747c6fa05d9abb6b7ab45f3714e4ed01b50a0f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 16:23:03 -0400 Subject: [PATCH 73/88] Added unit tests for unfinished flag errors --- cmd2/argparse_completer.py | 8 ++--- tests/test_argparse_completer.py | 53 +++++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index b957a8bf5..c7284361b 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -550,13 +550,9 @@ def _print_unfinished_flag_error(flag_arg_state: _ArgumentState) -> None: out_str += ' {0: <{width}} '.format(prefix, width=20) out_str += "Flag requires " + # This handles ONE_OR_MORE if flag_arg_state.max == float('inf'): - out_str += "at least {} ".format(flag_arg_state.min) - - if flag_arg_state.min == 1: - out_str += "argument" - else: - out_str += "arguments" + out_str += "at least {} argument".format(flag_arg_state.min) else: if flag_arg_state.min == flag_arg_state.max: out_str += "{} ".format(flag_arg_state.min) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 3274ad0c2..3be9561d0 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -549,14 +549,53 @@ def test_autcomp_nargs(ac_app, args, completions): assert ac_app.completion_matches == sorted(completions, key=ac_app.matches_sort_key) -""" -TODO: Add unit tests for unfinished flag errors - # Double dash ends the current flag (even if all expected args aren't entered) - ('--set_value --', positional_choices), +@pytest.mark.parametrize('command_and_args, text, is_error', [ + # Flag is finished before moving on + ('hint --flag foo --', '', False), + ('hint --flag foo --help', '', False), + ('hint --flag foo', '--', False), + + ('nargs --one_or_more one --', '', False), + ('nargs --one_or_more one or --set_value', '', False), + ('nargs --one_or_more one or more', '--', False), + + ('nargs --set_value set value --', '', False), + ('nargs --set_value set value --one_or_more', '', False), + ('nargs --set_value set value', '--', False), + + ('nargs --range choices --', '', False), + ('nargs --range choices range --set_value', '', False), + ('nargs --range range', '--', False), + + # Flag is not finished before moving on + ('hint --flag --', '', True), + ('hint --flag --help', '', True), + ('hint --flag', '--', True), + + ('nargs --one_or_more --', '', True), + ('nargs --one_or_more --set_value', '', True), + ('nargs --one_or_more', '--', True), + + ('nargs --set_value set --', '', True), + ('nargs --set_value set --one_or_more', '', True), + ('nargs --set_value set', '--', True), + + ('nargs --range --', '', True), + ('nargs --range --set_value', '', True), + ('nargs --range', '--', True), +]) +def test_unfinished_flag_error(ac_app, command_and_args, text, is_error, capsys): + line = '{} {}'.format(command_and_args, text) + endidx = len(line) + begidx = endidx - len(text) - # Another flag can't start until all expected args are filled out - ('--set_value --one_or_more', set_value_choices), -""" + complete_tester(text, line, begidx, endidx, ac_app) + + out, err = capsys.readouterr() + if is_error: + assert "Flag requires" in out + else: + assert not out def test_completion_items_default_header(ac_app): From 191b4c25293ab1d68b93862243c295d248fd959c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 16:36:59 -0400 Subject: [PATCH 74/88] More unit tests --- cmd2/argparse_completer.py | 2 +- tests/test_argparse_completer.py | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index c7284361b..3a5cb64c0 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -23,7 +23,7 @@ def single_prefix_char(token: str, parser: argparse.ArgumentParser) -> bool: """Returns if a token is just a single flag prefix character""" - return token and token[0] in parser.prefix_chars + return len(token) == 1 and token[0] in parser.prefix_chars # noinspection PyProtectedMember diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 3be9561d0..ec3c940b1 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -595,7 +595,7 @@ def test_unfinished_flag_error(ac_app, command_and_args, text, is_error, capsys) if is_error: assert "Flag requires" in out else: - assert not out + assert "Flag requires" not in out def test_completion_items_default_header(ac_app): @@ -689,6 +689,22 @@ def test_autocomp_hint_no_help(ac_app, capsys): ''' +def test_single_prefix_char(): + from cmd2.argparse_completer import single_prefix_char + parser = cmd2.ArgParser(prefix_chars='-+') + + # Invalid + assert not single_prefix_char('', parser) + assert not single_prefix_char('--', parser) + assert not single_prefix_char('-+', parser) + assert not single_prefix_char('++has space', parser) + assert not single_prefix_char('foo', parser) + + # Valid + assert single_prefix_char('-', parser) + assert single_prefix_char('+', parser) + + def test_starts_like_flag(): from cmd2.argparse_completer import starts_like_flag parser = cmd2.ArgParser() From 96e16c90965952182d9e60c8ddb249cbe2236e08 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 16:43:05 -0400 Subject: [PATCH 75/88] More unit tests --- tests/test_argparse_completer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index ec3c940b1..e8ff1aaf8 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -530,6 +530,7 @@ def test_completion_items(ac_app, num_aliases, show_description): # REMAINDER positional ('the positional', remainder_choices), ('the positional remainder', ['choices ']), + ('the positional remainder choices', []), # REMAINDER positional. Flags don't work in REMAINDER ('the positional --set_value', remainder_choices), @@ -562,6 +563,7 @@ def test_autcomp_nargs(ac_app, args, completions): ('nargs --set_value set value --', '', False), ('nargs --set_value set value --one_or_more', '', False), ('nargs --set_value set value', '--', False), + ('nargs --set_val set value', '--', False), # This exercises our abbreviated flag detection ('nargs --range choices --', '', False), ('nargs --range choices range --set_value', '', False), @@ -579,6 +581,7 @@ def test_autcomp_nargs(ac_app, args, completions): ('nargs --set_value set --', '', True), ('nargs --set_value set --one_or_more', '', True), ('nargs --set_value set', '--', True), + ('nargs --set_val set', '--', True), # This exercises our abbreviated flag detection ('nargs --range --', '', True), ('nargs --range --set_value', '', True), From 5d1fcdba6f1674b46629e92bb3075c12d706af5f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 16:46:07 -0400 Subject: [PATCH 76/88] Simplified unit tests --- tests/test_argparse_completer.py | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index e8ff1aaf8..72efdc209 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -623,12 +623,7 @@ def test_autocomp_hint_flag(ac_app, capsys): first_match = complete_tester(text, line, begidx, endidx, ac_app) out, err = capsys.readouterr() - assert first_match is None - assert out == ''' -Hint: - -f, --flag FLAG a flag arg - -''' + assert first_match is None and "Hint" in out def test_autocomp_hint_suppressed_help(ac_app, capsys): @@ -666,13 +661,7 @@ def test_autocomp_hint_pos(ac_app, capsys): first_match = complete_tester(text, line, begidx, endidx, ac_app) out, err = capsys.readouterr() - assert first_match is None - assert out == ''' -Hint: - HINT_POS here is a hint - with new lines - -''' + assert first_match is None and "Hint" in out def test_autocomp_hint_no_help(ac_app, capsys): @@ -684,12 +673,7 @@ def test_autocomp_hint_no_help(ac_app, capsys): first_match = complete_tester(text, line, begidx, endidx, ac_app) out, err = capsys.readouterr() - assert first_match is None - assert not out == ''' -Hint: - NO_HELP_POS - -''' + assert first_match is None and "Hint" in out def test_single_prefix_char(): From 0b9794ab065c7f981fe8841e53c8cb7805026dcc Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Fri, 12 Jul 2019 17:47:30 -0400 Subject: [PATCH 77/88] Added more hint unit tests --- cmd2/argparse_completer.py | 9 +++- tests/test_argparse_completer.py | 75 ++++++++++++++++++++------------ 2 files changed, 53 insertions(+), 31 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 3a5cb64c0..ae82a4a70 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -187,7 +187,9 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: consumed_arg_values.setdefault(arg_state.action.dest, []) consumed_arg_values[arg_state.action.dest].append(token) - # Enumerate over the sliced list up to the token being completed + ############################################################################################# + # 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 @@ -304,7 +306,10 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: self._positional_actions[next_pos_arg_index].nargs == argparse.REMAINDER: skip_remaining_flags = True - # We have now parsed all tokens up to the one being completed and have enough information to do so. + ############################################################################################# + # We have parsed all but the last token and have enough information to complete it + ############################################################################################# + # 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 diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 72efdc209..c477f92fc 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -534,6 +534,7 @@ def test_completion_items(ac_app, num_aliases, show_description): # REMAINDER positional. Flags don't work in REMAINDER ('the positional --set_value', remainder_choices), + ('the positional remainder --set_value', ['choices ']) ]) def test_autcomp_nargs(ac_app, args, completions): text = '' @@ -614,45 +615,50 @@ def test_completion_items_default_header(ac_app): assert DEFAULT_DESCRIPTIVE_HEADER in ac_app.completion_header -def test_autocomp_hint_flag(ac_app, capsys): - text = '' - line = 'hint --flag {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, ac_app) - out, err = capsys.readouterr() +@pytest.mark.parametrize('command_and_args, text, has_hint', [ + # Normal cases + ('hint', '', True), + ('hint --flag', '', True), + ('hint --suppressed_help', '', False), + ('hint --suppressed_hint', '--', False), - assert first_match is None and "Hint" in out + # Hint because flag does not have enough values to be considered finished + ('nargs --one_or_more', '-', True), + # This flag has reached its minimum value count and therefore a new flag could start. + # However the flag can still consume values and the text is not a single prefix character. + # Therefor a hint will be shown. + ('nargs --one_or_more choices', 'bad_completion', True), -def test_autocomp_hint_suppressed_help(ac_app, capsys): - text = '' - line = 'hint --suppressed_help {}'.format(text) - endidx = len(line) - begidx = endidx - len(text) - - first_match = complete_tester(text, line, begidx, endidx, ac_app) - out, err = capsys.readouterr() + # Like the previous case, but this time text is a single prefix character which will cause flag + # name completion to occur instead of a hint for the current flag. + ('nargs --one_or_more choices', '-', False), - assert first_match is None - assert not out + # Hint because this is a REMAINDER flag and therefore no more flag name completions occur. + ('nargs --remainder', '-', True), + # No hint for the positional because text is a single prefix character which results in flag name completion + ('hint', '-', False), -def test_autocomp_hint_suppressed_hint(ac_app, capsys): - text = '' - line = 'hint --suppressed_hint {}'.format(text) + # Hint because this is a REMAINDER positional and therefore no more flag name completions occur. + ('nargs the choices', '-', True), + ('nargs the choices remainder', '-', True), +]) +def test_autocomp_hint(ac_app, command_and_args, text, has_hint, capsys): + line = '{} {}'.format(command_and_args, text) endidx = len(line) begidx = endidx - len(text) - first_match = complete_tester(text, line, begidx, endidx, ac_app) + complete_tester(text, line, begidx, endidx, ac_app) out, err = capsys.readouterr() - assert first_match is None - assert not out + if has_hint: + assert "Hint" in out + else: + assert "Hint" not in out -def test_autocomp_hint_pos(ac_app, capsys): +def test_autocomp_hint_multiple_lines(ac_app, capsys): text = '' line = 'hint {}'.format(text) endidx = len(line) @@ -661,10 +667,16 @@ def test_autocomp_hint_pos(ac_app, capsys): first_match = complete_tester(text, line, begidx, endidx, ac_app) out, err = capsys.readouterr() - assert first_match is None and "Hint" in out + assert first_match is None + assert out == ''' +Hint: + HINT_POS here is a hint + with new lines + +''' -def test_autocomp_hint_no_help(ac_app, capsys): +def test_autocomp_hint_no_help_text(ac_app, capsys): text = '' line = 'hint foo {}'.format(text) endidx = len(line) @@ -673,7 +685,12 @@ def test_autocomp_hint_no_help(ac_app, capsys): first_match = complete_tester(text, line, begidx, endidx, ac_app) out, err = capsys.readouterr() - assert first_match is None and "Hint" in out + assert first_match is None + assert not out == ''' +Hint: + NO_HELP_POS + +''' def test_single_prefix_char(): From d6d50a001b29f3c13ac60cbba7c50a2a5aea1b2e Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 15 Jul 2019 11:27:53 -0400 Subject: [PATCH 78/88] Removed link to deleted example --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index ea7420f30..c703d068e 100755 --- a/README.md +++ b/README.md @@ -182,8 +182,7 @@ Instructions for implementing each feature follow. - `delimiter_complete` helper method for tab completion against a list but each match is split on a delimiter - See the [tab_autocompletion.py](https://github.com/python-cmd2/cmd2/blob/master/examples/tab_autocompletion.py) example for a demonstration of how to use this feature - `cmd2` in combination with `argparse` also provide several advanced capabilities for automatic tab-completion - - See the [tab_autocompletion.py](https://github.com/python-cmd2/cmd2/blob/master/examples/tab_autocompletion.py) and - [tab_autocomp_dynamic.py](https://github.com/python-cmd2/cmd2/blob/master/examples/tab_autocomp_dynamic.py) examples for more info + - See the [tab_autocompletion.py](https://github.com/python-cmd2/cmd2/blob/master/examples/tab_autocompletion.py) example for more info - Multi-line commands From 87702b56c5b146ef9ce867343ceaa675ae9c1c21 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 15 Jul 2019 11:30:24 -0400 Subject: [PATCH 79/88] Made a few module functions protected --- cmd2/argparse_completer.py | 18 ++++++++-------- tests/test_argparse_completer.py | 36 ++++++++++++++++---------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index ae82a4a70..875fb3dbd 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -21,16 +21,16 @@ DEFAULT_DESCRIPTIVE_HEADER = 'Description' -def single_prefix_char(token: str, parser: argparse.ArgumentParser) -> bool: +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 # noinspection PyProtectedMember -def starts_like_flag(token: str, parser: argparse.ArgumentParser) -> bool: +def _looks_like_flag(token: str, parser: argparse.ArgumentParser) -> bool: """ - Determine if a token starts like a flag. Unless an argument has nargs set to argparse.REMAINDER, - then anything that starts like a flag can't be consumed as a value for it. + 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 @@ -219,7 +219,7 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: continue # Check the format of the current token to see if it can be an argument's value - if starts_like_flag(token, self._parser) and not skip_remaining_flags: + 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: @@ -314,7 +314,7 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: # 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 starts_like_flag(text, self._parser) and not skip_remaining_flags: + 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 [] @@ -335,7 +335,7 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: # 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: + not _single_prefix_char(text, self._parser) or skip_remaining_flags: self._print_arg_hint(flag_arg_state.action) return [] @@ -357,13 +357,13 @@ def consume_argument(arg_state: AutoCompleter._ArgumentState) -> None: 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: + 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: + 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 diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index c477f92fc..1262b9e1a 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -694,36 +694,36 @@ def test_autocomp_hint_no_help_text(ac_app, capsys): def test_single_prefix_char(): - from cmd2.argparse_completer import single_prefix_char + from cmd2.argparse_completer import _single_prefix_char parser = cmd2.ArgParser(prefix_chars='-+') # Invalid - assert not single_prefix_char('', parser) - assert not single_prefix_char('--', parser) - assert not single_prefix_char('-+', parser) - assert not single_prefix_char('++has space', parser) - assert not single_prefix_char('foo', parser) + assert not _single_prefix_char('', parser) + assert not _single_prefix_char('--', parser) + assert not _single_prefix_char('-+', parser) + assert not _single_prefix_char('++has space', parser) + assert not _single_prefix_char('foo', parser) # Valid - assert single_prefix_char('-', parser) - assert single_prefix_char('+', parser) + assert _single_prefix_char('-', parser) + assert _single_prefix_char('+', parser) -def test_starts_like_flag(): - from cmd2.argparse_completer import starts_like_flag +def test_looks_like_flag(): + from cmd2.argparse_completer import _looks_like_flag parser = cmd2.ArgParser() # Does not start like a flag - assert not starts_like_flag('', parser) - assert not starts_like_flag('non-flag', parser) - assert not starts_like_flag('-', parser) - assert not starts_like_flag('--has space', parser) - assert not starts_like_flag('-2', parser) + assert not _looks_like_flag('', parser) + assert not _looks_like_flag('non-flag', parser) + assert not _looks_like_flag('-', parser) + assert not _looks_like_flag('--has space', parser) + assert not _looks_like_flag('-2', parser) # Does start like a flag - assert starts_like_flag('--', parser) - assert starts_like_flag('-flag', parser) - assert starts_like_flag('--flag', parser) + assert _looks_like_flag('--', parser) + assert _looks_like_flag('-flag', parser) + assert _looks_like_flag('--flag', parser) def test_complete_command_no_tokens(ac_app): From bff6e04607ea9bede7bc981755cdf41740c15462 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 15 Jul 2019 11:39:16 -0400 Subject: [PATCH 80/88] Make max_completion_items settable --- cmd2/cmd2.py | 12 +++++++----- tests/conftest.py | 2 ++ tests/transcripts/regex_set.txt | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 6667911a5..4ba2e83ec 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -352,6 +352,12 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, 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 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, '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' @@ -467,11 +474,6 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, # Otherwise it can be set to any custom key to meet your needs. self.matches_sort_key = ALPHABETICAL_SORT_KEY - # 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 - ############################################################################################################ # The following variables are used by tab-completion functions. They are reset each time complete() is run # in reset_completion_defaults() and it is up to completer functions to set them before returning results. diff --git a/tests/conftest.py b/tests/conftest.py index 8040c21de..c0aea4a62 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,6 +95,7 @@ def verify_help_text(cmd2_app: cmd2.Cmd, help_output: Union[str, List[str]]) -> editor: vim feedback_to_output: False locals_in_py: False +max_completion_items: 50 prompt: (Cmd) quiet: False timing: False @@ -108,6 +109,7 @@ def verify_help_text(cmd2_app: cmd2.Cmd, help_output: Union[str, List[str]]) -> editor: vim # Program used by ``edit`` feedback_to_output: False # Include nonessentials in `|`, `>` results locals_in_py: False # Allow access to your application in py via self +max_completion_items: 50 # Maximum number of CompletionItems to display during tab completion prompt: (Cmd) # The prompt issued to solicit input quiet: False # Don't print nonessential feedback timing: False # Report execution times diff --git a/tests/transcripts/regex_set.txt b/tests/transcripts/regex_set.txt index 02bc98759..fdcca3a81 100644 --- a/tests/transcripts/regex_set.txt +++ b/tests/transcripts/regex_set.txt @@ -11,6 +11,7 @@ echo: False editor: /.*/ feedback_to_output: False locals_in_py: False +max_completion_items: 50 maxrepeats: 3 prompt: (Cmd)/ / quiet: False From 2e541a8a9a52ec23f5e337175314606ce2702381 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 15 Jul 2019 12:00:15 -0400 Subject: [PATCH 81/88] Updated documentation --- cmd2/argparse_custom.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index e902daf08..5bcbc91a2 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -7,8 +7,8 @@ class called ArgParser which improves error and help output over normal argparse 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 output of ArgParser is customized to notate nargs ranges -whereas any other parser class won't be as explicit in the usage statement. +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 From 218091f1ae3fd9ee0435fb126ea8e032ed3de76f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 15 Jul 2019 16:29:06 -0400 Subject: [PATCH 82/88] Added ability to specify nargs ranges with no upper bound --- cmd2/argparse_completer.py | 22 ++------- cmd2/argparse_custom.py | 83 +++++++++++++++++++++++++++----- tests/test_argparse_completer.py | 11 +---- tests/test_argparse_custom.py | 33 ++++++------- 4 files changed, 91 insertions(+), 58 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 875fb3dbd..737286c11 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -14,7 +14,7 @@ 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 +from .argparse_custom import ChoicesCallable, CompletionItem, ATTR_CHOICES_CALLABLE, INFINITY, generate_range_error from .rl_utils import rl_force_redisplay # If no descriptive header is supplied, then this will be used instead @@ -83,10 +83,10 @@ def __init__(self, arg_action: argparse.Action) -> None: self.max = 1 elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER: self.min = 0 - self.max = float('inf') + self.max = INFINITY elif self.action.nargs == argparse.ONE_OR_MORE: self.min = 1 - self.max = float('inf') + self.max = INFINITY else: self.min = self.action.nargs self.max = self.action.nargs @@ -553,21 +553,7 @@ def _print_unfinished_flag_error(flag_arg_state: _ArgumentState) -> None: out_str = "\nError:\n" out_str += ' {0: <{width}} '.format(prefix, width=20) - out_str += "Flag requires " - - # This handles ONE_OR_MORE - if flag_arg_state.max == float('inf'): - out_str += "at least {} argument".format(flag_arg_state.min) - else: - if flag_arg_state.min == flag_arg_state.max: - out_str += "{} ".format(flag_arg_state.min) - else: - out_str += "{} to {} ".format(flag_arg_state.min, flag_arg_state.max) - - if flag_arg_state.max == 1: - out_str += "argument" - else: - out_str += "arguments" + out_str += generate_range_error(flag_arg_state.min, flag_arg_state.max) out_str += ' ({} entered)'.format(flag_arg_state.count) print(style_error('{}\n'.format(out_str))) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 5bcbc91a2..9e6805aad 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -14,9 +14,14 @@ class called ArgParser which improves error and help output over normal argparse # Added capabilities ############################################################################################################ -Extends argparse nargs functionality by allowing tuples which specify a range (min, max) +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: - The following command says the -f argument expects between 3 and 5 values (inclusive) + # -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: @@ -153,6 +158,9 @@ def my_completer_function(text, line, begidx, endidx): # 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' @@ -167,6 +175,27 @@ def my_completer_function(text, line, begidx, endidx): 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 @@ -218,7 +247,7 @@ def __init__(self, is_method: bool, is_completer: bool, to_call: Callable): def _add_argument_wrapper(self, *args, - nargs: Union[int, str, Tuple[int, int], None] = None, + 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, @@ -235,6 +264,7 @@ def _add_argument_wrapper(self, *args, # 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 @@ -265,9 +295,14 @@ def _add_argument_wrapper(self, *args, # 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): - raise ValueError('Ranged values for nargs must be a tuple of 2 integers') + 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: @@ -275,13 +310,26 @@ def _add_argument_wrapper(self, *args, # 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 nargs_range[0] == 0: - if nargs_range[1] > 1: - nargs_adjusted = argparse.ZERO_OR_MORE - else: + 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: @@ -342,7 +390,12 @@ 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: - nargs_pattern = '(-*A{{{},{}}}-*)'.format(nargs_range[0], nargs_range[1]) + 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: @@ -375,8 +428,7 @@ def _match_argument_wrapper(self, action, arg_strings_pattern) -> int: if match is None: nargs_range = getattr(action, ATTR_NARGS_RANGE, None) if nargs_range is not None: - raise ArgumentError(action, - 'Expected between {} and {} arguments'.format(nargs_range[0], nargs_range[1])) + raise ArgumentError(action, generate_range_error(nargs_range[0], nargs_range[1])) return orig_argument_parser_match_argument(self, action, arg_strings_pattern) @@ -563,7 +615,12 @@ def _format_args(self, action, default_metavar) -> str: nargs_range = getattr(action, ATTR_NARGS_RANGE, None) if nargs_range is not None: - result = '{}{{{}..{}}}'.format('%s' % get_metavar(1), nargs_range[0], nargs_range[1]) + 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: diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 1262b9e1a..4ad4c5604 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -596,10 +596,7 @@ def test_unfinished_flag_error(ac_app, command_and_args, text, is_error, capsys) complete_tester(text, line, begidx, endidx, ac_app) out, err = capsys.readouterr() - if is_error: - assert "Flag requires" in out - else: - assert "Flag requires" not in out + assert is_error == all(x in out for x in ["Error:\n", "expected"]) def test_completion_items_default_header(ac_app): @@ -651,11 +648,7 @@ def test_autocomp_hint(ac_app, command_and_args, text, has_hint, capsys): complete_tester(text, line, begidx, endidx, ac_app) out, err = capsys.readouterr() - - if has_hint: - assert "Hint" in out - else: - assert "Hint" not in out + assert has_hint == ("Hint:\n" in out) def test_autocomp_hint_multiple_lines(ac_app, capsys): diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index b738efa34..17fd83347 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -70,28 +70,20 @@ def test_apcustom_nargs_help_format(cust_app): def test_apcustom_nargs_not_enough(cust_app): out, err = run_cmd(cust_app, 'range --arg1 one') - assert 'Error: argument --arg1: Expected between 2 and 3 arguments' in err[2] + assert 'Error: argument --arg1: expected 2 to 3 arguments' in err[2] -def test_apcustom_narg_empty_tuple(): - with pytest.raises(ValueError) as excinfo: - parser = cmd2.ArgParser(prog='test') - parser.add_argument('invalid_tuple', nargs=()) - assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value) - - -def test_apcustom_narg_single_tuple(): - with pytest.raises(ValueError) as excinfo: - parser = cmd2.ArgParser(prog='test') - parser.add_argument('invalid_tuple', nargs=(1,)) - assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value) - - -def test_apcustom_narg_tuple_triple(): +@pytest.mark.parametrize('nargs_tuple', [ + (), + ('f', 5), + (5, 'f'), + (1, 2, 3), +]) +def test_apcustom_narg_invalid_tuples(nargs_tuple): with pytest.raises(ValueError) as excinfo: parser = cmd2.ArgParser(prog='test') - parser.add_argument('invalid_tuple', nargs=(1, 2, 3)) - assert 'Ranged values for nargs must be a tuple of 2 integers' in str(excinfo.value) + parser.add_argument('invalid_tuple', nargs=nargs_tuple) + assert 'Ranged values for nargs must be a tuple of 1 or 2 integers' in str(excinfo.value) def test_apcustom_narg_tuple_order(): @@ -113,6 +105,11 @@ def test_apcustom_narg_tuple_zero_base(): parser.add_argument('tuple', nargs=(0, 3)) +def test_apcustom_narg_single_tuple(): + parser = cmd2.ArgParser(prog='test') + parser.add_argument('tuple', nargs=(5,)) + + def test_apcustom_narg_tuple_zero_to_one(): parser = cmd2.ArgParser(prog='test') parser.add_argument('tuple', nargs=(0, 1)) From 22a1d42ad997a69a6870fb3b4a33a4ee0b56a54b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 15 Jul 2019 16:52:59 -0400 Subject: [PATCH 83/88] Added unit tests --- tests/test_argparse_custom.py | 65 +++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 17fd83347..6d4184323 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -7,6 +7,7 @@ import pytest import cmd2 +from cmd2.argparse_custom import generate_range_error, INFINITY from .conftest import run_cmd @@ -58,8 +59,7 @@ def test_apcustom_invalid_args(args, is_valid): def test_apcustom_usage(): usage = "A custom usage statement" parser = cmd2.ArgParser(usage=usage) - help = parser.format_help() - assert usage in help + assert usage in parser.format_help() def test_apcustom_nargs_help_format(cust_app): @@ -100,19 +100,43 @@ def test_apcustom_narg_tuple_negative(): assert 'Negative numbers are invalid for nargs range' in str(excinfo.value) +# noinspection PyUnresolvedReferences def test_apcustom_narg_tuple_zero_base(): parser = cmd2.ArgParser(prog='test') - parser.add_argument('tuple', nargs=(0, 3)) + arg = parser.add_argument('tuple', nargs=(0,)) + assert arg.nargs == argparse.ZERO_OR_MORE + assert arg.nargs_range is None + parser = cmd2.ArgParser(prog='test') + arg = parser.add_argument('tuple', nargs=(0, 1)) + assert arg.nargs == argparse.OPTIONAL + assert arg.nargs_range is None -def test_apcustom_narg_single_tuple(): parser = cmd2.ArgParser(prog='test') - parser.add_argument('tuple', nargs=(5,)) + arg = parser.add_argument('tuple', nargs=(0, 3)) + assert arg.nargs == argparse.ZERO_OR_MORE + assert arg.nargs_range == (0, 3) + +# noinspection PyUnresolvedReferences +def test_apcustom_narg_tuple_one_base(): + parser = cmd2.ArgParser(prog='test') + arg = parser.add_argument('tuple', nargs=(1,)) + assert arg.nargs == argparse.ONE_OR_MORE + assert arg.nargs_range is None -def test_apcustom_narg_tuple_zero_to_one(): parser = cmd2.ArgParser(prog='test') - parser.add_argument('tuple', nargs=(0, 1)) + arg = parser.add_argument('tuple', nargs=(1, 5)) + assert arg.nargs == argparse.ONE_OR_MORE + assert arg.nargs_range is (1, 5) + + +# noinspection PyUnresolvedReferences +def test_apcustom_narg_tuple_other(): + parser = cmd2.ArgParser(prog='test') + arg = parser.add_argument('tuple', nargs=(2, 5)) + assert arg.nargs == argparse.ONE_OR_MORE + assert arg.nargs_range is (2, 5) def test_apcustom_print_message(capsys): @@ -132,10 +156,31 @@ def test_apcustom_print_message(capsys): assert test_message in err +def test_generate_range_error(): + # max is INFINITY + err_str = generate_range_error(1, INFINITY) + assert err_str == "expected at least 1 argument" + + err_str = generate_range_error(2, INFINITY) + assert err_str == "expected at least 2 arguments" + + # min and max are equal + err_str = generate_range_error(1, 1) + assert err_str == "expected 1 argument" + + err_str = generate_range_error(2, 2) + assert err_str == "expected 2 arguments" + + # min and max are not equal + err_str = generate_range_error(0, 1) + assert err_str == "expected 0 to 1 argument" + + err_str = generate_range_error(0, 2) + assert err_str == "expected 0 to 2 arguments" + + def test_apcustom_required_options(): # Make sure a 'required arguments' section shows when a flag is marked required parser = cmd2.ArgParser(prog='test') parser.add_argument('--required_flag', required=True) - help = parser.format_help() - - assert 'required arguments' in help + assert 'required arguments' in parser.format_help() From 719641ca5d99386a9446ef0908c964122e8cfc86 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 15 Jul 2019 17:21:41 -0400 Subject: [PATCH 84/88] More unit tests --- tests/test_argparse_custom.py | 56 +++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 6d4184323..dd9c9757e 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -103,40 +103,78 @@ def test_apcustom_narg_tuple_negative(): # noinspection PyUnresolvedReferences def test_apcustom_narg_tuple_zero_base(): parser = cmd2.ArgParser(prog='test') - arg = parser.add_argument('tuple', nargs=(0,)) + arg = parser.add_argument('arg', nargs=(0,)) assert arg.nargs == argparse.ZERO_OR_MORE assert arg.nargs_range is None + assert "[arg [...]]" in parser.format_help() parser = cmd2.ArgParser(prog='test') - arg = parser.add_argument('tuple', nargs=(0, 1)) + arg = parser.add_argument('arg', nargs=(0, 1)) assert arg.nargs == argparse.OPTIONAL assert arg.nargs_range is None + assert "[arg]" in parser.format_help() parser = cmd2.ArgParser(prog='test') - arg = parser.add_argument('tuple', nargs=(0, 3)) + arg = parser.add_argument('arg', nargs=(0, 3)) assert arg.nargs == argparse.ZERO_OR_MORE assert arg.nargs_range == (0, 3) + assert "arg{0..3}" in parser.format_help() # noinspection PyUnresolvedReferences def test_apcustom_narg_tuple_one_base(): parser = cmd2.ArgParser(prog='test') - arg = parser.add_argument('tuple', nargs=(1,)) + arg = parser.add_argument('arg', nargs=(1,)) assert arg.nargs == argparse.ONE_OR_MORE assert arg.nargs_range is None + assert "arg [...]" in parser.format_help() parser = cmd2.ArgParser(prog='test') - arg = parser.add_argument('tuple', nargs=(1, 5)) + arg = parser.add_argument('arg', nargs=(1, 5)) assert arg.nargs == argparse.ONE_OR_MORE - assert arg.nargs_range is (1, 5) + assert arg.nargs_range == (1, 5) + assert "arg{1..5}" in parser.format_help() # noinspection PyUnresolvedReferences -def test_apcustom_narg_tuple_other(): +def test_apcustom_narg_tuple_other_ranges(): + + # Test range with no upper bound on max + parser = cmd2.ArgParser(prog='test') + arg = parser.add_argument('arg', nargs=(2,)) + assert arg.nargs == argparse.ONE_OR_MORE + assert arg.nargs_range == (2, INFINITY) + assert "arg{2+}" in parser.format_help() + + # Valid number of args + parser.parse_args('one two'.split()) + parser.parse_args('one two three'.split()) + + # Not enough args + with pytest.raises(SystemExit): + parser.parse_args('one'.split()) + + # Test finite range parser = cmd2.ArgParser(prog='test') - arg = parser.add_argument('tuple', nargs=(2, 5)) + arg = parser.add_argument('arg', nargs=(2, 5)) assert arg.nargs == argparse.ONE_OR_MORE - assert arg.nargs_range is (2, 5) + assert arg.nargs_range == (2, 5) + assert "arg{2..5}" in parser.format_help() + + # Valid number of args + parser.parse_args('one two'.split()) + parser.parse_args('one two'.split()) + parser.parse_args('one two three'.split()) + parser.parse_args('one two three four'.split()) + parser.parse_args('one two three four five'.split()) + + # Not enough args + with pytest.raises(SystemExit): + parser.parse_args('one'.split()) + + # Too many args + with pytest.raises(SystemExit): + parser.parse_args('one two three four five six'.split()) def test_apcustom_print_message(capsys): From aa394cd88077b37b8ee5796a4d0fe8f7ae5837aa Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 15 Jul 2019 19:16:26 -0400 Subject: [PATCH 85/88] Changed format of help where nargs is a number greater than 1 --- cmd2/argparse_custom.py | 2 ++ tests/test_argparse_custom.py | 61 ++++++++++++++++------------------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 9e6805aad..1cdb78405 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -625,6 +625,8 @@ def _format_args(self, action, default_metavar) -> str: 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) diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index dd9c9757e..caf300805 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -17,9 +17,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) range_parser = cmd2.ArgParser() - range_parser.add_argument('--arg1', nargs=(2, 3)) - range_parser.add_argument('--arg2', nargs=argparse.ZERO_OR_MORE) - range_parser.add_argument('--arg3', nargs=argparse.ONE_OR_MORE) + range_parser.add_argument('--arg0', nargs=1) + range_parser.add_argument('--arg1', nargs=2) + range_parser.add_argument('--arg2', nargs=(3,)) + range_parser.add_argument('--arg3', nargs=(2, 3)) + range_parser.add_argument('--arg4', nargs=argparse.ZERO_OR_MORE) + range_parser.add_argument('--arg5', nargs=argparse.ONE_OR_MORE) @cmd2.with_argparser(range_parser) def do_range(self, _): @@ -64,13 +67,30 @@ def test_apcustom_usage(): def test_apcustom_nargs_help_format(cust_app): out, err = run_cmd(cust_app, 'help range') - assert 'Usage: range [-h] [--arg1 ARG1{2..3}] [--arg2 [ARG2 [...]]]' in out[0] - assert ' [--arg3 ARG3 [...]]' in out[1] + assert 'Usage: range [-h] [--arg0 ARG0] [--arg1 ARG1{2}] [--arg2 ARG2{3+}]' in out[0] + assert ' [--arg3 ARG3{2..3}] [--arg4 [ARG4 [...]]] [--arg5 ARG5 [...]]' in out[1] -def test_apcustom_nargs_not_enough(cust_app): - out, err = run_cmd(cust_app, 'range --arg1 one') - assert 'Error: argument --arg1: expected 2 to 3 arguments' in err[2] +def test_apcustom_nargs_range_validation(cust_app): + # nargs = (3,) + out, err = run_cmd(cust_app, 'range --arg2 one two') + assert 'Error: argument --arg2: expected at least 3 arguments' in err[2] + + out, err = run_cmd(cust_app, 'range --arg2 one two three') + assert not err + + out, err = run_cmd(cust_app, 'range --arg2 one two three four') + assert not err + + # nargs = (2,3) + out, err = run_cmd(cust_app, 'range --arg3 one') + assert 'Error: argument --arg3: expected 2 to 3 arguments' in err[2] + + out, err = run_cmd(cust_app, 'range --arg3 one two') + assert not err + + out, err = run_cmd(cust_app, 'range --arg2 one two three') + assert not err @pytest.mark.parametrize('nargs_tuple', [ @@ -144,37 +164,12 @@ def test_apcustom_narg_tuple_other_ranges(): arg = parser.add_argument('arg', nargs=(2,)) assert arg.nargs == argparse.ONE_OR_MORE assert arg.nargs_range == (2, INFINITY) - assert "arg{2+}" in parser.format_help() - - # Valid number of args - parser.parse_args('one two'.split()) - parser.parse_args('one two three'.split()) - - # Not enough args - with pytest.raises(SystemExit): - parser.parse_args('one'.split()) # Test finite range parser = cmd2.ArgParser(prog='test') arg = parser.add_argument('arg', nargs=(2, 5)) assert arg.nargs == argparse.ONE_OR_MORE assert arg.nargs_range == (2, 5) - assert "arg{2..5}" in parser.format_help() - - # Valid number of args - parser.parse_args('one two'.split()) - parser.parse_args('one two'.split()) - parser.parse_args('one two three'.split()) - parser.parse_args('one two three four'.split()) - parser.parse_args('one two three four five'.split()) - - # Not enough args - with pytest.raises(SystemExit): - parser.parse_args('one'.split()) - - # Too many args - with pytest.raises(SystemExit): - parser.parse_args('one two three four five six'.split()) def test_apcustom_print_message(capsys): From dda4fba203fbdc60919971cbe708f4fcac0e34f5 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 15 Jul 2019 19:51:54 -0400 Subject: [PATCH 86/88] Updated CODEOWNERS --- CODEOWNERS | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 6018eb868..01be734cf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -15,12 +15,14 @@ # cmd2 code cmd2/__init__.py @tleonhardt @kotfu -cmd2/argparse_completer.py @anselor @kmvanbrunt +cmd2/ansi.py @kmvanbrunt @tleonhardt +cmd2/argparse_*.py @kmvanbrunt cmd2/clipboard.py @tleonhardt cmd2/cmd2.py @tleonhardt @kmvanbrunt @kotfu cmd2/constants.py @kotfu cmd2/parsing.py @kotfu @kmvanbrunt -cmd2/pyscript_bridge.py @anselor @kmvanbrunt +cmd2/plugin.py @kotfu +cmd2/pyscript_bridge.py @kmvanbrunt cmd2/rl_utils.py @kmvanbrunt cmd2/transcript.py @kotfu cmd2/utils.py @tleonhardt @kotfu @kmvanbrunt @@ -29,23 +31,21 @@ cmd2/utils.py @tleonhardt @kotfu @kmvanbrunt docs/* @tleonhardt @kotfu # Examples -examples/env*.py @kotfu -examples/help*.py @anselor -examples/tab_au*.py @anselor -examples/tab_co*.py @kmvanbrunt +examples/async_printing.py @kmvanbrunt +examples/environment.py @kotfu +examples/tab_*.py @kmvanbrunt # Unit Tests -tests/pyscript/* @anselor @kmvanbrunt -tests/transcripts/* @kotfu -tests/__init__.py @kotfu -tests/conftest.py @kotfu @tleonhardt -tests/test_acar*.py @anselor -tests/test_argp*.py @kotfu -tests/test_auto*.py @anselor -tests/test_comp*.py @kmvanbrunt -tests/test_pars*.py @kotfu -tests/test_pysc*.py @anselor -tests/test_tran*.py @kotfu +tests/pyscript/* @kmvanbrunt +tests/transcripts/* @kotfu +tests/__init__.py @kotfu +tests/conftest.py @kotfu @tleonhardt +tests/test_argparse.py @kotfu +tests/test_argparse_*.py @kmvanbrunt +tests/test_comp*.py @kmvanbrunt +tests/test_pars*.py @kotfu +tests/test_run_pyscript.py @kmvanbrunt +tests/test_transcript.py @kotfu # Top-level project stuff CONTRIBUTING.md @tleonhardt @kotfu From def2f6a98958300c5acc3019d4676e785047a12f Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 15 Jul 2019 22:34:27 -0400 Subject: [PATCH 87/88] Added link to argparse_custom.py to change log --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c5690b0c..fb37f9e1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ ## 0.9.15 (July TBD, 2019) -*# Enhancements +* Enhancements * Greatly simplified using argparse-based tab completion. The new interface is a complete overhaul that breaks - the previous way of specifying completion and choices functions. See header of argparse_completer.py for more - information. + the previous way of specifying completion and choices functions. See header of [argparse_completer.py](https://github.com/python-cmd2/cmd2/blob/master/cmd2/argparse_custom.py) + for more information. * **Renamed Commands Notice** * The following commands were renamed in the last release and have been removed in this release * `load` - replaced by `run_script` From 3ad59ceffb9810b774a93448328c7c590080cc98 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 15 Jul 2019 22:36:59 -0400 Subject: [PATCH 88/88] Fixed link text --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb37f9e1c..14aff0122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## 0.9.15 (July TBD, 2019) * Enhancements * Greatly simplified using argparse-based tab completion. The new interface is a complete overhaul that breaks - the previous way of specifying completion and choices functions. See header of [argparse_completer.py](https://github.com/python-cmd2/cmd2/blob/master/cmd2/argparse_custom.py) + the previous way of specifying completion and choices functions. See header of [argparse_custom.py](https://github.com/python-cmd2/cmd2/blob/master/cmd2/argparse_custom.py) for more information. * **Renamed Commands Notice** * The following commands were renamed in the last release and have been removed in this release