On Unix systems, when *echo_char* is set, the terminal will be
configured to operate in
:manpage:`noncanonical mode <termios(3)#Canonical_and_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.
class GetPassWarning(UserWarning): pass
+# Default POSIX control character mappings
+_POSIX_CTRL_CHARS = frozendict({
+ 'BS': '\x08', # Backspace
+ 'ERASE': '\x7f', # DEL
+ '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 is not available
+ or if the control character is not supported by termios.
+ """
+ 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
+
+ # Use defaults for Backspace (BS) and Ctrl+A/E/K (SOH/ENQ/VT)
+ # as they are not in the termios control characters array.
+ 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.
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 does not 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)
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
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
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:
+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)
+
+
+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
+ ctrl_chars['BS']: self.handle_erase, # Backspace
+ # special characters
+ ctrl_chars['LNEXT']: self.handle_literal_next, # Ctrl+V
+ ctrl_chars['EOF']: self.handle_eof, # Ctrl+D
+ ctrl_chars['INTR']: self.handle_interrupt, # Ctrl+C
+ '\x00': self.handle_nop, # ignore NUL
+ }
+
+ def refresh_display(self, prev_len=None):
+ """Redraw the entire password line with *echo_char*.
+
+ If *prev_len* is not specified, the current password length is used.
+ """
+ prompt_len = len(self.prompt)
+ 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 is_eol(self, char):
+ """Check if *char* is a line terminator."""
+ return char in ('\r', '\n')
+
+ def is_eof(self, char):
+ """Check if *char* is a file terminator."""
+ return char == self.ctrl['EOF']
+
+ def handle_move_start(self):
+ """Move cursor to beginning (Ctrl+A)."""
+ self.cursor_pos = 0
+ self.refresh_display()
+
+ def handle_move_end(self):
+ """Move cursor to end (Ctrl+E)."""
+ self.cursor_pos = len(self.password)
+ self.refresh_display()
+
+ def handle_erase(self):
+ """Delete character before cursor (Backspace/DEL)."""
+ if self.cursor_pos == 0:
+ return
+ assert self.cursor_pos > 0
+ self.cursor_pos -= 1
+ prev_len = len(self.password)
+ del self.password[self.cursor_pos]
+ 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
+ # Calculate the starting position of the previous word,
+ # ignoring trailing whitespaces.
+ while self.cursor_pos > 0 and self.password[self.cursor_pos - 1] == ' ':
+ self.cursor_pos -= 1
+ while self.cursor_pos > 0 and self.password[self.cursor_pos - 1] != ' ':
+ self.cursor_pos -= 1
+ # Delete the previous word and refresh the screen.
+ prev_len = len(self.password)
+ del self.password[self.cursor_pos:old_cursor]
+ self.refresh_display(prev_len)
+
+ def handle_literal_next(self):
+ """State transition to indicate that the next character is literal."""
+ assert self.literal_next is False
+ self.literal_next = True
+
+ def handle_eof(self):
+ """State transition to indicate that the pressed character was EOF."""
+ assert self.eof_pressed is False
+ self.eof_pressed = True
+
+ def handle_interrupt(self):
+ """Raise a KeyboardInterrupt after Ctrl+C has been received."""
+ raise KeyboardInterrupt
+
+ def handle_nop(self):
+ """Handler for an ignored character."""
+
+ 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:
+ assert self.cursor_pos >= 0
+ char = input.read(1)
+ if self.is_eol(char):
break
+ # Handle literal next mode first as Ctrl+V quotes characters.
+ elif self.literal_next:
+ self.insert_char(char)
+ self.literal_next = False
+ # Handle EOF now as Ctrl+D must be pressed twice
+ # consecutively to stop reading from the input.
+ elif self.is_eof(char):
+ if self.eof_pressed:
+ break
+ 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 = self.is_eof(char)
+
+ return ''.join(self.password)
def getuser():
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_null_char(self):
+ self.check_raw_input('pass\x00word\n', 'password')
+
+ 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_a_cursor_position(self):
+ # After Ctrl+A, cursor is at position 0.
+ # Refresh writes backspaces to move cursor from end to start.
+ output = self.check_raw_input('abc\x01\n', 'abc')
+ self.assertEndsWith(output, 'Password: ***\x08\x08\x08')
+
+ def test_ctrl_a_on_empty(self):
+ # Ctrl+A on empty line should be a no-op
+ self.check_raw_input('\x01hello\n', 'hello')
+
+ def test_ctrl_a_already_at_start(self):
+ # Double Ctrl+A should be same as single Ctrl+A
+ self.check_raw_input('abc\x01\x01start\n', 'startabc')
+
+ def test_ctrl_a_then_backspace(self):
+ # Backspace after Ctrl+A should do nothing (cursor at 0)
+ self.check_raw_input('abc\x01\x7f\n', 'abc')
+
+ 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_e_cursor_position(self):
+ # After Ctrl+A then Ctrl+E, cursor is back at end.
+ # Refresh has no backspaces since cursor is at end.
+ output = self.check_raw_input('abc\x01\x05\n', 'abc')
+ self.assertEndsWith(output, 'Password: ***')
+
+ def test_ctrl_e_on_empty(self):
+ # Ctrl+E on empty line should be a no-op
+ self.check_raw_input('\x05hello\n', 'hello')
+
+ def test_ctrl_e_already_at_end(self):
+ # Ctrl+E when already at end should be a no-op
+ self.check_raw_input('abc\x05more\n', 'abcmore')
+
+ def test_ctrl_a_then_ctrl_e(self):
+ # Ctrl+A then Ctrl+E should return cursor to end, typing appends
+ self.check_raw_input('abc\x01\x05def\n', 'abcdef')
+
+ 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
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):
--- /dev/null
+: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.