Repository: python-cmd2/cmd2 (python)

Issue:
Create CompletionError exception
Add an exception that can be raised during tab completion to print an error to the user.
Possible use cases

1. Reading a database to retrieve a tab completion data set failed
2. A previous command line argument that determines the data set being completed is invalid

Pull request description:
CompletionError class
Added CompletionError class that can be raised during argparse-based tab completion and printed to the user

Closes #773

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6e14f11..c69a0549 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
     argparse argument. This is helpful when one argument determines what is tab completed for another argument.
     If these functions have an argument called `arg_tokens`, then AutoCompleter will automatically pass this
     dictionary to them.
+    * Added CompletionError class that can be raised during argparse-based tab completion and printed to the user
 
 ## 0.9.16 (August 7, 2019)
 * Bug Fixes
diff --git a/cmd2/__init__.py b/cmd2/__init__.py
index 2653051a..c496a4f7 100644
--- a/cmd2/__init__.py
+++ b/cmd2/__init__.py
@@ -11,7 +11,7 @@ except DistributionNotFound:
     pass
 
 from .ansi import style
-from .argparse_custom import Cmd2ArgumentParser, CompletionItem
+from .argparse_custom import Cmd2ArgumentParser, CompletionError, CompletionItem
 from .cmd2 import Cmd, Statement, EmptyStatement, categorize
 from .cmd2 import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category
 from .constants import DEFAULT_SHORTCUTS
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
index fb485348..a866b3ff 100644
--- a/cmd2/argparse_completer.py
+++ b/cmd2/argparse_completer.py
@@ -15,8 +15,9 @@ from typing import Dict, List, Optional, Union
 from . import cmd2
 from . import utils
 from .ansi import ansi_safe_wcswidth, style_error
+from .argparse_custom import ATTR_CHOICES_CALLABLE, INFINITY, generate_range_error
 from .argparse_custom import ATTR_SUPPRESS_TAB_HINT, ATTR_DESCRIPTIVE_COMPLETION_HEADER, ATTR_NARGS_RANGE
-from .argparse_custom import ChoicesCallable, CompletionItem, ATTR_CHOICES_CALLABLE, INFINITY, generate_range_error
+from .argparse_custom import ChoicesCallable, CompletionError, CompletionItem
 from .rl_utils import rl_force_redisplay
 
 # If no descriptive header is supplied, then this will be used instead
@@ -319,8 +320,12 @@ class AutoCompleter(object):
 
         # Check if we are completing a flag's argument
         if flag_arg_state is not None:
-            completion_results = self._complete_for_arg(flag_arg_state.action, text, line,
-                                                        begidx, endidx, consumed_arg_values)
+            try:
+                completion_results = self._complete_for_arg(flag_arg_state.action, text, line,
+                                                            begidx, endidx, consumed_arg_values)
+            except CompletionError as ex:
+                self._print_completion_error(flag_arg_state.action, ex)
+                return []
 
             # If we have results, then return them
             if completion_results:
@@ -341,8 +346,12 @@ class AutoCompleter(object):
                 action = self._positional_actions[pos_index]
                 pos_arg_state = AutoCompleter._ArgumentState(action)
 
-            completion_results = self._complete_for_arg(pos_arg_state.action, text, line,
-                                                        begidx, endidx, consumed_arg_values)
+            try:
+                completion_results = self._complete_for_arg(pos_arg_state.action, text, line,
+                                                            begidx, endidx, consumed_arg_values)
+            except CompletionError as ex:
+                self._print_completion_error(pos_arg_state.action, ex)
+                return []
 
             # If we have results, then return them
             if completion_results:
@@ -456,7 +465,11 @@ class AutoCompleter(object):
     def _complete_for_arg(self, arg_action: argparse.Action,
                           text: str, line: str, begidx: int, endidx: int,
                           consumed_arg_values: Dict[str, List[str]]) -> List[str]:
-        """Tab completion routine for an argparse argument"""
+        """
+        Tab completion routine for an argparse argument
+        :return: list of completions
+        :raises CompletionError if the completer or choices function this calls raises one
+        """
         # Check if the arg provides choices to the user
         if arg_action.choices is not None:
             arg_choices = arg_action.choices
@@ -520,24 +533,35 @@ class AutoCompleter(object):
         return self._format_completions(arg_action, results)
 
     @staticmethod
