Skip to content

Commit 6bd2f2c

Browse files
authored
Merge pull request #993 from python-cmd2/completion_hints
Completion hint enhancements
2 parents 1054dda + e6a9a1c commit 6bd2f2c

File tree

8 files changed

+131
-55
lines changed

8 files changed

+131
-55
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## 1.4.0 (TBD, 2020)
2+
* Enhancements
3+
* Added user-settable option called `always_show_hint`. If True, then tab completion hints will always
4+
display even when tab completion suggestions print. Arguments whose help or hint text is suppressed will
5+
not display hints even when this setting is True.
6+
* Bug Fixes
7+
* Fixed issue where flag names weren't always sorted correctly in argparse tab completion
8+
19
## 1.3.9 (September 03, 2020)
210
* Breaking Changes
311
* `CommandSet.on_unregister()` is now called as first step in unregistering a `CommandSet` and not

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ example/transcript_regex.txt:
322322
# regexes on prompts just make the trailing space obvious
323323
(Cmd) set
324324
allow_style: '/(Terminal|Always|Never)/'
325+
always_show_hint: False
325326
debug: False
326327
echo: False
327328
editor: /.*?/

cmd2/argparse_completer.py

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@
3535
ARG_TOKENS = 'arg_tokens'
3636

3737

38+
# noinspection PyProtectedMember
39+
def _build_hint(parser: argparse.ArgumentParser, arg_action: argparse.Action) -> str:
40+
"""Build tab completion hint for a given argument"""
41+
# Check if hinting is disabled for this argument
42+
suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False)
43+
if suppress_hint or arg_action.help == argparse.SUPPRESS:
44+
return ''
45+
else:
46+
# Use the parser's help formatter to display just this action's help text
47+
formatter = parser._get_formatter()
48+
formatter.start_section("Hint")
49+
formatter.add_argument(arg_action)
50+
formatter.end_section()
51+
return formatter.format_help()
52+
53+
3854
def _single_prefix_char(token: str, parser: argparse.ArgumentParser) -> bool:
3955
"""Returns if a token is just a single flag prefix character"""
4056
return len(token) == 1 and token[0] in parser.prefix_chars
@@ -115,7 +131,6 @@ def __init__(self, flag_arg_state: _ArgumentState) -> None:
115131
super().__init__(error)
116132

117133

118-
# noinspection PyProtectedMember
119134
class _NoResultsError(CompletionError):
120135
def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action) -> None:
121136
"""
@@ -124,19 +139,8 @@ def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action)
124139
:param parser: ArgumentParser instance which owns the action being tab completed
125140
:param arg_action: action being tab completed
126141
"""
127-
# Check if hinting is disabled
128-
suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False)
129-
if suppress_hint or arg_action.help == argparse.SUPPRESS:
130-
hint_str = ''
131-
else:
132-
# Use the parser's help formatter to print just this action's help text
133-
formatter = parser._get_formatter()
134-
formatter.start_section("Hint")
135-
formatter.add_argument(arg_action)
136-
formatter.end_section()
137-
hint_str = formatter.format_help()
138142
# Set apply_style to False because we don't want hints to look like errors
139-
super().__init__(hint_str, apply_style=False)
143+
super().__init__(_build_hint(parser, arg_action), apply_style=False)
140144

141145

142146
# noinspection PyProtectedMember
@@ -411,6 +415,7 @@ def update_mutex_groups(arg_action: argparse.Action) -> None:
411415

412416
# If we have results, then return them
413417
if completion_results:
418+
self._cmd2_app.completion_hint = _build_hint(self._parser, flag_arg_state.action)
414419
return completion_results
415420

416421
# Otherwise, print a hint if the flag isn't finished or text isn't possibly the start of a flag
@@ -432,6 +437,7 @@ def update_mutex_groups(arg_action: argparse.Action) -> None:
432437

433438
# If we have results, then return them
434439
if completion_results:
440+
self._cmd2_app.completion_hint = _build_hint(self._parser, pos_arg_state.action)
435441
return completion_results
436442

