Skip to content

Commit eb8181e

Browse files
authored
Merge pull request #433 from python-cmd2/autocompleter
Autocompleter
2 parents d0e71c8 + e4d13c7 commit eb8181e

File tree

3 files changed

+84
-16
lines changed

3 files changed

+84
-16
lines changed

cmd2/argparse_completer.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str
5959

6060
import argparse
6161
from colorama import Fore
62+
import os
6263
import sys
6364
from typing import List, Dict, Tuple, Callable, Union
6465

@@ -78,6 +79,15 @@ def my_completer(text: str, line: str, begidx: int, endidx:int, extra_param: str
7879
ACTION_SUPPRESS_HINT = 'suppress_hint'
7980

8081

82+
class CompletionItem(str):
83+
def __new__(cls, o, desc='', *args, **kwargs):
84+
return str.__new__(cls, o, *args, **kwargs)
85+
86+
# noinspection PyMissingConstructor,PyUnusedLocal
87+
def __init__(self, o, desc='', *args, **kwargs):
88+
self.description = desc
89+
90+
8191
class _RangeAction(object):
8292
def __init__(self, nargs: Union[int, str, Tuple[int, int], None]):
8393
self.nargs_min = None
@@ -413,6 +423,8 @@ def consume_positional_argument() -> None:
413423
completion_results = self._complete_for_arg(flag_action, text, line, begidx, endidx, consumed)
414424
if not completion_results:
415425
self._print_action_help(flag_action)
426+
elif len(completion_results) > 1:
427+
completion_results = self._format_completions(flag_action, completion_results)
416428

417429
# ok, we're not a flag, see if there's a positional argument to complete
418430
else:
@@ -422,9 +434,35 @@ def consume_positional_argument() -> None:
422434
completion_results = self._complete_for_arg(pos_action, text, line, begidx, endidx, consumed)
423435
if not completion_results:
424436
self._print_action_help(pos_action)
437+
elif len(completion_results) > 1:
438+
completion_results = self._format_completions(pos_action, completion_results)
425439

426440
return completion_results
427441

442+
def _format_completions(self, action, completions: List[Union[str, CompletionItem]]):
443+
if completions and len(completions) > 1 and isinstance(completions[0], CompletionItem):
444+
token_width = len(action.dest)
445+
completions_with_desc = []
446+
447+
for item in completions:
448+
if len(item) > token_width:
449+
token_width = len(item)
450+
451+
term_size = os.get_terminal_size()
452+
fill_width = int(term_size.columns * .6) - (token_width + 2)
453+
for item in completions:
454+
entry = '{: <{token_width}}{: <{fill_width}}'.format(item, item.description,
455+
token_width=token_width+2,
456+
fill_width=fill_width)
457+
completions_with_desc.append(entry)
458+
459+
header = '\n{: <{token_width}}{}'.format(action.dest.upper(), action.desc_header, token_width=token_width+2)
460+
461+
self._cmd2_app.completion_header = header
462+
self._cmd2_app.display_matches = completions_with_desc
463+
464+
return completions
465+
428466
def complete_command_help(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]:
429467
"""Supports the completion of sub-commands for commands through the cmd2 help command."""
430468
for idx, token in enumerate(tokens):

cmd2/cmd2.py

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import cmd
3434
import collections
3535
from colorama import Fore
36+
import copy
3637
import glob
3738
import os
3839
import platform
@@ -494,13 +495,19 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, persistent_histor
494495
# will be added if there is an unmatched opening quote
495496
self.allow_closing_quote = True
496497

497-
# Use this list if you are completing strings that contain a common delimiter and you only want to
498-
# display the final portion of the matches as the tab-completion suggestions. The full matches
499-
# still must be returned from your completer function. For an example, look at path_complete()
500-
# which uses this to show only the basename of paths as the suggestions. delimiter_complete() also
501-
# populates this list.
498+
# An optional header that prints above the tab-completion suggestions
499+
self.completion_header = ''
500+
501+
# If the tab-completion suggestions should be displayed in a way that is different than the actual match values,
502+
# then place those results in this list. The full matches still must be returned from your completer function.
503+
# For an example, look at path_complete() which uses this to show only the basename of paths as the
504+
# suggestions. delimiter_complete() also populates this list.
502505
self.display_matches = []
503506

507+
# Used by functions like path_complete() and delimiter_complete() to properly
508+
# quote matches that are completed in a delimited fashion
509+
self.matches_delimited = False
510+
504511
# ----- Methods related to presenting output to the user -----
505512

506513
@property
@@ -657,7 +664,9 @@ def reset_completion_defaults(self):
657664
"""
658665
self.allow_appended_space = True
659666
self.allow_closing_quote = True
667+
self.completion_header = ''
660668
self.display_matches = []
669+
self.matches_delimited = False
661670

662671
if rl_type == RlType.GNU:
663672
readline.set_completion_display_matches_hook(self._display_matches_gnu_readline)
@@ -683,7 +692,6 @@ def tokens_for_completion(self, line, begidx, endidx):
683692
On Failure
684693
Both items are None
685694
"""
686-
import copy
687695
unclosed_quote = ''
688696
quotes_to_try = copy.copy(constants.QUOTES)
689697

@@ -836,6 +844,8 @@ def delimiter_complete(self, text, line, begidx, endidx, match_against, delimite
836844

837845
# Display only the portion of the match that's being completed based on delimiter
838846
if matches:
847+
# Set this to True for proper quoting of matches with spaces
848+
self.matches_delimited = True
839849

840850
# Get the common beginning for the matches
841851
common_prefix = os.path.commonprefix(matches)
@@ -1037,6 +1047,9 @@ def complete_users():
10371047
search_str = os.path.join(os.getcwd(), search_str)
10381048
cwd_added = True
10391049

1050+
# Set this to True for proper quoting of paths with spaces
1051+
self.matches_delimited = True
1052+
10401053
# Find all matching path completions
10411054
matches = glob.glob(search_str)
10421055

@@ -1245,6 +1258,10 @@ def _display_matches_gnu_readline(self, substitution, matches, longest_match_len
12451258
strings_array[1:-1] = encoded_matches
12461259
strings_array[-1] = None
12471260

1261+
# Print the header if one exists
1262+
if self.completion_header:
1263+
sys.stdout.write('\n' + self.completion_header)
1264+
12481265
# Call readline's display function
12491266
# rl_display_match_list(strings_array, number of completion matches, longest match length)
12501267
readline_lib.rl_display_match_list(strings_array, len(encoded_matches), longest_match_length)
@@ -1270,6 +1287,10 @@ def _display_matches_pyreadline(self, matches): # pragma: no cover
12701287
# Add padding for visual appeal
12711288
matches_to_display, _ = self._pad_matches_to_display(matches_to_display)
12721289

1290+
# Print the header if one exists
1291+
if self.completion_header:
1292+
readline.rl.mode.console.write('\n' + self.completion_header)
1293+
12731294
# Display matches using actual display function. This also redraws the prompt and line.
12741295
orig_pyreadline_display(matches_to_display)
12751296

@@ -1414,17 +1435,10 @@ def complete(self, text, state):
14141435
display_matches_set = set(self.display_matches)
14151436
self.display_matches = list(display_matches_set)
14161437

1417-
# Check if display_matches has been used. If so, then matches
1418-
# on delimited strings like paths was done.
1419-
if self.display_matches:
1420-
matches_delimited = True
1421-
else:
1422-
matches_delimited = False
1423-
1438+
if not self.display_matches:
14241439
# Since self.display_matches is empty, set it to self.completion_matches
14251440
# before we alter them. That way the suggestions will reflect how we parsed
14261441
# the token being completed and not how readline did.
1427-
import copy
14281442
self.display_matches = copy.copy(self.completion_matches)
14291443

14301444
# Check if we need to add an opening quote
@@ -1435,7 +1449,7 @@ def complete(self, text, state):
14351449
# This is the tab completion text that will appear on the command line.
14361450
common_prefix = os.path.commonprefix(self.completion_matches)
14371451

1438-
if matches_delimited:
1452+
if self.matches_delimited:
14391453
# Check if any portion of the display matches appears in the tab completion
14401454
display_prefix = os.path.commonprefix(self.display_matches)
14411455

examples/tab_autocompletion.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ def instance_query_actors(self) -> List[str]:
109109
"""Simulating a function that queries and returns a completion values"""
110110
return actors
111111

112+
def instance_query_movie_ids(self) -> List[str]:
113+
"""Demonstrates showing tabular hinting of tab completion information"""
114+
completions_with_desc = []
115+
116+
for movie_id, movie_entry in self.MOVIE_DATABASE.items():
117+
completions_with_desc.append(argparse_completer.CompletionItem(movie_id, movie_entry['title']))
118+
119+
setattr(self.vid_delete_movie_id, 'desc_header', 'Title')
120+
setattr(self.movies_delete_movie_id, 'desc_header', 'Title')
121+
122+
return completions_with_desc
123+
112124
# This demonstrates a number of customizations of the AutoCompleter version of ArgumentParser
113125
# - The help output will separately group required vs optional flags
114126
# - The help output for arguments with multiple flags or with append=True is more concise
@@ -253,6 +265,8 @@ def _do_vid_media_shows(self, args) -> None:
253265
('path_complete', [False, False]))
254266

255267
vid_movies_delete_parser = vid_movies_commands_subparsers.add_parser('delete')
268+
vid_delete_movie_id = vid_movies_delete_parser.add_argument('movie_id', help='Movie ID')
269+
setattr(vid_delete_movie_id, argparse_completer.ACTION_ARG_CHOICES, instance_query_movie_ids)
256270

257271
vid_shows_parser = video_types_subparsers.add_parser('shows')
258272
vid_shows_parser.set_defaults(func=_do_vid_media_shows)
@@ -328,6 +342,8 @@ def _do_media_shows(self, args) -> None:
328342
movies_add_parser.add_argument('actor', help='Actors', nargs='*')
329343

330344
movies_delete_parser = movies_commands_subparsers.add_parser('delete')
345+
movies_delete_movie_id = movies_delete_parser.add_argument('movie_id', help='Movie ID')
346+
setattr(movies_delete_movie_id, argparse_completer.ACTION_ARG_CHOICES, 'instance_query_movie_ids')
331347

332348
movies_load_parser = movies_commands_subparsers.add_parser('load')
333349
movie_file_action = movies_load_parser.add_argument('movie_file', help='Movie database')
@@ -362,7 +378,7 @@ def complete_media(self, text, line, begidx, endidx):
362378
'director': TabCompleteExample.static_list_directors, # static list
363379
'movie_file': (self.path_complete, [False, False])
364380
}
365-
completer = argparse_completer.AutoCompleter(TabCompleteExample.media_parser, arg_choices=choices)
381+
completer = argparse_completer.AutoCompleter(TabCompleteExample.media_parser, arg_choices=choices, cmd2_app=self)
366382

367383
tokens, _ = self.tokens_for_completion(line, begidx, endidx)
368384
results = completer.complete_command(tokens, text, line, begidx, endidx)

0 commit comments

Comments
 (0)