Skip to content

Commit 31e35e4

Browse files
Address reviews on dispatcher pattern + handle other ctrl chars
1 parent bb60653 commit 31e35e4

File tree

4 files changed

+326
-87
lines changed

4 files changed

+326
-87
lines changed

Doc/library/getpass.rst

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,26 @@ The :mod:`getpass` module provides two functions:
4343
On Unix systems, when *echo_char* is set, the terminal will be
4444
configured to operate in
4545
:manpage:`noncanonical mode <termios(3)#Canonical_and_noncanonical_mode>`.
46-
Common terminal control characters like :kbd:`Ctrl+U` (kill line),
47-
:kbd:`Ctrl+W` (erase word), and :kbd:`Ctrl+V` (literal next) are
48-
supported by reading the terminal's configured control character
49-
mappings.
46+
Common terminal control characters are supported:
47+
48+
* :kbd:`Ctrl+A` - Move cursor to beginning of line
49+
* :kbd:`Ctrl+E` - Move cursor to end of line
50+
* :kbd:`Ctrl+K` - Kill (delete) from cursor to end of line
51+
* :kbd:`Ctrl+U` - Kill (delete) entire line
52+
* :kbd:`Ctrl+W` - Erase previous word
53+
* :kbd:`Ctrl+V` - Insert next character literally (quote)
54+
* :kbd:`Backspace`/:kbd:`DEL` - Delete character before cursor
55+
56+
These shortcuts work by reading the terminal's configured control
57+
character mappings from termios settings.
5058

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

5462
.. versionchanged:: 3.15
55-
When using *echo_char* on Unix, keyboard shortcuts are now properly
56-
handled using the terminal's control character configuration.
63+
When using *echo_char* on Unix, keyboard shortcuts (including cursor
64+
movement and line editing) are now properly handled using the terminal's
65+
control character configuration.
5766

5867
.. exception:: GetPassWarning
5968

Lib/getpass.py

Lines changed: 191 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,52 @@
2626
class GetPassWarning(UserWarning): pass
2727

2828

