diff options
-rw-r--r-- | CHANGELOG.md | 2 | ||||
-rw-r--r-- | cmd2/argparse_completer.py | 50 | ||||
-rw-r--r-- | cmd2/cmd2.py | 27 | ||||
-rwxr-xr-x | examples/tab_autocomp_dynamic.py | 2 | ||||
-rwxr-xr-x | examples/tab_autocompletion.py | 2 | ||||
-rw-r--r-- | tests/test_autocompletion.py | 19 | ||||
-rw-r--r-- | tests/test_completion.py | 18 |
7 files changed, 74 insertions, 46 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 4967891a..ce72a858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ since the output will print at the same frequency as when the command is run in a terminal. * **ACArgumentParser** no longer prints complete help text when a parsing error occurs since long help messages scroll the actual error message off the screen. + * Exceptions occurring in tab completion functions are now printed to stderr before returning control back to + readline. This makes debugging a lot easier since readline suppresses these exceptions. * **Python 3.4 EOL notice** * Python 3.4 reached its [end of life](https://www.python.org/dev/peps/pep-0429/) on March 18, 2019 * This is the last release of `cmd2` which will support Python 3.4 diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index ac65185b..edfaeec4 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -667,7 +667,7 @@ class AutoCompleter(object): if callable(arg_choices[0]): completer = arg_choices[0] - elif isinstance(arg_choices[0], str) and callable(getattr(self._cmd2_app, arg_choices[0])): + else: completer = getattr(self._cmd2_app, arg_choices[0]) # extract the positional and keyword arguments from the tuple @@ -678,19 +678,16 @@ class AutoCompleter(object): list_args = arg_choices[index] elif isinstance(arg_choices[index], dict): kw_args = arg_choices[index] - try: - # call the provided function differently depending on the provided positional and keyword arguments - if list_args is not None and kw_args is not None: - return completer(text, line, begidx, endidx, *list_args, **kw_args) - elif list_args is not None: - return completer(text, line, begidx, endidx, *list_args) - elif kw_args is not None: - return completer(text, line, begidx, endidx, **kw_args) - else: - return completer(text, line, begidx, endidx) - except TypeError: - # assume this is due to an incorrect function signature, return nothing. - return [] + + # call the provided function differently depending on the provided positional and keyword arguments + if list_args is not None and kw_args is not None: + return completer(text, line, begidx, endidx, *list_args, **kw_args) + elif list_args is not None: + return completer(text, line, begidx, endidx, *list_args) + elif kw_args is not None: + return completer(text, line, begidx, endidx, **kw_args) + else: + return completer(text, line, begidx, endidx) else: return self._cmd2_app.basic_complete(text, line, begidx, endidx, self._resolve_choices_for_arg(action, used_values)) @@ -704,32 +701,17 @@ class AutoCompleter(object): # is the argument a string? If so, see if we can find an attribute in the # application matching the string. if isinstance(args, str): - try: - args = getattr(self._cmd2_app, args) - except AttributeError: - # Couldn't find anything matching the name - return [] + args = getattr(self._cmd2_app, args) # is the provided argument a callable. If so, call it if callable(args): try: - try: - args = args(self._cmd2_app) - except TypeError: - args = args() + args = args(self._cmd2_app) except TypeError: - return [] - - try: - iter(args) - except TypeError: - pass - else: - # filter out arguments we already used - args = [arg for arg in args if arg not in used_values] + args = args() - if len(args) > 0: - return args + # filter out arguments we already used + return [arg for arg in args if arg not in used_values] return [] diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index a7b60b1a..3c1c8d2c 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1362,16 +1362,13 @@ class Cmd(cmd.Cmd): # Display matches using actual display function. This also redraws the prompt and line. orig_pyreadline_display(matches_to_display) - # ----- Methods which override stuff in cmd ----- - - def complete(self, text: str, state: int) -> Optional[str]: - """Override of command method which returns the next possible completion for 'text'. + def _complete_worker(self, text: str, state: int) -> Optional[str]: + """The actual worker function for tab completion which is called by complete() and returns + the next possible completion for 'text'. If a command has not been entered, then complete against command list. Otherwise try to call complete_<command> to get list of completions. - This method gets called directly by readline because it is set as the tab-completion function. - This completer function is called as complete(text, state), for state in 0, 1, 2, …, until it returns a non-string value. It should return the next possible completion starting with text. @@ -1581,6 +1578,24 @@ class Cmd(cmd.Cmd): except IndexError: return None + def complete(self, text: str, state: int) -> Optional[str]: + """Override of cmd2's complete method which returns the next possible completion for 'text' + + This method gets called directly by readline. Since readline suppresses any exception raised + in completer functions, they can be difficult to debug. Therefore this function wraps the + actual tab completion logic and prints to stderr any exception that occurs before returning + control to readline. + + :param text: the current word that user is typing + :param state: non-negative integer + """ + # noinspection PyBroadException + try: + return self._complete_worker(text, state) + except Exception as e: + self.perror(e) + return None + def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int, argparser: argparse.ArgumentParser) -> List[str]: """Default completion function for argparse commands.""" diff --git a/examples/tab_autocomp_dynamic.py b/examples/tab_autocomp_dynamic.py index bedc9d4b..93b72442 100755 --- a/examples/tab_autocomp_dynamic.py +++ b/examples/tab_autocomp_dynamic.py @@ -69,7 +69,7 @@ class TabCompleteExample(cmd2.Cmd): setattr(director_action, argparse_completer.ACTION_ARG_CHOICES, TabCompleteExample.static_list_directors) setattr(actor_action, argparse_completer.ACTION_ARG_CHOICES, 'instance_query_actors') - # tag the file property with a custom completion function 'delimeter_complete' provided by cmd2. + # tag the file property with a custom completion function 'delimiter_complete' provided by cmd2. setattr(vid_movie_file_action, argparse_completer.ACTION_ARG_CHOICES, ('delimiter_complete', {'delimiter': '/', diff --git a/examples/tab_autocompletion.py b/examples/tab_autocompletion.py index aa28fc10..3f06a274 100755 --- a/examples/tab_autocompletion.py +++ b/examples/tab_autocompletion.py @@ -255,7 +255,7 @@ class TabCompleteExample(cmd2.Cmd): setattr(director_action, argparse_completer.ACTION_ARG_CHOICES, static_list_directors) setattr(actor_action, argparse_completer.ACTION_ARG_CHOICES, 'instance_query_actors') - # tag the file property with a custom completion function 'delimeter_complete' provided by cmd2. + # tag the file property with a custom completion function 'delimiter_complete' provided by cmd2. setattr(vid_movie_file_action, argparse_completer.ACTION_ARG_CHOICES, ('delimiter_complete', {'delimiter': '/', diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py index a5dafd2d..005eee81 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -229,7 +229,7 @@ def test_autocomp_subcmd_flag_comp_list_attr(cmd2_app): assert first_match is not None and first_match == '"Gareth Edwards' -def test_autcomp_pos_consumed(cmd2_app): +def test_autocomp_pos_consumed(cmd2_app): text = '' line = 'library movie add SW_EP01 {}'.format(text) endidx = len(line) @@ -239,7 +239,7 @@ def test_autcomp_pos_consumed(cmd2_app): assert first_match is None -def test_autcomp_pos_after_flag(cmd2_app): +def test_autocomp_pos_after_flag(cmd2_app): text = 'Joh' line = 'video movies add -d "George Lucas" -- "Han Solo" PG "Emilia Clarke" "{}'.format(text) endidx = len(line) @@ -250,7 +250,7 @@ def test_autcomp_pos_after_flag(cmd2_app): cmd2_app.completion_matches == ['John Boyega" '] -def test_autcomp_custom_func_list_arg(cmd2_app): +def test_autocomp_custom_func_list_arg(cmd2_app): text = 'SW_' line = 'library show add {}'.format(text) endidx = len(line) @@ -261,7 +261,7 @@ def test_autcomp_custom_func_list_arg(cmd2_app): cmd2_app.completion_matches == ['SW_CW', 'SW_REB', 'SW_TCW'] -def test_autcomp_custom_func_list_and_dict_arg(cmd2_app): +def test_autocomp_custom_func_list_and_dict_arg(cmd2_app): text = '' line = 'library show add SW_REB {}'.format(text) endidx = len(line) @@ -272,6 +272,17 @@ def test_autcomp_custom_func_list_and_dict_arg(cmd2_app): cmd2_app.completion_matches == ['S01E02', 'S01E03', 'S02E01', 'S02E03'] +def test_autocomp_custom_func_dict_arg(cmd2_app): + text = '/home/user/' + line = 'video movies load {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None and \ + cmd2_app.completion_matches == ['/home/user/another.db', '/home/user/file space.db', '/home/user/file.db'] + + def test_argparse_remainder_flag_completion(cmd2_app): import cmd2 import argparse diff --git a/tests/test_completion.py b/tests/test_completion.py index 23843012..158856ec 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -77,6 +77,12 @@ class CompletionsExample(cmd2.Cmd): num_strs = ['2', '11', '1'] return self.basic_complete(text, line, begidx, endidx, num_strs) + def do_test_raise_exception(self, args): + pass + + def complete_test_raise_exception(self, text, line, begidx, endidx): + raise IndexError("You are out of bounds!!") + @pytest.fixture def cmd2_app(): @@ -120,6 +126,18 @@ def test_complete_bogus_command(cmd2_app): first_match = complete_tester(text, line, begidx, endidx, cmd2_app) assert first_match is None +def test_complete_exception(cmd2_app, capsys): + text = '' + line = 'test_raise_exception {}'.format(text) + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + out, err = capsys.readouterr() + + assert first_match is None + assert "IndexError" in err + def test_complete_macro(base_app, request): # Create the macro out, err = run_cmd(base_app, 'macro create fake pyscript {1}') |