-    def _print_arg_hint(arg_action: argparse.Action) -> None:
-        """Print argument hint to the terminal when tab completion results in no results"""
-
-        # Check if hinting is disabled
-        suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False)
-        if suppress_hint or arg_action.help == argparse.SUPPRESS or arg_action.dest == argparse.SUPPRESS:
-            return
-
+    def _format_message_prefix(arg_action: argparse.Action) -> str:
+        """Format the arg prefix text that appears before messages printed to the user"""
         # Check if this is a flag
         if arg_action.option_strings:
             flags = ', '.join(arg_action.option_strings)
             param = ' ' + str(arg_action.dest).upper()
-            prefix = '{}{}'.format(flags, param)
+            return '{}{}'.format(flags, param)
 
         # Otherwise this is a positional
         else:
-            prefix = '{}'.format(str(arg_action.dest).upper())
+            return '{}'.format(str(arg_action.dest).upper())
+
+    @staticmethod
+    def _print_message(msg: str) -> None:
+        """Print a message instead of tab completions and redraw the prompt and input line"""
+        print(msg)
+        rl_force_redisplay()
+
+    def _print_arg_hint(self, arg_action: argparse.Action) -> None:
+        """
+        Print argument hint to the terminal when tab completion results in no results
+        :param arg_action: action being tab completed
+        """
+        # Check if hinting is disabled
+        suppress_hint = getattr(arg_action, ATTR_SUPPRESS_TAB_HINT, False)
+        if suppress_hint or arg_action.help == argparse.SUPPRESS or arg_action.dest == argparse.SUPPRESS:
+            return
 
+        prefix = self._format_message_prefix(arg_action)
         prefix = '  {0: <{width}}    '.format(prefix, width=20)
         pref_len = len(prefix)
 
@@ -545,28 +569,36 @@ class AutoCompleter(object):
         help_lines = help_text.splitlines()
 
         if len(help_lines) == 1:
-            print('\nHint:\n{}{}\n'.format(prefix, help_lines[0]))
+            self._print_message('\nHint:\n{}{}\n'.format(prefix, help_lines[0]))
         else:
             out_str = '\n{}'.format(prefix)
             out_str += '\n{0: <{width}}'.format('', width=pref_len).join(help_lines)
-            print('\nHint:' + out_str + '\n')
+            self._print_message('\nHint:' + out_str + '\n')
 
-        # Redraw prompt and input line
-        rl_force_redisplay()
-
-    @staticmethod
-    def _print_unfinished_flag_error(flag_arg_state: _ArgumentState) -> None:
-        """Print an error during tab completion when the user has not finished the current flag"""
-        flags = ', '.join(flag_arg_state.action.option_strings)
-        param = ' ' + str(flag_arg_state.action.dest).upper()
-        prefix = '{}{}'.format(flags, param)
+    def _print_unfinished_flag_error(self, flag_arg_state: _ArgumentState) -> None:
+        """
+        Print an error during tab completion when the user has not finished the current flag
+        :param flag_arg_state: information about the unfinished flag action
+        """
+        prefix = self._format_message_prefix(flag_arg_state.action)
 
         out_str = "\nError:\n"
         out_str += '  {0: <{width}}    '.format(prefix, width=20)
         out_str += generate_range_error(flag_arg_state.min, flag_arg_state.max)
 
         out_str += ' ({} entered)'.format(flag_arg_state.count)
-        print(style_error('{}\n'.format(out_str)))
+        self._print_message(style_error('{}\n'.format(out_str)))
 
-        # Redraw prompt and input line
-        rl_force_redisplay()
+    def _print_completion_error(self, arg_action: argparse.Action, completion_error: CompletionError) -> None:
+        """
+        Print a CompletionError to the user
+        :param arg_action: action being tab completed
+        :param completion_error: error that occurred
+        """
+        prefix = self._format_message_prefix(arg_action)
+
+        out_str = "\nError:\n"
+        out_str += '  {0: <{width}}    '.format(prefix, width=20)
+        out_str += str(completion_error)
+
+        self._print_message(style_error('{}\n'.format(out_str)))
diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py
index 940d6064..f7dbc8a3 100644
--- a/cmd2/argparse_custom.py
+++ b/cmd2/argparse_custom.py
@@ -94,7 +94,7 @@ Tab Completion:
     as dynamic. Therefore it is up to the developer to validate if the user has typed an acceptable value for these
     arguments.
 
-    The following functions exist in cases where you may want to manually add choice providing function/methods to
+    The following functions exist in cases where you may want to manually a add choice-providing function/method to
     an existing argparse action. For instance, in __init__() of a custom action class.
 
         set_choices_function(action, func)
@@ -116,6 +116,13 @@ Tab Completion:
     their values. All tokens are stored in the dictionary as the raw strings provided on the command line. It is up to
     the developer to determine if the user entered the correct argument type (e.g. int) and validate their values.
 
