Skip to content

Conversation

@CuriousLearner
Copy link
Member

@CuriousLearner CuriousLearner commented Nov 15, 2025

Fixes #138577

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.

Tested with:

import getpass

# Test Ctrl+U (clear line)
# Type "wrong", press Ctrl+U, type "correct"
password = getpass.getpass(echo_char="*")
print(f"Result: {password!r}")
# Expected: 'correct'

# Test Ctrl+W (erase word)
# Type "hello world", press Ctrl+W, type "python"
password = getpass.getpass(echo_char="*")
print(f"Result: {password!r}")
# Expected: 'hello python'

# Test Ctrl+V (literal next)
# Type "test", press Ctrl+V, press Ctrl+U, type "more"
password = getpass.getpass(echo_char="*")
print(f"Result: {password!r}")
# Expected: 'test\x15more' (contains literal Ctrl+U)

📚 Documentation preview 📚: https://cpython-previews--141597.org.readthedocs.build/

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.
Copy link
Member

@picnixz picnixz left a comment

Choose a reason for hiding this comment

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

Thanks for doing this but this looks like a very complicate patch which is what I feared :( Also, I personally would use Ctrl+a/Ctrl+e for jumping at the start/end of what I write and ctrl+k (I think people are more familiar with ctrl+a/ctrl+k rather than ctrl+u for erasing an entire line in general...).

So I'm not really confident in actually changing this. I think deleting the last character that was typed (with DEL) is usually what people expect.

In addition, to make the feature more extendable in the future, I would prefer a tokenizer-based + dispatcher based approach instead (like we currently do for the REPL) because with many ifs like that it becomes hard to follow what's being done. But this would require an refactoring of this module which I don't really know if it's worth.

Lib/getpass.py Outdated
Comment on lines 211 to 215
# 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
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.

Lib/getpass.py Outdated
Comment on lines 218 to 221
erase_char = '\x7f' # DEL
kill_char = '\x15' # Ctrl+U
werase_char = '\x17' # Ctrl+W
lnext_char = '\x16' # Ctrl+V
Copy link
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.

Lib/getpass.py Outdated
Comment on lines 83 to 88
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
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.

@bedevere-app
Copy link

bedevere-app bot commented Nov 15, 2025

A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated.

Once you have made the requested changes, please leave a comment on this pull request containing the phrase I have made the requested changes; please review again. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.

@CuriousLearner
Copy link
Member Author

CuriousLearner commented Nov 15, 2025

Also, I personally would use Ctrl+a/Ctrl+e for jumping at the start/end of what I write and ctrl+k (I think people are more familiar with ctrl+a/ctrl+k rather than ctrl+u for erasing an entire line in general...).

Agree on this, since the original ticket asked for Ctrl+U, I would handle all these Ctrl characters.

In addition, to make the feature more extendable in the future, I would prefer a tokenizer-based + dispatcher based approach instead (like we currently do for the REPL) because with many ifs like that it becomes hard to follow what's being done

I'm refactoring the patch to be more extensible in the future, especially as more control characters are added.

@CuriousLearner
Copy link
Member Author

It actually grew a lot bigger than I initially expected, but based on your review, I can iterate over this @picnixz

@picnixz
Copy link
Member

picnixz commented Nov 16, 2025

It looks great. The patch itself is small but the tests are long so I think it is fine. Nonetheless I think it would be better to have it in 3.15 rather than in 3.14.1. WDYT? (i need to review it but not today)

@picnixz picnixz dismissed their stale review November 16, 2025 10:26

Changes were made.

@picnixz picnixz self-requested a review November 16, 2025 10:26
Copy link
Member

@picnixz picnixz left a comment

Choose a reason for hiding this comment

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

Quick review.

Lib/getpass.py Outdated
# Handle literal next mode FIRST (Ctrl+V quotes next char)
if editor.literal_next:
editor.handle_literal_next(char)
continue
Copy link
Member

Choose a reason for hiding this comment

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

To reduce the number of continue, just use if-elif constructions here.

Lib/getpass.py Outdated
Comment on lines 387 to 392
handler = dispatch.get(char)
if handler:
handler()
else:
passwd += char
stream.write(echo_char)
stream.flush()
eof_pressed = False
return passwd
editor.insert_char(char)
editor.eof_pressed = False
Copy link
Member

Choose a reason for hiding this comment

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

Here's a suggestion: don't build a dispatch table via a function but make the editor handles it internally:

editor.handle(char)

Lib/getpass.py Outdated
for name, value in ctrl_chars.items()}

