-
-
Notifications
You must be signed in to change notification settings - Fork 33.6k
gh-138577: Fix keyboard shortcuts in getpass with echo_char #141597
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
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.
picnixz
left a comment
There was a problem hiding this 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
| # 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'] |
There was a problem hiding this comment.
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
| erase_char = '\x7f' # DEL | ||
| kill_char = '\x15' # Ctrl+U | ||
| werase_char = '\x17' # Ctrl+W | ||
| lnext_char = '\x16' # Ctrl+V |
There was a problem hiding this comment.
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
| 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', | ||
| } |
There was a problem hiding this comment.
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.
|
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 |
Agree on this, since the original ticket asked for Ctrl+U, I would handle all these Ctrl characters.
I'm refactoring the patch to be more extensible in the future, especially as more control characters are added. |
|
It actually grew a lot bigger than I initially expected, but based on your review, I can iterate over this @picnixz |
|
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
left a comment
There was a problem hiding this 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 |
There was a problem hiding this comment.
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
| 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 |
There was a problem hiding this comment.
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.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| """Redraw the entire password line with asterisks.""" | |
| """Redraw the entire password line with *echo_char*.""" |
Lib/getpass.py
Outdated
| 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() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| 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 |
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
| self.ctrl = {name: _decode_ctrl_char(value) | ||
| for name, value in ctrl_chars.items()} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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'], |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Hi @picnixz 👋🏼 Sorry, it took some time to address the review here. I've refactored the patch & I think |
Fixes #138577
When using
getpass.getpass(echo_char='*'), keyboard shortcuts likeCtrl+U(kill line),Ctrl+W(erase word), andCtrl+V(literal next) now work correctly by reading the terminal's control character settings and processing them in non-canonical mode.Tested with:
getpass'secho_charshould not affect keyboard shortcuts #138577📚 Documentation preview 📚: https://cpython-previews--141597.org.readthedocs.build/