Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 Nov 15, 2025
31e35e4
Address reviews on dispatcher pattern + handle other ctrl chars
CuriousLearner Nov 16, 2025
b8609bd
Address reviews
CuriousLearner Nov 30, 2025
2691ad9
Merge remote-tracking branch 'upstream/main' into fix-gh-138577
CuriousLearner Mar 21, 2026
6a59f3b
Address reviews
CuriousLearner Mar 22, 2026
d280ce9
fix: prevent prompt corruption during getpass echo_char line editing
CuriousLearner Mar 23, 2026
3f1a861
fix: disable IEXTEN in non-canonical mode to allow Ctrl+V (LNEXT) han…
CuriousLearner Mar 23, 2026
537392c
refactor: address review on getpass echo_char line editing
CuriousLearner Mar 23, 2026
e1e4aa3
fix: Add comments about ICANON and IEXTEN
CuriousLearner Mar 23, 2026
3e05fa3
Merge branch 'main' of github.com:python/cpython into fix-gh-138577
CuriousLearner Mar 23, 2026
90da605
Address reviews
CuriousLearner Mar 24, 2026
ef1efcb
Merge branch 'main' into fix-gh-138577
CuriousLearner Mar 24, 2026
a7c1de3
Remove prefix from private class methods
CuriousLearner Mar 24, 2026
78b2c6a
Merge branch 'fix-gh-138577' of github.com:CuriousLearner/cpython int…
CuriousLearner Mar 24, 2026
e1461a7
Merge branch 'main' of github.com:python/cpython into fix-gh-138577
CuriousLearner Mar 24, 2026
741a817
Apply suggestions from code review
vstinner Mar 24, 2026
cc5dc99
chore! update some documentation
picnixz Mar 29, 2026
0f5f5c8
fix! refresh screen on Ctrl+A/Ctrl-E
picnixz Mar 29, 2026
9d90dfa
refactor! use more handlers
picnixz Mar 29, 2026
3b2ae38
chore! reduce diff against `main`
picnixz Mar 29, 2026
919af5c
test: add cursor position tests for Ctrl+A/Ctrl+E in getpass echo_char
CuriousLearner Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Address reviews on dispatcher pattern + handle other ctrl chars
  • Loading branch information
CuriousLearner committed Nov 16, 2025
commit 31e35e4447f50851cd4ae7ea9d909ba441fe9ead
21 changes: 15 additions & 6 deletions Doc/library/getpass.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,26 @@ The :mod:`getpass` module provides two functions:
On Unix systems, when *echo_char* is set, the terminal will be
configured to operate in
:manpage:`noncanonical mode <termios(3)#Canonical_and_noncanonical_mode>`.
Common terminal control characters like :kbd:`Ctrl+U` (kill line),
:kbd:`Ctrl+W` (erase word), and :kbd:`Ctrl+V` (literal next) are
supported by reading the terminal's configured control character
mappings.
Common terminal control characters are supported:

* :kbd:`Ctrl+A` - Move cursor to beginning of line
* :kbd:`Ctrl+E` - Move cursor to end of line
* :kbd:`Ctrl+K` - Kill (delete) from cursor to end of line
* :kbd:`Ctrl+U` - Kill (delete) entire line
* :kbd:`Ctrl+W` - Erase previous word
* :kbd:`Ctrl+V` - Insert next character literally (quote)
* :kbd:`Backspace`/:kbd:`DEL` - Delete character before cursor

These shortcuts work by reading the terminal's configured control
character mappings from termios settings.

.. versionchanged:: 3.14
Added the *echo_char* parameter for keyboard feedback.

.. versionchanged:: 3.15
Comment thread
vstinner marked this conversation as resolved.
Outdated
When using *echo_char* on Unix, keyboard shortcuts are now properly
handled using the terminal's control character configuration.
When using *echo_char* on Unix, keyboard shortcuts (including cursor
Comment thread
vstinner marked this conversation as resolved.
Outdated
movement and line editing) are now properly handled using the terminal's
control character configuration.

.. exception:: GetPassWarning

Expand Down
268 changes: 191 additions & 77 deletions Lib/getpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,52 @@
class GetPassWarning(UserWarning): pass


