-
-
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
Merged
Merged
Changes from 3 commits
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 31e35e4
Address reviews on dispatcher pattern + handle other ctrl chars
CuriousLearner b8609bd
Address reviews
CuriousLearner 2691ad9
Merge remote-tracking branch 'upstream/main' into fix-gh-138577
CuriousLearner 6a59f3b
Address reviews
CuriousLearner d280ce9
fix: prevent prompt corruption during getpass echo_char line editing
CuriousLearner 3f1a861
fix: disable IEXTEN in non-canonical mode to allow Ctrl+V (LNEXT) han…
CuriousLearner 537392c
refactor: address review on getpass echo_char line editing
CuriousLearner e1e4aa3
fix: Add comments about ICANON and IEXTEN
CuriousLearner 3e05fa3
Merge branch 'main' of github.com:python/cpython into fix-gh-138577
CuriousLearner 90da605
Address reviews
CuriousLearner ef1efcb
Merge branch 'main' into fix-gh-138577
CuriousLearner a7c1de3
Remove prefix from private class methods
CuriousLearner 78b2c6a
Merge branch 'fix-gh-138577' of github.com:CuriousLearner/cpython int…
CuriousLearner e1461a7
Merge branch 'main' of github.com:python/cpython into fix-gh-138577
CuriousLearner 741a817
Apply suggestions from code review
vstinner cc5dc99
chore! update some documentation
picnixz 0f5f5c8
fix! refresh screen on Ctrl+A/Ctrl-E
picnixz 9d90dfa
refactor! use more handlers
picnixz 3b2ae38
chore! reduce diff against `main`
picnixz 919af5c
test: add cursor position tests for Ctrl+A/Ctrl+E in getpass echo_char
CuriousLearner File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 = { | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
| '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() | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
| 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 | ||
|
vstinner marked this conversation as resolved.
Outdated
vstinner marked this conversation as resolved.
Outdated
|
||
| 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,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 +197,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 +216,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 +226,144 @@ 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 = 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): | ||
| """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)) | ||
| 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.""" | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
| 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_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: | ||
|
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() | ||
|
|
||
| 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() | ||
|
|
||
| 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() | ||
|
|
||
| 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() | ||
|
|
||
| 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): | ||
| """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) | ||
|
|
||
| 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) | ||
| 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 | ||
|
vstinner marked this conversation as resolved.
Outdated
|
||
|
|
||
| return editor.passwd | ||
|
|
||
|
|
||
| def getuser(): | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.