Skip to content

[3.13] GH-132439: Fix REPL swallowing characters entered with AltGr on cmd.exe (GH-132440) #133460

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions Lib/_pyrepl/windows_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ def get_event(self, block: bool = True) -> Event | None:

if key == "\r":
# Make enter unix-like
return Event(evt="key", data="\n", raw=b"\n")
return Event(evt="key", data="\n")
elif key_event.wVirtualKeyCode == 8:
# Turn backspace directly into the command
key = "backspace"
Expand All @@ -460,9 +460,9 @@ def get_event(self, block: bool = True) -> Event | None:
key = f"ctrl {key}"
elif key_event.dwControlKeyState & ALT_ACTIVE:
# queue the key, return the meta command
self.event_queue.insert(Event(evt="key", data=key, raw=key))
self.event_queue.insert(Event(evt="key", data=key))
return Event(evt="key", data="\033") # keymap.py uses this for meta
return Event(evt="key", data=key, raw=key)
return Event(evt="key", data=key)
if block:
continue

Expand All @@ -473,11 +473,15 @@ def get_event(self, block: bool = True) -> Event | None:
continue

if key_event.dwControlKeyState & ALT_ACTIVE:
# queue the key, return the meta command
self.event_queue.insert(Event(evt="key", data=key, raw=raw_key))
return Event(evt="key", data="\033") # keymap.py uses this for meta

return Event(evt="key", data=key, raw=raw_key)
# Do not swallow characters that have been entered via AltGr:
# Windows internally converts AltGr to CTRL+ALT, see
# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-vkkeyscanw
if not key_event.dwControlKeyState & CTRL_ACTIVE:
# queue the key, return the meta command
self.event_queue.insert(Event(evt="key", data=key))
return Event(evt="key", data="\033") # keymap.py uses this for meta

return Event(evt="key", data=key)
return self.event_queue.get()

def push_char(self, char: int | bytes) -> None:
Expand Down
221 changes: 220 additions & 1 deletion Lib/test/test_pyrepl/test_windows_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
MOVE_DOWN,
ERASE_IN_LINE,
)
import _pyrepl.windows_console as wc
except ImportError:
pass

Expand Down Expand Up @@ -340,8 +341,226 @@ def test_multiline_ctrl_z(self):
Event(evt="key", data='\x1a', raw=bytearray(b'\x1a')),
],
)
reader, _ = self.handle_events_narrow(events)
reader, con = self.handle_events_narrow(events)
self.assertEqual(reader.cxy, (2, 3))
con.restore()


class WindowsConsoleGetEventTests(TestCase):
# Virtual-Key Codes: https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
VK_BACK = 0x08
VK_RETURN = 0x0D
VK_LEFT = 0x25
VK_7 = 0x37
VK_M = 0x4D
# Used for miscellaneous characters; it can vary by keyboard.
# For the US standard keyboard, the '" key.
# For the German keyboard, the Ä key.
VK_OEM_7 = 0xDE

# State of control keys: https://learn.microsoft.com/en-us/windows/console/key-event-record-str
RIGHT_ALT_PRESSED = 0x0001
RIGHT_CTRL_PRESSED = 0x0004
LEFT_ALT_PRESSED = 0x0002
LEFT_CTRL_PRESSED = 0x0008
ENHANCED_KEY = 0x0100
SHIFT_PRESSED = 0x0010


def get_event(self, input_records, **kwargs) -> Console:
self.console = WindowsConsole(encoding='utf-8')
self.mock = MagicMock(side_effect=input_records)
self.console._read_input = self.mock
self.console._WindowsConsole__vt_support = kwargs.get("vt_support",
False)
event = self.console.get_event(block=False)
return event

def get_input_record(self, unicode_char, vcode=0, control=0):
return wc.INPUT_RECORD(
wc.KEY_EVENT,
wc.ConsoleEvent(KeyEvent=
wc.KeyEvent(
bKeyDown=True,
wRepeatCount=1,
wVirtualKeyCode=vcode,
wVirtualScanCode=0, # not used
uChar=wc.Char(unicode_char),
dwControlKeyState=control
)))