# Default POSIX control character mappings
_POSIX_CTRL_CHARS = {
Comment thread
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'],
Comment thread
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()
Comment thread
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.

Expand Down Expand Up @@ -77,15 +123,7 @@ def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
term_ctrl_chars = None
if echo_char:
new[3] &= ~termios.ICANON
Comment thread
vstinner marked this conversation as resolved.
# Get control characters from terminal settings
# Index 6 is cc (control characters array)
cc = old[6]
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',
}
term_ctrl_chars = _get_terminal_ctrl_chars(fd)
tcsetattr_flags = termios.TCSAFLUSH
if hasattr(termios, 'TCSASOFT'):
tcsetattr_flags |= termios.TCSASOFT
Expand Down Expand Up @@ -200,84 +238,160 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None,
return line


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 = ""
Comment thread
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()}
Copy link
Copy Markdown
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
Copy Markdown
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


def refresh_display(self):
"""Redraw the entire password line with asterisks."""
Comment thread
vstinner marked this conversation as resolved.
Outdated
self.stream.write('\r' + ' ' * (len(self.passwd) + 20) + '\r')
Copy link
Copy Markdown
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
Copy Markdown
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')

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."""
Comment thread
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:
Comment thread
vstinner marked this conversation as resolved.
Outdated
self.passwd = self.passwd[:self.cursor_pos-1] + self.passwd[self.cursor_pos:]
Comment thread
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You can actually skip the trailing spaces as follows:

stripped = self.passwd.rstrip(' ')
self.cursor_pos = self.cursor_pos - (len(self.passwd) - len(stripped))

# Delete the word
while self.cursor_pos > 0 and self.passwd[self.cursor_pos-1] != ' ':
self.cursor_pos -= 1
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

And here, use str.rfind using the start of the new cursor position.

# Remove the deleted portion
self.passwd = self.passwd[:self.cursor_pos] + self.passwd[old_cursor:]
self.refresh_display()
self.eof_pressed = False

Comment thread
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):
passwd = ""
eof_pressed = False
literal_next = False # For LNEXT (Ctrl+V)

# Convert terminal control characters to strings for comparison
# Default to standard POSIX values if not provided
if term_ctrl_chars:
# 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']
else:
# Standard POSIX defaults
erase_char = '\x7f' # DEL
kill_char = '\x15' # Ctrl+U
werase_char = '\x17' # Ctrl+W
lnext_char = '\x16' # Ctrl+V
"""Read password with echo character and line editing support."""
if term_ctrl_chars is None:
term_ctrl_chars = _POSIX_CTRL_CHARS.copy()
Comment thread
vstinner marked this conversation as resolved.
Outdated

editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars)
dispatch = editor.build_dispatch_table()

while True:
Comment thread
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
Comment thread
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 == '\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
# Handle LNEXT (Ctrl+V) - insert next character literally
elif literal_next:
passwd += char
stream.write(echo_char)
stream.flush()
literal_next = False
eof_pressed = False
elif char == lnext_char:
literal_next = True
eof_pressed = False
# Handle ERASE (Backspace/DEL) - delete one character
elif char == erase_char or char == '\b':
if passwd:
stream.write("\b \b")
stream.flush()
passwd = passwd[:-1]
eof_pressed = False
# Handle KILL (Ctrl+U) - erase entire line
elif char == kill_char:
# Clear all echoed characters
while passwd:
stream.write("\b \b")
passwd = passwd[:-1]
stream.flush()
eof_pressed = False
# Handle WERASE (Ctrl+W) - erase previous word
elif char == werase_char:
# Delete backwards until we find a space or reach the beginning
# First, skip any trailing spaces
while passwd and passwd[-1] == ' ':
stream.write("\b \b")
passwd = passwd[:-1]
# Then delete the word
while passwd and passwd[-1] != ' ':
stream.write("\b \b")
passwd = passwd[:-1]
stream.flush()
eof_pressed = False

# 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
Comment thread
vstinner marked this conversation as resolved.
Outdated

return editor.passwd


def getuser():
Expand Down
Loading
Loading