From 62401b12f82d82b3e788d6ed24606f4cec820a6f Mon Sep 17 00:00:00 2001 From: harjoth Date: Wed, 8 Jul 2026 23:12:49 -0700 Subject: [PATCH] gh-152907: Restore cooked output flags around the input hook in the new REPL pyrepl clears OPOST for its own cursor rendering but calls PyOS_InputHook from inside the raw-mode read loop, so output written by an input hook (GUI toolkit event loops, and any warning/traceback/print they emit) is emitted with bare '\n' and no '\r'. Restore the terminal's saved output flags around the hook call and re-enter raw mode afterwards; only oflag is toggled so ECHO/ICANON stay off at the prompt. --- Lib/_pyrepl/unix_console.py | 19 +++- Lib/test/test_pyrepl/test_unix_console.py | 91 +++++++++++++++++++ ...-07-08-23-20-00.gh-issue-152907.oyPV9Y.rst | 4 + 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-08-23-20-00.gh-issue-152907.oyPV9Y.rst diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py index 8edd1c36a7232d7..0e7f8f6d47e6a0d 100644 --- a/Lib/_pyrepl/unix_console.py +++ b/Lib/_pyrepl/unix_console.py @@ -491,6 +491,7 @@ def prepare(self) -> None: raw.cc[termios.VMIN] = b"\x01" raw.cc[termios.VTIME] = b"\x00" self.__input_fd_set(raw) + self.__rawtermstate = raw # Apple Terminal will re-wrap lines for us unless we preempt the # damage. @@ -731,7 +732,23 @@ def input_hook(self): # avoid inline imports here so the repl doesn't get flooded # with import logging from -X importtime=2 if posix is not None and posix._is_inputhook_installed(): - return posix._inputhook + return self.__run_input_hook + + def __run_input_hook(self): + # Input-hook callbacks (GUI toolkit event loops, and any warning, + # traceback or print they emit) expect normal cooked-mode output, but + # pyrepl runs with OPOST disabled so it can drive the cursor itself. + # Temporarily restore the terminal's saved output flags around the + # hook so that '\n' is translated to '\r\n' as it was under the + # readline REPL, then re-enter raw mode. Only oflag is toggled -- + # re-enabling ECHO/ICANON at the prompt would be wrong. See gh-152907. + cooked = self.__rawtermstate.copy() + cooked.oflag = self.__svtermstate.oflag + self.__input_fd_set(cooked) + try: + posix._inputhook() + finally: + self.__input_fd_set(self.__rawtermstate) def __enable_bracketed_paste(self) -> None: os.write(self.output_fd, b"\x1b[?2004h") diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index 2fc8398923cbf38..2d0b3c536d4f1af 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -1,9 +1,11 @@ import errno import itertools import os +import select import signal import sys import threading +import time import unittest from functools import partial from _colorize import ANSIColors @@ -424,3 +426,92 @@ def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr): # EIO error should be handled gracefully in restore() console.restore() + + +try: + import pty + import termios as _termios +except ImportError: + pty = None + + +@unittest.skipIf(sys.platform == "win32", "No Unix console on Windows") +@unittest.skipUnless(pty, "requires pty") +class TestUnixConsoleInputHook(TestCase): + # gh-152907: pyrepl runs with OPOST disabled so it can drive the cursor + # itself, but input-hook callbacks (GUI event loops, and any warning, + # traceback or print they emit) expect normal cooked-mode output. The + # console must restore cooked output around the hook call so that '\n' is + # translated back to '\r\n', then re-enter raw mode. + + def test_input_hook_output_is_cooked(self): + master_fd, slave_fd = pty.openpty() + + # Continuously drain the master side, like a real terminal. This is + # required because pyrepl switches the terminal mode with TCSADRAIN, + # which blocks until queued output has been consumed. + chunks = [] + reading = True + + def reader(): + while reading: + r, _, _ = select.select([master_fd], [], [], 0.1) + if master_fd in r: + try: + data = os.read(master_fd, 4096) + except OSError: + break + if not data: + break + chunks.append(data) + + reader_thread = threading.Thread(target=reader) + reader_thread.start() + + def cleanup(): + nonlocal reading + reading = False + reader_thread.join() + os.close(master_fd) + self.addCleanup(cleanup) + + # Establish a normal cooked terminal as the saved state so the fix has + # something to restore around the hook call. + attr = _termios.tcgetattr(slave_fd) + attr[1] |= _termios.OPOST | _termios.ONLCR + _termios.tcsetattr(slave_fd, _termios.TCSANOW, attr) + + console = UnixConsole(slave_fd, slave_fd, term="xterm") + console.prepare() + try: + # pyrepl's own rendering runs with OPOST cleared. + self.assertFalse(_termios.tcgetattr(slave_fd)[1] & _termios.OPOST) + + observed = {} + + def fake_hook(): + observed["oflag"] = _termios.tcgetattr(slave_fd)[1] + os.write(slave_fd, b"line1\nline2\n") + return 0 + + with patch("_pyrepl.unix_console.posix") as mock_posix: + mock_posix._is_inputhook_installed.return_value = True + mock_posix._inputhook.side_effect = fake_hook + hook = console.input_hook + self.assertIsNotNone(hook) + hook() + + # The hook ran with cooked output (OPOST on)... + self.assertTrue(observed["oflag"] & _termios.OPOST) + # ...and raw mode was restored afterwards. + self.assertFalse(_termios.tcgetattr(slave_fd)[1] & _termios.OPOST) + finally: + console.restore() + os.close(slave_fd) + + # Give the reader a moment to drain the hook's output. + time.sleep(0.2) + data = b"".join(chunks) + # The tty translated the hook's bare '\n' into '\r\n'. + self.assertIn(b"line1\r\nline2\r\n", data) + self.assertNotIn(b"line1\nline2\n", data) diff --git a/Misc/NEWS.d/next/Library/2026-07-08-23-20-00.gh-issue-152907.oyPV9Y.rst b/Misc/NEWS.d/next/Library/2026-07-08-23-20-00.gh-issue-152907.oyPV9Y.rst new file mode 100644 index 000000000000000..33247f0f3a3b968 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-08-23-20-00.gh-issue-152907.oyPV9Y.rst @@ -0,0 +1,4 @@ +Restore cooked-mode terminal output flags around :c:data:`PyOS_InputHook` +callbacks in the new :term:`REPL` (:mod:`!_pyrepl`), so that output written +by an input hook (for example a GUI toolkit event loop) is no longer emitted +with ``OPOST`` disabled and keeps its carriage returns.