Repository: python-cmd2/cmd2

Issue:
command scripts cannot be interrupted
It seems that even with quit_on_sigint set to True, ^C doesn't interrupt a command script.  Am I missing something?

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 04351333..bae7f7d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,6 @@
 ## 1.0.2 (TBD, 2020)
+* Bug Fixes
+    * Ctrl-C now stops a running text script instead of just the current script command
 * Enhancements
     * `do_shell()` now saves the return code of the command it runs in `self.last_result` for use in pyscripts
 
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 630ab9b2..fdc7a2d8 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -187,7 +187,7 @@ class Cmd(cmd.Cmd):
 
         # Attributes which should NOT be dynamically settable via the set command at runtime
         self.default_to_shell = False  # Attempt to run unrecognized commands as shell commands
-        self.quit_on_sigint = False  # Quit the loop on interrupt instead of just resetting prompt
+        self.quit_on_sigint = False  # Ctrl-C at the prompt will quit the program instead of just resetting prompt
         self.allow_redirection = allow_redirection  # Security setting to prevent redirection of stdout
 
         # Attributes which ARE dynamically settable via the set command at runtime
@@ -1584,11 +1584,15 @@ class Cmd(cmd.Cmd):
         statement = self.statement_parser.parse_command_only(line)
         return statement.command, statement.args, statement.command_and_args
 
-    def onecmd_plus_hooks(self, line: str, *, add_to_history: bool = True, py_bridge_call: bool = False) -> bool:
+    def onecmd_plus_hooks(self, line: str, *, add_to_history: bool = True,
+                          raise_keyboard_interrupt: bool = False, py_bridge_call: bool = False) -> bool:
         """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks.
 
         :param line: command line to run
         :param add_to_history: If True, then add this command to history. Defaults to True.
+        :param raise_keyboard_interrupt: if True, then KeyboardInterrupt exceptions will be raised. This is used when
+                                         running commands in a loop to be able to stop the whole loop and not just
+                                         the current command. Defaults to False.
         :param py_bridge_call: This should only ever be set to True by PyBridge to signify the beginning
                                of an app() call from Python. It is used to enable/disable the storage of the
                                command's stdout.
@@ -1681,14 +1685,18 @@ class Cmd(cmd.Cmd):
                     if py_bridge_call:
                         # Stop saving command's stdout before command finalization hooks run
                         self.stdout.pause_storage = True
-
+        except KeyboardInterrupt as ex:
+            if raise_keyboard_interrupt:
+                raise ex
         except (Cmd2ArgparseError, EmptyStatement):
             # Don't do anything, but do allow command finalization hooks to run
             pass
         except Exception as ex:
             self.pexcept(ex)
         finally:
-            return self._run_cmdfinalization_hooks(stop, statement)
+            stop = self._run_cmdfinalization_hooks(stop, statement)
+
+        return stop
 
     def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement]) -> bool:
         """Run the command finalization hooks"""
@@ -1711,13 +1719,16 @@ class Cmd(cmd.Cmd):
         except Exception as ex:
             self.pexcept(ex)
 
-    def runcmds_plus_hooks(self, cmds: List[Union[HistoryItem, str]], *, add_to_history: bool = True) -> bool:
+    def runcmds_plus_hooks(self, cmds: List[Union[HistoryItem, str]], *, add_to_history: bool = True,
+                           stop_on_keyboard_interrupt: bool = True) -> bool:
         """
         Used when commands are being run in an automated fashion like text scripts or history replays.
         The prompt and command line for each command will be printed if echo is True.
 
         :param cmds: commands to run
         :param add_to_history: If True, then add these commands to history. Defaults to True.
+        :param stop_on_keyboard_interrupt: stop command loop if Ctrl-C is pressed instead of just
+                                           moving to the next command. Defaults to True.
         :return: True if running of commands should stop
         """
         for line in cmds:
@@ -1727,8 +1738,14 @@ class Cmd(cmd.Cmd):
             if self.echo:
                 self.poutput('{}{}'.format(self.prompt, line))
 
-            if self.onecmd_plus_hooks(line, add_to_history=add_to_history):
-                return True
+            try:
+                if self.onecmd_plus_hooks(line, add_to_history=add_to_history,
+                                          raise_keyboard_interrupt=stop_on_keyboard_interrupt):
+                    return True
+            except KeyboardInterrupt as e:
+                if stop_on_keyboard_interrupt:
+                    self.perror(e)
+                    break
 
         return False
 
@@ -3269,9 +3286,6 @@ class Cmd(cmd.Cmd):
                         if saved_cmd2_env is not None:
                             self._restore_cmd2_env(saved_cmd2_env)
 
-        except KeyboardInterrupt:
-            pass
-
         finally:
             with self.sigint_protection:
                 if saved_sys_path is not None:
@@ -3302,8 +3316,6 @@ class Cmd(cmd.Cmd):
             if selection != 'Yes':
                 return
 
-        py_return = False
-
         # Save current command line arguments
         orig_args = sys.argv
 
@@ -3314,9 +3326,6 @@ class Cmd(cmd.Cmd):
             # noinspection PyTypeChecker
             py_return = self.do_py('--pyscript {}'.format(utils.quote_string(args.script_path)))
 
-        except KeyboardInterrupt:
-            pass
-
         finally:
             # Restore command line arguments to original state
             sys.argv = orig_args
@@ -3629,7 +3638,12 @@ class Cmd(cmd.Cmd):
                 self.stdout = utils.StdSim(self.stdout)
 
                 # then run the command and let the output go into our buffer
-                stop = self.onecmd_plus_hooks(history_item)
+                try:
+                    stop = self.onecmd_plus_hooks(history_item, raise_keyboard_interrupt=True)
+                except KeyboardInterrupt as e:
+                    self.perror(e)
+                    stop = True
+
                 commands_run += 1
 
                 # add the regex-escaped output to the transcript
diff --git a/docs/features/initialization.rst b/docs/features/initialization.rst
index b1ca4f05..d79b3818 100644
--- a/docs/features/initialization.rst
+++ b/docs/features/initialization.rst
@@ -146,8 +146,8 @@ override:
   everything available with **self_in_py**)
 - **quiet**: if ``True`` then completely suppress nonessential output (Default:
   ``False``)
-- **quit_on_sigint**: if ``True`` quit the main loop on interrupt instead of
-  just resetting prompt
+- **quit_on_sigint**: if ``True`` Ctrl-C at the prompt will quit the program
+  instead of just resetting prompt
 - **settable**: dictionary that controls which of these instance attributes
   are settable at runtime using the *set* command
 - **timing**: if ``True`` display execution time for each command (Default:
diff --git a/docs/features/misc.rst b/docs/features/misc.rst
index 0dc1939c..3825065f 100644
--- a/docs/features/misc.rst
+++ b/docs/features/misc.rst
@@ -104,10 +104,10 @@ method be called.
 Quit on SIGINT
 --------------
 
-On many shells, SIGINT (most often triggered by the user pressing Ctrl+C) only
-cancels the current line, not the entire command loop. By default, a ``cmd2``
-application will quit on receiving this signal. However, if ``quit_on_sigint``
-is set to ``False``, then the current line will simply be cancelled.
+On many shells, SIGINT (most often triggered by the user pressing Ctrl+C)
+while at the prompt only cancels the current line, not the entire command
+loop. By default, a ``cmd2`` application matches this behavior. However, if
+``quit_on_sigint`` is set to ``True``, the command loop will quit instead.
 
 ::
 


Test patch:
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index 66ef33de..b86ddfa6 100755
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -376,6 +376,28 @@ set allow_style Never""" % (prefilepath, postfilepath)
     out, err = run_cmd(base_app, 'history -s')
     assert out == normalize(expected)
 
+def test_runcmds_plus_hooks_ctrl_c(base_app, capsys):
+    """Test Ctrl-C while in runcmds_plus_hooks"""
+    import types
+
+    def do_keyboard_interrupt(self, _):
+        raise KeyboardInterrupt('Interrupting this command')
+    setattr(base_app, 'do_keyboard_interrupt', types.MethodType(do_keyboard_interrupt, base_app))
+
+    # Default behavior is to stop command loop on Ctrl-C
+    base_app.history.clear()
+    base_app.runcmds_plus_hooks(['help', 'keyboard_interrupt', 'shortcuts'])
+    out, err = capsys.readouterr()
+    assert err.startswith("Interrupting this command")
+    assert len(base_app.history) == 2
+
+    # Ctrl-C should not stop command loop in this case
+    base_app.history.clear()
+    base_app.runcmds_plus_hooks(['help', 'keyboard_interrupt', 'shortcuts'], stop_on_keyboard_interrupt=False)
+    out, err = capsys.readouterr()
+    assert not err
+    assert len(base_app.history) == 3
+
 def test_relative_run_script(base_app, request):
     test_dir = os.path.dirname(request.module.__file__)
     filename = os.path.join(test_dir, 'script.txt')
diff --git a/tests/test_transcript.py b/tests/test_transcript.py
index 64c95b30..aa6f8c4e 100644
--- a/tests/test_transcript.py
+++ b/tests/test_transcript.py
@@ -81,6 +81,9 @@ class CmdLineApp(cmd2.Cmd):
         """Do nothing and output nothing"""
         pass
 
+    def do_keyboard_interrupt(self, _):
+        raise KeyboardInterrupt('Interrupting this command')
+
 
 def test_commands_at_invocation():
     testargs = ["prog", "say hello", "say Gracie", "quit"]
@@ -235,6 +238,12 @@ def test_generate_transcript_stop(capsys):
     _, err = capsys.readouterr()
     assert err.startswith("Command 2 triggered a stop")
 
+    # keyboard_interrupt command should stop the loop and not run the third command
+    commands = ['help', 'keyboard_interrupt', 'set']
+    app._generate_transcript(commands, transcript_fname)
+    _, err = capsys.readouterr()
+    assert err.startswith("Interrupting this command\nCommand 2 triggered a stop")
+
 
 @pytest.mark.parametrize('expected, transformed', [
     # strings with zero or one slash or with escaped slashes means no regular