Skip to content

Commit d0b21b5

Browse files
authored
Optimize performance of running command finalization hooks (#1504)
Optimize performance of running command finalization hooks (#1504) * Add code to cache initial termios settings in cmd2.Cmd.__init__ * Update _run_cmdfinalization_hooks to use termios instead of subprocess('stty sane') * Added a note to the CHANGELOG about this optimization
1 parent f7f8a8d commit d0b21b5

File tree

3 files changed

+86
-16
lines changed

3 files changed

+86
-16
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ time reading the [rich documentation](https://rich.readthedocs.io/).
4444
- [argparse_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_example.py)
4545
- [command_sets.py](https://github.com/python-cmd2/cmd2/blob/main/examples/command_sets.py)
4646
- [getting_started.py](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py)
47+
- Optimized performance of terminal fixup during command finalization by replacing `stty sane`
48+
with `termios.tcsetattr`
4749

4850
- Bug Fixes
4951
- Fixed a redirection bug where `cmd2` could unintentionally overwrite an application's

cmd2/cmd2.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,19 @@ def __init__(
508508
# Commands that will run at the beginning of the command loop
509509
self._startup_commands: list[str] = []
510510

511+
# Store initial termios settings to restore after each command.
512+
# This is a faster way of accomplishing what "stty sane" does.
513+
self._initial_termios_settings = None
514+
if not sys.platform.startswith('win') and self.stdin.isatty():
515+
try:
516+
import io
517+
import termios
518+
519+
self._initial_termios_settings = termios.tcgetattr(self.stdin.fileno())
520+
except (ImportError, io.UnsupportedOperation, termios.error):
521+
# This can happen if termios isn't available or stdin is a pseudo-TTY
522+
self._initial_termios_settings = None
523+
511524
# If a startup script is provided and exists, then execute it in the startup commands
512525
if startup_script:
513526
startup_script = os.path.abspath(os.path.expanduser(startup_script))
@@ -2822,14 +2835,15 @@ def onecmd_plus_hooks(
28222835

28232836
def _run_cmdfinalization_hooks(self, stop: bool, statement: Statement | None) -> bool:
28242837
"""Run the command finalization hooks."""
2825-
with self.sigint_protection:
2826-
if not sys.platform.startswith('win') and self.stdin.isatty():
2827-
# Before the next command runs, fix any terminal problems like those
2828-
# caused by certain binary characters having been printed to it.
2829-
import subprocess
2830-
2831-
proc = subprocess.Popen(['stty', 'sane']) # noqa: S607
2832-
proc.communicate()
2838+
if self._initial_termios_settings is not None and self.stdin.isatty():
2839+
import io
2840+
import termios
2841+
2842+
# Before the next command runs, fix any terminal problems like those
2843+
# caused by certain binary characters having been printed to it.
2844+
with self.sigint_protection, contextlib.suppress(io.UnsupportedOperation, termios.error):
2845+
# This can fail if stdin is a pseudo-TTY, in which case we just ignore it
2846+
termios.tcsetattr(self.stdin.fileno(), termios.TCSANOW, self._initial_termios_settings)
28332847

28342848
data = plugin.CommandFinalizationData(stop, statement)
28352849
for func in self._cmdfinalization_hooks:

tests/test_cmd2.py

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,16 +1068,70 @@ def test_cmdloop_without_rawinput() -> None:
10681068
assert out == expected
10691069

10701070

1071-
@pytest.mark.skipif(sys.platform.startswith('win'), reason="stty sane only run on Linux/Mac")
1072-
def test_stty_sane(base_app, monkeypatch) -> None:
1073-
"""Make sure stty sane is run on Linux/Mac after each command if stdin is a terminal"""
1074-
with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)):
1075-
# Mock out the subprocess.Popen call so we don't actually run stty sane
1076-
m = mock.MagicMock(name='Popen')
1077-
monkeypatch.setattr("subprocess.Popen", m)
1071+
def test_cmdfinalizations_runs(base_app, monkeypatch) -> None:
1072+
"""Make sure _run_cmdfinalization_hooks is run after each command."""
1073+
with (
1074+
mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)),
1075+
mock.patch('sys.stdin.fileno', mock.MagicMock(name='fileno', return_value=0)),
1076+
):
1077+
monkeypatch.setattr(base_app.stdin, "fileno", lambda: 0)
1078+
monkeypatch.setattr(base_app.stdin, "isatty", lambda: True)
1079+
1080+
cmd_fin = mock.MagicMock(name='cmdfinalization')
1081+
monkeypatch.setattr("cmd2.Cmd._run_cmdfinalization_hooks", cmd_fin)
10781082

10791083
base_app.onecmd_plus_hooks('help')
1080-
m.assert_called_once_with(['stty', 'sane'])
1084+
cmd_fin.assert_called_once()
1085+
1086+
1087+
@pytest.mark.skipif(sys.platform.startswith('win'), reason="termios is not available on Windows")
1088+
@pytest.mark.parametrize(
1089+
('is_tty', 'settings_set', 'raised_exception', 'should_call'),
1090+
[
1091+
(True, True, None, True),
1092+
(True, True, 'termios_error', True),
1093+
(True, True, 'unsupported_operation', True),
1094+
(False, True, None, False),
1095+
(True, False, None, False),
1096+
],
1097+
)
1098+
def test_restore_termios_settings(base_app, monkeypatch, is_tty, settings_set, raised_exception, should_call):
1099+
"""Test that terminal settings are restored after a command and that errors are suppressed."""
1100+
import io
1101+
import termios # Mock termios since it's imported within the method
1102+
1103+
termios_mock = mock.MagicMock()
1104+
# The error attribute needs to be the actual exception for isinstance checks
1105+
termios_mock.error = termios.error
1106+
monkeypatch.setitem(sys.modules, 'termios', termios_mock)
1107+
1108+
# Set the exception to be raised by tcsetattr
1109+
if raised_exception == 'termios_error':
1110+
termios_mock.tcsetattr.side_effect = termios.error("test termios error")
1111+
elif raised_exception == 'unsupported_operation':
1112+
termios_mock.tcsetattr.side_effect = io.UnsupportedOperation("test io error")
1113+
1114+
# Set initial termios settings so the logic will run
1115+
if settings_set:
1116+
termios_settings = ["dummy settings"]
1117+
base_app._initial_termios_settings = termios_settings
1118+
else:
1119+
base_app._initial_termios_settings = None
1120+
termios_settings = None # for the assert
1121+
1122+
# Mock stdin to make it look like a TTY
1123+
monkeypatch.setattr(base_app.stdin, "isatty", lambda: is_tty)
1124+
monkeypatch.setattr(base_app.stdin, "fileno", lambda: 0)
1125+
1126+
# Run a command to trigger _run_cmdfinalization_hooks
1127+
# This should not raise an exception
1128+
base_app.onecmd_plus_hooks('help')
1129+
1130+
# Verify that tcsetattr was called with the correct arguments
1131+
if should_call:
1132+
termios_mock.tcsetattr.assert_called_once_with(0, termios_mock.TCSANOW, termios_settings)
1133+
else:
1134+
termios_mock.tcsetattr.assert_not_called()
10811135

10821136

10831137
def test_sigint_handler(base_app) -> None:

0 commit comments

Comments
 (0)