Skip to content

Commit 37d415b

Browse files
committed
Stopping a shell command with Ctrl-C now raises a KeyboardInterrupt to support stopping a text script which ran the shell command.
On POSIX systems, shell commands and processes being piped to are now run in the user's preferred shell instead of /bin/sh.
1 parent 329a2e2 commit 37d415b

File tree

4 files changed

+72
-15
lines changed

4 files changed

+72
-15
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949
* Added `include_py` keyword parameter to `cmd2.Cmd.__init__()`. If `False`, then the `py` command will
5050
not be available. Defaults to `False`. `run_pyscript` is not affected by this parameter.
5151
* Made the amount of space between columns in a SimpleTable configurable
52+
* On POSIX systems, shell commands and processes being piped to are now run in the user's preferred shell
53+
instead of /bin/sh. The preferred shell is obtained by reading the SHELL environment variable. If that
54+
doesn't exist or is empty, then /bin/sh is used.
5255

5356
## 1.5.0 (January 31, 2021)
5457
* Bug Fixes

cmd2/cmd2.py

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2114,10 +2114,10 @@ def complete( # type: ignore[override]
21142114
ansi.style_aware_write(sys.stdout, '\n' + err_str + '\n')
21152115
rl_force_redisplay()
21162116
return None
2117-
except Exception as e:
2117+
except Exception as ex:
21182118
# Insert a newline so the exception doesn't print in the middle of the command line being tab completed
21192119
self.perror()
2120-
self.pexcept(e)
2120+
self.pexcept(ex)
21212121
rl_force_redisplay()
21222122
return None
21232123

@@ -2199,7 +2199,11 @@ def sigint_handler(self, signum: int, _: FrameType) -> None:
21992199

22002200
# Check if we are allowed to re-raise the KeyboardInterrupt
22012201
if not self.sigint_protection:
2202-
raise KeyboardInterrupt("Got a keyboard interrupt")
2202+
self._raise_keyboard_interrupt()
2203+
2204+
def _raise_keyboard_interrupt(self) -> None:
2205+
"""Helper function to raise a KeyboardInterrupt"""
2206+
raise KeyboardInterrupt("Got a keyboard interrupt")
22032207

22042208
def precmd(self, statement: Union[Statement, str]) -> Statement:
22052209
"""Hook method executed just before the command is executed by
@@ -2430,9 +2434,9 @@ def runcmds_plus_hooks(
24302434
line, add_to_history=add_to_history, raise_keyboard_interrupt=stop_on_keyboard_interrupt
24312435
):
24322436
return True
2433-
except KeyboardInterrupt as e:
2437+
except KeyboardInterrupt as ex:
24342438
if stop_on_keyboard_interrupt:
2435-
self.perror(e)
2439+
self.perror(ex)
24362440
break
24372441

24382442
return False
@@ -2623,12 +2627,17 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
26232627
# Create pipe process in a separate group to isolate our signals from it. If a Ctrl-C event occurs,
26242628
# our sigint handler will forward it only to the most recent pipe process. This makes sure pipe
26252629
# processes close in the right order (most recent first).
2626-
kwargs = dict()
2630+
kwargs: Dict[str, Any] = dict()
26272631
if sys.platform == 'win32':
26282632
kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP
26292633
else:
26302634
kwargs['start_new_session'] = True
26312635

2636+
# Attempt to run the pipe process in the user's preferred shell instead of the default behavior of using sh.
2637+
shell = os.environ.get("SHELL")
2638+
if shell:
2639+
kwargs['executable'] = shell
2640+
26322641
# For any stream that is a StdSim, we will use a pipe so we can capture its output
26332642
proc = subprocess.Popen( # type: ignore[call-overload]
26342643
statement.pipe_to,
@@ -2654,7 +2663,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
26542663
new_stdout.close()
26552664
raise RedirectionError(f'Pipe process exited with code {proc.returncode} before command could run')
26562665
else:
2657-
redir_saved_state.redirecting = True
2666+
redir_saved_state.redirecting = True # type: ignore[unreachable]
26582667
cmd_pipe_proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr)
26592668
sys.stdout = self.stdout = new_stdout
26602669

@@ -3825,8 +3834,8 @@ def do_set(self, args: argparse.Namespace) -> None:
38253834
orig_value = settable.get_value()
38263835
new_value = settable.set_value(utils.strip_quotes(args.value))
38273836
# noinspection PyBroadException
3828-
except Exception as e:
3829-
self.perror(f"Error setting {args.param}: {e}")
3837+
except Exception as ex:
3838+
self.perror(f"Error setting {args.param}: {ex}")
38303839
else:
38313840
self.poutput(f"{args.param} - was: {orig_value!r}\nnow: {new_value!r}")
38323841
return
@@ -3863,8 +3872,30 @@ def do_set(self, args: argparse.Namespace) -> None:
38633872
@with_argparser(shell_parser, preserve_quotes=True)
38643873
def do_shell(self, args: argparse.Namespace) -> None:
38653874
"""Execute a command as if at the OS prompt"""
3875+
import signal
38663876
import subprocess
38673877

3878+
kwargs: Dict[str, Any] = dict()
3879+
3880+
# Set OS-specific parameters
3881+
if sys.platform.startswith('win'):
3882+
# Windows returns STATUS_CONTROL_C_EXIT when application stopped by Ctrl-C
3883+
ctrl_c_ret_code = 0xC000013A
3884+
else:
3885+
# On POSIX, Popen() returns -SIGINT when application stopped by Ctrl-C
3886+
ctrl_c_ret_code = signal.SIGINT.value * -1
3887+
3888+
# On POSIX with shell=True, Popen() defaults to /bin/sh as the shell.
3889+
# sh reports an incorrect return code for some applications when Ctrl-C is pressed within that
3890+
# application (e.g. less). Since sh received the SIGINT, it sets the return code to reflect being
3891+
# closed by SIGINT even though less did not exit upon a Ctrl-C press. In the same situation, other
3892+
# shells like bash and zsh report the actual return code of less. Therefore we will try to run the
3893+
# user's preferred shell which most likely will be something other than sh. This also allows the user
3894+
# to run builtin commands of their preferred shell.
3895+
shell = os.environ.get("SHELL")
3896+
if shell:
3897+
kwargs['executable'] = shell
3898+
38683899
# Create a list of arguments to shell
38693900
tokens = [args.command] + args.command_args
38703901

@@ -3876,19 +3907,25 @@ def do_shell(self, args: argparse.Namespace) -> None:
38763907
# still receive the SIGINT since it is in the same process group as us.
38773908
with self.sigint_protection:
38783909
# For any stream that is a StdSim, we will use a pipe so we can capture its output
3879-
proc = subprocess.Popen(
3910+
proc = subprocess.Popen( # type: ignore[call-overload]
38803911
expanded_command,
38813912
stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable]
38823913
stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, # type: ignore[unreachable]
38833914
shell=True,
3915+
**kwargs,
38843916
)
38853917

3886-
proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr)
3918+
proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr) # type: ignore[arg-type]
38873919
proc_reader.wait()
38883920

38893921
# Save the return code of the application for use in a pyscript
38903922
self.last_result = proc.returncode
38913923

3924+
# If the process was stopped by Ctrl-C, then inform the caller by raising a KeyboardInterrupt.
3925+
# This is to support things like stop_on_keyboard_interrupt in run_cmds_plus_hooks().
3926+
if proc.returncode == ctrl_c_ret_code:
3927+
self._raise_keyboard_interrupt()
3928+
38923929
@staticmethod
38933930
def _reset_py_display() -> None:
38943931
"""
@@ -4545,8 +4582,8 @@ def _generate_transcript(self, history: Union[List[HistoryItem], List[str]], tra
45454582
# then run the command and let the output go into our buffer
45464583
try:
45474584
stop = self.onecmd_plus_hooks(history_item, raise_keyboard_interrupt=True)
4548-
except KeyboardInterrupt as e:
4549-
self.perror(e)
4585+
except KeyboardInterrupt as ex:
4586+
self.perror(ex)
45504587
stop = True
45514588

45524589
commands_run += 1

cmd2/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -601,8 +601,8 @@ def send_sigint(self) -> None:
601601
import signal
602602

603603
if sys.platform.startswith('win'):
604-
# cmd2 started the Windows process in a new process group. Therefore
605-
# a CTRL_C_EVENT can't be sent to it. Send a CTRL_BREAK_EVENT instead.
604+
# cmd2 started the Windows process in a new process group. Therefore we must send
605+
# a CTRL_BREAK_EVENT since CTRL_C_EVENT signals cannot be generated for process groups.
606606
self._proc.send_signal(signal.CTRL_BREAK_EVENT)
607607
else:
608608
# Since cmd2 uses shell=True in its Popen calls, we need to send the SIGINT to

tests/test_cmd2.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import builtins
88
import io
99
import os
10+
import signal
1011
import sys
1112
import tempfile
1213
from code import (
@@ -902,6 +903,22 @@ def test_stty_sane(base_app, monkeypatch):
902903
m.assert_called_once_with(['stty', 'sane'])
903904

904905

906+
def test_sigint_handler(base_app):
907+
# No KeyboardInterrupt should be raised when using sigint_protection
908+
with base_app.sigint_protection:
909+
base_app.sigint_handler(signal.SIGINT, 1)
910+
911+
# Without sigint_protection, a KeyboardInterrupt is raised
912+
with pytest.raises(KeyboardInterrupt):
913+
base_app.sigint_handler(signal.SIGINT, 1)
914+
915+
916+
def test_raise_keyboard_interrupt(base_app):
917+
with pytest.raises(KeyboardInterrupt) as excinfo:
918+
base_app._raise_keyboard_interrupt()
919+
assert 'Got a keyboard interrupt' in str(excinfo.value)
920+
921+
905922
class HookFailureApp(cmd2.Cmd):
906923
def __init__(self, *args, **kwargs):
907924
super().__init__(*args, **kwargs)

0 commit comments

Comments
 (0)