GH-132439: Fix REPL swallowing characters entered with AltGr on cmd.exe (GH-132440)

Co-authored-by: Stan Ulbrych <89152624+StanFromIreland@users.noreply.github.com>
This commit is contained in:
Chris Eibl 2025-05-05 18:45:45 +02:00 committed by GitHub
parent b6c2ef0c7a
commit 07f416a3f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 233 additions and 8 deletions

View file

@ -464,7 +464,7 @@ class WindowsConsole(Console):
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"
@ -476,9 +476,9 @@ class WindowsConsole(Console):
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
@ -490,11 +490,15 @@ class WindowsConsole(Console):
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
# 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, raw=raw_key)
return Event(evt="key", data=key)
return self.event_queue.get()
def push_char(self, char: int | bytes) -> None:

View file

@ -24,6 +24,7 @@ try:
MOVE_DOWN,
ERASE_IN_LINE,
)
import _pyrepl.windows_console as wc
except ImportError:
pass
@ -350,8 +351,226 @@ class WindowsConsoleTests(TestCase):
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__":

View file

@ -0,0 +1,2 @@
Fix ``PyREPL`` on Windows: characters entered via AltGr are swallowed.
Patch by Chris Eibl.