Skip to content

Commit 4193ef0

Browse files
committed
Initial customization of CompletionFinder
1 parent f11b063 commit 4193ef0

File tree

3 files changed

+224
-17
lines changed

3 files changed

+224
-17
lines changed

cmd2/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,20 @@
33
#
44
from .cmd2 import __version__, Cmd, set_posix_shlex, set_strip_quotes, AddSubmenu, CmdResult, categorize
55
from .cmd2 import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category
6+
7+
# Used for tab completion and word breaks. Do not change.
8+
QUOTES = ['"', "'"]
9+
REDIRECTION_CHARS = ['|', '<', '>']
10+
11+
12+
def strip_quotes(arg: str) -> str:
13+
""" Strip outer quotes from a string.
14+
15+
Applies to both single and double quotes.
16+
17+
:param arg: string to strip outer quotes from
18+
:return: same string with potentially outer quotes stripped
19+
"""
20+
if len(arg) > 1 and arg[0] == arg[-1] and arg[0] in QUOTES:
21+
arg = arg[1:-1]
22+
return arg

cmd2/argcomplete_bridge.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# coding=utf-8
2+
"""Hijack the ArgComplete's bash completion handler to return AutoCompleter results"""
3+
4+
import argcomplete
5+
import copy
6+
import os
7+
import shlex
8+
import sys
9+
10+
from . import strip_quotes, QUOTES
11+
12+
13+
def tokens_for_completion(line, endidx):
14+
"""
15+
Used by tab completion functions to get all tokens through the one being completed
16+
:param line: str - the current input line with leading whitespace removed
17+
:param endidx: int - the ending index of the prefix text
18+
:return: A 4 item tuple where the items are
19+
On Success
20+
tokens: list of unquoted tokens
21+
this is generally the list needed for tab completion functions
22+
raw_tokens: list of tokens with any quotes preserved
23+
this can be used to know if a token was quoted or is missing a closing quote
24+
begidx: beginning of last token
25+
endidx: cursor position
26+
27+
Both lists are guaranteed to have at least 1 item
28+
The last item in both lists is the token being tab completed
29+
30+
On Failure
31+
Both items are None
32+
"""
33+
unclosed_quote = ''
34+
quotes_to_try = copy.copy(QUOTES)
35+
36+
tmp_line = line[:endidx]
37+
tmp_endidx = endidx
38+
39+
# Parse the line into tokens
40+
while True:
41+
try:
42+
# Use non-POSIX parsing to keep the quotes around the tokens
43+
initial_tokens = shlex.split(tmp_line[:tmp_endidx], posix=False)
44+
45+
# calculate begidx
46+
if unclosed_quote:
47+
begidx = tmp_line[:tmp_endidx].rfind(initial_tokens[-1]) + 1
48+
else:
49+
if tmp_endidx > 0 and tmp_line[tmp_endidx - 1] == ' ':
50+
begidx = endidx
51+
else:
52+
begidx = tmp_line[:tmp_endidx].rfind(initial_tokens[-1])
53+
54+
# If the cursor is at an empty token outside of a quoted string,
55+
# then that is the token being completed. Add it to the list.
56+
if not unclosed_quote and begidx == tmp_endidx:
57+
initial_tokens.append('')
58+
break
59+
except ValueError:
60+
# ValueError can be caused by missing closing quote
61+
if not quotes_to_try:
62+
# Since we have no more quotes to try, something else
63+
# is causing the parsing error. Return None since
64+
# this means the line is malformed.
65+
return None, None, None, None
66+
67+
# Add a closing quote and try to parse again
68+
unclosed_quote = quotes_to_try[0]
69+
quotes_to_try = quotes_to_try[1:]
70+
71+
tmp_line = line[:endidx]
72+
tmp_line += unclosed_quote
73+
tmp_endidx = endidx + 1
74+
75+
raw_tokens = initial_tokens
76+
77+
# Save the unquoted tokens
78+
tokens = [strip_quotes(cur_token) for cur_token in raw_tokens]
79+
80+
# If the token being completed had an unclosed quote, we need
81+
# to remove the closing quote that was added in order for it
82+
# to match what was on the command line.
83+
if unclosed_quote:
84+
raw_tokens[-1] = raw_tokens[-1][:-1]
85+
86+
return tokens, raw_tokens, begidx, endidx
87+
88+
class CompletionFinder(argcomplete.CompletionFinder):
89+
"""Hijack the functor from argcomplete to call AutoCompleter"""
90+
91+
def __call__(self, argument_parser, completer=None, always_complete_options=True, exit_method=os._exit, output_stream=None,
92+
exclude=None, validator=None, print_suppressed=False, append_space=None,
93+
default_completer=argcomplete.FilesCompleter()):
94+
"""
95+
:param argument_parser: The argument parser to autocomplete on
96+
:type argument_parser: :class:`argparse.ArgumentParser`
97+
:param always_complete_options:
98+
Controls the autocompletion of option strings if an option string opening character (normally ``-``) has not
99+
been entered. If ``True`` (default), both short (``-x``) and long (``--x``) option strings will be
100+
suggested. If ``False``, no option strings will be suggested. If ``long``, long options and short options
101+
with no long variant will be suggested. If ``short``, short options and long options with no short variant
102+
will be suggested.
103+
:type always_complete_options: boolean or string
104+
:param exit_method:
105+
Method used to stop the program after printing completions. Defaults to :meth:`os._exit`. If you want to
106+
perform a normal exit that calls exit handlers, use :meth:`sys.exit`.
107+
:type exit_method: callable
108+
:param exclude: List of strings representing options to be omitted from autocompletion
109+
:type exclude: iterable
110+
:param validator:
111+
Function to filter all completions through before returning (called with two string arguments, completion
112+
and prefix; return value is evaluated as a boolean)
113+
:type validator: callable
114+
:param print_suppressed:
115+
Whether or not to autocomplete options that have the ``help=argparse.SUPPRESS`` keyword argument set.
116+
:type print_suppressed: boolean
117+
:param append_space:
118+
Whether to append a space to unique matches. The default is ``True``.
119+
:type append_space: boolean
120+
121+
.. note::
122+
If you are not subclassing CompletionFinder to override its behaviors,
123+
use ``argcomplete.autocomplete()`` directly. It has the same signature as this method.
124+
125+
Produces tab completions for ``argument_parser``. See module docs for more info.
126+
127+
Argcomplete only executes actions if their class is known not to have side effects. Custom action classes can be
128+
added to argcomplete.safe_actions, if their values are wanted in the ``parsed_args`` completer argument, or
129+
their execution is otherwise desirable.
130+
"""
131+
self.__init__(argument_parser, always_complete_options=always_complete_options, exclude=exclude,
132+
validator=validator, print_suppressed=print_suppressed, append_space=append_space,
133+
default_completer=default_completer)
134+
135+
if "_ARGCOMPLETE" not in os.environ:
136+
# not an argument completion invocation
137+
return
138+
139+
global debug_stream
140+
try:
141+
debug_stream = os.fdopen(9, "w")
142+
except:
143+
debug_stream = sys.stderr
144+
145+
if output_stream is None:
146+
try:
147+
output_stream = os.fdopen(8, "wb")
148+
except:
149+
argcomplete.debug("Unable to open fd 8 for writing, quitting")
150+
exit_method(1)
151+
152+
# print("", stream=debug_stream)
153+
# for v in "COMP_CWORD COMP_LINE COMP_POINT COMP_TYPE COMP_KEY _ARGCOMPLETE_COMP_WORDBREAKS COMP_WORDS".split():
154+
# print(v, os.environ[v], stream=debug_stream)
155+
156+
ifs = os.environ.get("_ARGCOMPLETE_IFS", "\013")
157+
if len(ifs) != 1:
158+
argcomplete.debug("Invalid value for IFS, quitting [{v}]".format(v=ifs))
159+
exit_method(1)
160+
161+
comp_line = os.environ["COMP_LINE"]
162+
comp_point = int(os.environ["COMP_POINT"])
163+
164+
comp_line = argcomplete.ensure_str(comp_line)
165+
### SWAPPED FOR AUTOCOMPLETER
166+
# cword_prequote, cword_prefix, cword_suffix, comp_words, last_wordbreak_pos = split_line(comp_line, comp_point)
167+
tokens, _, begidx, endidx = tokens_for_completion(comp_line, comp_point)
168+
169+
# _ARGCOMPLETE is set by the shell script to tell us where comp_words
170+
# should start, based on what we're completing.
171+
# 1: <script> [args]
172+
# 2: python <script> [args]
173+
# 3: python -m <module> [args]
174+
start = int(os.environ["_ARGCOMPLETE"]) - 1
175+
### SWAPPED FOR AUTOCOMPLETER
176+
# comp_words = comp_words[start:]
177+
tokens = tokens[start:]
178+
179+
# debug("\nLINE: {!r}".format(comp_line),
180+
# "\nPOINT: {!r}".format(comp_point),
181+
# "\nPREQUOTE: {!r}".format(cword_prequote),
182+
# "\nPREFIX: {!r}".format(cword_prefix),
183+
# "\nSUFFIX: {!r}".format(cword_suffix),
184+
# "\nWORDS:", comp_words)
185+
186+
### SWAPPED FOR AUTOCOMPLETER
187+
# completions = self._get_completions(comp_words, cword_prefix, cword_prequote, last_wordbreak_pos)
188+
completions = completer.complete_command(tokens, tokens[-1], comp_line, begidx, endidx)
189+
190+
if completions is not None:
191+
# If any completion has a space in it, then quote all completions
192+
# this improves the user experience so they don't nede to go back and add a quote
193+
if ' ' in ''.join(completions):
194+
completions = ['"{}"'.format(entry) for entry in completions]
195+
196+
argcomplete.debug("\nReturning completions:", completions)
197+
198+
output_stream.write(ifs.join(completions).encode(argcomplete.sys_encoding))
199+
else:
200+
# if completions is None we assume we don't know how to handle it so let bash
201+
# go forward with normal filesystem completion
202+
output_stream.write(ifs.join([]).encode(argcomplete.sys_encoding))
203+
output_stream.flush()
204+
debug_stream.flush()
205+
exit_method(0)
206+

