Repository: python-cmd2/cmd2

Issue:
Propagate exception flag
I would like to know if there is a way that I can propagate an exception instead of catch and print it?

I would expect that this can be some kwarg in the `cmd2.__init__` that would allow for the exceptions to be raised instead of printed.

The use case is more for the `onecmd` functionality.  We have a GUI that uses the some built-in commands like `run_script` and some other custom commands that are only implemented in the `cmd2` based application. The exceptions should be caught and displayed by the GUI rather than hidden in the terminal.

Though this can be dealt with on the application level it, maybe I would not be the only one who wants this as an optional argument to be able to control the exceptions?

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index afed0b53..1e049bc9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@
     * Added support for custom tab completion and up-arrow input history to `cmd2.Cmd2.read_input`.
       See [read_input.py](https://github.com/python-cmd2/cmd2/blob/master/examples/read_input.py)
       for an example.
+    * Added `cmd2.exceptions.PassThroughException` to raise unhandled command exceptions instead of printing them.
 
 ## 1.5.0 (January 31, 2021)
 * Bug Fixes
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 0b198111..d01dfaa0 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -95,6 +95,7 @@ from .exceptions import (
     CompletionError,
     EmbeddedConsoleExit,
     EmptyStatement,
+    PassThroughException,
     RedirectionError,
     SkipPostcommandHooks,
 )
@@ -2259,6 +2260,8 @@ class Cmd(cmd.Cmd):
                 raise ex
         except SystemExit:
             stop = True
+        except PassThroughException as ex:
+            raise ex.wrapped_ex
         except Exception as ex:
             self.pexcept(ex)
         finally:
@@ -2269,6 +2272,8 @@ class Cmd(cmd.Cmd):
                     raise ex
             except SystemExit:
                 stop = True
+            except PassThroughException as ex:
+                raise ex.wrapped_ex
             except Exception as ex:
                 self.pexcept(ex)
 
diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py
index 39b7e333..b45167d1 100644
--- a/cmd2/exceptions.py
+++ b/cmd2/exceptions.py
@@ -62,6 +62,21 @@ class CompletionError(Exception):
         super().__init__(*args)
 
 
+class PassThroughException(Exception):
+    """
+    Normally all unhandled exceptions raised during commands get printed to the user.
+    This class is used to wrap an exception that should be raised instead of printed.
+    """
+
+    def __init__(self, *args, wrapped_ex: BaseException):
+        """
+        Initializer for PassThroughException
+        :param wrapped_ex: the exception that will be raised
+        """
+        self.wrapped_ex = wrapped_ex
+        super().__init__(*args)
+
+
 ############################################################################################################
 # The following exceptions are NOT part of the public API and are intended for internal use only.
 ############################################################################################################
diff --git a/docs/api/exceptions.rst b/docs/api/exceptions.rst
index 98afa97a..efa26957 100644
--- a/docs/api/exceptions.rst
+++ b/docs/api/exceptions.rst
@@ -15,3 +15,6 @@ Custom cmd2 exceptions
 
 .. autoclass:: cmd2.exceptions.CompletionError
     :members:
+
+.. autoclass:: cmd2.exceptions.PassThroughException
+    :members:


Test patch:
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index 0c3333c1..b4b13945 100755
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -534,7 +534,7 @@ def test_in_script(request):
 
 
 def test_system_exit_in_command(base_app, capsys):
-    """Test raising SystemExit from a command"""
+    """Test raising SystemExit in a command"""
     import types
 
     def do_system_exit(self, _):
@@ -546,6 +546,21 @@ def test_system_exit_in_command(base_app, capsys):
     assert stop
 
 
+def test_passthrough_exception_in_command(base_app):
+    """Test raising a PassThroughException in a command"""
+    import types
+
+    def do_passthrough(self, _):
+        wrapped_ex = OSError("Pass me up")
+        raise exceptions.PassThroughException(wrapped_ex=wrapped_ex)
+
+    setattr(base_app, 'do_passthrough', types.MethodType(do_passthrough, base_app))
+
+    with pytest.raises(OSError) as excinfo:
+        base_app.onecmd_plus_hooks('passthrough')
+    assert 'Pass me up' in str(excinfo.value)
+
+
 def test_output_redirection(base_app):
     fd, filename = tempfile.mkstemp(prefix='cmd2_test', suffix='.txt')
     os.close(fd)
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 97c046a2..1e12d655 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -238,6 +238,14 @@ class Plugin:
         self.called_cmdfinalization += 1
         raise KeyboardInterrupt
 
+    def cmdfinalization_hook_passthrough_exception(
+        self, data: cmd2.plugin.CommandFinalizationData
+    ) -> cmd2.plugin.CommandFinalizationData:
+        """A command finalization hook which raises a PassThroughException"""
+        self.called_cmdfinalization += 1
+        wrapped_ex = OSError("Pass me up")
+        raise exceptions.PassThroughException(wrapped_ex=wrapped_ex)
+
     def cmdfinalization_hook_not_enough_parameters(self) -> plugin.CommandFinalizationData:
         """A command finalization hook with no parameters."""
         pass
@@ -916,7 +924,7 @@ def test_cmdfinalization_hook_exception(capsys):
     assert app.called_cmdfinalization == 1
 
 
-def test_cmdfinalization_hook_system_exit(capsys):
+def test_cmdfinalization_hook_system_exit():
     app = PluggedApp()
     app.register_cmdfinalization_hook(app.cmdfinalization_hook_system_exit)
     stop = app.onecmd_plus_hooks('say hello')
@@ -924,7 +932,7 @@ def test_cmdfinalization_hook_system_exit(capsys):
     assert app.called_cmdfinalization == 1
 
 
-def test_cmdfinalization_hook_keyboard_interrupt(capsys):
+def test_cmdfinalization_hook_keyboard_interrupt():
     app = PluggedApp()
     app.register_cmdfinalization_hook(app.cmdfinalization_hook_keyboard_interrupt)
 
@@ -947,6 +955,16 @@ def test_cmdfinalization_hook_keyboard_interrupt(capsys):
     assert app.called_cmdfinalization == 1
 
 
+def test_cmdfinalization_hook_passthrough_exception():
+    app = PluggedApp()
+    app.register_cmdfinalization_hook(app.cmdfinalization_hook_passthrough_exception)
+
+    with pytest.raises(OSError) as excinfo:
+        app.onecmd_plus_hooks('say hello')
+    assert 'Pass me up' in str(excinfo.value)
+    assert app.called_cmdfinalization == 1
+
+
 def test_skip_postcmd_hooks(capsys):
     app = PluggedApp()
     app.register_postcmd_hook(app.postcmd_hook)