def test_EmptyBuffer(self):
self.assertEqual(self.get_event([None]), None)
self.assertEqual(self.mock.call_count, 1)

def test_WINDOW_BUFFER_SIZE_EVENT(self):
ir = wc.INPUT_RECORD(
wc.WINDOW_BUFFER_SIZE_EVENT,
wc.ConsoleEvent(WindowsBufferSizeEvent=
wc.WindowsBufferSizeEvent(
wc._COORD(0, 0))))
self.assertEqual(self.get_event([ir]), Event("resize", ""))
self.assertEqual(self.mock.call_count, 1)

def test_KEY_EVENT_up_ignored(self):
ir = wc.INPUT_RECORD(
wc.KEY_EVENT,
wc.ConsoleEvent(KeyEvent=
wc.KeyEvent(bKeyDown=False)))
self.assertEqual(self.get_event([ir]), None)
self.assertEqual(self.mock.call_count, 1)

def test_unhandled_events(self):
for event in (wc.FOCUS_EVENT, wc.MENU_EVENT, wc.MOUSE_EVENT):
ir = wc.INPUT_RECORD(
event,
# fake data, nothing is read except bKeyDown
wc.ConsoleEvent(KeyEvent=
wc.KeyEvent(bKeyDown=False)))
self.assertEqual(self.get_event([ir]), None)
self.assertEqual(self.mock.call_count, 1)

def test_enter(self):
ir = self.get_input_record("\r", self.VK_RETURN)
self.assertEqual(self.get_event([ir]), Event("key", "\n"))
self.assertEqual(self.mock.call_count, 1)

def test_backspace(self):
ir = self.get_input_record("\x08", self.VK_BACK)
self.assertEqual(
self.get_event([ir]), Event("key", "backspace"))
self.assertEqual(self.mock.call_count, 1)

def test_m(self):
ir = self.get_input_record("m", self.VK_M)
self.assertEqual(self.get_event([ir]), Event("key", "m"))
self.assertEqual(self.mock.call_count, 1)

def test_M(self):
ir = self.get_input_record("M", self.VK_M, self.SHIFT_PRESSED)
self.assertEqual(self.get_event([ir]), Event("key", "M"))
self.assertEqual(self.mock.call_count, 1)

def test_left(self):
# VK_LEFT is sent as ENHANCED_KEY
ir = self.get_input_record("\x00", self.VK_LEFT, self.ENHANCED_KEY)
self.assertEqual(self.get_event([ir]), Event("key", "left"))
self.assertEqual(self.mock.call_count, 1)

def test_left_RIGHT_CTRL_PRESSED(self):
ir = self.get_input_record(
"\x00", self.VK_LEFT, self.RIGHT_CTRL_PRESSED | self.ENHANCED_KEY)
self.assertEqual(
self.get_event([ir]), Event("key", "ctrl left"))
self.assertEqual(self.mock.call_count, 1)

def test_left_LEFT_CTRL_PRESSED(self):
ir = self.get_input_record(
"\x00", self.VK_LEFT, self.LEFT_CTRL_PRESSED | self.ENHANCED_KEY)
self.assertEqual(
self.get_event([ir]), Event("key", "ctrl left"))
self.assertEqual(self.mock.call_count, 1)

def test_left_RIGHT_ALT_PRESSED(self):
ir = self.get_input_record(
"\x00", self.VK_LEFT, self.RIGHT_ALT_PRESSED | self.ENHANCED_KEY)
self.assertEqual(self.get_event([ir]), Event(evt="key", data="\033"))
self.assertEqual(
self.console.get_event(), Event("key", "left"))
# self.mock is not called again, since the second time we read from the
# command queue
self.assertEqual(self.mock.call_count, 1)

def test_left_LEFT_ALT_PRESSED(self):
ir = self.get_input_record(
"\x00", self.VK_LEFT, self.LEFT_ALT_PRESSED | self.ENHANCED_KEY)
self.assertEqual(self.get_event([ir]), Event(evt="key", data="\033"))
self.assertEqual(
self.console.get_event(), Event("key", "left"))
self.assertEqual(self.mock.call_count, 1)

