Skip to content

Commit 07f416a

Browse files
GH-132439: Fix REPL swallowing characters entered with AltGr on cmd.exe (GH-132440)
Co-authored-by: Stan Ulbrych <[email protected]>
1 parent b6c2ef0 commit 07f416a

File tree

3 files changed

+234
-9
lines changed

3 files changed

+234
-9
lines changed

Lib/_pyrepl/windows_console.py

+12-8
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ def get_event(self, block: bool = True) -> Event | None:
464464

465465
if key == "\r":
466466
# Make enter unix-like
467-
return Event(evt="key", data="\n", raw=b"\n")
467+
return Event(evt="key", data="\n")
468468
elif key_event.wVirtualKeyCode == 8:
469469
# Turn backspace directly into the command
470470
key = "backspace"
@@ -476,9 +476,9 @@ def get_event(self, block: bool = True) -> Event | None:
476476
key = f"ctrl {key}"
477477
elif key_event.dwControlKeyState & ALT_ACTIVE:
478478
# queue the key, return the meta command
479-
self.event_queue.insert(Event(evt="key", data=key, raw=key))
479+
self.event_queue.insert(Event(evt="key", data=key))
480480
return Event(evt="key", data="\033") # keymap.py uses this for meta
481-
return Event(evt="key", data=key, raw=key)
481+
return Event(evt="key", data=key)
482482
if block:
483483
continue
484484

@@ -490,11 +490,15 @@ def get_event(self, block: bool = True) -> Event | None:
490490
continue
491491

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

500504
def push_char(self, char: int | bytes) -> None:

Lib/test/test_pyrepl/test_windows_console.py

+220-1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
MOVE_DOWN,
2525
ERASE_IN_LINE,
2626
)
27+
import _pyrepl.windows_console as wc
2728
except ImportError:
2829
pass
2930