+CompletionError Class:
+    Raised during tab-completion operations to report any sort of error you want printed by the AutoCompleter
+
+    Example use cases
+    - Reading a database to retrieve a tab completion data set failed
+    - A previous command line argument that determines the data set being completed is invalid
+
 CompletionItem Class:
     This class was added to help in cases where uninformative data is being tab completed. For instance,
     tab completing ID numbers isn't very helpful to a user without context. Returning a list of CompletionItems
@@ -221,6 +228,17 @@ def generate_range_error(range_min: int, range_max: Union[int, float]) -> str:
     return err_str
 
 
+class CompletionError(Exception):
+    """
+    Raised during tab-completion operations to report any sort of error you want printed by the AutoCompleter
+
+    Example use cases
+    - Reading a database to retrieve a tab completion data set failed
+    - A previous command line argument that determines the data set being completed is invalid
+    """
+    pass
+
+
 class CompletionItem(str):
     """
     Completion item with descriptive text attached
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 87f8684d..6bfcfdc8 100755
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -341,7 +341,7 @@ class Cmd(cmd.Cmd):
 
     def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,
                  persistent_history_file: str = '', persistent_history_length: int = 1000,
-                 startup_script: Optional[str] = None, use_ipython: bool = False,
+                 startup_script: str = '', use_ipython: bool = False,
                  allow_cli_args: bool = True, transcript_files: Optional[List[str]] = None,
                  allow_redirection: bool = True, multiline_commands: Optional[List[str]] = None,
                  terminators: Optional[List[str]] = None, shortcuts: Optional[Dict[str, str]] = None) -> None:
@@ -499,7 +499,7 @@ class Cmd(cmd.Cmd):
         self._startup_commands = []
 
         # If a startup script is provided, then execute it in the startup commands
-        if startup_script is not None:
+        if startup_script:
             startup_script = os.path.abspath(os.path.expanduser(startup_script))
             if os.path.exists(startup_script) and os.path.getsize(startup_script) > 0:
                 self._startup_commands.append("run_script '{}'".format(startup_script))


Test patch:
diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py
index 788a7e59..308a4d95 100644
--- a/tests/test_argparse_completer.py
+++ b/tests/test_argparse_completer.py
@@ -9,7 +9,7 @@ from typing import List
 import pytest
 
 import cmd2
-from cmd2 import with_argparser, Cmd2ArgumentParser, CompletionItem
+from cmd2 import with_argparser, Cmd2ArgumentParser, CompletionError, CompletionItem
 from cmd2.utils import StdSim, basic_complete
 from .conftest import run_cmd, complete_tester
 
@@ -210,6 +210,27 @@ class AutoCompleteTester(cmd2.Cmd):
     def do_hint(self, args: argparse.Namespace) -> None:
         pass
 
+    ############################################################################################################
+    # Begin code related to CompletionError
+    ############################################################################################################
+    def completer_raise_error(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
+        """Raises CompletionError"""
+        raise CompletionError('completer broke something')
+
+    def choice_raise_error(self) -> List[str]:
+        """Raises CompletionError"""
+        raise CompletionError('choice broke something')
+
+    comp_error_parser = Cmd2ArgumentParser()
+    comp_error_parser.add_argument('completer', help='positional arg',
+                                   completer_method=completer_raise_error)
+    comp_error_parser.add_argument('--choice', help='flag arg',
+                                   choices_method=choice_raise_error)
+
+    @with_argparser(comp_error_parser)
+    def do_raise_completion_error(self, args: argparse.Namespace) -> None:
+        pass
+
     ############################################################################################################
     # Begin code related to receiving arg_tokens
     ############################################################################################################
@@ -723,6 +744,25 @@ Hint:
 '''
 
 
+@pytest.mark.parametrize('args, text', [
+    # Exercise a flag arg and choices function that raises a CompletionError
+    ('--choice ', 'choice'),
+
+    # Exercise a positional arg and completer that raises a CompletionError
+    ('', 'completer')
+])
+def test_completion_error(ac_app, capsys, args, text):
+    line = 'raise_completion_error {} {}'.format(args, text)
+    endidx = len(line)
+    begidx = endidx - len(text)
+
+    first_match = complete_tester(text, line, begidx, endidx, ac_app)
+    out, err = capsys.readouterr()
+
+    assert first_match is None
+    assert "{} broke something".format(text) in out
+
+
 @pytest.mark.parametrize('command_and_args, completions', [
     # Exercise a choices function that receives arg_tokens dictionary
     ('arg_tokens choice subcmd', ['choice', 'subcmd']),