-
-
Notifications
You must be signed in to change notification settings - Fork 34.4k
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
Changes from 2 commits
bb60653
31e35e4
b8609bd
2691ad9
6a59f3b
d280ce9
3f1a861
537392c
e1e4aa3
3e05fa3
90da605
ef1efcb
a7c1de3
78b2c6a
e1461a7
741a817
cc5dc99
0f5f5c8
9d90dfa
3b2ae38
919af5c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,52 @@ | |
| class GetPassWarning(UserWarning): pass | ||
|
|
||
|
|
||
| # Default POSIX control character mappings | ||
| _POSIX_CTRL_CHARS = { | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
| 'ERASE': b'\x7f', # DEL/Backspace | ||
| 'KILL': b'\x15', # Ctrl+U - kill line | ||
| 'WERASE': b'\x17', # Ctrl+W - erase word | ||
| 'LNEXT': b'\x16', # Ctrl+V - literal next | ||
| 'EOF': b'\x04', # Ctrl+D - EOF | ||
| 'INTR': b'\x03', # Ctrl+C - interrupt | ||
| 'SOH': b'\x01', # Ctrl+A - start of heading (beginning of line) | ||
| 'ENQ': b'\x05', # Ctrl+E - enquiry (end of line) | ||
| 'VT': b'\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 byte values. | ||
| Falls back to POSIX defaults if termios isn't available. | ||
| """ | ||
| 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'], | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
| # 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() | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| def _decode_ctrl_char(char_value): | ||
| """Convert a control character from bytes to str.""" | ||
| if isinstance(char_value, bytes): | ||
| return char_value.decode('latin-1') | ||
| return char_value | ||
|
|
||
|
|
||
| def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None): | ||
| """Prompt for a password, with echo turned off. | ||
|
|
||
|
|
@@ -73,15 +119,19 @@ 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 | ||
|
vstinner marked this conversation as resolved.
|
||
| 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) | ||
|
|
@@ -159,7 +209,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 | ||
|
|
@@ -177,7 +228,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 | ||
|
|
@@ -186,33 +238,160 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None): | |
| return line | ||
|
|
||
|
|
||
| def _readline_with_echo_char(stream, input, echo_char): | ||
| passwd = "" | ||
| eof_pressed = False | ||
| class _PasswordLineEditor: | ||
| """Handles line editing for password input with echo character.""" | ||
|
|
||
| def __init__(self, stream, echo_char, ctrl_chars): | ||
| self.stream = stream | ||
| self.echo_char = echo_char | ||
| self.passwd = "" | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
| self.cursor_pos = 0 | ||
| self.eof_pressed = False | ||
| self.literal_next = False | ||
| self.ctrl = {name: _decode_ctrl_char(value) | ||
| for name, value in ctrl_chars.items()} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can't we have the POSIX defaults already decoded?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Refactored this in b8609bd |
||
|
|
||
| def refresh_display(self): | ||
| """Redraw the entire password line with asterisks.""" | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
| self.stream.write('\r' + ' ' * (len(self.passwd) + 20) + '\r') | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are we adding 20 extra characters?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 self.stream.write('\r' + ' ' * len(self.passwd) + '\r') |
||
| self.stream.write(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 asterisks from display.""" | ||
| self.stream.write("\b \b" * count) | ||
|
|
||
| def insert_char(self, char): | ||
| """Insert character at cursor position.""" | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
| self.passwd = self.passwd[:self.cursor_pos] + char + self.passwd[self.cursor_pos:] | ||
| self.cursor_pos += 1 | ||
| # Only refresh if inserting in middle | ||
| if self.cursor_pos < len(self.passwd): | ||
| self.refresh_display() | ||
| else: | ||
| self.stream.write(self.echo_char) | ||
| self.stream.flush() | ||
|
|
||
| def handle_literal_next(self, char): | ||
| """Insert next character literally (Ctrl+V).""" | ||
| self.insert_char(char) | ||
| self.literal_next = False | ||
| self.eof_pressed = False | ||
|
|
||
| def handle_move_start(self): | ||
| """Move cursor to beginning (Ctrl+A).""" | ||
| self.cursor_pos = 0 | ||
| self.eof_pressed = False | ||
|
|
||
| def handle_move_end(self): | ||
| """Move cursor to end (Ctrl+E).""" | ||
| self.cursor_pos = len(self.passwd) | ||
| self.eof_pressed = False | ||
|
|
||
| def handle_erase(self): | ||
| """Delete character before cursor (Backspace/DEL).""" | ||
| if self.cursor_pos > 0: | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
| self.passwd = self.passwd[:self.cursor_pos-1] + self.passwd[self.cursor_pos:] | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
| 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.eof_pressed = False | ||
|
|
||
| def handle_kill_line(self): | ||
| """Erase entire line (Ctrl+U).""" | ||
| self.erase_chars(len(self.passwd)) | ||
| self.passwd = "" | ||
| self.cursor_pos = 0 | ||
| self.stream.flush() | ||
| self.eof_pressed = False | ||
|
|
||
| def handle_kill_forward(self): | ||
| """Kill from cursor to end (Ctrl+K).""" | ||
| chars_to_delete = len(self.passwd) - self.cursor_pos | ||
| self.passwd = self.passwd[:self.cursor_pos] | ||
| self.erase_chars(chars_to_delete) | ||
| self.stream.flush() | ||
| self.eof_pressed = False | ||
|
|
||
| def handle_erase_word(self): | ||
| """Erase previous word (Ctrl+W).""" | ||
| old_cursor = self.cursor_pos | ||
| # Skip trailing spaces | ||
| while self.cursor_pos > 0 and self.passwd[self.cursor_pos-1] == ' ': | ||
| self.cursor_pos -= 1 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can actually skip the trailing spaces as follows: |
||
| # Delete the word | ||
| while self.cursor_pos > 0 and self.passwd[self.cursor_pos-1] != ' ': | ||
| self.cursor_pos -= 1 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And here, use |
||
| # Remove the deleted portion | ||
| self.passwd = self.passwd[:self.cursor_pos] + self.passwd[old_cursor:] | ||
| self.refresh_display() | ||
| self.eof_pressed = False | ||
|
|
||
|
vstinner marked this conversation as resolved.
|
||
| def build_dispatch_table(self): | ||
| """Build dispatch table mapping control chars to handlers.""" | ||
| return { | ||
| self.ctrl['SOH']: self.handle_move_start, # Ctrl+A | ||
| self.ctrl['ENQ']: self.handle_move_end, # Ctrl+E | ||
| self.ctrl['VT']: self.handle_kill_forward, # Ctrl+K | ||
| self.ctrl['KILL']: self.handle_kill_line, # Ctrl+U | ||
| self.ctrl['WERASE']: self.handle_erase_word, # Ctrl+W | ||
| self.ctrl['ERASE']: self.handle_erase, # DEL | ||
| '\b': self.handle_erase, # Backspace | ||
| } | ||
|
|
||
|
|
||
| def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None): | ||
| """Read password with echo character and line editing support.""" | ||
| if term_ctrl_chars is None: | ||
| term_ctrl_chars = _POSIX_CTRL_CHARS.copy() | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
|
|
||
| editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars) | ||
| dispatch = editor.build_dispatch_table() | ||
|
|
||
| while True: | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
| char = input.read(1) | ||
| if char == '\n' or char == '\r': | ||
|
|
||
| # Check for line terminators | ||
| if char in ('\n', '\r'): | ||
| break | ||
| elif char == '\x03': | ||
|
|
||
| # Handle literal next mode FIRST (Ctrl+V quotes next char) | ||
| if editor.literal_next: | ||
| editor.handle_literal_next(char) | ||
| continue | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
|
|
||
| # Check if it's the LNEXT character | ||
| if char == editor.ctrl['LNEXT']: | ||
| editor.literal_next = True | ||
| editor.eof_pressed = False | ||
| continue | ||
|
|
||
| # Check for special control characters | ||
| if char == editor.ctrl['INTR']: | ||
| 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: | ||
| if char == editor.ctrl['EOF']: | ||
| if editor.eof_pressed: | ||
| break | ||
| else: | ||
| eof_pressed = True | ||
| elif char == '\x00': | ||
| editor.eof_pressed = True | ||
| continue | ||
| if char == '\x00': | ||
| continue | ||
|
|
||
| # Dispatch to handler or insert as normal character | ||
| 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 | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
|
|
||
| return editor.passwd | ||
|
|
||
|
|
||
| def getuser(): | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.