Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bb60653
gh-138577: Fix keyboard shortcuts in getpass with echo_char
CuriousLearner Nov 15, 2025
31e35e4
Address reviews on dispatcher pattern + handle other ctrl chars
CuriousLearner Nov 16, 2025
b8609bd
Address reviews
CuriousLearner Nov 30, 2025
2691ad9
Merge remote-tracking branch 'upstream/main' into fix-gh-138577
CuriousLearner Mar 21, 2026
6a59f3b
Address reviews
CuriousLearner Mar 22, 2026
d280ce9
fix: prevent prompt corruption during getpass echo_char line editing
CuriousLearner Mar 23, 2026
3f1a861
fix: disable IEXTEN in non-canonical mode to allow Ctrl+V (LNEXT) han…
CuriousLearner Mar 23, 2026
537392c
refactor: address review on getpass echo_char line editing
CuriousLearner Mar 23, 2026
e1e4aa3
fix: Add comments about ICANON and IEXTEN
CuriousLearner Mar 23, 2026
3e05fa3
Merge branch 'main' of github.com:python/cpython into fix-gh-138577
CuriousLearner Mar 23, 2026
90da605
Address reviews
CuriousLearner Mar 24, 2026
ef1efcb
Merge branch 'main' into fix-gh-138577
CuriousLearner Mar 24, 2026
a7c1de3
Remove prefix from private class methods
CuriousLearner Mar 24, 2026
78b2c6a
Merge branch 'fix-gh-138577' of github.com:CuriousLearner/cpython int…
CuriousLearner Mar 24, 2026
e1461a7
Merge branch 'main' of github.com:python/cpython into fix-gh-138577
CuriousLearner Mar 24, 2026
741a817
Apply suggestions from code review
vstinner Mar 24, 2026
cc5dc99
chore! update some documentation
picnixz Mar 29, 2026
0f5f5c8
fix! refresh screen on Ctrl+A/Ctrl-E
picnixz Mar 29, 2026
9d90dfa
refactor! use more handlers
picnixz Mar 29, 2026
3b2ae38
chore! reduce diff against `main`
picnixz Mar 29, 2026
919af5c
test: add cursor position tests for Ctrl+A/Ctrl+E in getpass echo_char
CuriousLearner Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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.
  • Loading branch information
CuriousLearner committed Nov 15, 2025
commit bb60653a826838b8072b87f11454ebd9bec798e4
11 changes: 8 additions & 3 deletions Doc/library/getpass.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 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
Comment thread
vstinner marked this conversation as resolved.
Outdated
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.
Expand Down
83 changes: 74 additions & 9 deletions Lib/getpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
vstinner marked this conversation as resolved.
# 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',
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code must be refactored with a combination of a global dict with the defaults and a function.

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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -186,27 +200,78 @@ 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']
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are too long loines and they are unreadable. All of that can be a single function that is given the action to perform and the current capabilities.

else:
# Standard POSIX defaults
erase_char = '\x7f' # DEL
kill_char = '\x15' # Ctrl+U
werase_char = '\x17' # Ctrl+W
lnext_char = '\x16' # Ctrl+V
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, we should have them defined in a global private dict instead to ease maintenance.


while True:
Comment thread
vstinner marked this conversation as resolved.
Outdated
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
else:
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)
Expand Down
38 changes: 37 additions & 1 deletion Lib/test/test_getpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Comment thread
vstinner marked this conversation as resolved.
Outdated
# 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):

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading