Skip to content

Commit 6cf2e87

Browse files
pablogsalharjothkharaclaude
authored
[3.13] gh-152907: Restore cooked output flags around the input hook in the new REPL (GH-153389) (#158119)
(cherry picked from commit 46ee358) Co-authored-by: Harjoth Khara <harjoth.khara@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 46d85fb commit 6cf2e87

3 files changed

Lines changed: 110 additions & 1 deletion

File tree

‎Lib/_pyrepl/unix_console.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ def prepare(self):
363363
raw.cc[termios.VMIN] = 1
364364
raw.cc[termios.VTIME] = 0
365365
self.__input_fd_set(raw)
366+
self.__rawtermstate = raw
366367

367368
# In macOS terminal we need to deactivate line wrap via ANSI escape code
368369
if self.is_apple_terminal:
@@ -599,7 +600,20 @@ def input_hook(self):
599600
except ImportError:
600601
return None
601602
if posix._is_inputhook_installed():
602-
return posix._inputhook
603+
return self.__run_input_hook
604+
605+
def __run_input_hook(self):
606+
import posix
607+
# gh-152907: input hooks expect cooked output, but pyrepl runs with
608+
# OPOST disabled. Restore the saved output flags around the hook
609+
# (only oflag; input must stay raw at the prompt).
610+
cooked = self.__rawtermstate.copy()
611+
cooked.oflag = self.__svtermstate.oflag
612+
self.__input_fd_set(cooked)
613+
try:
614+
return posix._inputhook()
615+
finally:
616+
self.__input_fd_set(self.__rawtermstate)
603617

604618
def __enable_bracketed_paste(self) -> None:
605619
os.write(self.output_fd, b"\x1b[?2004h")

‎Lib/test/test_pyrepl/test_unix_console.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import errno
22
import itertools
33
import os
4+
import select
45
import signal
56
import sys
67
import threading
78
import unittest
89
from functools import partial
910
from test.support import os_helper
11+
from test.support import is_android, is_apple_mobile, is_emscripten, is_wasi
1012
from test.support import threading_helper
1113

1214
from unittest import TestCase
@@ -392,3 +394,92 @@ def test_eio_error_handling_in_restore(self, mock_tcgetattr, mock_tcsetattr):
392394

393395
# EIO error should be handled gracefully in restore()
394396
console.restore()
397+
398+
399+
try:
400+
import pty
401+
import termios as _termios
402+
except ImportError:
403+
pty = None
404+
405+
406+
@unittest.skipIf(sys.platform == "win32", "No Unix console on Windows")
407+
@unittest.skipUnless(pty, "requires pty")
408+
@unittest.skipIf(is_android or is_apple_mobile or is_emscripten or is_wasi,
409+
"pty is not available on this platform")
410+
class TestUnixConsoleInputHook(TestCase):
411+
# gh-152907: the console must restore cooked output (OPOST) around
412+
# input-hook calls, then re-enter raw mode.
413+
414+
def test_input_hook_output_is_cooked(self):
415+
master_fd, slave_fd = pty.openpty()
416+
self.addCleanup(os.close, master_fd)
417+
418+
# tcsetattr(TCSADRAIN) blocks on some platforms (e.g. macOS) while the
419+
# master still holds unread output, so empty it before each mode switch.
420+
def drain():
421+
out = b""
422+
while select.select([master_fd], [], [], 0)[0]:
423+
try:
424+
data = os.read(master_fd, 4096)
425+
except OSError:
426+
break
427+
if not data:
428+
break
429+
out += data
430+
return out
431+
432+
# Start from a cooked terminal so there are saved flags to restore.
433+
attr = _termios.tcgetattr(slave_fd)
434+
attr[1] |= _termios.OPOST | _termios.ONLCR
435+
_termios.tcsetattr(slave_fd, _termios.TCSANOW, attr)
436+
437+
console = UnixConsole(slave_fd, slave_fd, term="xterm")
438+
console.prepare()
439+
try:
440+
drain() # discard prepare()'s own setup sequences
441+
# pyrepl's own rendering runs with OPOST cleared.
442+
self.assertFalse(_termios.tcgetattr(slave_fd)[1] & _termios.OPOST)
443+
444+
observed = {}
445+
446+
def fake_hook():
447+
observed["oflag"] = _termios.tcgetattr(slave_fd)[1]
448+
os.write(slave_fd, b"line1\nline2\n")
449+
observed["output"] = drain()
450+
return 0
451+
452+
with (patch("posix._is_inputhook_installed", return_value=True),
453+
patch("posix._inputhook", side_effect=fake_hook)):
454+
hook = console.input_hook
455+
self.assertIsNotNone(hook)
456+
self.assertEqual(hook(), 0)
457+
458+
# The hook ran with cooked output (OPOST on)...
459+
self.assertTrue(observed["oflag"] & _termios.OPOST)
460+
# ...and raw mode was restored afterwards.
461+
self.assertFalse(_termios.tcgetattr(slave_fd)[1] & _termios.OPOST)
462+
# The tty translated the hook's bare '\n' into '\r\n'.
463+
self.assertEqual(observed["output"], b"line1\r\nline2\r\n")
464+
finally:
465+
# restore() writes and only then switches modes, so there is no
466+
# point left to drain from here; keep the master empty elsewhere.
467+
stop = threading.Event()
468+
469+
def pump():
470+
while not stop.is_set():
471+
if select.select([master_fd], [], [], 0.05)[0]:
472+
try:
473+
if not os.read(master_fd, 4096):
474+
break
475+
except OSError:
476+
break
477+
478+
pump_thread = threading.Thread(target=pump)
479+
pump_thread.start()
480+
try:
481+
console.restore()
482+
finally:
483+
stop.set()
484+
pump_thread.join()
485+
os.close(slave_fd)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Restore cooked-mode terminal output flags around :c:data:`PyOS_InputHook`
2+
callbacks in the new :term:`REPL` (:mod:`!_pyrepl`), so that output written
3+
by an input hook (for example a GUI toolkit event loop) is no longer emitted
4+
with ``OPOST`` disabled and keeps its carriage returns.

0 commit comments

Comments
 (0)