Skip to content

Commit 46f0aed

Browse files
authored
Merge pull request #671 from python-cmd2/completion_exceptions
Improved tab completion error feedback
2 parents 673d8a1 + 47999ff commit 46f0aed

File tree

7 files changed

+74
-46
lines changed

7 files changed

+74
-46
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
since the output will print at the same frequency as when the command is run in a terminal.
1010
* **ACArgumentParser** no longer prints complete help text when a parsing error occurs since long help messages
1111
scroll the actual error message off the screen.
12+
* Exceptions occurring in tab completion functions are now printed to stderr before returning control back to
13+
readline. This makes debugging a lot easier since readline suppresses these exceptions.
1214
* **Python 3.4 EOL notice**
1315
* Python 3.4 reached its [end of life](https://www.python.org/dev/peps/pep-0429/) on March 18, 2019
1416
* This is the last release of `cmd2` which will support Python 3.4

cmd2/argparse_completer.py

Lines changed: 16 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ def _complete_for_arg(self, action: argparse.Action,
667667

668668
if callable(arg_choices[0]):
669669
completer = arg_choices[0]
670-
elif isinstance(arg_choices[0], str) and callable(getattr(self._cmd2_app, arg_choices[0])):
670+
else:
671671
completer = getattr(self._cmd2_app, arg_choices[0])
672672

673673
# extract the positional and keyword arguments from the tuple
@@ -678,19 +678,16 @@ def _complete_for_arg(self, action: argparse.Action,
678678
list_args = arg_choices[index]
679679
elif isinstance(arg_choices[index], dict):
680680
kw_args = arg_choices[index]
681-
try:
682-
# call the provided function differently depending on the provided positional and keyword arguments
683-
if list_args is not None and kw_args is not None:
684-
return completer(text, line, begidx, endidx, *list_args, **kw_args)
685-
elif list_args is not None:
686-
return completer(text, line, begidx, endidx, *list_args)
687-
elif kw_args is not None:
688-
return completer(text, line, begidx, endidx, **kw_args)
689-
else:
690-
return completer(text, line, begidx, endidx)
691-
except TypeError:
692-
# assume this is due to an incorrect function signature, return nothing.
693-
return []
681+
682+
# call the provided function differently depending on the provided positional and keyword arguments
683+
if list_args is not None and kw_args is not None:
684+
return completer(text, line, begidx, endidx, *list_args, **kw_args)
685+
elif list_args is not None:
686+
return completer(text, line, begidx, endidx, *list_args)
687+
elif kw_args is not None:
688+
return completer(text, line, begidx, endidx, **kw_args)
689+
else:
690+
return completer(text, line, begidx, endidx)
694691
else:
695692
return self._cmd2_app.basic_complete(text, line, begidx, endidx,
696693
self._resolve_choices_for_arg(action, used_values))
@@ -704,32 +701,17 @@ def _resolve_choices_for_arg(self, action: argparse.Action, used_values=()) -> L
704701
# is the argument a string? If so, see if we can find an attribute in the
705702
# application matching the string.
706703
if isinstance(args, str):
707-
try:
708-
args = getattr(self._cmd2_app, args)
709-
except AttributeError:
710-
# Couldn't find anything matching the name
711-
return []
704+
args = getattr(self._cmd2_app, args)
712705

713706
# is the provided argument a callable. If so, call it
714707
if callable(args):
715708
try:
716-
try:
717-
args = args(self._cmd2_app)
718-
except TypeError:
719-
args = args()
709+
args = args(self._cmd2_app)
720710
except TypeError:
721-
return []
722-
723-
try:
724-
iter(args)
725-
except TypeError:
726-
pass
727-
else:
728-
# filter out arguments we already used
729-
args = [arg for arg in args if arg not in used_values]
711+
args = args()
730712

731-
if len(args) > 0:
732-
return args
713+
# filter out arguments we already used
714+
return [arg for arg in args if arg not in used_values]
733715

734716
return []
735717

cmd2/cmd2.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,16 +1362,13 @@ def _display_matches_pyreadline(self, matches: List[str]) -> None: # pragma: no
13621362
# Display matches using actual display function. This also redraws the prompt and line.
13631363
orig_pyreadline_display(matches_to_display)
13641364

1365-
# ----- Methods which override stuff in cmd -----
1366-
1367-
def complete(self, text: str, state: int) -> Optional[str]:
1368-
"""Override of command method which returns the next possible completion for 'text'.
1365+
def _complete_worker(self, text: str, state: int) -> Optional[str]:
1366+
"""The actual worker function for tab completion which is called by complete() and returns
1367+
the next possible completion for 'text'.
13691368
13701369
If a command has not been entered, then complete against command list.
13711370
Otherwise try to call complete_<command> to get list of completions.
13721371
1373-
This method gets called directly by readline because it is set as the tab-completion function.
1374-
13751372
This completer function is called as complete(text, state), for state in 0, 1, 2, …, until it returns a
13761373
non-string value. It should return the next possible completion starting with text.
13771374
@@ -1581,6 +1578,24 @@ def complete(self, text: str, state: int) -> Optional[str]:
15811578
except IndexError:
15821579
return None
15831580

1581+
def complete(self, text: str, state: int) -> Optional[str]:
1582+
"""Override of cmd2's complete method which returns the next possible completion for 'text'
1583+
1584+
This method gets called directly by readline. Since readline suppresses any exception raised
1585+
in completer functions, they can be difficult to debug. Therefore this function wraps the
1586+
actual tab completion logic and prints to stderr any exception that occurs before returning
1587+
control to readline.
1588+
1589+
:param text: the current word that user is typing
1590+
:param state: non-negative integer
1591+
"""
1592+
# noinspection PyBroadException
1593+
try:
1594+
return self._complete_worker(text, state)
1595+
except Exception as e:
1596+
self.perror(e)
1597+
return None
1598+
15841599
def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int,
15851600
argparser: argparse.ArgumentParser) -> List[str]:
15861601
"""Default completion function for argparse commands."""

examples/tab_autocomp_dynamic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def __init__(self):
6969
setattr(director_action, argparse_completer.ACTION_ARG_CHOICES, TabCompleteExample.static_list_directors)
7070
setattr(actor_action, argparse_completer.ACTION_ARG_CHOICES, 'instance_query_actors')
7171

72-
# tag the file property with a custom completion function 'delimeter_complete' provided by cmd2.
72+
# tag the file property with a custom completion function 'delimiter_complete' provided by cmd2.
7373
setattr(vid_movie_file_action, argparse_completer.ACTION_ARG_CHOICES,
7474
('delimiter_complete',
7575
{'delimiter': '/',

examples/tab_autocompletion.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ def _do_vid_media_shows(self, args) -> None:
255255
setattr(director_action, argparse_completer.ACTION_ARG_CHOICES, static_list_directors)
256256
setattr(actor_action, argparse_completer.ACTION_ARG_CHOICES, 'instance_query_actors')
257257

258-
# tag the file property with a custom completion function 'delimeter_complete' provided by cmd2.
258+
# tag the file property with a custom completion function 'delimiter_complete' provided by cmd2.
259259
setattr(vid_movie_file_action, argparse_completer.ACTION_ARG_CHOICES,
260260
('delimiter_complete',
261261
{'delimiter': '/',

tests/test_autocompletion.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ def test_autocomp_subcmd_flag_comp_list_attr(cmd2_app):
229229
assert first_match is not None and first_match == '"Gareth Edwards'
230230

231231

232-
def test_autcomp_pos_consumed(cmd2_app):
232+
def test_autocomp_pos_consumed(cmd2_app):
233233
text = ''
234234
line = 'library movie add SW_EP01 {}'.format(text)
235235
endidx = len(line)
@@ -239,7 +239,7 @@ def test_autcomp_pos_consumed(cmd2_app):
239239
assert first_match is None
240240

241241

242-
def test_autcomp_pos_after_flag(cmd2_app):
242+
def test_autocomp_pos_after_flag(cmd2_app):
243243
text = 'Joh'
244244
line = 'video movies add -d "George Lucas" -- "Han Solo" PG "Emilia Clarke" "{}'.format(text)
245245
endidx = len(line)
@@ -250,7 +250,7 @@ def test_autcomp_pos_after_flag(cmd2_app):
250250
cmd2_app.completion_matches == ['John Boyega" ']
251251

252252

253-
def test_autcomp_custom_func_list_arg(cmd2_app):
253+
def test_autocomp_custom_func_list_arg(cmd2_app):
254254
text = 'SW_'
255255
line = 'library show add {}'.format(text)
256256
endidx = len(line)
@@ -261,7 +261,7 @@ def test_autcomp_custom_func_list_arg(cmd2_app):
261261
cmd2_app.completion_matches == ['SW_CW', 'SW_REB', 'SW_TCW']
262262

263263

264-
def test_autcomp_custom_func_list_and_dict_arg(cmd2_app):
264+
def test_autocomp_custom_func_list_and_dict_arg(cmd2_app):
265265
text = ''
266266
line = 'library show add SW_REB {}'.format(text)
267267
endidx = len(line)
@@ -272,6 +272,17 @@ def test_autcomp_custom_func_list_and_dict_arg(cmd2_app):
272272
cmd2_app.completion_matches == ['S01E02', 'S01E03', 'S02E01', 'S02E03']
273273

274274

275+
def test_autocomp_custom_func_dict_arg(cmd2_app):
276+
text = '/home/user/'
277+
line = 'video movies load {}'.format(text)
278+
endidx = len(line)
279+
begidx = endidx - len(text)
280+
281+
first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
282+
assert first_match is not None and \
283+
cmd2_app.completion_matches == ['/home/user/another.db', '/home/user/file space.db', '/home/user/file.db']
284+
285+
275286
def test_argparse_remainder_flag_completion(cmd2_app):
276287
import cmd2
277288
import argparse

tests/test_completion.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ def complete_test_sort_key(self, text, line, begidx, endidx):
7777
num_strs = ['2', '11', '1']
7878
return self.basic_complete(text, line, begidx, endidx, num_strs)
7979

80+
def do_test_raise_exception(self, args):
81+
pass
82+
83+
def complete_test_raise_exception(self, text, line, begidx, endidx):
84+
raise IndexError("You are out of bounds!!")
85+
8086

8187
@pytest.fixture
8288
def cmd2_app():
@@ -120,6 +126,18 @@ def test_complete_bogus_command(cmd2_app):
120126
first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
121127
assert first_match is None
122128

129+
def test_complete_exception(cmd2_app, capsys):
130+
text = ''
131+
line = 'test_raise_exception {}'.format(text)
132+
endidx = len(line)
133+
begidx = endidx - len(text)
134+
135+
first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
136+
out, err = capsys.readouterr()
137+
138+
assert first_match is None
139+
assert "IndexError" in err
140+
123141
def test_complete_macro(base_app, request):
124142
# Create the macro
125143
out, err = run_cmd(base_app, 'macro create fake pyscript {1}')

0 commit comments

Comments
 (0)