Skip to content

Commit 274a57b

Browse files
authored
Merge pull request #910 from python-cmd2/ctrl-c-script
Ctrl-C now stops a running text script instead of just the current script command
2 parents 990ec45 + 38b37a9 commit 274a57b

File tree

6 files changed

+69
-22
lines changed

6 files changed

+69
-22
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
## 1.0.2 (TBD, 2020)
2+
* Bug Fixes
3+
* Ctrl-C now stops a running text script instead of just the current script command
24
* Enhancements
35
* `do_shell()` now saves the return code of the command it runs in `self.last_result` for use in pyscripts
46

cmd2/cmd2.py

+30-16
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *,
187187

188188
# Attributes which should NOT be dynamically settable via the set command at runtime
189189
self.default_to_shell = False # Attempt to run unrecognized commands as shell commands
190-
self.quit_on_sigint = False # Quit the loop on interrupt instead of just resetting prompt
190+
self.quit_on_sigint = False # Ctrl-C at the prompt will quit the program instead of just resetting prompt
191191
self.allow_redirection = allow_redirection # Security setting to prevent redirection of stdout
192192

193193
# Attributes which ARE dynamically settable via the set command at runtime
@@ -1584,11 +1584,15 @@ def parseline(self, line: str) -> Tuple[str, str, str]:
15841584
statement = self.statement_parser.parse_command_only(line)
15851585
return statement.command, statement.args, statement.command_and_args
15861586