def test_m_LEFT_ALT_PRESSED_and_LEFT_CTRL_PRESSED(self):
# For the shift keys, Windows does not send anything when
# ALT and CTRL are both pressed, so let's test with VK_M.
# get_event() receives this input, but does not
# generate an event.
# This is for e.g. an English keyboard layout, for a
# German layout this returns `µ`, see test_AltGr_m.
ir = self.get_input_record(
"\x00", self.VK_M, self.LEFT_ALT_PRESSED | self.LEFT_CTRL_PRESSED)
self.assertEqual(self.get_event([ir]), None)
self.assertEqual(self.mock.call_count, 1)

def test_m_LEFT_ALT_PRESSED(self):
ir = self.get_input_record(
"m", vcode=self.VK_M, control=self.LEFT_ALT_PRESSED)
self.assertEqual(self.get_event([ir]), Event(evt="key", data="\033"))
self.assertEqual(self.console.get_event(), Event("key", "m"))
self.assertEqual(self.mock.call_count, 1)

def test_m_RIGHT_ALT_PRESSED(self):
ir = self.get_input_record(
"m", vcode=self.VK_M, control=self.RIGHT_ALT_PRESSED)
self.assertEqual(self.get_event([ir]), Event(evt="key", data="\033"))
self.assertEqual(self.console.get_event(), Event("key", "m"))
self.assertEqual(self.mock.call_count, 1)

def test_AltGr_7(self):
# E.g. on a German keyboard layout, '{' is entered via
# AltGr + 7, where AltGr is the right Alt key on the keyboard.
# In this case, Windows automatically sets
# RIGHT_ALT_PRESSED = 0x0001 + LEFT_CTRL_PRESSED = 0x0008
# This can also be entered like
# LeftAlt + LeftCtrl + 7 or
# LeftAlt + RightCtrl + 7
# See https://learn.microsoft.com/en-us/windows/console/key-event-record-str
# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-vkkeyscanw
ir = self.get_input_record(
"{", vcode=self.VK_7,
control=self.RIGHT_ALT_PRESSED | self.LEFT_CTRL_PRESSED)
self.assertEqual(self.get_event([ir]), Event("key", "{"))
self.assertEqual(self.mock.call_count, 1)

def test_AltGr_m(self):
# E.g. on a German keyboard layout, this yields 'µ'
# Let's use LEFT_ALT_PRESSED and RIGHT_CTRL_PRESSED this
# time, to cover that, too. See above in test_AltGr_7.
ir = self.get_input_record(
"µ", vcode=self.VK_M, control=self.LEFT_ALT_PRESSED | self.RIGHT_CTRL_PRESSED)
self.assertEqual(self.get_event([ir]), Event("key", "µ"))
self.assertEqual(self.mock.call_count, 1)

def test_umlaut_a_german(self):
ir = self.get_input_record("ä", self.VK_OEM_7)
self.assertEqual(self.get_event([ir]), Event("key", "ä"))
self.assertEqual(self.mock.call_count, 1)

# virtual terminal tests
# Note: wVirtualKeyCode, wVirtualScanCode and dwControlKeyState
# are always zero in this case.
# "\r" and backspace are handled specially, everything else
# is handled in "elif self.__vt_support:" in WindowsConsole.get_event().
# Hence, only one regular key ("m") and a terminal sequence
# are sufficient to test here, the real tests happen in test_eventqueue
# and test_keymap.

def test_enter_vt(self):
ir = self.get_input_record("\r")
self.assertEqual(self.get_event([ir], vt_support=True),
Event("key", "\n"))
self.assertEqual(self.mock.call_count, 1)

def test_backspace_vt(self):
ir = self.get_input_record("\x7f")
self.assertEqual(self.get_event([ir], vt_support=True),
Event("key", "backspace", b"\x7f"))
self.assertEqual(self.mock.call_count, 1)

def test_up_vt(self):
irs = [self.get_input_record(x) for x in "\x1b[A"]
self.assertEqual(self.get_event(irs, vt_support=True),
Event(evt='key', data='up', raw=bytearray(b'\x1b[A')))
self.assertEqual(self.mock.call_count, 3)


if __name__ == "__main__":
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix ``PyREPL`` on Windows: characters entered via AltGr are swallowed.
Patch by Chris Eibl.
Loading