diff options
-rw-r--r-- | CHANGELOG.md | 8 | ||||
-rwxr-xr-x | README.md | 1 | ||||
-rw-r--r-- | cmd2/argparse_completer.py | 68 | ||||
-rw-r--r-- | cmd2/cmd2.py | 49 | ||||
-rw-r--r-- | examples/transcripts/transcript_regex.txt | 1 | ||||
-rw-r--r-- | tests/conftest.py | 2 | ||||
-rw-r--r-- | tests/test_argparse_completer.py | 104 | ||||
-rw-r--r-- | tests/transcripts/regex_set.txt | 1 |
8 files changed, 156 insertions, 78 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index a78f00c1..5ef17000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ See [read_input.py](https://github.com/python-cmd2/cmd2/blob/master/examples/read_input.py) for an example. +## 1.4.0 (TBD, 2020) +* Enhancements + * Added user-settable option called `always_show_hint`. If True, then tab completion hints will always + display even when tab completion suggestions print. Arguments whose help or hint text is suppressed will + not display hints even when this setting is True. +* Bug Fixes + * Fixed issue where flag names weren't always sorted correctly in argparse tab completion + ## 1.3.9 (September 03, 2020) * Breaking Changes * `CommandSet.on_unregister()` is now called as first step in unregistering a `CommandSet` and not @@ -322,6 +322,7 @@ example/transcript_regex.txt: # regexes on prompts just make the trailing space obvious (Cmd) set allow_style: '/(Terminal|Always|Never)/' +always_show_hint: False debug: False echo: False editor: /.*?/ diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 39e9607b..535caf88 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -35,6 +35,22 @@ DEFAULT_DESCRIPTIVE_HEADER = 'Description' ARG_TOKENS = 'arg_tokens' +# noinspection PyProtectedMember +def _build_hint(parser: argparse.ArgumentParser, arg_action: argparse.Action) -> str: + """Build tab completion hint for a given argument""" + # Check if hinting is disabled for this argument + suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False) + if suppress_hint or arg_action.help == argparse.SUPPRESS: + return '' + else: + # Use the parser's help formatter to display just this action's help text + formatter = parser._get_formatter() + formatter.start_section("Hint") + formatter.add_argument(arg_action) + formatter.end_section() + return formatter.format_help() + + 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 @@ -115,7 +131,6 @@ class _UnfinishedFlagError(CompletionError): super().__init__(error) -# noinspection PyProtectedMember class _NoResultsError(CompletionError): def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action) -> None: """ @@ -124,19 +139,8 @@ class _NoResultsError(CompletionError): :param parser: ArgumentParser instance which owns the action being tab completed :param arg_action: action being tab completed """ - # Check if hinting is disabled - suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False) - if suppress_hint or arg_action.help == argparse.SUPPRESS: - hint_str = '' - else: - # Use the parser's help formatter to print just this action's help text - formatter = parser._get_formatter() - formatter.start_section("Hint") - formatter.add_argument(arg_action) - formatter.end_section() - hint_str = formatter.format_help() # Set apply_style to False because we don't want hints to look like errors - super().__init__(hint_str, apply_style=False) + super().__init__(_build_hint(parser, arg_action), apply_style=False) # noinspection PyProtectedMember @@ -419,6 +423,7 @@ class ArgparseCompleter: # If we have results, then return them if completion_results: + self._cmd2_app.completion_hint = _build_hint(self._parser, flag_arg_state.action) return completion_results # Otherwise, print a hint if the flag isn't finished or text isn't possibly the start of a flag @@ -439,6 +444,7 @@ class ArgparseCompleter: # If we have results, then return them if completion_results: + self._cmd2_app.completion_hint = _build_hint(self._parser, pos_arg_state.action) return completion_results # Otherwise, print a hint if text isn't possibly the start of a flag @@ -572,12 +578,21 @@ class ArgparseCompleter: """ # Check if the arg provides choices to the user if arg_state.action.choices is not None: - arg_choices = arg_state.action.choices + arg_choices = list(arg_state.action.choices) + if not arg_choices: + return [] + + # If these choices are numbers, then sort them now + if all(isinstance(x, numbers.Number) for x in arg_choices): + arg_choices.sort() + self._cmd2_app.matches_sorted = True + + # Since choices can be various types, convert them all to strings + arg_choices = [str(x) for x in arg_choices] else: arg_choices = getattr(arg_state.action, ATTR_CHOICES_CALLABLE, None) - - if arg_choices is None: - return [] + if arg_choices is None: + return [] # If we are going to call a completer/choices function, then set up the common arguments args = [] @@ -617,19 +632,6 @@ class ArgparseCompleter: if isinstance(arg_choices, ChoicesCallable) and not arg_choices.is_completer: arg_choices = arg_choices.to_call(*args, **kwargs) - # Since arg_choices can be any iterable type, convert to a list - arg_choices = list(arg_choices) - - # If these choices are numbers, and have not yet been sorted, then sort them now - if not self._cmd2_app.matches_sorted and all(isinstance(x, numbers.Number) for x in arg_choices): - arg_choices.sort() - self._cmd2_app.matches_sorted = True - - # 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 used_values = consumed_arg_values.get(arg_state.action.dest, []) arg_choices = [choice for choice in arg_choices if choice not in used_values] @@ -637,4 +639,10 @@ class ArgparseCompleter: # Do tab completion on the choices results = self._cmd2_app.basic_complete(text, line, begidx, endidx, arg_choices) + if not results: + # Reset the value for matches_sorted. This is because completion of flag names + # may still be attempted after we return and they haven't been sorted yet. + self._cmd2_app.matches_sorted = False + return [] + return self._format_completions(arg_state, results) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index a8a84499..088bb500 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -244,6 +244,7 @@ class Cmd(cmd.Cmd): self.allow_redirection = allow_redirection # Security setting to prevent redirection of stdout # Attributes which ARE dynamically settable via the set command at runtime + self.always_show_hint = False self.debug = False self.echo = False self.editor = Cmd.DEFAULT_EDITOR @@ -411,17 +412,21 @@ class Cmd(cmd.Cmd): # will be added if there is an unmatched opening quote self.allow_closing_quote = True - # An optional header that prints above the tab completion suggestions + # An optional hint which prints above tab completion suggestions + self.completion_hint = '' + + # Header which prints above CompletionItem tables self.completion_header = '' # Used by complete() for readline tab completion self.completion_matches = [] - # Use this list if you are completing strings that contain a common delimiter and you only want to - # display the final portion of the matches as the tab completion suggestions. The full matches - # still must be returned from your completer function. For an example, look at path_complete() - # which uses this to show only the basename of paths as the suggestions. delimiter_complete() also - # populates this list. + # Use this list if you need to display tab completion suggestions that are different than the actual text + # of the matches. For instance, if you are completing strings that contain a common delimiter and you only + # want to display the final portion of the matches as the tab completion suggestions. The full matches + # still must be returned from your completer function. For an example, look at path_complete() which + # uses this to show only the basename of paths as the suggestions. delimiter_complete() also populates + # this list. self.display_matches = [] # Used by functions like path_complete() and delimiter_complete() to properly @@ -824,6 +829,8 @@ class Cmd(cmd.Cmd): ansi.STYLE_NEVER), choices=[ansi.STYLE_TERMINAL, ansi.STYLE_ALWAYS, ansi.STYLE_NEVER])) + self.add_settable(Settable('always_show_hint', bool, + 'Display tab completion hint even when completion suggestions print')) self.add_settable(Settable('debug', bool, "Show full traceback on exception")) self.add_settable(Settable('echo', bool, "Echo command issued into output")) self.add_settable(Settable('editor', str, "Program used by 'edit'")) @@ -1020,6 +1027,7 @@ class Cmd(cmd.Cmd): """ self.allow_appended_space = True self.allow_closing_quote = True + self.completion_hint = '' self.completion_header = '' self.completion_matches = [] self.display_matches = [] @@ -1530,6 +1538,22 @@ class Cmd(cmd.Cmd): return [cur_match + padding for cur_match in matches_to_display], len(padding) + def _build_completion_metadata_string(self) -> str: # pragma: no cover + """Build completion metadata string which can contain a hint and CompletionItem table header""" + metadata = '' + + # Add hint if one exists and we are supposed to display it + if self.always_show_hint and self.completion_hint: + metadata += '\n' + self.completion_hint + + # Add table header if one exists + if self.completion_header: + if not metadata: + metadata += '\n' + metadata += '\n' + self.completion_header + + return metadata + def _display_matches_gnu_readline(self, substitution: str, matches: List[str], longest_match_length: int) -> None: # pragma: no cover """Prints a match list using GNU readline's rl_display_match_list() @@ -1574,9 +1598,8 @@ class Cmd(cmd.Cmd): strings_array[1:-1] = encoded_matches strings_array[-1] = None - # Print the header if one exists - if self.completion_header: - sys.stdout.write('\n\n' + self.completion_header) + # Print any metadata like a hint or table header + sys.stdout.write(self._build_completion_metadata_string()) # Call readline's display function # rl_display_match_list(strings_array, number of completion matches, longest match length) @@ -1602,10 +1625,8 @@ class Cmd(cmd.Cmd): # Add padding for visual appeal matches_to_display, _ = self._pad_matches_to_display(matches_to_display) - # Print the header if one exists - if self.completion_header: - # noinspection PyUnresolvedReferences - readline.rl.mode.console.write('\n\n' + self.completion_header) + # Print any metadata like a hint or table header + readline.rl.mode.console.write(sys.stdout.write(self._build_completion_metadata_string())) # Display matches using actual display function. This also redraws the prompt and line. orig_pyreadline_display(matches_to_display) @@ -3433,7 +3454,7 @@ class Cmd(cmd.Cmd): # Create the parser for the set command set_parser = DEFAULT_ARGUMENT_PARSER(parents=[set_parser_parent]) set_parser.add_argument('value', nargs=argparse.OPTIONAL, help='new value for settable', - completer=complete_set_value) + completer=complete_set_value, suppress_tab_hint=True) # Preserve quotes so users can pass in quoted empty strings and flags (e.g. -h) as the value @with_argparser(set_parser, preserve_quotes=True) diff --git a/examples/transcripts/transcript_regex.txt b/examples/transcripts/transcript_regex.txt index a3a91236..3065aae5 100644 --- a/examples/transcripts/transcript_regex.txt +++ b/examples/transcripts/transcript_regex.txt @@ -4,6 +4,7 @@ # regexes on prompts just make the trailing space obvious (Cmd) set allow_style: '/(Terminal|Always|Never)/' +always_show_hint: False debug: False echo: False editor: /.*?/ diff --git a/tests/conftest.py b/tests/conftest.py index 5b1a6f05..73080b5c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -93,6 +93,7 @@ SHORTCUTS_TXT = """Shortcuts for other commands: # Output from the show command with default settings SHOW_TXT = """allow_style: 'Terminal' +always_show_hint: False debug: False echo: False editor: 'vim' @@ -104,6 +105,7 @@ timing: False SHOW_LONG = """ allow_style: 'Terminal' # Allow ANSI text style sequences in output (valid values: Terminal, Always, Never) +always_show_hint: False # Display tab completion hint even when completion suggestions print debug: False # Show full traceback on exception echo: False # Echo command issued into output editor: 'vim' # Program used by 'edit' diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index b63511cd..3ee9766e 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -4,6 +4,7 @@ Unit/functional testing for argparse completer in cmd2 """ import argparse +import numbers from typing import List import pytest @@ -28,7 +29,7 @@ def standalone_completer(cli: cmd2.Cmd, text: str, line: str, begidx: int, endid # noinspection PyMethodMayBeStatic,PyUnusedLocal,PyProtectedMember -class AutoCompleteTester(cmd2.Cmd): +class ArgparseCompleterTester(cmd2.Cmd): """Cmd2 app that exercises ArgparseCompleter class""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -84,7 +85,9 @@ class AutoCompleteTester(cmd2.Cmd): TUPLE_METAVAR = ('arg1', 'others') CUSTOM_DESC_HEADER = "Custom Header" - static_int_choices_list = [-1, 1, -2, 2, 0, -12] + # Lists used in our tests (there is a mix of sorted and unsorted on purpose) + non_negative_int_choices = [1, 2, 3, 0, 22] + int_choices = [-1, 1, -2, 2, 0, -12] static_choices_list = ['static', 'choices', 'stop', 'here'] choices_from_provider = ['choices', 'provider', 'probably', 'improved'] @@ -116,13 +119,17 @@ class AutoCompleteTester(cmd2.Cmd): choices_provider=completion_item_method, metavar=TUPLE_METAVAR, nargs=argparse.ONE_OR_MORE) choices_parser.add_argument('-i', '--int', type=int, help='a flag with an int type', - choices=static_int_choices_list) + choices=int_choices) # Positional args for choices command choices_parser.add_argument("list_pos", help="a positional populated with a choices list", choices=static_choices_list) choices_parser.add_argument("method_pos", help="a positional populated with a choices provider", choices_provider=choices_provider) + choices_parser.add_argument('non_negative_int', type=int, help='a positional with non-negative int choices', + choices=non_negative_int_choices) + choices_parser.add_argument('empty_choices', help='a positional with empty choices', + choices=[]) @with_argparser(choices_parser) def do_choices(self, args: argparse.Namespace) -> None: @@ -290,7 +297,7 @@ class AutoCompleteTester(cmd2.Cmd): @pytest.fixture def ac_app(): - app = AutoCompleteTester() + app = ArgparseCompleterTester() app.stdout = StdSim(app.stdout) return app @@ -417,18 +424,16 @@ def test_autcomp_flag_completion(ac_app, command_and_args, text, completions): @pytest.mark.parametrize('flag, text, completions', [ - ('-l', '', AutoCompleteTester.static_choices_list), + ('-l', '', ArgparseCompleterTester.static_choices_list), ('--list', 's', ['static', 'stop']), - ('-p', '', AutoCompleteTester.choices_from_provider), + ('-p', '', ArgparseCompleterTester.choices_from_provider), ('--provider', 'pr', ['provider', 'probably']), - ('-i', '', AutoCompleteTester.static_int_choices_list), + ('-i', '', ArgparseCompleterTester.int_choices), ('--int', '1', ['1 ']), ('--int', '-', [-1, -2, -12]), ('--int', '-1', [-1, -12]) ]) def test_autocomp_flag_choices_completion(ac_app, flag, text, completions): - import numbers - line = 'choices {} {}'.format(flag, text) endidx = len(line) begidx = endidx - len(text) @@ -440,7 +445,7 @@ def test_autocomp_flag_choices_completion(ac_app, flag, text, completions): assert first_match is None # Numbers will be sorted in ascending order and then converted to strings by ArgparseCompleter - if all(isinstance(x, numbers.Number) for x in completions): + if completions and all(isinstance(x, numbers.Number) for x in completions): completions.sort() completions = [str(x) for x in completions] else: @@ -450,10 +455,13 @@ def test_autocomp_flag_choices_completion(ac_app, flag, text, completions): @pytest.mark.parametrize('pos, text, completions', [ - (1, '', AutoCompleteTester.static_choices_list), + (1, '', ArgparseCompleterTester.static_choices_list), (1, 's', ['static', 'stop']), - (2, '', AutoCompleteTester.choices_from_provider), + (2, '', ArgparseCompleterTester.choices_from_provider), (2, 'pr', ['provider', 'probably']), + (3, '', ArgparseCompleterTester.non_negative_int_choices), + (3, '2', [2, 22]), + (4, '', []), ]) def test_autocomp_positional_choices_completion(ac_app, pos, text, completions): # Generate line were preceding positionals are already filled @@ -467,11 +475,39 @@ def test_autocomp_positional_choices_completion(ac_app, pos, text, completions): else: assert first_match is None - assert ac_app.completion_matches == sorted(completions, key=ac_app.default_sort_key) + # Numbers will be sorted in ascending order and then converted to strings by ArgparseCompleter + if completions and all(isinstance(x, numbers.Number) for x in completions): + completions.sort() + completions = [str(x) for x in completions] + else: + completions.sort(key=ac_app.default_sort_key) + + assert ac_app.completion_matches == completions + + +def test_flag_sorting(ac_app): + # This test exercises the case where a positional arg has non-negative integers for its choices. + # ArgparseCompleter will sort these numerically before converting them to strings. As a result, + # cmd2.matches_sorted gets set to True. If no completion matches are returned and the entered + # text looks like the beginning of a flag (e.g -), then ArgparseCompleter will try to complete + # flag names next. Before it does this, cmd2.matches_sorted is reset to make sure the flag names + # get sorted correctly. + option_strings = [] + for action in ac_app.choices_parser._actions: + option_strings.extend(action.option_strings) + option_strings.sort(key=ac_app.default_sort_key) + + text = '-' + line = 'choices arg1 arg2 arg3 {}'.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 == option_strings @pytest.mark.parametrize('flag, text, completions', [ - ('-c', '', AutoCompleteTester.completions_for_flag), + ('-c', '', ArgparseCompleterTester.completions_for_flag), ('--completer', 'f', ['flag', 'fairly']) ]) def test_autocomp_flag_completers(ac_app, flag, text, completions): @@ -489,9 +525,9 @@ def test_autocomp_flag_completers(ac_app, flag, text, completions): @pytest.mark.parametrize('pos, text, completions', [ - (1, '', AutoCompleteTester.completions_for_pos_1), + (1, '', ArgparseCompleterTester.completions_for_pos_1), (1, 'p', ['positional_1', 'probably']), - (2, '', AutoCompleteTester.completions_for_pos_2), + (2, '', ArgparseCompleterTester.completions_for_pos_2), (2, 'm', ['missed', 'me']), ]) def test_autocomp_positional_completers(ac_app, pos, text, completions): @@ -524,7 +560,7 @@ def test_autocomp_blank_token(ac_app): completer = ArgparseCompleter(ac_app.completer_parser, ac_app) tokens = ['-c', blank, text] completions = completer.complete(text, line, begidx, endidx, tokens) - assert sorted(completions) == sorted(AutoCompleteTester.completions_for_pos_1) + assert sorted(completions) == sorted(ArgparseCompleterTester.completions_for_pos_1) # Blank arg for first positional will be consumed. Therefore we expect to be completing the second positional. text = '' @@ -535,7 +571,7 @@ def test_autocomp_blank_token(ac_app): completer = ArgparseCompleter(ac_app.completer_parser, ac_app) tokens = [blank, text] completions = completer.complete(text, line, begidx, endidx, tokens) - assert sorted(completions) == sorted(AutoCompleteTester.completions_for_pos_2) + assert sorted(completions) == sorted(ArgparseCompleterTester.completions_for_pos_2) @pytest.mark.parametrize('num_aliases, show_description', [ @@ -569,54 +605,54 @@ def test_completion_items(ac_app, num_aliases, show_description): @pytest.mark.parametrize('args, completions', [ # Flag with nargs = 2 - ('--set_value', AutoCompleteTester.set_value_choices), + ('--set_value', ArgparseCompleterTester.set_value_choices), ('--set_value set', ['value', 'choices']), # Both args are filled. At positional arg now. - ('--set_value set value', AutoCompleteTester.positional_choices), + ('--set_value set value', ArgparseCompleterTester.positional_choices), # Using the flag again will reset the choices available - ('--set_value set value --set_value', AutoCompleteTester.set_value_choices), + ('--set_value set value --set_value', ArgparseCompleterTester.set_value_choices), # Flag with nargs = ONE_OR_MORE - ('--one_or_more', AutoCompleteTester.one_or_more_choices), + ('--one_or_more', ArgparseCompleterTester.one_or_more_choices), ('--one_or_more one', ['or', 'more', 'choices']), # Flag with nargs = OPTIONAL - ('--optional', AutoCompleteTester.optional_choices), + ('--optional', ArgparseCompleterTester.optional_choices), # Only one arg allowed for an OPTIONAL. At positional now. - ('--optional optional', AutoCompleteTester.positional_choices), + ('--optional optional', ArgparseCompleterTester.positional_choices), # Flag with nargs range (1, 2) - ('--range', AutoCompleteTester.range_choices), + ('--range', ArgparseCompleterTester.range_choices), ('--range some', ['range', 'choices']), # Already used 2 args so at positional - ('--range some range', AutoCompleteTester.positional_choices), + ('--range some range', ArgparseCompleterTester.positional_choices), # Flag with nargs = REMAINDER - ('--remainder', AutoCompleteTester.remainder_choices), + ('--remainder', ArgparseCompleterTester.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 - ('--range choice --', AutoCompleteTester.positional_choices), + ('--range choice --', ArgparseCompleterTester.positional_choices), # Double dash ends a REMAINDER flag - ('--remainder remainder --', AutoCompleteTester.positional_choices), + ('--remainder remainder --', ArgparseCompleterTester.positional_choices), # No more flags after a double dash - ('-- --one_or_more ', AutoCompleteTester.positional_choices), + ('-- --one_or_more ', ArgparseCompleterTester.positional_choices), # Consume positional - ('', AutoCompleteTester.positional_choices), + ('', ArgparseCompleterTester.positional_choices), ('positional', ['the', 'choices']), # Intermixed flag and positional - ('positional --set_value', AutoCompleteTester.set_value_choices), + ('positional --set_value', ArgparseCompleterTester.set_value_choices), ('positional --set_value set', ['choices', 'value']), # Intermixed flag and positional with flag finishing @@ -624,12 +660,12 @@ def test_completion_items(ac_app, num_aliases, show_description): ('positional --range choice --', ['the', 'choices']), # REMAINDER positional - ('the positional', AutoCompleteTester.remainder_choices), + ('the positional', ArgparseCompleterTester.remainder_choices), ('the positional remainder', ['choices ']), ('the positional remainder choices', []), # REMAINDER positional. Flags don't work in REMAINDER - ('the positional --set_value', AutoCompleteTester.remainder_choices), + ('the positional --set_value', ArgparseCompleterTester.remainder_choices), ('the positional remainder --set_value', ['choices ']) ]) def test_autcomp_nargs(ac_app, args, completions): diff --git a/tests/transcripts/regex_set.txt b/tests/transcripts/regex_set.txt index 5004adc5..623df8ed 100644 --- a/tests/transcripts/regex_set.txt +++ b/tests/transcripts/regex_set.txt @@ -5,6 +5,7 @@ (Cmd) set allow_style: /'(Terminal|Always|Never)'/ +always_show_hint: False debug: False echo: False editor: /'.*'/ |