-
-
Notifications
You must be signed in to change notification settings - Fork 34.3k
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?
Changes from all commits
bb60653
31e35e4
b8609bd
2691ad9
6a59f3b
d280ce9
3f1a861
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,40 @@ | |||||||||||||||||||||||
| class GetPassWarning(UserWarning): pass | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| # Default POSIX control character mappings | ||||||||||||||||||||||||
| _POSIX_CTRL_CHARS = { | ||||||||||||||||||||||||
|
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. It seems like this dictionary should not be modified. I suggest using a frozendict here in this case. |
||||||||||||||||||||||||
| 'ERASE': '\x7f', # DEL/Backspace | ||||||||||||||||||||||||
| 'KILL': '\x15', # Ctrl+U - kill line | ||||||||||||||||||||||||
| 'WERASE': '\x17', # Ctrl+W - erase word | ||||||||||||||||||||||||
| 'LNEXT': '\x16', # Ctrl+V - literal next | ||||||||||||||||||||||||
| 'EOF': '\x04', # Ctrl+D - EOF | ||||||||||||||||||||||||
| 'INTR': '\x03', # Ctrl+C - interrupt | ||||||||||||||||||||||||
| 'SOH': '\x01', # Ctrl+A - start of heading (beginning of line) | ||||||||||||||||||||||||
| 'ENQ': '\x05', # Ctrl+E - enquiry (end of line) | ||||||||||||||||||||||||
| 'VT': '\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 str values. | ||||||||||||||||||||||||
| Falls back to POSIX defaults if termios isn't available. | ||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||
| res = _POSIX_CTRL_CHARS.copy() | ||||||||||||||||||||||||
|
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 you rename |
||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||
|
Comment on lines
+54
to
+55
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 add an empty line for readability:
Suggested change
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 should move You may add the names:
|
||||||||||||||||||||||||
| 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') | ||||||||||||||||||||||||
| return res | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None): | ||||||||||||||||||||||||
| """Prompt for a password, with echo turned off. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
@@ -73,15 +107,20 @@ 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 | ||||||||||||||||||||||||
|
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. Would you mind to add a comment to explain ICANON and IEXTEN flags? |
||||||||||||||||||||||||
| new[3] &= ~termios.IEXTEN | ||||||||||||||||||||||||
| 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 +198,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 +217,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, prompt) | ||||||||||||||||||||||||
| line = input.readline() | ||||||||||||||||||||||||
| if not line: | ||||||||||||||||||||||||
| raise EOFError | ||||||||||||||||||||||||
|
|
@@ -186,33 +227,143 @@ 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, prompt=""): | ||||||||||||||||||||||||
| self.stream = stream | ||||||||||||||||||||||||
| self.echo_char = echo_char | ||||||||||||||||||||||||
| self.prompt = prompt | ||||||||||||||||||||||||
| self.passwd = [] | ||||||||||||||||||||||||
|
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 may rename the attribute to |
||||||||||||||||||||||||
| self.cursor_pos = 0 | ||||||||||||||||||||||||
| self.eof_pressed = False | ||||||||||||||||||||||||
| self.literal_next = False | ||||||||||||||||||||||||
| self.ctrl = ctrl_chars | ||||||||||||||||||||||||
| self._dispatch = { | ||||||||||||||||||||||||
| ctrl_chars['SOH']: self._handle_move_start, # Ctrl+A | ||||||||||||||||||||||||
| ctrl_chars['ENQ']: self._handle_move_end, # Ctrl+E | ||||||||||||||||||||||||
| ctrl_chars['VT']: self._handle_kill_forward, # Ctrl+K | ||||||||||||||||||||||||
| ctrl_chars['KILL']: self._handle_kill_line, # Ctrl+U | ||||||||||||||||||||||||
| ctrl_chars['WERASE']: self._handle_erase_word, # Ctrl+W | ||||||||||||||||||||||||
| ctrl_chars['ERASE']: self._handle_erase, # DEL | ||||||||||||||||||||||||
| '\b': self._handle_erase, # Backspace | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def _refresh_display(self, prev_len=None): | ||||||||||||||||||||||||
| """Redraw the entire password line with *echo_char*.""" | ||||||||||||||||||||||||
| 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 _insert_char(self, char): | ||||||||||||||||||||||||
| """Insert *char* at cursor position.""" | ||||||||||||||||||||||||
| self.passwd.insert(self.cursor_pos, char) | ||||||||||||||||||||||||
| 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_move_start(self): | ||||||||||||||||||||||||
| """Move cursor to beginning (Ctrl+A).""" | ||||||||||||||||||||||||
| self.cursor_pos = 0 | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def _handle_move_end(self): | ||||||||||||||||||||||||
| """Move cursor to end (Ctrl+E).""" | ||||||||||||||||||||||||
| self.cursor_pos = len(self.passwd) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||
| self._refresh_display(prev_len) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def _handle_kill_line(self): | ||||||||||||||||||||||||
| """Erase entire line (Ctrl+U).""" | ||||||||||||||||||||||||
| prev_len = len(self.passwd) | ||||||||||||||||||||||||
| self.passwd.clear() | ||||||||||||||||||||||||
| self.cursor_pos = 0 | ||||||||||||||||||||||||
| self._refresh_display(prev_len) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def _handle_kill_forward(self): | ||||||||||||||||||||||||
| """Kill from cursor to end (Ctrl+K).""" | ||||||||||||||||||||||||
| prev_len = len(self.passwd) | ||||||||||||||||||||||||
| del self.passwd[self.cursor_pos:] | ||||||||||||||||||||||||
| self._refresh_display(prev_len) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||
| # Skip the word | ||||||||||||||||||||||||
| 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(prev_len) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def handle(self, char): | ||||||||||||||||||||||||
| """Handle a single character input. Returns True if handled.""" | ||||||||||||||||||||||||
| self.eof_pressed = False | ||||||||||||||||||||||||
| handler = self._dispatch.get(char) | ||||||||||||||||||||||||
| if handler: | ||||||||||||||||||||||||
| handler() | ||||||||||||||||||||||||
| return True | ||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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() | ||||||||||||||||||||||||
|
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. Is it needed to get a copy? |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars, prompt) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| while True: | ||||||||||||||||||||||||
|
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 should move this code (including |
||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||
| elif editor.literal_next: | ||||||||||||||||||||||||
| editor._insert_char(char) | ||||||||||||||||||||||||
| editor.literal_next = False | ||||||||||||||||||||||||
| editor.eof_pressed = False | ||||||||||||||||||||||||
| # Check if it's the LNEXT character | ||||||||||||||||||||||||
| elif char == editor.ctrl['LNEXT']: | ||||||||||||||||||||||||
| editor.literal_next = True | ||||||||||||||||||||||||
| editor.eof_pressed = False | ||||||||||||||||||||||||
| # Check for special control characters | ||||||||||||||||||||||||
| elif 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: | ||||||||||||||||||||||||
| elif char == editor.ctrl['EOF']: | ||||||||||||||||||||||||
| if editor.eof_pressed: | ||||||||||||||||||||||||
| break | ||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||
| eof_pressed = True | ||||||||||||||||||||||||
| editor.eof_pressed = True | ||||||||||||||||||||||||
| elif char == '\x00': | ||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||
| passwd += char | ||||||||||||||||||||||||
| stream.write(echo_char) | ||||||||||||||||||||||||
| stream.flush() | ||||||||||||||||||||||||
| eof_pressed = False | ||||||||||||||||||||||||
| return passwd | ||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||
| # Dispatch to handler or insert as normal character | ||||||||||||||||||||||||
| elif not editor.handle(char): | ||||||||||||||||||||||||
| editor._insert_char(char) | ||||||||||||||||||||||||
| editor.eof_pressed = False | ||||||||||||||||||||||||
|
Comment on lines
+361
to
+364
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.
Suggested change
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return ''.join(editor.passwd) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def getuser(): | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
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.