diff --git a/Doc/library/getpass.rst b/Doc/library/getpass.rst index a0c0c6dee2d513..f932827c003f9b 100644 --- a/Doc/library/getpass.rst +++ b/Doc/library/getpass.rst @@ -43,13 +43,27 @@ 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 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 (including cursor + movement and line editing) 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..60f0416fcdeb64 100644 --- a/Lib/getpass.py +++ b/Lib/getpass.py @@ -26,6 +26,40 @@ class GetPassWarning(UserWarning): pass +# Default POSIX control character mappings +_POSIX_CTRL_CHARS = { + '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 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 + except (termios.error, OSError): + 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): """Prompt for a password, with echo turned off. @@ -73,15 +107,19 @@ 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 + term_ctrl_chars = _get_terminal_ctrl_chars(fd) 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 +197,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 +216,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,33 +226,144 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None): return line -def _readline_with_echo_char(stream, input, echo_char): - passwd = "" - eof_pressed = False +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 = 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 *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 echo characters 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_move_start(self): + """Move cursor to beginning (Ctrl+A).""" + self.cursor_pos = 0 + + def _handle_move_end(self): + """Move cursor to end (Ctrl+E).""" + self.cursor_pos = len(self.passwd) + + 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() + + 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() + + 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() + + 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() + + 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): + """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) + 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) + elif editor.literal_next: + editor._insert_char(char) + editor.literal_next = False + editor.eof_pressed = False + # Check if it's the LNEXT character + elif char == editor.ctrl['LNEXT']: + editor.literal_next = True + editor.eof_pressed = False + # Check for special control characters + elif char == editor.ctrl['INTR']: 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: + elif char == editor.ctrl['EOF']: + if editor.eof_pressed: break - else: - eof_pressed = True + editor.eof_pressed = True elif char == '\x00': - continue - else: - passwd += char - stream.write(echo_char) - stream.flush() - eof_pressed = False - return passwd + pass + # Dispatch to handler or insert as normal character + elif not editor.handle(char): + 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 9c3def2c3be59b..43ad3d548c955e 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,157 @@ 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) + + 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 new file mode 100644 index 00000000000000..bd3c725afe559e --- /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 +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.