def refresh_display(self):
"""Redraw the entire password line with asterisks."""
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
"""Redraw the entire password line with asterisks."""
"""Redraw the entire password line with *echo_char*."""

Lib/getpass.py Outdated
Comment on lines 49 to 65
try:
old = termios.tcgetattr(fd)
cc = old[6] # Index 6 is the control characters array
return {
'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else _POSIX_CTRL_CHARS['ERASE'],
'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else _POSIX_CTRL_CHARS['KILL'],
'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else _POSIX_CTRL_CHARS['WERASE'],
'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else _POSIX_CTRL_CHARS['LNEXT'],
'EOF': cc[termios.VEOF] if termios.VEOF < len(cc) else _POSIX_CTRL_CHARS['EOF'],
'INTR': cc[termios.VINTR] if termios.VINTR < len(cc) else _POSIX_CTRL_CHARS['INTR'],
# Ctrl+A/E/K are not in termios, use POSIX defaults
'SOH': _POSIX_CTRL_CHARS['SOH'],
'ENQ': _POSIX_CTRL_CHARS['ENQ'],
'VT': _POSIX_CTRL_CHARS['VT'],
}
except (termios.error, OSError):
return _POSIX_CTRL_CHARS.copy()
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
try:
old = termios.tcgetattr(fd)
cc = old[6] # Index 6 is the control characters array
return {
'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else _POSIX_CTRL_CHARS['ERASE'],
'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else _POSIX_CTRL_CHARS['KILL'],
'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else _POSIX_CTRL_CHARS['WERASE'],
'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else _POSIX_CTRL_CHARS['LNEXT'],
'EOF': cc[termios.VEOF] if termios.VEOF < len(cc) else _POSIX_CTRL_CHARS['EOF'],
'INTR': cc[termios.VINTR] if termios.VINTR < len(cc) else _POSIX_CTRL_CHARS['INTR'],
# Ctrl+A/E/K are not in termios, use POSIX defaults
'SOH': _POSIX_CTRL_CHARS['SOH'],
'ENQ': _POSIX_CTRL_CHARS['ENQ'],
'VT': _POSIX_CTRL_CHARS['VT'],
}
except (termios.error, OSError):
return _POSIX_CTRL_CHARS.copy()
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[flag]
return res

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for the suggestion, there was some minor issue here, which I've fixed in my refactoring

Lib/getpass.py Outdated

def refresh_display(self):
"""Redraw the entire password line with asterisks."""
self.stream.write('\r' + ' ' * (len(self.passwd) + 20) + '\r')
Copy link
Member

Choose a reason for hiding this comment

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

Why are we adding 20 extra characters?

Copy link
Member Author

Choose a reason for hiding this comment

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

The +20 was an arbitrary buffer and has been removed. The current implementation now uses just len(self.passwd) to clear only the necessary characters:

self.stream.write('\r' + ' ' * len(self.passwd) + '\r')

Lib/getpass.py Outdated
Comment on lines 251 to 252
self.ctrl = {name: _decode_ctrl_char(value)
for name, value in ctrl_chars.items()}
Copy link
Member

Choose a reason for hiding this comment

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

Can't we have the POSIX defaults already decoded?

Copy link
Member Author

Choose a reason for hiding this comment

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

Refactored this in b8609bd

Lib/getpass.py Outdated
'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else _POSIX_CTRL_CHARS['WERASE'],
'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else _POSIX_CTRL_CHARS['LNEXT'],
'EOF': cc[termios.VEOF] if termios.VEOF < len(cc) else _POSIX_CTRL_CHARS['EOF'],
'INTR': cc[termios.VINTR] if termios.VINTR < len(cc) else _POSIX_CTRL_CHARS['INTR'],
Copy link
Member

Choose a reason for hiding this comment

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

I'm assuming cc[termios.VINTR] gives bytes right? in this case we can already decode it.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, cc[termios.VINTR] returns bytes. This is now decoded inline in the refactored implementation:

  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')

The _POSIX_CTRL_CHARS defaults are also now stored as already-decoded strings.

@CuriousLearner
Copy link
Member Author

Hi @picnixz 👋🏼

Sorry, it took some time to address the review here. I've refactored the patch & I think Python 3.15 would be a better choice for this, though I don't see any issues if it gets into Python 3.14.1 (unless I'm missing something).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

getpass's echo_char should not affect keyboard shortcuts

2 participants