Skip to content

Commit

Permalink
Added always_show_hint setting
Browse files Browse the repository at this point in the history
Fixed issue where flag names weren't always sorted correctly in argparse tab completion
  • Loading branch information
kmvanbrunt committed Sep 5, 2020
1 parent 1054dda commit e6a9a1c
Show file tree
Hide file tree
Showing 8 changed files with 131 additions and 55 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## 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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: /.*?/
Expand Down
68 changes: 38 additions & 30 deletions cmd2/argparse_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@
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
Expand Down Expand Up @@ -115,7 +131,6 @@ def __init__(self, flag_arg_state: _ArgumentState) -> None:
super().__init__(error)


# noinspection PyProtectedMember
class _NoResultsError(CompletionError):
def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action) -> None:
"""
Expand All @@ -124,19 +139,8 @@ def __init__(self, parser: argparse.ArgumentParser, arg_action: argparse.Action)
: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
Expand Down Expand Up @@ -411,6 +415,7 @@ def update_mutex_groups(arg_action: argparse.Action) -> None:

# 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
Expand All @@ -432,6 +437,7 @@ def update_mutex_groups(arg_action: argparse.Action) -> None:

# 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
Expand Down Expand Up @@ -566,12 +572,21 @@ def _complete_for_arg(self, arg_state: _ArgumentState,
"""
# 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 = []
Expand Down Expand Up @@ -612,24 +627,17 @@ def _complete_for_arg(self, arg_state: _ArgumentState,
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]

# Do tab completion on the choices
results = 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)
49 changes: 35 additions & 14 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,
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
Expand Down Expand Up @@ -375,17 +376,21 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,
# 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
Expand Down Expand Up @@ -788,6 +793,8 @@ def build_settables(self):
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'"))
Expand Down Expand Up @@ -984,6 +991,7 @@ def _reset_completion_defaults(self) -> None:
"""
self.allow_appended_space = True
self.allow_closing_quote = True
self.completion_hint = ''
self.completion_header = ''
self.completion_matches = []
self.display_matches = []
Expand Down Expand Up @@ -1479,6 +1487,22 @@ def _pad_matches_to_display(matches_to_display: List[str]) -> Tuple[List[str], i

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()
Expand Down Expand Up @@ -1523,9 +1547,8 @@ def _display_matches_gnu_readline(self, substitution: str, matches: List[str],
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)
Expand All @@ -1551,10 +1574,8 @@ def _display_matches_pyreadline(self, matches: List[str]) -> None: # pragma: no
# 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)
Expand Down Expand Up @@ -3317,7 +3338,7 @@ def complete_set_value(self, text: str, line: str, begidx: int, endidx: int,
# 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_method=complete_set_value)
completer_method=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)
Expand Down
1 change: 1 addition & 0 deletions examples/transcripts/transcript_regex.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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: /.*?/
Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def verify_help_text(cmd2_app: cmd2.Cmd,

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

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'
Expand Down
Loading

0 comments on commit e6a9a1c

Please sign in to comment.