437443
# Otherwise, print a hint if text isn't possibly the start of a flag
@@ -566,12 +572,21 @@ def _complete_for_arg(self, arg_state: _ArgumentState,
566572
"""
567573
# Check if the arg provides choices to the user
568574
if arg_state.action.choices is not None:
569-
arg_choices = arg_state.action.choices
575+
arg_choices = list(arg_state.action.choices)
576+
if not arg_choices:
577+
return []
578+
579+
# If these choices are numbers, then sort them now
580+
if all(isinstance(x, numbers.Number) for x in arg_choices):
581+
arg_choices.sort()
582+
self._cmd2_app.matches_sorted = True
583+
584+
# Since choices can be various types, convert them all to strings
585+
arg_choices = [str(x) for x in arg_choices]
570586
else:
571587
arg_choices = getattr(arg_state.action, ATTR_CHOICES_CALLABLE, None)
572-
573-
if arg_choices is None:
574-
return []
588+
if arg_choices is None:
589+
return []
575590

576591
# If we are going to call a completer/choices function, then set up the common arguments
577592
args = []
@@ -612,24 +627,17 @@ def _complete_for_arg(self, arg_state: _ArgumentState,
612627
if isinstance(arg_choices, ChoicesCallable) and not arg_choices.is_completer:
613628
arg_choices = arg_choices.to_call(*args, **kwargs)
614629

615-
# Since arg_choices can be any iterable type, convert to a list
616-
arg_choices = list(arg_choices)
617-
618-
# If these choices are numbers, and have not yet been sorted, then sort them now
619-
if not self._cmd2_app.matches_sorted and all(isinstance(x, numbers.Number) for x in arg_choices):
620-
arg_choices.sort()
621-
self._cmd2_app.matches_sorted = True
622-
623-
# Since choices can be various types like int, we must convert them to strings
624-
for index, choice in enumerate(arg_choices):
625-
if not isinstance(choice, str):
626-
arg_choices[index] = str(choice)
627-
628630
# Filter out arguments we already used
629631
used_values = consumed_arg_values.get(arg_state.action.dest, [])
630632
arg_choices = [choice for choice in arg_choices if choice not in used_values]
631633

632634
# Do tab completion on the choices
633635
results = basic_complete(text, line, begidx, endidx, arg_choices)
634636

637+
if not results:
638+
# Reset the value for matches_sorted. This is because completion of flag names
639+
# may still be attempted after we return and they haven't been sorted yet.
640+
self._cmd2_app.matches_sorted = False
641+
return []
642+
635643
return self._format_completions(arg_state, results)

cmd2/cmd2.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,
208208
self.allow_redirection = allow_redirection # Security setting to prevent redirection of stdout
209209

210210
# Attributes which ARE dynamically settable via the set command at runtime
211+
self.always_show_hint = False
211212
self.debug = False
212213
self.echo = False
213214
self.editor = Cmd.DEFAULT_EDITOR
@@ -375,17 +376,21 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,
375376
# will be added if there is an unmatched opening quote
376377
self.allow_closing_quote = True
377378

378-
# An optional header that prints above the tab completion suggestions
379+
# An optional hint which prints above tab completion suggestions
380+
self.completion_hint = ''
381+
382+
# Header which prints above CompletionItem tables
379383
self.completion_header = ''
380384

381385
# Used by complete() for readline tab completion
382386
self.completion_matches = []
383387

384-
# Use this list if you are completing strings that contain a common delimiter and you only want to
385-
# display the final portion of the matches as the tab completion suggestions. The full matches
386-
# still must be returned from your completer function. For an example, look at path_complete()
387-
# which uses this to show only the basename of paths as the suggestions. delimiter_complete() also
388-
# populates this list.
388+
# Use this list if you need to display tab completion suggestions that are different than the actual text
389+
# of the matches. For instance, if you are completing strings that contain a common delimiter and you only
390+
# want to display the final portion of the matches as the tab completion suggestions. The full matches
391+
# still must be returned from your completer function. For an example, look at path_complete() which
392+
# uses this to show only the basename of paths as the suggestions. delimiter_complete() also populates
393+
# this list.
389394
self.display_matches = []
390395

391396
# Used by functions like path_complete() and delimiter_complete() to properly
@@ -788,6 +793,8 @@ def build_settables(self):
788793
ansi.STYLE_NEVER),
789794
choices=[ansi.STYLE_TERMINAL, ansi.STYLE_ALWAYS, ansi.STYLE_NEVER]))
790795

796+
self.add_settable(Settable('always_show_hint', bool,
797+
'Display tab completion hint even when completion suggestions print'))
791798
self.add_settable(Settable('debug', bool, "Show full traceback on exception"))
792799
self.add_settable(Settable('echo', bool, "Echo command issued into output"))
793800
self.add_settable(Settable('editor', str, "Program used by 'edit'"))
@@ -984,6 +991,7 @@ def _reset_completion_defaults(self) -> None:
984991
"""
985992
self.allow_appended_space = True
986993
self.allow_closing_quote = True
994+
self.completion_hint = ''
987995
self.completion_header = ''
988996
self.completion_matches = []
989997
self.display_matches = []
@@ -1479,6 +1487,22 @@ def _pad_matches_to_display(matches_to_display: List[str]) -> Tuple[List[str], i
14791487

14801488
return [cur_match + padding for cur_match in matches_to_display], len(padding)
14811489

1490+
def _build_completion_metadata_string(self) -> str: # pragma: no cover
1491+
"""Build completion metadata string which can contain a hint and CompletionItem table header"""
1492+
metadata = ''
1493+
1494+
# Add hint if one exists and we are supposed to display it
1495+
if self.always_show_hint and self.completion_hint:
1496+
metadata += '\n' + self.completion_hint
1497+
1498+
# Add table header if one exists
1499+
if self.completion_header:
1500+
if not metadata:
1501+
metadata += '\n'
1502+
metadata += '\n' + self.completion_header
1503+
1504+
return metadata
1505+
14821506
def _display_matches_gnu_readline(self, substitution: str, matches: List[str],
14831507
longest_match_length: int) -> None: # pragma: no cover
14841508
"""Prints a match list using GNU readline's rl_display_match_list()
@@ -1523,9 +1547,8 @@ def _display_matches_gnu_readline(self, substitution: str, matches: List[str],
15231547
strings_array[1:-1] = encoded_matches
15241548
strings_array[-1] = None
15251549

1526-
# Print the header if one exists
1527-
if self.completion_header:
1528-
sys.stdout.write('\n\n' + self.completion_header)
1550+
# Print any metadata like a hint or table header
1551+
sys.stdout.write(self._build_completion_metadata_string())
15291552

15301553
# Call readline's display function
15311554
# rl_display_match_list(strings_array, number of completion matches, longest match length)
@@ -1551,10 +1574,8 @@ def _display_matches_pyreadline(self, matches: List[str]) -> None: # pragma: no
15511574
# Add padding for visual appeal
15521575
matches_to_display, _ = self._pad_matches_to_display(matches_to_display)
15531576

1554-
# Print the header if one exists
1555-
if self.completion_header:
1556-
# noinspection PyUnresolvedReferences
1557-
readline.rl.mode.console.write('\n\n' + self.completion_header)
1577+
# Print any metadata like a hint or table header
1578+
readline.rl.mode.console.write(sys.stdout.write(self._build_completion_metadata_string()))
15581579

15591580
# Display matches using actual display function. This also redraws the prompt and line.
15601581
orig_pyreadline_display(matches_to_display)
@@ -3317,7 +3338,7 @@ def complete_set_value(self, text: str, line: str, begidx: int, endidx: int,
33173338
# Create the parser for the set command
33183339
set_parser = DEFAULT_ARGUMENT_PARSER(parents=[set_parser_parent])
33193340
set_parser.add_argument('value', nargs=argparse.OPTIONAL, help='new value for settable',
3320-
completer_method=complete_set_value)
3341+
completer_method=complete_set_value, suppress_tab_hint=True)
33213342

33223343
# Preserve quotes so users can pass in quoted empty strings and flags (e.g. -h) as the value
33233344
@with_argparser(set_parser, preserve_quotes=True)

examples/transcripts/transcript_regex.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# regexes on prompts just make the trailing space obvious
55
(Cmd) set
66
allow_style: '/(Terminal|Always|Never)/'
7+
always_show_hint: False
78
debug: False
89
echo: False
910
editor: /.*?/

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def verify_help_text(cmd2_app: cmd2.Cmd,
9393

9494
# Output from the show command with default settings
9595
SHOW_TXT = """allow_style: 'Terminal'
96+
always_show_hint: False
9697
debug: False
9798
echo: False
9899
editor: 'vim'
@@ -104,6 +105,7 @@ def verify_help_text(cmd2_app: cmd2.Cmd,
104105

105106
SHOW_LONG = """
106107
allow_style: 'Terminal' # Allow ANSI text style sequences in output (valid values: Terminal, Always, Never)
108+
always_show_hint: False # Display tab completion hint even when completion suggestions print
107109
debug: False # Show full traceback on exception
108110
echo: False # Echo command issued into output
109111
editor: 'vim' # Program used by 'edit'

0 commit comments

Comments
 (0)