cmd2/cmd2.py

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,7 @@ def __subclasshook__(cls, C):
135135
STRIP_QUOTES_FOR_NON_POSIX = True
136136

137137
# Used for tab completion and word breaks. Do not change.
138-
QUOTES = ['"', "'"]
139-
REDIRECTION_CHARS = ['|', '<', '>']
138+
from . import strip_quotes, QUOTES, REDIRECTION_CHARS
140139

141140
# optional attribute, when tagged on a function, allows cmd2 to categorize commands
142141
HELP_CATEGORY = 'help_category'
@@ -185,21 +184,6 @@ def _which(editor: str) -> Optional[str]:
185184
return editor_path
186185

187186

188-
def strip_quotes(arg: str) -> str:
189-
""" Strip outer quotes from a string.
190-
191-
Applies to both single and double quotes.
192-
193-
:param arg: string to strip outer quotes from
194-
:return: same string with potentially outer quotes stripped
195-
"""
196-
quote_chars = '"' + "'"
197-
198-
if len(arg) > 1 and arg[0] == arg[-1] and arg[0] in quote_chars:
199-
arg = arg[1:-1]
200-
return arg
201-
202-
203187
def parse_quoted_string(cmdline: str) -> List[str]:
204188
"""Parse a quoted string into a list of arguments."""
205189
if isinstance(cmdline, list):

0 commit comments

Comments
 (0)