29+
# Default POSIX control character mappings
30+
_POSIX_CTRL_CHARS = {
31+
'ERASE': b'\x7f', # DEL/Backspace
32+
'KILL': b'\x15', # Ctrl+U - kill line
33+
'WERASE': b'\x17', # Ctrl+W - erase word
34+
'LNEXT': b'\x16', # Ctrl+V - literal next
35+
'EOF': b'\x04', # Ctrl+D - EOF
36+
'INTR': b'\x03', # Ctrl+C - interrupt
37+
'SOH': b'\x01', # Ctrl+A - start of heading (beginning of line)
38+
'ENQ': b'\x05', # Ctrl+E - enquiry (end of line)
39+
'VT': b'\x0b', # Ctrl+K - vertical tab (kill forward)
40+
}
41+
42+
43+
def _get_terminal_ctrl_chars(fd):
44+
"""Extract control characters from terminal settings.
45+
46+
Returns a dict mapping control char names to their byte values.
47+
Falls back to POSIX defaults if termios isn't available.
48+
"""
49+
try:
50+
old = termios.tcgetattr(fd)
51+
cc = old[6] # Index 6 is the control characters array
52+
return {
53+
'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else _POSIX_CTRL_CHARS['ERASE'],
54+
'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else _POSIX_CTRL_CHARS['KILL'],
55+
'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else _POSIX_CTRL_CHARS['WERASE'],
56+
'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else _POSIX_CTRL_CHARS['LNEXT'],
57+
'EOF': cc[termios.VEOF] if termios.VEOF < len(cc) else _POSIX_CTRL_CHARS['EOF'],
58+
'INTR': cc[termios.VINTR] if termios.VINTR < len(cc) else _POSIX_CTRL_CHARS['INTR'],
59+
# Ctrl+A/E/K are not in termios, use POSIX defaults
60+
'SOH': _POSIX_CTRL_CHARS['SOH'],
61+
'ENQ': _POSIX_CTRL_CHARS['ENQ'],
62+
'VT': _POSIX_CTRL_CHARS['VT'],
63+
}
64+
except (termios.error, OSError):
65+
return _POSIX_CTRL_CHARS.copy()
66+
67+
68+
def _decode_ctrl_char(char_value):
69+
"""Convert a control character from bytes to str."""
70+
if isinstance(char_value, bytes):
71+
return char_value.decode('latin-1')
72+
return char_value
73+
74+
2975
def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
3076
"""Prompt for a password, with echo turned off.
3177
@@ -77,15 +123,7 @@ def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
77123
term_ctrl_chars = None
78124
if echo_char:
79125
new[3] &= ~termios.ICANON
80-
# Get control characters from terminal settings
81-
# Index 6 is cc (control characters array)
82-
cc = old[6]
83-
term_ctrl_chars = {
84-
'ERASE': cc[termios.VERASE] if termios.VERASE < len(cc) else b'\x7f',
85-
'KILL': cc[termios.VKILL] if termios.VKILL < len(cc) else b'\x15',
86-
'WERASE': cc[termios.VWERASE] if termios.VWERASE < len(cc) else b'\x17',
87-
'LNEXT': cc[termios.VLNEXT] if termios.VLNEXT < len(cc) else b'\x16',
88-
}
126+
term_ctrl_chars = _get_terminal_ctrl_chars(fd)
89127
tcsetattr_flags = termios.TCSAFLUSH
90128
if hasattr(termios, 'TCSASOFT'):
91129
tcsetattr_flags |= termios.TCSASOFT
@@ -200,84 +238,160 @@ def _raw_input(prompt="", stream=None, input=None, echo_char=None,
200238
return line
201239

202240

241+
class _PasswordLineEditor:
242+
"""Handles line editing for password input with echo character."""
243+
244+
def __init__(self, stream, echo_char, ctrl_chars):
245+
self.stream = stream
246+
self.echo_char = echo_char
247+
self.passwd = ""
248+
self.cursor_pos = 0
249+
self.eof_pressed = False
250+
self.literal_next = False
251+
self.ctrl = {name: _decode_ctrl_char(value)
252+
for name, value in ctrl_chars.items()}
253+
254+
def refresh_display(self):
255+
"""Redraw the entire password line with asterisks."""
256+
self.stream.write('\r' + ' ' * (len(self.passwd) + 20) + '\r')
257+
self.stream.write(self.echo_char * len(self.passwd))
258+
if self.cursor_pos < len(self.passwd):
259+
self.stream.write('\b' * (len(self.passwd) - self.cursor_pos))
260+
self.stream.flush()
261+
262+
def erase_chars(self, count):
263+
"""Erase count asterisks from display."""
264+
self.stream.write("\b \b" * count)
265+
266+
def insert_char(self, char):
267+
"""Insert character at cursor position."""
268+
self.passwd = self.passwd[:self.cursor_pos] + char + self.passwd[self.cursor_pos:]
269+
self.cursor_pos += 1
270+
# Only refresh if inserting in middle
271+
if self.cursor_pos < len(self.passwd):
272+
self.refresh_display()
273+
else:
274+
self.stream.write(self.echo_char)
275+
self.stream.flush()
276+
277+
def handle_literal_next(self, char):
278+
"""Insert next character literally (Ctrl+V)."""
279+
self.insert_char(char)
280+
self.literal_next = False
281+
self.eof_pressed = False
282+
283+
def handle_move_start(self):
284+
"""Move cursor to beginning (Ctrl+A)."""
285+
self.cursor_pos = 0
286+
self.eof_pressed = False
287+
288+
def handle_move_end(self):
289+
"""Move cursor to end (Ctrl+E)."""
290+
self.cursor_pos = len(self.passwd)
291+
self.eof_pressed = False
292+
293+
def handle_erase(self):
294+
"""Delete character before cursor (Backspace/DEL)."""
295+
if self.cursor_pos > 0:
296+
self.passwd = self.passwd[:self.cursor_pos-1] + self.passwd[self.cursor_pos:]
297+
self.cursor_pos -= 1
298+
# Only refresh if deleting from middle
299+
if self.cursor_pos < len(self.passwd):
300+
self.refresh_display()
301+
else:
302+
self.stream.write("\b \b")
303+
self.stream.flush()
304+
self.eof_pressed = False
305+
306+
def handle_kill_line(self):
307+
"""Erase entire line (Ctrl+U)."""
308+
self.erase_chars(len(self.passwd))
309+
self.passwd = ""
310+
self.cursor_pos = 0
311+
self.stream.flush()
312+
self.eof_pressed = False
313+
314+
def handle_kill_forward(self):
315+
"""Kill from cursor to end (Ctrl+K)."""
316+
chars_to_delete = len(self.passwd) - self.cursor_pos
317+
self.passwd = self.passwd[:self.cursor_pos]
318+
self.erase_chars(chars_to_delete)
319+
self.stream.flush()
320+
self.eof_pressed = False
321+
322+
def handle_erase_word(self):
323+
"""Erase previous word (Ctrl+W)."""
324+
old_cursor = self.cursor_pos
325+
# Skip trailing spaces
326+
while self.cursor_pos > 0 and self.passwd[self.cursor_pos-1] == ' ':
327+
self.cursor_pos -= 1
328+
# Delete the word
329+
while self.cursor_pos > 0 and self.passwd[self.cursor_pos-1] != ' ':
330+
self.cursor_pos -= 1
331+
# Remove the deleted portion
332+
self.passwd = self.passwd[:self.cursor_pos] + self.passwd[old_cursor:]
333+
self.refresh_display()
334+
self.eof_pressed = False
335+
336+
def build_dispatch_table(self):
337+
"""Build dispatch table mapping control chars to handlers."""
338+
return {
339+
self.ctrl['SOH']: self.handle_move_start, # Ctrl+A
340+
self.ctrl['ENQ']: self.handle_move_end, # Ctrl+E
341+
self.ctrl['VT']: self.handle_kill_forward, # Ctrl+K
342+
self.ctrl['KILL']: self.handle_kill_line, # Ctrl+U
343+
self.ctrl['WERASE']: self.handle_erase_word, # Ctrl+W
344+
self.ctrl['ERASE']: self.handle_erase, # DEL
345+
'\b': self.handle_erase, # Backspace
346+
}
347+
348+
203349
def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None):
204-
passwd = ""
205-
eof_pressed = False
206-
literal_next = False # For LNEXT (Ctrl+V)
207-
208-
# Convert terminal control characters to strings for comparison
209-
# Default to standard POSIX values if not provided
210-
if term_ctrl_chars:
211-
# Control chars from termios are bytes, convert to str
212-
erase_char = term_ctrl_chars['ERASE'].decode('latin-1') if isinstance(term_ctrl_chars['ERASE'], bytes) else term_ctrl_chars['ERASE']
213-
kill_char = term_ctrl_chars['KILL'].decode('latin-1') if isinstance(term_ctrl_chars['KILL'], bytes) else term_ctrl_chars['KILL']
214-
werase_char = term_ctrl_chars['WERASE'].decode('latin-1') if isinstance(term_ctrl_chars['WERASE'], bytes) else term_ctrl_chars['WERASE']
215-
lnext_char = term_ctrl_chars['LNEXT'].decode('latin-1') if isinstance(term_ctrl_chars['LNEXT'], bytes) else term_ctrl_chars['LNEXT']
216-
else:
217-
# Standard POSIX defaults
218-
erase_char = '\x7f' # DEL
219-
kill_char = '\x15' # Ctrl+U
220-
werase_char = '\x17' # Ctrl+W
221-
lnext_char = '\x16' # Ctrl+V
350+
"""Read password with echo character and line editing support."""
351+
if term_ctrl_chars is None:
352+
term_ctrl_chars = _POSIX_CTRL_CHARS.copy()
353+
354+
editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars)
355+
dispatch = editor.build_dispatch_table()
222356

223357
while True:
224358
char = input.read(1)
225359

226-
if char == '\n' or char == '\r':
360+
# Check for line terminators
361+
if char in ('\n', '\r'):
227362
break
228-
elif char == '\x03':
363+
364+
# Handle literal next mode FIRST (Ctrl+V quotes next char)
365+
if editor.literal_next:
366+
editor.handle_literal_next(char)
367+
continue
368+
369+
# Check if it's the LNEXT character
370+
if char == editor.ctrl['LNEXT']:
371+
editor.literal_next = True
372+
editor.eof_pressed = False
373+
continue
374+
375+
# Check for special control characters
376+
if char == editor.ctrl['INTR']:
229377
raise KeyboardInterrupt
230-
elif char == '\x04':
231-
if eof_pressed:
378+
if char == editor.ctrl['EOF']:
379+
if editor.eof_pressed:
232380
break
233-
else:
234-
eof_pressed = True
235-
elif char == '\x00':
381+
editor.eof_pressed = True
382+
continue
383+
if char == '\x00':
236384
continue
237-
# Handle LNEXT (Ctrl+V) - insert next character literally
238-
elif literal_next:
239-
passwd += char
240-
stream.write(echo_char)
241-
stream.flush()
242-
literal_next = False
243-
eof_pressed = False
244-
elif char == lnext_char:
245-
literal_next = True
246-
eof_pressed = False
247-
# Handle ERASE (Backspace/DEL) - delete one character
248-
elif char == erase_char or char == '\b':
249-
if passwd:
250-
stream.write("\b \b")
251-
stream.flush()
252-
passwd = passwd[:-1]
253-
eof_pressed = False
254-
# Handle KILL (Ctrl+U) - erase entire line
255-
elif char == kill_char:
256-
# Clear all echoed characters
257-
while passwd:
258-
stream.write("\b \b")
259-
passwd = passwd[:-1]
260-
stream.flush()
261-
eof_pressed = False
262-
# Handle WERASE (Ctrl+W) - erase previous word
263-
elif char == werase_char:
264-
# Delete backwards until we find a space or reach the beginning
265-
# First, skip any trailing spaces
266-
while passwd and passwd[-1] == ' ':
267-
stream.write("\b \b")
268-
passwd = passwd[:-1]
269-
# Then delete the word
270-
while passwd and passwd[-1] != ' ':
271-
stream.write("\b \b")
272-
passwd = passwd[:-1]
273-
stream.flush()
274-
eof_pressed = False
385+
386+
# Dispatch to handler or insert as normal character
387+
handler = dispatch.get(char)
388+
if handler:
389+
handler()
275390
else:
276-
passwd += char
277-
stream.write(echo_char)
278-
stream.flush()
279-
eof_pressed = False
280-
return passwd
391+
editor.insert_char(char)
392+
editor.eof_pressed = False
393+
394+
return editor.passwd
281395

282396

283397
def getuser():

0 commit comments

Comments
 (0)