Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
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
20 changes: 17 additions & 3 deletions Doc/library/getpass.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,27 @@ 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>`.
In particular, this means that line editing shortcuts such as
:kbd:`Ctrl+U` will not work and may insert unexpected characters into
the input.
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:: next
When using non-empty *echo_char* on Unix, keyboard shortcuts (including cursor
movement and line editing) are now properly handled using the terminal's
control character configuration.

.. exception:: GetPassWarning

A :exc:`UserWarning` subclass issued when password input may be echoed.
Expand Down
218 changes: 190 additions & 28 deletions Lib/getpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,41 @@
class GetPassWarning(UserWarning): pass


# Default POSIX control character mappings
_POSIX_CTRL_CHARS = frozendict({
'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.
"""
ctrl = dict(_POSIX_CTRL_CHARS)
try:
old = termios.tcgetattr(fd)
cc = old[6] # Index 6 is the control characters array
except (termios.error, OSError):
return ctrl

# Ctrl+A/E/K (SOH/ENQ/VT) 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):
ctrl[name] = cc[cap].decode('latin-1')
return ctrl


def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
"""Prompt for a password, with echo turned off.

Expand Down Expand Up @@ -73,15 +108,27 @@ 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:
# ICANON enables canonical (line-buffered) mode where
# the terminal handles line editing. Disable it so we
# can read input char by char and handle editing ourselves.
new[3] &= ~termios.ICANON
# IEXTEN enables implementation-defined input processing
# such as LNEXT (Ctrl+V). Disable it so the terminal
# driver doesn't intercept these characters before our
# code can handle them.
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)
Expand Down Expand Up @@ -159,7 +206,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
Expand All @@ -177,7 +225,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
Expand All @@ -186,33 +235,146 @@ 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
while True:
char = input.read(1)
if char == '\n' or char == '\r':
break
elif char == '\x03':
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:
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.password = []
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):
Copy link
Member

Choose a reason for hiding this comment

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

The whole class is private, I don't think that it's useful to mark methods as private as well. Can you remove th underscore ("_") prefix from all methods?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done, I’ve made the change. Just for my understanding and to follow the preferred style going forward: if a class is already internal/non-public, is it generally considered unnecessary to prefix all its methods with _ as well?

"""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.password)
# 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.password))
if self.cursor_pos < len(self.password):
self.stream.write('\b' * (len(self.password) - self.cursor_pos))
self.stream.flush()

def _insert_char(self, char):
"""Insert *char* at cursor position."""
self.password.insert(self.cursor_pos, char)
self.cursor_pos += 1
# Only refresh if inserting in middle
if self.cursor_pos < len(self.password):
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.password)

def _handle_erase(self):
"""Delete character before cursor (Backspace/DEL)."""
if self.cursor_pos <= 0:
return
prev_len = len(self.password)
del self.password[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.password)
self.password.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.password)
del self.password[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.password[self.cursor_pos - 1] == ' ':
self.cursor_pos -= 1
# Skip the word
while self.cursor_pos > 0 and self.password[self.cursor_pos - 1] != ' ':
self.cursor_pos -= 1
# Remove the deleted portion
prev_len = len(self.password)
del self.password[self.cursor_pos:old_cursor]
self._refresh_display(prev_len)

def _handle(self, char):
"""Handle a single character input. Returns True if handled."""
handler = self._dispatch.get(char)
if handler:
handler()
return True
return False

def readline(self, input):
"""Read a line of password input with echo character support."""
while True:
char = input.read(1)

# Check for line terminators
if char in ('\n', '\r'):
break
# Handle literal next mode FIRST (Ctrl+V quotes next char)
elif self.literal_next:
self._insert_char(char)
self.literal_next = False
# Check if it's the LNEXT character
elif char == self.ctrl['LNEXT']:
self.literal_next = True
# Check for special control characters
elif char == self.ctrl['INTR']:
raise KeyboardInterrupt
elif char == self.ctrl['EOF']:
if self.eof_pressed:
break
elif char == '\x00':
pass
elif self._handle(char):
# Dispatched to handler
pass
else:
eof_pressed = True
elif char == '\x00':
continue
else:
passwd += char
stream.write(echo_char)
stream.flush()
eof_pressed = False
return passwd
# Insert as normal character
self._insert_char(char)

self.eof_pressed = (char == self.ctrl['EOF'])

return ''.join(self.password)


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

editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars, prompt)
return editor.readline(input)


def getuser():
Expand Down
Loading
Loading