@@ -350,8 +351,226 @@ def test_multiline_ctrl_z(self):
350351
Event(evt="key", data='\x1a', raw=bytearray(b'\x1a')),
351352
],
352353
)
353-
reader, _ = self.handle_events_narrow(events)
354+
reader, con = self.handle_events_narrow(events)
354355
self.assertEqual(reader.cxy, (2, 3))
356+
con.restore()
357+
358+
359+
class WindowsConsoleGetEventTests(TestCase):
360+
# Virtual-Key Codes: https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
361+
VK_BACK = 0x08
362+
VK_RETURN = 0x0D
363+
VK_LEFT = 0x25
364+
VK_7 = 0x37
365+
VK_M = 0x4D
366+
# Used for miscellaneous characters; it can vary by keyboard.
367+
# For the US standard keyboard, the '" key.
368+
# For the German keyboard, the Ä key.
369+
VK_OEM_7 = 0xDE
370+
371+
# State of control keys: https://learn.microsoft.com/en-us/windows/console/key-event-record-str
372+
RIGHT_ALT_PRESSED = 0x0001
373+
RIGHT_CTRL_PRESSED = 0x0004
374+
LEFT_ALT_PRESSED = 0x0002
375+
LEFT_CTRL_PRESSED = 0x0008
376+
ENHANCED_KEY = 0x0100
377+
SHIFT_PRESSED = 0x0010
378+
379+
380+
def get_event(self, input_records, **kwargs) -> Console:
381+
self.console = WindowsConsole(encoding='utf-8')
382+
self.mock = MagicMock(side_effect=input_records)
383+
self.console._read_input = self.mock
384+
self.console._WindowsConsole__vt_support = kwargs.get("vt_support",
385+
False)
386+
event = self.console.get_event(block=False)
387+
return event
388+
389+
def get_input_record(self, unicode_char, vcode=0, control=0):
390+
return wc.INPUT_RECORD(
391+
wc.KEY_EVENT,
392+
wc.ConsoleEvent(KeyEvent=
393+
wc.KeyEvent(
394+
bKeyDown=True,
395+
wRepeatCount=1,
396+
wVirtualKeyCode=vcode,
397+
wVirtualScanCode=0, # not used
398+
uChar=wc.Char(unicode_char),
399+
dwControlKeyState=control
400+
)))
401+
402+
def test_EmptyBuffer(self):
403+
self.assertEqual(self.get_event([None]), None)
404+
self.assertEqual(self.mock.call_count, 1)
405+
406+
def test_WINDOW_BUFFER_SIZE_EVENT(self):
407+
ir = wc.INPUT_RECORD(
408+
wc.WINDOW_BUFFER_SIZE_EVENT,
409+
wc.ConsoleEvent(WindowsBufferSizeEvent=
410+
wc.WindowsBufferSizeEvent(
411+
wc._COORD(0, 0))))
412+
self.assertEqual(self.get_event([ir]), Event("resize", ""))
413+
self.assertEqual(self.mock.call_count, 1)
414+
415+
def test_KEY_EVENT_up_ignored(self):
416+
ir = wc.INPUT_RECORD(
417+
wc.KEY_EVENT,
418+
wc.ConsoleEvent(KeyEvent=
419+
wc.KeyEvent(bKeyDown=False)))
420+
self.assertEqual(self.get_event([ir]), None)
421+
self.assertEqual(self.mock.call_count, 1)
422+
423+
def test_unhandled_events(self):
424+
for event in (wc.FOCUS_EVENT, wc.MENU_EVENT, wc.MOUSE_EVENT):
425+
ir = wc.INPUT_RECORD(
426+
event,
427+
# fake data, nothing is read except bKeyDown
428+
wc.ConsoleEvent(KeyEvent=
429+
wc.KeyEvent(bKeyDown=False)))
430+
self.assertEqual(self.get_event([ir]), None)
431+
self.assertEqual(self.mock.call_count, 1)
432+
433+
def test_enter(self):
434+
ir = self.get_input_record("\r", self.VK_RETURN)
435+
self.assertEqual(self.get_event([ir]), Event("key", "\n"))
436+
self.assertEqual(self.mock.call_count, 1)
437+
438+
def test_backspace(self):
439+
ir = self.get_input_record("\x08", self.VK_BACK)
440+
self.assertEqual(
441+
self.get_event([ir]), Event("key", "backspace"))
442+
self.assertEqual(self.mock.call_count, 1)
443+
444+
def test_m(self):
445+
ir = self.get_input_record("m", self.VK_M)
446+
self.assertEqual(self.get_event([ir]), Event("key", "m"))
447+
self.assertEqual(self.mock.call_count, 1)
448+
449+
def test_M(self):
450+
ir = self.get_input_record("M", self.VK_M, self.SHIFT_PRESSED)
451+
self.assertEqual(self.get_event([ir]), Event("key", "M"))
452+
self.assertEqual(self.mock.call_count, 1)
453+
454+
def test_left(self):
455+
# VK_LEFT is sent as ENHANCED_KEY
456+
ir = self.get_input_record("\x00", self.VK_LEFT, self.ENHANCED_KEY)
457+
self.assertEqual(self.get_event([ir]), Event("key", "left"))
458+
self.assertEqual(self.mock.call_count, 1)
459+
460+
def test_left_RIGHT_CTRL_PRESSED(self):
461+
ir = self.get_input_record(
462+
"\x00", self.VK_LEFT, self.RIGHT_CTRL_PRESSED | self.ENHANCED_KEY)
463+
self.assertEqual(
464+
self.get_event([ir]), Event("key", "ctrl left"))
465+
self.assertEqual(self.mock.call_count, 1)
466+
467+
def test_left_LEFT_CTRL_PRESSED(self):
468+
ir = self.get_input_record(
469+
"\x00", self.VK_LEFT, self.LEFT_CTRL_PRESSED | self.ENHANCED_KEY)
470+
self.assertEqual(
471+
self.get_event([ir]), Event("key", "ctrl left"))
472+
self.assertEqual(self.mock.call_count, 1)
473+
474+
def test_left_RIGHT_ALT_PRESSED(self):
475+
ir = self.get_input_record(
476+
"\x00", self.VK_LEFT, self.RIGHT_ALT_PRESSED | self.ENHANCED_KEY)
477+
self.assertEqual(self.get_event([ir]), Event(evt="key", data="\033"))
478+
self.assertEqual(
479+
self.console.get_event(), Event("key", "left"))
480+
# self.mock is not called again, since the second time we read from the
481+
# command queue
482+
self.assertEqual(self.mock.call_count, 1)
483+
484+
def test_left_LEFT_ALT_PRESSED(self):
485+
ir = self.get_input_record(
486+
"\x00", self.VK_LEFT, self.LEFT_ALT_PRESSED | self.ENHANCED_KEY)
487+
self.assertEqual(self.get_event([ir]), Event(evt="key", data="\033"))
488+
self.assertEqual(
489+
self.console.get_event(), Event("key", "left"))
490+
self.assertEqual(self.mock.call_count, 1)
491+
492+
def test_m_LEFT_ALT_PRESSED_and_LEFT_CTRL_PRESSED(self):
493+
# For the shift keys, Windows does not send anything when
494+
# ALT and CTRL are both pressed, so let's test with VK_M.
495+
# get_event() receives this input, but does not
496+
# generate an event.
497+
# This is for e.g. an English keyboard layout, for a
498+
# German layout this returns `µ`, see test_AltGr_m.
499+
ir = self.get_input_record(
500+
"\x00", self.VK_M, self.LEFT_ALT_PRESSED | self.LEFT_CTRL_PRESSED)
501+
self.assertEqual(self.get_event([ir]), None)
502+
self.assertEqual(self.mock.call_count, 1)
503+
504+
def test_m_LEFT_ALT_PRESSED(self):
505+
ir = self.get_input_record(
506+
"m", vcode=self.VK_M, control=self.LEFT_ALT_PRESSED)
507+
self.assertEqual(self.get_event([ir]), Event(evt="key", data="\033"))
508+
self.assertEqual(self.console.get_event(), Event("key", "m"))
509+
self.assertEqual(self.mock.call_count, 1)
510+
511+
def test_m_RIGHT_ALT_PRESSED(self):
512+
ir = self.get_input_record(
513+
"m", vcode=self.VK_M, control=self.RIGHT_ALT_PRESSED)
514+
self.assertEqual(self.get_event([ir]), Event(evt="key", data="\033"))
515+
self.assertEqual(self.console.get_event(), Event("key", "m"))
516+
self.assertEqual(self.mock.call_count, 1)
517+
518+
def test_AltGr_7(self):
519+
# E.g. on a German keyboard layout, '{' is entered via
520+
# AltGr + 7, where AltGr is the right Alt key on the keyboard.
521+
# In this case, Windows automatically sets
522+
# RIGHT_ALT_PRESSED = 0x0001 + LEFT_CTRL_PRESSED = 0x0008
523+
# This can also be entered like
524+
# LeftAlt + LeftCtrl + 7 or
525+
# LeftAlt + RightCtrl + 7
526+
# See https://learn.microsoft.com/en-us/windows/console/key-event-record-str
527+
# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-vkkeyscanw
528+
ir = self.get_input_record(
529+
"{", vcode=self.VK_7,
530+
control=self.RIGHT_ALT_PRESSED | self.LEFT_CTRL_PRESSED)
531+
self.assertEqual(self.get_event([ir]), Event("key", "{"))
532+
self.assertEqual(self.mock.call_count, 1)
533+
534+
def test_AltGr_m(self):
535+
# E.g. on a German keyboard layout, this yields 'µ'
536+
# Let's use LEFT_ALT_PRESSED and RIGHT_CTRL_PRESSED this
537+
# time, to cover that, too. See above in test_AltGr_7.
538+
ir = self.get_input_record(
539+
"µ", vcode=self.VK_M, control=self.LEFT_ALT_PRESSED | self.RIGHT_CTRL_PRESSED)
540+
self.assertEqual(self.get_event([ir]), Event("key", "µ"))
541+
self.assertEqual(self.mock.call_count, 1)
542+
543+
def test_umlaut_a_german(self):
544+
ir = self.get_input_record("ä", self.VK_OEM_7)
545+
self.assertEqual(self.get_event([ir]), Event("key", "ä"))
546+
self.assertEqual(self.mock.call_count, 1)
547+
548+
# virtual terminal tests
549+
# Note: wVirtualKeyCode, wVirtualScanCode and dwControlKeyState
550+
# are always zero in this case.
551+
# "\r" and backspace are handled specially, everything else
552+
# is handled in "elif self.__vt_support:" in WindowsConsole.get_event().
553+
# Hence, only one regular key ("m") and a terminal sequence
554+
# are sufficient to test here, the real tests happen in test_eventqueue
555+
# and test_keymap.
556+
557+
def test_enter_vt(self):
558+
ir = self.get_input_record("\r")
559+
self.assertEqual(self.get_event([ir], vt_support=True),
560+
Event("key", "\n"))
561+
self.assertEqual(self.mock.call_count, 1)
562+
563+
def test_backspace_vt(self):
564+
ir = self.get_input_record("\x7f")
565+
self.assertEqual(self.get_event([ir], vt_support=True),
566+
Event("key", "backspace", b"\x7f"))
567+
self.assertEqual(self.mock.call_count, 1)
568+
569+
def test_up_vt(self):
570+
irs = [self.get_input_record(x) for x in "\x1b[A"]
571+
self.assertEqual(self.get_event(irs, vt_support=True),
572+
Event(evt='key', data='up', raw=bytearray(b'\x1b[A')))
573+
self.assertEqual(self.mock.call_count, 3)
355574

356575

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

0 commit comments

Comments
 (0)