https://github.com/python/cpython/commit/6cf2e87634887f93986f6defc20c44697f385ecf
commit: 6cf2e87634887f93986f6defc20c44697f385ecf
branch: 3.13
author: Pablo Galindo Salgado <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-09-24T16:36:40Z
summary:

[3.13] gh-152907: Restore cooked output flags around the input hook in the new 
REPL (GH-153389) (#158119)

(cherry picked from commit 46ee3580c0d1c8b2d527d13b6956881b61b26d34)

Co-authored-by: Harjoth Khara <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-07-08-23-20-00.gh-issue-152907.oyPV9Y.rst
M Lib/_pyrepl/unix_console.py
M Lib/test/test_pyrepl/test_unix_console.py

diff --git a/Lib/_pyrepl/unix_console.py b/Lib/_pyrepl/unix_console.py
index cf82721ed89395..28cfeaaa7953e6 100644
--- a/Lib/_pyrepl/unix_console.py
+++ b/Lib/_pyrepl/unix_console.py
@@ -363,6 +363,7 @@ def prepare(self):
         raw.cc[termios.VMIN] = 1
         raw.cc[termios.VTIME] = 0
         self.__input_fd_set(raw)
+        self.__rawtermstate = raw
 
         # In macOS terminal we need to deactivate line wrap via ANSI escape 
code
         if self.is_apple_terminal:
@@ -599,7 +600,20 @@ def input_hook(self):
         except ImportError:
             return None
         if posix._is_inputhook_installed():
-            return posix._inputhook
+            return self.__run_input_hook
+
+    def __run_input_hook(self):
+        import posix
+        # gh-152907: input hooks expect cooked output, but pyrepl runs with
+        # OPOST disabled.  Restore the saved output flags around the hook
+        # (only oflag; input must stay raw at the prompt).
+        cooked = self.__rawtermstate.copy()
+        cooked.oflag = self.__svtermstate.oflag
+        self.__input_fd_set(cooked)
+        try:
+            return 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 d50a7a8af18faf..98c2bc40bfd426 100644
--- a/Lib/test/test_pyrepl/test_unix_console.py
+++ b/Lib/test/test_pyrepl/test_unix_console.py
@@ -1,12 +1,14 @@
 import errno
 import itertools
 import os
+import select
 import signal
 import sys
 import threading
 import unittest
 from functools import partial
 from test.support import os_helper
+from test.support import is_android, is_apple_mobile, is_emscripten, is_wasi
 from test.support import threading_helper
 
 from unittest import TestCase
@@ -392,3 +394,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
+
+
[email protected](sys.platform == "win32", "No Unix console on Windows")
[email protected](pty, "requires pty")
[email protected](is_android or is_apple_mobile or is_emscripten or is_wasi,
+                 "pty is not available on this platform")
+class TestUnixConsoleInputHook(TestCase):
+    # gh-152907: the console must restore cooked output (OPOST) around
+    # input-hook calls, then re-enter raw mode.
+
+    def test_input_hook_output_is_cooked(self):
+        master_fd, slave_fd = pty.openpty()
+        self.addCleanup(os.close, master_fd)
+
+        # tcsetattr(TCSADRAIN) blocks on some platforms (e.g. macOS) while the
+        # master still holds unread output, so empty it before each mode 
switch.
+        def drain():
+            out = b""
+            while select.select([master_fd], [], [], 0)[0]:
+                try:
+                    data = os.read(master_fd, 4096)
+                except OSError:
+                    break
+                if not data:
+                    break
+                out += data
+            return out
+
+        # Start from a cooked terminal so there are saved flags to restore.
+        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:
+            drain()  # discard prepare()'s own setup sequences
+            # 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")
+                observed["output"] = drain()
+                return 0
+
+            with (patch("posix._is_inputhook_installed", return_value=True),
+                  patch("posix._inputhook", side_effect=fake_hook)):
+                hook = console.input_hook
+                self.assertIsNotNone(hook)
+                self.assertEqual(hook(), 0)
+
+            # 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)
+            # The tty translated the hook's bare '\n' into '\r\n'.
+            self.assertEqual(observed["output"], b"line1\r\nline2\r\n")
+        finally:
+            # restore() writes and only then switches modes, so there is no
+            # point left to drain from here; keep the master empty elsewhere.
+            stop = threading.Event()
+
+            def pump():
+                while not stop.is_set():
+                    if select.select([master_fd], [], [], 0.05)[0]:
+                        try:
+                            if not os.read(master_fd, 4096):
+                                break
+                        except OSError:
+                            break
+
+            pump_thread = threading.Thread(target=pump)
+            pump_thread.start()
+            try:
+                console.restore()
+            finally:
+                stop.set()
+                pump_thread.join()
+                os.close(slave_fd)
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 00000000000000..33247f0f3a3b96
--- /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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to