diff --git a/Doc/library/getpass.rst b/Doc/library/getpass.rst index 1fb34d14d8b007..a0946203035879 100644 --- a/Doc/library/getpass.rst +++ b/Doc/library/getpass.rst @@ -39,13 +39,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:: next + When using non-empty *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..6244c01d6c0188 100644 --- a/Lib/getpass.py +++ b/Lib/getpass.py @@ -26,6 +26,41 @@ class GetPassWarning(UserWarning): pass +# Default POSIX control character mappings +_POSIX_CTRL_CHARS = frozendict({ + '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. + """ + ctrl = dict(_POSIX_CTRL_CHARS) + try: + old = termios.tcgetattr(fd) + cc = old[6] # Index 6 is the control characters array + except (termios.error, OSError): + return ctrl + + # Ctrl+A/E/K (SOH/ENQ/VT) 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): + ctrl[name] = cc[cap].decode('latin-1') + return ctrl + + def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None): """Prompt for a password, with echo turned off. @@ -73,15 +108,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: + # ICANON enables canonical (line-buffered) mode where + # the terminal handles line editing. Disable it so we + # can read input char by char and handle editing ourselves. new[3] &= ~termios.ICANON + # IEXTEN enables implementation-defined input processing + # such as LNEXT (Ctrl+V). Disable it so the terminal + # driver doesn't intercept these characters before our + # code can handle them. + new[3] &= ~termios.IEXTEN + 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 +206,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 +225,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, prompt) line = input.readline() if not line: raise EOFError @@ -186,33 +235,146 @@ 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 - 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: +class _PasswordLineEditor: + """Handles line editing for password input with echo character.""" + + def __init__(self, stream, echo_char, ctrl_chars, prompt=""): + self.stream = stream + self.echo_char = echo_char + self.prompt = prompt + self.password = [] + 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, prev_len=None): + """Redraw the entire password line with *echo_char*.""" + prompt_len = len(self.prompt) + # Use prev_len if given, otherwise current password length + clear_len = prev_len if prev_len is not None else len(self.password) + # Clear the entire line (prompt + password) and rewrite + self.stream.write('\r' + ' ' * (prompt_len + clear_len) + '\r') + self.stream.write(self.prompt + self.echo_char * len(self.password)) + if self.cursor_pos < len(self.password): + self.stream.write('\b' * (len(self.password) - self.cursor_pos)) + self.stream.flush() + + def _insert_char(self, char): + """Insert *char* at cursor position.""" + self.password.insert(self.cursor_pos, char) + self.cursor_pos += 1 + # Only refresh if inserting in middle + if self.cursor_pos < len(self.password): + 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.password) + + def _handle_erase(self): + """Delete character before cursor (Backspace/DEL).""" + if self.cursor_pos <= 0: + return + prev_len = len(self.password) + del self.password[self.cursor_pos - 1] + self.cursor_pos -= 1 + self._refresh_display(prev_len) + + def _handle_kill_line(self): + """Erase entire line (Ctrl+U).""" + prev_len = len(self.password) + self.password.clear() + self.cursor_pos = 0 + self._refresh_display(prev_len) + + def _handle_kill_forward(self): + """Kill from cursor to end (Ctrl+K).""" + prev_len = len(self.password) + del self.password[self.cursor_pos:] + self._refresh_display(prev_len) + + 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.password[self.cursor_pos - 1] == ' ': + self.cursor_pos -= 1 + # Skip the word + while self.cursor_pos > 0 and self.password[self.cursor_pos - 1] != ' ': + self.cursor_pos -= 1 + # Remove the deleted portion + prev_len = len(self.password) + del self.password[self.cursor_pos:old_cursor] + self._refresh_display(prev_len) + + def _handle(self, char): + """Handle a single character input. Returns True if handled.""" + handler = self._dispatch.get(char) + if handler: + handler() + return True + return False + + def readline(self, input): + """Read a line of password input with echo character support.""" + while True: + char = input.read(1) + + # Check for line terminators + if char in ('\n', '\r'): break + # Handle literal next mode FIRST (Ctrl+V quotes next char) + elif self.literal_next: + self._insert_char(char) + self.literal_next = False + # Check if it's the LNEXT character + elif char == self.ctrl['LNEXT']: + self.literal_next = True + # Check for special control characters + elif char == self.ctrl['INTR']: + raise KeyboardInterrupt + elif char == self.ctrl['EOF']: + if self.eof_pressed: + break + elif char == '\x00': + pass + elif self._handle(char): + # Dispatched to handler + pass else: - eof_pressed = True - elif char == '\x00': - continue - else: - passwd += char - stream.write(echo_char) - stream.flush() - eof_pressed = False - return passwd + # Insert as normal character + self._insert_char(char) + + self.eof_pressed = (char == self.ctrl['EOF']) + + return ''.join(self.password) + + +def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None, + prompt=""): + """Read password with echo character and line editing support.""" + if term_ctrl_chars is None: + term_ctrl_chars = _POSIX_CTRL_CHARS + + editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars, prompt) + return editor.readline(input) def getuser(): diff --git a/Lib/test/test_getpass.py b/Lib/test/test_getpass.py index 9c3def2c3be59b..1c45a899e18a48 100644 --- a/Lib/test/test_getpass.py +++ b/Lib/test/test_getpass.py @@ -88,6 +88,108 @@ def test_trims_trailing_newline(self): input = StringIO('test\n') self.assertEqual('test', getpass._raw_input(input=input)) + def check_raw_input(self, inputs, expect_result, prompt='Password: '): + mock_input = StringIO(inputs) + mock_output = StringIO() + result = getpass._raw_input(prompt, mock_output, mock_input, '*') + self.assertEqual(result, expect_result) + return mock_output.getvalue() + + def test_raw_input_with_echo_char(self): + output = self.check_raw_input('my1pa$$word!\n', 'my1pa$$word!') + self.assertEqual('Password: ************', output) + + def test_control_chars_with_echo_char(self): + output = self.check_raw_input('pass\twd\b\n', 'pass\tw') + # After backspace: refresh rewrites prompt + 6 echo chars + self.assertEqual( + 'Password: *******' # initial prompt + 7 echo chars + '\r' + ' ' * 17 + '\r' # clear line (10 prompt + 7 prev) + 'Password: ******', # rewrite prompt + 6 echo chars + output + ) + + def test_kill_ctrl_u_with_echo_char(self): + # Ctrl+U (KILL) should clear the entire line + output = self.check_raw_input('foo\x15bar\n', 'bar') + # Should show "***" then refresh to clear, then show "***" for "bar" + self.assertIn('***', output) + # Display refresh uses \r to rewrite the line including prompt + self.assertIn('\r', output) + + def test_werase_ctrl_w_with_echo_char(self): + # Ctrl+W (WERASE) should delete the previous word + self.check_raw_input('hello world\x17end\n', 'hello end') + + def test_ctrl_w_display_preserves_prompt(self): + # Reproducer from gh-138577: type "hello world", Ctrl+W + # Display must show "Password: ******" not "******rd: ***********" + output = self.check_raw_input('hello world\x17\n', 'hello ') + # The final visible state should be "Password: ******" + # Verify prompt is rewritten during refresh, not overwritten by stars + self.assertEndsWith(output, 'Password: ******') + + def test_ctrl_a_insert_display_preserves_prompt(self): + # Reproducer from gh-138577: type "abc", Ctrl+A, type "x" + # Display must show "Password: ****" not "****word: ***" + output = self.check_raw_input('abc\x01x\n', 'xabc') + # The final visible state should be "Password: ****" + self.assertEndsWith(output, 'Password: ****\x08\x08\x08') + + def test_lnext_ctrl_v_with_echo_char(self): + # Ctrl+V (LNEXT) should insert the next character literally + self.check_raw_input('test\x16\x15more\n', 'test\x15more') + + def test_ctrl_a_move_to_start_with_echo_char(self): + # Ctrl+A should move cursor to start + self.check_raw_input('end\x01start\n', 'startend') + + def test_ctrl_e_move_to_end_with_echo_char(self): + # Ctrl+E should move cursor to end + self.check_raw_input('start\x01X\x05end\n', 'Xstartend') + + def test_ctrl_k_kill_forward_with_echo_char(self): + # Ctrl+K should kill from cursor to end + self.check_raw_input('delete\x01\x0bkeep\n', 'keep') + + def test_ctrl_c_interrupt_with_echo_char(self): + # Ctrl+C should raise KeyboardInterrupt + with self.assertRaises(KeyboardInterrupt): + self.check_raw_input('test\x03more', '') + + def test_ctrl_d_eof_with_echo_char(self): + # Ctrl+D twice should cause EOF + self.check_raw_input('test\x04\x04', 'test') + + def test_backspace_at_start_with_echo_char(self): + # Backspace at start should do nothing + self.check_raw_input('\x7fhello\n', 'hello') + + def test_ctrl_k_at_end_with_echo_char(self): + # Ctrl+K at end should do nothing + self.check_raw_input('hello\x0b\n', 'hello') + + def test_ctrl_w_on_empty_with_echo_char(self): + # Ctrl+W on empty line should do nothing + self.check_raw_input('\x17hello\n', 'hello') + + def test_ctrl_u_on_empty_with_echo_char(self): + # Ctrl+U on empty line should do nothing + self.check_raw_input('\x15hello\n', 'hello') + + 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" + self.check_raw_input('world\x01hello \x05!\x01\x0bstart\n', 'start') + + def test_ctrl_w_multiple_words_with_echo_char(self): + # Ctrl+W should delete only the last word + self.check_raw_input('one two three\x17\n', 'one two ') + + def test_ctrl_v_then_ctrl_c_with_echo_char(self): + # Ctrl+V should make Ctrl+C literal (not interrupt) + self.check_raw_input('test\x16\x03end\n', 'test\x03end') + # Some of these tests are a bit white-box. The functional requirement is that # the password input be taken directly from the tty, and that it not be echoed @@ -174,33 +276,10 @@ 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): - passwd = 'my1pa$$word!' - mock_input = StringIO(f'{passwd}\n') - mock_output = StringIO() - with mock.patch('sys.stdin', mock_input), \ - mock.patch('sys.stdout', mock_output): - result = getpass._raw_input('Password: ', mock_output, mock_input, - '*') - self.assertEqual(result, passwd) - self.assertEqual('Password: ************', mock_output.getvalue()) - - def test_control_chars_with_echo_char(self): - passwd = 'pass\twd\b' - expect_result = 'pass\tw' - mock_input = StringIO(f'{passwd}\n') - mock_output = StringIO() - with mock.patch('sys.stdin', mock_input), \ - mock.patch('sys.stdout', mock_output): - result = getpass._raw_input('Password: ', mock_output, mock_input, - '*') - self.assertEqual(result, expect_result) - self.assertEqual('Password: *******\x08 \x08', mock_output.getvalue()) - - class GetpassEchoCharTest(unittest.TestCase): def test_accept_none(self): 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..df24f62982a424 --- /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 non-empty ``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.