1587-
def onecmd_plus_hooks(self, line: str, *, add_to_history: bool = True, py_bridge_call: bool = False) -> bool:
1587+
def onecmd_plus_hooks(self, line: str, *, add_to_history: bool = True,
1588+
raise_keyboard_interrupt: bool = False, py_bridge_call: bool = False) -> bool:
15881589
"""Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks.
15891590
15901591
:param line: command line to run
15911592
:param add_to_history: If True, then add this command to history. Defaults to True.
1593+
:param raise_keyboard_interrupt: if True, then KeyboardInterrupt exceptions will be raised. This is used when
1594+
running commands in a loop to be able to stop the whole loop and not just
1595+
the current command. Defaults to False.
15921596
:param py_bridge_call: This should only ever be set to True by PyBridge to signify the beginning
15931597
of an app() call from Python. It is used to enable/disable the storage of the
15941598
command's stdout.
@@ -1681,14 +1685,18 @@ def onecmd_plus_hooks(self, line: str, *, add_to_history: bool = True, py_bridge
16811685
if py_bridge_call:
16821686
# Stop saving command's stdout before command finalization hooks run
16831687
self.stdout.pause_storage = True
1684-
1688+
except KeyboardInterrupt as ex:
1689+
if raise_keyboard_interrupt:
1690+
raise ex
16851691
except (Cmd2ArgparseError, EmptyStatement):
16861692
# Don't do anything, but do allow command finalization hooks to run
16871693
pass
16881694
except Exception as ex:
16891695
self.pexcept(ex)
16901696
finally:
1691-
return self._run_cmdfinalization_hooks(stop, statement)
1697+
stop = self._run_cmdfinalization_hooks(stop, statement)
1698+
1699+
return stop
16921700

16931701
def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement]) -> bool:
16941702
"""Run the command finalization hooks"""
@@ -1711,13 +1719,16 @@ def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement])
17111719
except Exception as ex:
17121720
self.pexcept(ex)
17131721

1714-
def runcmds_plus_hooks(self, cmds: List[Union[HistoryItem, str]], *, add_to_history: bool = True) -> bool:
1722+
def runcmds_plus_hooks(self, cmds: List[Union[HistoryItem, str]], *, add_to_history: bool = True,
1723+
stop_on_keyboard_interrupt: bool = True) -> bool:
17151724
"""
17161725
Used when commands are being run in an automated fashion like text scripts or history replays.
17171726
The prompt and command line for each command will be printed if echo is True.
17181727
17191728
:param cmds: commands to run
17201729
:param add_to_history: If True, then add these commands to history. Defaults to True.
1730+
:param stop_on_keyboard_interrupt: stop command loop if Ctrl-C is pressed instead of just
1731+
moving to the next command. Defaults to True.
17211732
:return: True if running of commands should stop
17221733
"""
17231734
for line in cmds:
@@ -1727,8 +1738,14 @@ def runcmds_plus_hooks(self, cmds: List[Union[HistoryItem, str]], *, add_to_hist
17271738
if self.echo:
17281739
self.poutput('{}{}'.format(self.prompt, line))
17291740

1730-
if self.onecmd_plus_hooks(line, add_to_history=add_to_history):
1731-
return True
1741+
try:
1742+
if self.onecmd_plus_hooks(line, add_to_history=add_to_history,
1743+
raise_keyboard_interrupt=stop_on_keyboard_interrupt):
1744+
return True
1745+
except KeyboardInterrupt as e:
1746+
if stop_on_keyboard_interrupt:
1747+
self.perror(e)
1748+
break
17321749

17331750
return False
17341751

@@ -3269,9 +3286,6 @@ def py_quit():
32693286
if saved_cmd2_env is not None:
32703287
self._restore_cmd2_env(saved_cmd2_env)
32713288

3272-
except KeyboardInterrupt:
3273-
pass
3274-
32753289
finally:
32763290
with self.sigint_protection:
32773291
if saved_sys_path is not None:
@@ -3302,8 +3316,6 @@ def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]:
33023316
if selection != 'Yes':
33033317
return
33043318

3305-
py_return = False
3306-
33073319
# Save current command line arguments
33083320
orig_args = sys.argv
33093321

@@ -3314,9 +3326,6 @@ def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]:
33143326
# noinspection PyTypeChecker
33153327
py_return = self.do_py('--pyscript {}'.format(utils.quote_string(args.script_path)))
33163328

3317-
except KeyboardInterrupt:
3318-
pass
3319-
33203329
finally:
33213330
# Restore command line arguments to original state
33223331
sys.argv = orig_args
@@ -3629,7 +3638,12 @@ def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcrip
36293638
self.stdout = utils.StdSim(self.stdout)
36303639

36313640
# then run the command and let the output go into our buffer
3632-
stop = self.onecmd_plus_hooks(history_item)
3641+
try:
3642+
stop = self.onecmd_plus_hooks(history_item, raise_keyboard_interrupt=True)
3643+
except KeyboardInterrupt as e:
3644+
self.perror(e)
3645+
stop = True
3646+
36333647
commands_run += 1
36343648

36353649
# add the regex-escaped output to the transcript

docs/features/initialization.rst

+2-2
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ override:
146146
everything available with **self_in_py**)
147147
- **quiet**: if ``True`` then completely suppress nonessential output (Default:
148148
``False``)
149-
- **quit_on_sigint**: if ``True`` quit the main loop on interrupt instead of
150-
just resetting prompt
149+
- **quit_on_sigint**: if ``True`` Ctrl-C at the prompt will quit the program
150+
instead of just resetting prompt
151151
- **settable**: dictionary that controls which of these instance attributes
152152
are settable at runtime using the *set* command
153153
- **timing**: if ``True`` display execution time for each command (Default:

docs/features/misc.rst

+4-4
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,10 @@ method be called.
104104
Quit on SIGINT
105105
--------------
106106

107-
On many shells, SIGINT (most often triggered by the user pressing Ctrl+C) only
108-
cancels the current line, not the entire command loop. By default, a ``cmd2``
109-
application will quit on receiving this signal. However, if ``quit_on_sigint``
110-
is set to ``False``, then the current line will simply be cancelled.
107+
On many shells, SIGINT (most often triggered by the user pressing Ctrl+C)
108+
while at the prompt only cancels the current line, not the entire command
109+
loop. By default, a ``cmd2`` application matches this behavior. However, if
110+
``quit_on_sigint`` is set to ``True``, the command loop will quit instead.
111111

112112
::
113113

tests/test_cmd2.py

+22
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,28 @@ def test_runcmds_plus_hooks(base_app, request):
376376
out, err = run_cmd(base_app, 'history -s')
377377
assert out == normalize(expected)
378378

379+
def test_runcmds_plus_hooks_ctrl_c(base_app, capsys):
380+
"""Test Ctrl-C while in runcmds_plus_hooks"""
381+
import types
382+
383+
def do_keyboard_interrupt(self, _):
384+
raise KeyboardInterrupt('Interrupting this command')
385+
setattr(base_app, 'do_keyboard_interrupt', types.MethodType(do_keyboard_interrupt, base_app))
386+
387+
# Default behavior is to stop command loop on Ctrl-C
388+
base_app.history.clear()
389+
base_app.runcmds_plus_hooks(['help', 'keyboard_interrupt', 'shortcuts'])
390+
out, err = capsys.readouterr()
391+
assert err.startswith("Interrupting this command")
392+
assert len(base_app.history) == 2
393+
394+
# Ctrl-C should not stop command loop in this case
395+
base_app.history.clear()
396+
base_app.runcmds_plus_hooks(['help', 'keyboard_interrupt', 'shortcuts'], stop_on_keyboard_interrupt=False)
397+
out, err = capsys.readouterr()
398+
assert not err
399+
assert len(base_app.history) == 3
400+
379401
def test_relative_run_script(base_app, request):
380402
test_dir = os.path.dirname(request.module.__file__)
381403
filename = os.path.join(test_dir, 'script.txt')

tests/test_transcript.py

+9
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ def do_nothing(self, statement):
8181
"""Do nothing and output nothing"""
8282
pass
8383

84+
def do_keyboard_interrupt(self, _):
85+
raise KeyboardInterrupt('Interrupting this command')
86+
8487

8588
def test_commands_at_invocation():
8689
testargs = ["prog", "say hello", "say Gracie", "quit"]
@@ -235,6 +238,12 @@ def test_generate_transcript_stop(capsys):
235238
_, err = capsys.readouterr()
236239
assert err.startswith("Command 2 triggered a stop")
237240

241+
# keyboard_interrupt command should stop the loop and not run the third command
242+
commands = ['help', 'keyboard_interrupt', 'set']
243+
app._generate_transcript(commands, transcript_fname)
244+
_, err = capsys.readouterr()
245+
assert err.startswith("Interrupting this command\nCommand 2 triggered a stop")
246+
238247

239248
@pytest.mark.parametrize('expected, transformed', [
240249
# strings with zero or one slash or with escaped slashes means no regular

0 commit comments

Comments
 (0)