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
Prev Previous commit
Next Next commit
fix: prevent prompt corruption during getpass echo_char line editing
  • Loading branch information
CuriousLearner committed Mar 23, 2026
commit d280ce970ad96c6cd2ab69d9bd24735580764111
44 changes: 21 additions & 23 deletions Lib/getpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None,
# NOTE: The Python C API calls flockfile() (and unlock) during readline.
if echo_char:
return _readline_with_echo_char(stream, input, echo_char,
term_ctrl_chars)
term_ctrl_chars, prompt)
line = input.readline()
if not line:
raise EOFError
Expand All @@ -229,9 +229,10 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None,
class _PasswordLineEditor:
"""Handles line editing for password input with echo character."""

def __init__(self, stream, echo_char, ctrl_chars):
def __init__(self, stream, echo_char, ctrl_chars, prompt=""):
self.stream = stream
self.echo_char = echo_char
self.prompt = prompt
self.passwd = []
Comment thread
vstinner marked this conversation as resolved.
Outdated
self.cursor_pos = 0
self.eof_pressed = False
Expand All @@ -247,18 +248,18 @@ def __init__(self, stream, echo_char, ctrl_chars):
'\b': self._handle_erase, # Backspace
}

def _refresh_display(self):
def _refresh_display(self, prev_len=None):

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.

The whole class is private, I don't think that it's useful to mark methods as private as well. Can you remove th underscore ("_") prefix from all methods?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done, I’ve made the change. Just for my understanding and to follow the preferred style going forward: if a class is already internal/non-public, is it generally considered unnecessary to prefix all its methods with _ as well?

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.

I don't know if it's a general rule, but yes, if a class is private, I consider that it's not needed to mark its methods as private as well.

"""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))
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.passwd)
# 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.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 *char* at cursor position."""
self.passwd.insert(self.cursor_pos, char)
Expand All @@ -282,28 +283,23 @@ def _handle_erase(self):
"""Delete character before cursor (Backspace/DEL)."""
if self.cursor_pos <= 0:
return
prev_len = len(self.passwd)
del self.passwd[self.cursor_pos - 1]
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._refresh_display(prev_len)

def _handle_kill_line(self):
"""Erase entire line (Ctrl+U)."""
self._erase_chars(len(self.passwd))
prev_len = len(self.passwd)
self.passwd.clear()
self.cursor_pos = 0
self.stream.flush()
self._refresh_display(prev_len)

def _handle_kill_forward(self):
"""Kill from cursor to end (Ctrl+K)."""
chars_to_delete = len(self.passwd) - self.cursor_pos
prev_len = len(self.passwd)
del self.passwd[self.cursor_pos:]
self._erase_chars(chars_to_delete)
self.stream.flush()
self._refresh_display(prev_len)

def _handle_erase_word(self):
"""Erase previous word (Ctrl+W)."""
Expand All @@ -315,8 +311,9 @@ def _handle_erase_word(self):
while self.cursor_pos > 0 and self.passwd[self.cursor_pos - 1] != ' ':
self.cursor_pos -= 1
# Remove the deleted portion
prev_len = len(self.passwd)
del self.passwd[self.cursor_pos:old_cursor]
self._refresh_display()
self._refresh_display(prev_len)

def handle(self, char):
"""Handle a single character input. Returns True if handled."""
Expand All @@ -328,12 +325,13 @@ def handle(self, char):
return False


def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None):
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.copy()
Comment thread
vstinner marked this conversation as resolved.
Outdated

editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars)
editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars, prompt)

while True:
Comment thread
vstinner marked this conversation as resolved.
Outdated
char = input.read(1)
Expand Down
37 changes: 36 additions & 1 deletion Lib/test/test_getpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,13 @@ def test_control_chars_with_echo_char(self):
result = getpass._raw_input('Password: ', mock_output, mock_input,
'*')
self.assertEqual(result, expect_result)
self.assertEqual('Password: *******\x08 \x08', mock_output.getvalue())
# 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
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
Expand All @@ -226,6 +232,35 @@ def test_werase_ctrl_w_with_echo_char(self):
'*')
self.assertEqual(result, expect_result)

def test_ctrl_w_display_preserves_prompt(self):
# Reproducer from gh-138577: type "hello world", Ctrl+W
# Display must show "Password: ******" not "******rd: ***********"
passwd = 'hello world\x17'
mock_input = StringIO(f'{passwd}\n')
mock_output = StringIO()
result = getpass._raw_input('Password: ', mock_output, mock_input,
'*')
self.assertEqual(result, 'hello ')
output = mock_output.getvalue()
# The final visible state should be "Password: ******"
# Verify prompt is rewritten during refresh, not overwritten by stars
self.assertTrue(output.endswith('Password: ******'),
f'Prompt corrupted in display: {output!r}')

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: ***"
passwd = 'abc\x01x'
mock_input = StringIO(f'{passwd}\n')
mock_output = StringIO()
result = getpass._raw_input('Password: ', mock_output, mock_input,
'*')
self.assertEqual(result, 'xabc')
output = mock_output.getvalue()
# The final visible state should be "Password: ****"
self.assertTrue(output.endswith('Password: ****\x08\x08\x08'),
f'Prompt corrupted in display: {output!r}')

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"
Expand Down
Loading