Skip to content

Commit

Permalink
Merge pull request #910 from python-cmd2/ctrl-c-script
Browse files Browse the repository at this point in the history
Ctrl-C now stops a running text script instead of just the current script command
  • Loading branch information
kmvanbrunt authored Mar 26, 2020
2 parents 990ec45 + 38b37a9 commit 274a57b
Show file tree
Hide file tree
Showing 6 changed files with 69 additions and 22 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
46 changes: 30 additions & 16 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,

# 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
Expand Down Expand Up @@ -1584,11 +1584,15 @@ def parseline(self, line: str) -> Tuple[str, str, str]:
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.
Expand Down Expand Up @@ -1681,14 +1685,18 @@ def onecmd_plus_hooks(self, line: str, *, add_to_history: bool = True, py_bridge
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"""
Expand All @@ -1711,13 +1719,16 @@ def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement])
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:
Expand All @@ -1727,8 +1738,14 @@ def runcmds_plus_hooks(self, cmds: List[Union[HistoryItem, str]], *, add_to_hist
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

Expand Down Expand Up @@ -3269,9 +3286,6 @@ def py_quit():
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:
Expand Down Expand Up @@ -3302,8 +3316,6 @@ def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]:
if selection != 'Yes':
return

py_return = False

# Save current command line arguments
orig_args = sys.argv

Expand All @@ -3314,9 +3326,6 @@ def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]:
# 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
Expand Down Expand Up @@ -3629,7 +3638,12 @@ def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcrip
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
Expand Down
4 changes: 2 additions & 2 deletions docs/features/initialization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions docs/features/misc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

::

Expand Down
22 changes: 22 additions & 0 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,28 @@ def test_runcmds_plus_hooks(base_app, request):
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')
Expand Down
9 changes: 9 additions & 0 deletions tests/test_transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ def do_nothing(self, statement):
"""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"]
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 274a57b

Please sign in to comment.