From bb60653a826838b8072b87f11454ebd9bec798e4 Mon Sep 17 00:00:00 2001 From: Sanyam Khurana Date: Sat, 15 Nov 2025 23:32:40 +0530 Subject: [PATCH 1/3] gh-138577: Fix keyboard shortcuts in getpass with echo_char When using getpass.getpass(echo_char='*'), keyboard shortcuts like Ctrl+U (kill line), Ctrl+W (erase word), and Ctrl+V (literal next) now work correctly by reading the terminal's control character settings and processing them in non-canonical mode. --- Doc/library/getpass.rst | 11 ++- Lib/getpass.py | 83 +++++++++++++++++-- Lib/test/test_getpass.py | 38 ++++++++- ...-11-15-23-14-30.gh-issue-138577.KbShrt.rst | 4 + 4 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-11-15-23-14-30.gh-issue-138577.KbShrt.rst diff --git a/Doc/library/getpass.rst b/Doc/library/getpass.rst index a0c0c6dee2d513..6001bcb0337a78 100644 --- a/Doc/library/getpass.rst +++ b/Doc/library/getpass.rst @@ -43,13 +43,18 @@ The :mod:`getpass` module provides two functions: On Unix systems, when *echo_char* is set, the terminal will be configured to operate in :manpage:`noncanonical mode `. - In particular, this means that line editing shortcuts such as - :kbd:`Ctrl+U` will not work and may insert unexpected characters into - the input. + Common terminal control characters like :kbd:`Ctrl+U` (kill line), + :kbd:`Ctrl+W` (erase word), and :kbd:`Ctrl+V` (literal next) are + supported by reading the terminal's configured control character + mappings. .. versionchanged:: 3.14 Added the *echo_char* parameter for keyboard feedback. + .. versionchanged:: 3.15 + When using *echo_char* on Unix, keyboard shortcuts are now properly + handled using the terminal's control character configuration. + .. exception:: GetPassWarning A :exc:`UserWarning` subclass issued when password input may be echoed. diff --git a/Lib/getpass.py b/Lib/getpass.py index 3d9bb1f0d146a4..e64d7dba6b2cf8 100644 --- a/Lib/getpass.py +++ b/Lib/getpass.py @@ -73,15 +73,27 @@ def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None): old = termios.tcgetattr(fd) # a copy to save new = old[:] new[3] &= ~termios.ECHO # 3 == 'lflags' + # Extract control characters before changing terminal mode + term_ctrl_chars = None if echo_char: new[3] &= ~termios.ICANON + # Get control characters from terminal settings + # Index 6 is cc (control characters array) + cc = old[6] + term_ctrl_chars = { + 'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else b'\x7f', + 'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else b'\x15', + 'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else b'\x17', + 'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else b'\x16', + } tcsetattr_flags = termios.TCSAFLUSH if hasattr(termios, 'TCSASOFT'): tcsetattr_flags |= termios.TCSASOFT try: termios.tcsetattr(fd, tcsetattr_flags, new) passwd = _raw_input(prompt, stream, input=input, - echo_char=echo_char) + echo_char=echo_char, + term_ctrl_chars=term_ctrl_chars) finally: termios.tcsetattr(fd, tcsetattr_flags, old) @@ -159,7 +171,8 @@ def _check_echo_char(echo_char): f"character, got: {echo_char!r}") -def _raw_input(prompt="", stream=None, input=None, echo_char=None): +def _raw_input(prompt="", stream=None, input=None, echo_char=None, + term_ctrl_chars=None): # This doesn't save the string in the GNU readline history. if not stream: stream = sys.stderr @@ -177,7 +190,8 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None): stream.flush() # NOTE: The Python C API calls flockfile() (and unlock) during readline. if echo_char: - return _readline_with_echo_char(stream, input, echo_char) + return _readline_with_echo_char(stream, input, echo_char, + term_ctrl_chars) line = input.readline() if not line: raise EOFError @@ -186,20 +200,33 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None): return line -def _readline_with_echo_char(stream, input, echo_char): +def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None): passwd = "" eof_pressed = False + literal_next = False # For LNEXT (Ctrl+V) + + # Convert terminal control characters to strings for comparison + # Default to standard POSIX values if not provided + if term_ctrl_chars: + # Control chars from termios are bytes, convert to str + erase_char = term_ctrl_chars['ERASE'].decode('latin-1') if isinstance(term_ctrl_chars['ERASE'], bytes) else term_ctrl_chars['ERASE'] + kill_char = term_ctrl_chars['KILL'].decode('latin-1') if isinstance(term_ctrl_chars['KILL'], bytes) else term_ctrl_chars['KILL'] + werase_char = term_ctrl_chars['WERASE'].decode('latin-1') if isinstance(term_ctrl_chars['WERASE'], bytes) else term_ctrl_chars['WERASE'] + lnext_char = term_ctrl_chars['LNEXT'].decode('latin-1') if isinstance(term_ctrl_chars['LNEXT'], bytes) else term_ctrl_chars['LNEXT'] + else: + # Standard POSIX defaults + erase_char = '\x7f' # DEL + kill_char = '\x15' # Ctrl+U + werase_char = '\x17' # Ctrl+W + lnext_char = '\x16' # Ctrl+V + while True: char = input.read(1) + if char == '\n' or char == '\r': break elif char == '\x03': raise KeyboardInterrupt - elif char == '\x7f' or char == '\b': - if passwd: - stream.write("\b \b") - stream.flush() - passwd = passwd[:-1] elif char == '\x04': if eof_pressed: break @@ -207,6 +234,44 @@ def _readline_with_echo_char(stream, input, echo_char): eof_pressed = True elif char == '\x00': continue + # Handle LNEXT (Ctrl+V) - insert next character literally + elif literal_next: + passwd += char + stream.write(echo_char) + stream.flush() + literal_next = False + eof_pressed = False + elif char == lnext_char: + literal_next = True + eof_pressed = False + # Handle ERASE (Backspace/DEL) - delete one character + elif char == erase_char or char == '\b': + if passwd: + stream.write("\b \b") + stream.flush() + passwd = passwd[:-1] + eof_pressed = False + # Handle KILL (Ctrl+U) - erase entire line + elif char == kill_char: + # Clear all echoed characters + while passwd: + stream.write("\b \b") + passwd = passwd[:-1] + stream.flush() + eof_pressed = False + # Handle WERASE (Ctrl+W) - erase previous word + elif char == werase_char: + # Delete backwards until we find a space or reach the beginning + # First, skip any trailing spaces + while passwd and passwd[-1] == ' ': + stream.write("\b \b") + passwd = passwd[:-1] + # Then delete the word + while passwd and passwd[-1] != ' ': + stream.write("\b \b") + passwd = passwd[:-1] + stream.flush() + eof_pressed = False else: passwd += char stream.write(echo_char) diff --git a/Lib/test/test_getpass.py b/Lib/test/test_getpass.py index 9c3def2c3be59b..aa21ea235c532d 100644 --- a/Lib/test/test_getpass.py +++ b/Lib/test/test_getpass.py @@ -174,7 +174,8 @@ def test_echo_char_replaces_input_with_asterisks(self): result = getpass.unix_getpass(echo_char='*') mock_input.assert_called_once_with('Password: ', textio(), - input=textio(), echo_char='*') + input=textio(), echo_char='*', + term_ctrl_chars=mock.ANY) self.assertEqual(result, mock_result) def test_raw_input_with_echo_char(self): @@ -200,6 +201,41 @@ def test_control_chars_with_echo_char(self): self.assertEqual(result, expect_result) self.assertEqual('Password: *******\x08 \x08', mock_output.getvalue()) + def test_kill_ctrl_u_with_echo_char(self): + # Ctrl+U (KILL) should clear the entire line + passwd = 'foo\x15bar' # Type "foo", hit Ctrl+U, type "bar" + expect_result = 'bar' + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, + '*') + self.assertEqual(result, expect_result) + # Should show "***" then clear all 3, then show "***" for "bar" + output = mock_output.getvalue() + self.assertIn('***', output) + # Should have backspaces to clear the "foo" part + self.assertIn('\b', output) + + def test_werase_ctrl_w_with_echo_char(self): + # Ctrl+W (WERASE) should delete the previous word + passwd = 'hello world\x17end' # Type "hello world", hit Ctrl+W, type "end" + expect_result = 'hello end' + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, + '*') + self.assertEqual(result, expect_result) + + def test_lnext_ctrl_v_with_echo_char(self): + # Ctrl+V (LNEXT) should insert the next character literally + passwd = 'test\x16\x15more' # Type "test", hit Ctrl+V, then Ctrl+U (literal), type "more" + expect_result = 'test\x15more' # Should contain literal Ctrl+U + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, + '*') + self.assertEqual(result, expect_result) + class GetpassEchoCharTest(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Library/2025-11-15-23-14-30.gh-issue-138577.KbShrt.rst b/Misc/NEWS.d/next/Library/2025-11-15-23-14-30.gh-issue-138577.KbShrt.rst new file mode 100644 index 00000000000000..0d9e716e7b7919 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-11-15-23-14-30.gh-issue-138577.KbShrt.rst @@ -0,0 +1,4 @@ +:func:`getpass.getpass` with ``echo_char`` now handles keyboard shortcuts like +Ctrl+U (kill line), Ctrl+W (erase word), and Ctrl+V (literal next) by reading +the terminal's control character settings and processing them appropriately in +non-canonical mode. Patch by Sanyam Khurana. From 31e35e4447f50851cd4ae7ea9d909ba441fe9ead Mon Sep 17 00:00:00 2001 From: Sanyam Khurana Date: Sun, 16 Nov 2025 09:02:55 +0530 Subject: [PATCH 2/3] Address reviews on dispatcher pattern + handle other ctrl chars --- Doc/library/getpass.rst | 21 +- Lib/getpass.py | 268 +++++++++++++----- Lib/test/test_getpass.py | 116 ++++++++ ...-11-15-23-14-30.gh-issue-138577.KbShrt.rst | 8 +- 4 files changed, 326 insertions(+), 87 deletions(-) diff --git a/Doc/library/getpass.rst b/Doc/library/getpass.rst index 6001bcb0337a78..f932827c003f9b 100644 --- a/Doc/library/getpass.rst +++ b/Doc/library/getpass.rst @@ -43,17 +43,26 @@ The :mod:`getpass` module provides two functions: On Unix systems, when *echo_char* is set, the terminal will be configured to operate in :manpage:`noncanonical mode `. - Common terminal control characters like :kbd:`Ctrl+U` (kill line), - :kbd:`Ctrl+W` (erase word), and :kbd:`Ctrl+V` (literal next) are - supported by reading the terminal's configured control character - mappings. + Common terminal control characters are supported: + + * :kbd:`Ctrl+A` - Move cursor to beginning of line + * :kbd:`Ctrl+E` - Move cursor to end of line + * :kbd:`Ctrl+K` - Kill (delete) from cursor to end of line + * :kbd:`Ctrl+U` - Kill (delete) entire line + * :kbd:`Ctrl+W` - Erase previous word + * :kbd:`Ctrl+V` - Insert next character literally (quote) + * :kbd:`Backspace`/:kbd:`DEL` - Delete character before cursor + + These shortcuts work by reading the terminal's configured control + character mappings from termios settings. .. versionchanged:: 3.14 Added the *echo_char* parameter for keyboard feedback. .. versionchanged:: 3.15 - When using *echo_char* on Unix, keyboard shortcuts are now properly - handled using the terminal's control character configuration. + When using *echo_char* on Unix, keyboard shortcuts (including cursor + movement and line editing) are now properly handled using the terminal's + control character configuration. .. exception:: GetPassWarning diff --git a/Lib/getpass.py b/Lib/getpass.py index e64d7dba6b2cf8..dae8f416c3d2b0 100644 --- a/Lib/getpass.py +++ b/Lib/getpass.py @@ -26,6 +26,52 @@ class GetPassWarning(UserWarning): pass +# Default POSIX control character mappings +_POSIX_CTRL_CHARS = { + 'ERASE': b'\x7f', # DEL/Backspace + 'KILL': b'\x15', # Ctrl+U - kill line + 'WERASE': b'\x17', # Ctrl+W - erase word + 'LNEXT': b'\x16', # Ctrl+V - literal next + 'EOF': b'\x04', # Ctrl+D - EOF + 'INTR': b'\x03', # Ctrl+C - interrupt + 'SOH': b'\x01', # Ctrl+A - start of heading (beginning of line) + 'ENQ': b'\x05', # Ctrl+E - enquiry (end of line) + 'VT': b'\x0b', # Ctrl+K - vertical tab (kill forward) +} + + +def _get_terminal_ctrl_chars(fd): + """Extract control characters from terminal settings. + + Returns a dict mapping control char names to their byte values. + Falls back to POSIX defaults if termios isn't available. + """ + try: + old = termios.tcgetattr(fd) + cc = old[6] # Index 6 is the control characters array + return { + 'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else _POSIX_CTRL_CHARS['ERASE'], + 'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else _POSIX_CTRL_CHARS['KILL'], + 'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else _POSIX_CTRL_CHARS['WERASE'], + 'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else _POSIX_CTRL_CHARS['LNEXT'], + 'EOF': cc[termios.VEOF] if termios.VEOF < len(cc) else _POSIX_CTRL_CHARS['EOF'], + 'INTR': cc[termios.VINTR] if termios.VINTR < len(cc) else _POSIX_CTRL_CHARS['INTR'], + # Ctrl+A/E/K are not in termios, use POSIX defaults + 'SOH': _POSIX_CTRL_CHARS['SOH'], + 'ENQ': _POSIX_CTRL_CHARS['ENQ'], + 'VT': _POSIX_CTRL_CHARS['VT'], + } + except (termios.error, OSError): + return _POSIX_CTRL_CHARS.copy() + + +def _decode_ctrl_char(char_value): + """Convert a control character from bytes to str.""" + if isinstance(char_value, bytes): + return char_value.decode('latin-1') + return char_value + + def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None): """Prompt for a password, with echo turned off. @@ -77,15 +123,7 @@ def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None): term_ctrl_chars = None if echo_char: new[3] &= ~termios.ICANON - # Get control characters from terminal settings - # Index 6 is cc (control characters array) - cc = old[6] - term_ctrl_chars = { - 'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else b'\x7f', - 'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else b'\x15', - 'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else b'\x17', - 'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else b'\x16', - } + term_ctrl_chars = _get_terminal_ctrl_chars(fd) tcsetattr_flags = termios.TCSAFLUSH if hasattr(termios, 'TCSASOFT'): tcsetattr_flags |= termios.TCSASOFT @@ -200,84 +238,160 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None, return line +class _PasswordLineEditor: + """Handles line editing for password input with echo character.""" + + def __init__(self, stream, echo_char, ctrl_chars): + self.stream = stream + self.echo_char = echo_char + self.passwd = "" + self.cursor_pos = 0 + self.eof_pressed = False + self.literal_next = False + self.ctrl = {name: _decode_ctrl_char(value) + for name, value in ctrl_chars.items()} + + def refresh_display(self): + """Redraw the entire password line with asterisks.""" + self.stream.write('\r' + ' ' * (len(self.passwd) + 20) + '\r') + self.stream.write(self.echo_char * len(self.passwd)) + if self.cursor_pos < len(self.passwd): + self.stream.write('\b' * (len(self.passwd) - self.cursor_pos)) + self.stream.flush() + + def erase_chars(self, count): + """Erase count asterisks from display.""" + self.stream.write("\b \b" * count) + + def insert_char(self, char): + """Insert character at cursor position.""" + self.passwd = self.passwd[:self.cursor_pos] + char + self.passwd[self.cursor_pos:] + self.cursor_pos += 1 + # Only refresh if inserting in middle + if self.cursor_pos < len(self.passwd): + self.refresh_display() + else: + self.stream.write(self.echo_char) + self.stream.flush() + + def handle_literal_next(self, char): + """Insert next character literally (Ctrl+V).""" + self.insert_char(char) + self.literal_next = False + self.eof_pressed = False + + def handle_move_start(self): + """Move cursor to beginning (Ctrl+A).""" + self.cursor_pos = 0 + self.eof_pressed = False + + def handle_move_end(self): + """Move cursor to end (Ctrl+E).""" + self.cursor_pos = len(self.passwd) + self.eof_pressed = False + + def handle_erase(self): + """Delete character before cursor (Backspace/DEL).""" + if self.cursor_pos > 0: + self.passwd = self.passwd[:self.cursor_pos-1] + self.passwd[self.cursor_pos:] + self.cursor_pos -= 1 + # Only refresh if deleting from middle + if self.cursor_pos < len(self.passwd): + self.refresh_display() + else: + self.stream.write("\b \b") + self.stream.flush() + self.eof_pressed = False + + def handle_kill_line(self): + """Erase entire line (Ctrl+U).""" + self.erase_chars(len(self.passwd)) + self.passwd = "" + self.cursor_pos = 0 + self.stream.flush() + self.eof_pressed = False + + def handle_kill_forward(self): + """Kill from cursor to end (Ctrl+K).""" + chars_to_delete = len(self.passwd) - self.cursor_pos + self.passwd = self.passwd[:self.cursor_pos] + self.erase_chars(chars_to_delete) + self.stream.flush() + self.eof_pressed = False + + def handle_erase_word(self): + """Erase previous word (Ctrl+W).""" + old_cursor = self.cursor_pos + # Skip trailing spaces + while self.cursor_pos > 0 and self.passwd[self.cursor_pos-1] == ' ': + self.cursor_pos -= 1 + # Delete the word + while self.cursor_pos > 0 and self.passwd[self.cursor_pos-1] != ' ': + self.cursor_pos -= 1 + # Remove the deleted portion + self.passwd = self.passwd[:self.cursor_pos] + self.passwd[old_cursor:] + self.refresh_display() + self.eof_pressed = False + + def build_dispatch_table(self): + """Build dispatch table mapping control chars to handlers.""" + return { + self.ctrl['SOH']: self.handle_move_start, # Ctrl+A + self.ctrl['ENQ']: self.handle_move_end, # Ctrl+E + self.ctrl['VT']: self.handle_kill_forward, # Ctrl+K + self.ctrl['KILL']: self.handle_kill_line, # Ctrl+U + self.ctrl['WERASE']: self.handle_erase_word, # Ctrl+W + self.ctrl['ERASE']: self.handle_erase, # DEL + '\b': self.handle_erase, # Backspace + } + + def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None): - passwd = "" - eof_pressed = False - literal_next = False # For LNEXT (Ctrl+V) - - # Convert terminal control characters to strings for comparison - # Default to standard POSIX values if not provided - if term_ctrl_chars: - # Control chars from termios are bytes, convert to str - erase_char = term_ctrl_chars['ERASE'].decode('latin-1') if isinstance(term_ctrl_chars['ERASE'], bytes) else term_ctrl_chars['ERASE'] - kill_char = term_ctrl_chars['KILL'].decode('latin-1') if isinstance(term_ctrl_chars['KILL'], bytes) else term_ctrl_chars['KILL'] - werase_char = term_ctrl_chars['WERASE'].decode('latin-1') if isinstance(term_ctrl_chars['WERASE'], bytes) else term_ctrl_chars['WERASE'] - lnext_char = term_ctrl_chars['LNEXT'].decode('latin-1') if isinstance(term_ctrl_chars['LNEXT'], bytes) else term_ctrl_chars['LNEXT'] - else: - # Standard POSIX defaults - erase_char = '\x7f' # DEL - kill_char = '\x15' # Ctrl+U - werase_char = '\x17' # Ctrl+W - lnext_char = '\x16' # Ctrl+V + """Read password with echo character and line editing support.""" + if term_ctrl_chars is None: + term_ctrl_chars = _POSIX_CTRL_CHARS.copy() + + editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars) + dispatch = editor.build_dispatch_table() while True: char = input.read(1) - if char == '\n' or char == '\r': + # Check for line terminators + if char in ('\n', '\r'): break - elif char == '\x03': + + # Handle literal next mode FIRST (Ctrl+V quotes next char) + if editor.literal_next: + editor.handle_literal_next(char) + continue + + # Check if it's the LNEXT character + if char == editor.ctrl['LNEXT']: + editor.literal_next = True + editor.eof_pressed = False + continue + + # Check for special control characters + if char == editor.ctrl['INTR']: raise KeyboardInterrupt - elif char == '\x04': - if eof_pressed: + if char == editor.ctrl['EOF']: + if editor.eof_pressed: break - else: - eof_pressed = True - elif char == '\x00': + editor.eof_pressed = True + continue + if char == '\x00': continue - # Handle LNEXT (Ctrl+V) - insert next character literally - elif literal_next: - passwd += char - stream.write(echo_char) - stream.flush() - literal_next = False - eof_pressed = False - elif char == lnext_char: - literal_next = True - eof_pressed = False - # Handle ERASE (Backspace/DEL) - delete one character - elif char == erase_char or char == '\b': - if passwd: - stream.write("\b \b") - stream.flush() - passwd = passwd[:-1] - eof_pressed = False - # Handle KILL (Ctrl+U) - erase entire line - elif char == kill_char: - # Clear all echoed characters - while passwd: - stream.write("\b \b") - passwd = passwd[:-1] - stream.flush() - eof_pressed = False - # Handle WERASE (Ctrl+W) - erase previous word - elif char == werase_char: - # Delete backwards until we find a space or reach the beginning - # First, skip any trailing spaces - while passwd and passwd[-1] == ' ': - stream.write("\b \b") - passwd = passwd[:-1] - # Then delete the word - while passwd and passwd[-1] != ' ': - stream.write("\b \b") - passwd = passwd[:-1] - stream.flush() - eof_pressed = False + + # Dispatch to handler or insert as normal character + handler = dispatch.get(char) + if handler: + handler() else: - passwd += char - stream.write(echo_char) - stream.flush() - eof_pressed = False - return passwd + editor.insert_char(char) + editor.eof_pressed = False + + return editor.passwd def getuser(): diff --git a/Lib/test/test_getpass.py b/Lib/test/test_getpass.py index aa21ea235c532d..43ad3d548c955e 100644 --- a/Lib/test/test_getpass.py +++ b/Lib/test/test_getpass.py @@ -236,6 +236,122 @@ def test_lnext_ctrl_v_with_echo_char(self): '*') self.assertEqual(result, expect_result) + def test_ctrl_a_move_to_start_with_echo_char(self): + # Ctrl+A should move cursor to start + # Type "end", Ctrl+A, type "start", result should be "startend" + passwd = 'end\x01start' + expect_result = 'startend' + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, + '*') + self.assertEqual(result, expect_result) + + def test_ctrl_e_move_to_end_with_echo_char(self): + # Ctrl+E should move cursor to end + # Type "start", Ctrl+A, "X", Ctrl+E, "end" -> "Xstartend" + passwd = 'start\x01X\x05end' + expect_result = 'Xstartend' + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, + '*') + self.assertEqual(result, expect_result) + + def test_ctrl_k_kill_forward_with_echo_char(self): + # Ctrl+K should kill from cursor to end + # Type "hello world", Ctrl+A, move 6 chars right (type 6 random chars then backspace 6), + # Actually simpler: type "deleteworld", Ctrl+A, 6 chars forward somehow... + # Let me use a different approach: "hello\x01\x0b" = type "hello", Ctrl+A, Ctrl+K + # This should delete everything, resulting in "" + passwd = 'delete\x01\x0bkeep' # "delete", Ctrl+A, Ctrl+K, "keep" + expect_result = 'keep' + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, + '*') + self.assertEqual(result, expect_result) + + def test_ctrl_c_interrupt_with_echo_char(self): + # Ctrl+C should raise KeyboardInterrupt + passwd = 'test\x03more' # Type "test", hit Ctrl+C + mock_input = StringIO(passwd) + mock_output = StringIO() + with self.assertRaises(KeyboardInterrupt): + getpass._raw_input('Password: ', mock_output, mock_input, '*') + + def test_ctrl_d_eof_with_echo_char(self): + # Ctrl+D twice should cause EOF + passwd = 'test\x04\x04' # Type "test", hit Ctrl+D twice + mock_input = StringIO(passwd) + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, '*') + self.assertEqual(result, 'test') + + def test_backspace_at_start_with_echo_char(self): + # Backspace at start should do nothing + passwd = '\x7fhello' # Backspace, then "hello" + expect_result = 'hello' + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, '*') + self.assertEqual(result, expect_result) + + def test_ctrl_k_at_end_with_echo_char(self): + # Ctrl+K at end should do nothing + passwd = 'hello\x0b' # Type "hello", Ctrl+K at end + expect_result = 'hello' + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, '*') + self.assertEqual(result, expect_result) + + def test_ctrl_w_on_empty_with_echo_char(self): + # Ctrl+W on empty line should do nothing + passwd = '\x17hello' # Ctrl+W, then "hello" + expect_result = 'hello' + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, '*') + self.assertEqual(result, expect_result) + + def test_ctrl_u_on_empty_with_echo_char(self): + # Ctrl+U on empty line should do nothing + passwd = '\x15hello' # Ctrl+U, then "hello" + expect_result = 'hello' + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, '*') + self.assertEqual(result, expect_result) + + def test_multiple_ctrl_operations_with_echo_char(self): + # Test combination: type, move, insert, delete + # "world", Ctrl+A, "hello ", Ctrl+E, "!", Ctrl+A, Ctrl+K, "start" + passwd = 'world\x01hello \x05!\x01\x0bstart' + expect_result = 'start' + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, '*') + self.assertEqual(result, expect_result) + + def test_ctrl_w_multiple_words_with_echo_char(self): + # Ctrl+W should delete only the last word + passwd = 'one two three\x17' # Delete "three" + expect_result = 'one two ' + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, '*') + self.assertEqual(result, expect_result) + + def test_ctrl_v_then_ctrl_c_with_echo_char(self): + # Ctrl+V should make Ctrl+C literal (not interrupt) + passwd = 'test\x16\x03end' # Type "test", Ctrl+V, Ctrl+C, "end" + expect_result = 'test\x03end' # Should contain literal Ctrl+C + mock_input = StringIO(f'{passwd}\n') + mock_output = StringIO() + result = getpass._raw_input('Password: ', mock_output, mock_input, '*') + self.assertEqual(result, expect_result) + class GetpassEchoCharTest(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Library/2025-11-15-23-14-30.gh-issue-138577.KbShrt.rst b/Misc/NEWS.d/next/Library/2025-11-15-23-14-30.gh-issue-138577.KbShrt.rst index 0d9e716e7b7919..bd3c725afe559e 100644 --- a/Misc/NEWS.d/next/Library/2025-11-15-23-14-30.gh-issue-138577.KbShrt.rst +++ b/Misc/NEWS.d/next/Library/2025-11-15-23-14-30.gh-issue-138577.KbShrt.rst @@ -1,4 +1,4 @@ -:func:`getpass.getpass` with ``echo_char`` now handles keyboard shortcuts like -Ctrl+U (kill line), Ctrl+W (erase word), and Ctrl+V (literal next) by reading -the terminal's control character settings and processing them appropriately in -non-canonical mode. Patch by Sanyam Khurana. +:func:`getpass.getpass` with ``echo_char`` now handles keyboard shortcuts +including Ctrl+A/E (cursor movement), Ctrl+K/U (kill line), Ctrl+W (erase word), +and Ctrl+V (literal next) by reading the terminal's control character settings +and processing them appropriately in non-canonical mode. Patch by Sanyam Khurana. From b8609bd4c3863f3cbf5ccf34cb33b350a94deca8 Mon Sep 17 00:00:00 2001 From: Sanyam Khurana Date: Mon, 1 Dec 2025 00:07:36 +0530 Subject: [PATCH 3/3] Address reviews --- Lib/getpass.py | 156 ++++++++++++++++++++----------------------------- 1 file changed, 64 insertions(+), 92 deletions(-) diff --git a/Lib/getpass.py b/Lib/getpass.py index dae8f416c3d2b0..60f0416fcdeb64 100644 --- a/Lib/getpass.py +++ b/Lib/getpass.py @@ -28,48 +28,36 @@ class GetPassWarning(UserWarning): pass # Default POSIX control character mappings _POSIX_CTRL_CHARS = { - 'ERASE': b'\x7f', # DEL/Backspace - 'KILL': b'\x15', # Ctrl+U - kill line - 'WERASE': b'\x17', # Ctrl+W - erase word - 'LNEXT': b'\x16', # Ctrl+V - literal next - 'EOF': b'\x04', # Ctrl+D - EOF - 'INTR': b'\x03', # Ctrl+C - interrupt - 'SOH': b'\x01', # Ctrl+A - start of heading (beginning of line) - 'ENQ': b'\x05', # Ctrl+E - enquiry (end of line) - 'VT': b'\x0b', # Ctrl+K - vertical tab (kill forward) + 'ERASE': '\x7f', # DEL/Backspace + 'KILL': '\x15', # Ctrl+U - kill line + 'WERASE': '\x17', # Ctrl+W - erase word + 'LNEXT': '\x16', # Ctrl+V - literal next + 'EOF': '\x04', # Ctrl+D - EOF + 'INTR': '\x03', # Ctrl+C - interrupt + 'SOH': '\x01', # Ctrl+A - start of heading (beginning of line) + 'ENQ': '\x05', # Ctrl+E - enquiry (end of line) + 'VT': '\x0b', # Ctrl+K - vertical tab (kill forward) } def _get_terminal_ctrl_chars(fd): """Extract control characters from terminal settings. - Returns a dict mapping control char names to their byte values. + Returns a dict mapping control char names to their str values. Falls back to POSIX defaults if termios isn't available. """ + res = _POSIX_CTRL_CHARS.copy() try: old = termios.tcgetattr(fd) cc = old[6] # Index 6 is the control characters array - return { - 'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else _POSIX_CTRL_CHARS['ERASE'], - 'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else _POSIX_CTRL_CHARS['KILL'], - 'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else _POSIX_CTRL_CHARS['WERASE'], - 'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else _POSIX_CTRL_CHARS['LNEXT'], - 'EOF': cc[termios.VEOF] if termios.VEOF < len(cc) else _POSIX_CTRL_CHARS['EOF'], - 'INTR': cc[termios.VINTR] if termios.VINTR < len(cc) else _POSIX_CTRL_CHARS['INTR'], - # Ctrl+A/E/K are not in termios, use POSIX defaults - 'SOH': _POSIX_CTRL_CHARS['SOH'], - 'ENQ': _POSIX_CTRL_CHARS['ENQ'], - 'VT': _POSIX_CTRL_CHARS['VT'], - } except (termios.error, OSError): - return _POSIX_CTRL_CHARS.copy() - - -def _decode_ctrl_char(char_value): - """Convert a control character from bytes to str.""" - if isinstance(char_value, bytes): - return char_value.decode('latin-1') - return char_value + return res + # Ctrl+A/E/K are not in termios, use POSIX defaults + for name in ('ERASE', 'KILL', 'WERASE', 'LNEXT', 'EOF', 'INTR'): + cap = getattr(termios, f'V{name}') + if cap < len(cc): + res[name] = cc[cap].decode('latin-1') + return res def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None): @@ -248,78 +236,75 @@ def __init__(self, stream, echo_char, ctrl_chars): self.cursor_pos = 0 self.eof_pressed = False self.literal_next = False - self.ctrl = {name: _decode_ctrl_char(value) - for name, value in ctrl_chars.items()} + self.ctrl = ctrl_chars + self._dispatch = { + ctrl_chars['SOH']: self._handle_move_start, # Ctrl+A + ctrl_chars['ENQ']: self._handle_move_end, # Ctrl+E + ctrl_chars['VT']: self._handle_kill_forward, # Ctrl+K + ctrl_chars['KILL']: self._handle_kill_line, # Ctrl+U + ctrl_chars['WERASE']: self._handle_erase_word, # Ctrl+W + ctrl_chars['ERASE']: self._handle_erase, # DEL + '\b': self._handle_erase, # Backspace + } - def refresh_display(self): - """Redraw the entire password line with asterisks.""" - self.stream.write('\r' + ' ' * (len(self.passwd) + 20) + '\r') + def _refresh_display(self): + """Redraw the entire password line with *echo_char*.""" + self.stream.write('\r' + ' ' * len(self.passwd) + '\r') self.stream.write(self.echo_char * len(self.passwd)) if self.cursor_pos < len(self.passwd): self.stream.write('\b' * (len(self.passwd) - self.cursor_pos)) self.stream.flush() - def erase_chars(self, count): - """Erase count asterisks from display.""" + def _erase_chars(self, count): + """Erase count echo characters from display.""" self.stream.write("\b \b" * count) - def insert_char(self, char): + def _insert_char(self, char): """Insert character at cursor position.""" self.passwd = self.passwd[:self.cursor_pos] + char + self.passwd[self.cursor_pos:] self.cursor_pos += 1 # Only refresh if inserting in middle if self.cursor_pos < len(self.passwd): - self.refresh_display() + self._refresh_display() else: self.stream.write(self.echo_char) self.stream.flush() - def handle_literal_next(self, char): - """Insert next character literally (Ctrl+V).""" - self.insert_char(char) - self.literal_next = False - self.eof_pressed = False - - def handle_move_start(self): + def _handle_move_start(self): """Move cursor to beginning (Ctrl+A).""" self.cursor_pos = 0 - self.eof_pressed = False - def handle_move_end(self): + def _handle_move_end(self): """Move cursor to end (Ctrl+E).""" self.cursor_pos = len(self.passwd) - self.eof_pressed = False - def handle_erase(self): + def _handle_erase(self): """Delete character before cursor (Backspace/DEL).""" if self.cursor_pos > 0: self.passwd = self.passwd[:self.cursor_pos-1] + self.passwd[self.cursor_pos:] self.cursor_pos -= 1 # Only refresh if deleting from middle if self.cursor_pos < len(self.passwd): - self.refresh_display() + self._refresh_display() else: self.stream.write("\b \b") self.stream.flush() - self.eof_pressed = False - def handle_kill_line(self): + def _handle_kill_line(self): """Erase entire line (Ctrl+U).""" - self.erase_chars(len(self.passwd)) + self._erase_chars(len(self.passwd)) self.passwd = "" self.cursor_pos = 0 self.stream.flush() - self.eof_pressed = False - def handle_kill_forward(self): + def _handle_kill_forward(self): """Kill from cursor to end (Ctrl+K).""" chars_to_delete = len(self.passwd) - self.cursor_pos self.passwd = self.passwd[:self.cursor_pos] - self.erase_chars(chars_to_delete) + self._erase_chars(chars_to_delete) self.stream.flush() - self.eof_pressed = False - def handle_erase_word(self): + def _handle_erase_word(self): """Erase previous word (Ctrl+W).""" old_cursor = self.cursor_pos # Skip trailing spaces @@ -330,20 +315,16 @@ def handle_erase_word(self): self.cursor_pos -= 1 # Remove the deleted portion self.passwd = self.passwd[:self.cursor_pos] + self.passwd[old_cursor:] - self.refresh_display() - self.eof_pressed = False + self._refresh_display() - def build_dispatch_table(self): - """Build dispatch table mapping control chars to handlers.""" - return { - self.ctrl['SOH']: self.handle_move_start, # Ctrl+A - self.ctrl['ENQ']: self.handle_move_end, # Ctrl+E - self.ctrl['VT']: self.handle_kill_forward, # Ctrl+K - self.ctrl['KILL']: self.handle_kill_line, # Ctrl+U - self.ctrl['WERASE']: self.handle_erase_word, # Ctrl+W - self.ctrl['ERASE']: self.handle_erase, # DEL - '\b': self.handle_erase, # Backspace - } + def handle(self, char): + """Handle a single character input. Returns True if handled.""" + self.eof_pressed = False + handler = self._dispatch.get(char) + if handler: + handler() + return True + return False def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None): @@ -352,7 +333,6 @@ def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None): term_ctrl_chars = _POSIX_CTRL_CHARS.copy() editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars) - dispatch = editor.build_dispatch_table() while True: char = input.read(1) @@ -360,35 +340,27 @@ def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None): # Check for line terminators if char in ('\n', '\r'): break - # Handle literal next mode FIRST (Ctrl+V quotes next char) - if editor.literal_next: - editor.handle_literal_next(char) - continue - + elif editor.literal_next: + editor._insert_char(char) + editor.literal_next = False + editor.eof_pressed = False # Check if it's the LNEXT character - if char == editor.ctrl['LNEXT']: + elif char == editor.ctrl['LNEXT']: editor.literal_next = True editor.eof_pressed = False - continue - # Check for special control characters - if char == editor.ctrl['INTR']: + elif char == editor.ctrl['INTR']: raise KeyboardInterrupt - if char == editor.ctrl['EOF']: + elif char == editor.ctrl['EOF']: if editor.eof_pressed: break editor.eof_pressed = True - continue - if char == '\x00': - continue - + elif char == '\x00': + pass # Dispatch to handler or insert as normal character - handler = dispatch.get(char) - if handler: - handler() - else: - editor.insert_char(char) + elif not editor.handle(char): + editor._insert_char(char) editor.eof_pressed = False return editor.passwd