Repository: python-cmd2/cmd2

Issue:
Give informative error message for invalid completer instead of empty completion results.
The following code suppresses an error when a completion function with an invalid signature is provided https://github.com/python-cmd2/cmd2/blob/de701086ff832bad0f0d97ffb10c2159d56ede7d/cmd2/argparse_completer.py#L691-L693

Instead of returning an empty list, I think that an informative error message explaining that the function signature is incorrect would be much more helpful. When I was implementing completion I was confused about why my completer wasn't returning any results until I traced it to this `return []`.

Comments:

Patch:
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': '/',


Test patch:
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}')