Skip to content

Commit 537392c

Browse files
refactor: address review on getpass echo_char line editing
1 parent 3f1a861 commit 537392c

4 files changed

Lines changed: 176 additions & 270 deletions

File tree

Doc/library/getpass.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ The :mod:`!getpass` module provides two functions:
5656
Added the *echo_char* parameter for keyboard feedback.
5757

5858
.. versionchanged:: 3.15
59-
When using *echo_char* on Unix, keyboard shortcuts (including cursor
59+
When using non-empty *echo_char* on Unix, keyboard shortcuts (including cursor
6060
movement and line editing) are now properly handled using the terminal's
6161
control character configuration.
6262

Lib/getpass.py

Lines changed: 68 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class GetPassWarning(UserWarning): pass
2727

2828

2929
# Default POSIX control character mappings
30-
_POSIX_CTRL_CHARS = {
30+
_POSIX_CTRL_CHARS = frozendict({
3131
'ERASE': '\x7f', # DEL/Backspace
3232
'KILL': '\x15', # Ctrl+U - kill line
3333
'WERASE': '\x17', # Ctrl+W - erase word
@@ -37,7 +37,7 @@ class GetPassWarning(UserWarning): pass
3737
'SOH': '\x01', # Ctrl+A - start of heading (beginning of line)
3838
'ENQ': '\x05', # Ctrl+E - enquiry (end of line)
3939
'VT': '\x0b', # Ctrl+K - vertical tab (kill forward)
40-
}
40+
})
4141

4242

4343
def _get_terminal_ctrl_chars(fd):
@@ -46,18 +46,19 @@ def _get_terminal_ctrl_chars(fd):
4646
Returns a dict mapping control char names to their str values.
4747
Falls back to POSIX defaults if termios isn't available.
4848
"""
49-
res = _POSIX_CTRL_CHARS.copy()
49+
ctrl = dict(_POSIX_CTRL_CHARS)
5050
try:
5151
old = termios.tcgetattr(fd)
5252
cc = old[6] # Index 6 is the control characters array
5353
except (termios.error, OSError):
54-
return res
55-
# Ctrl+A/E/K are not in termios, use POSIX defaults
54+
return ctrl
55+
56+
# Ctrl+A/E/K (SOH/ENQ/VT) are not in termios, use POSIX defaults
5657
for name in ('ERASE', 'KILL', 'WERASE', 'LNEXT', 'EOF', 'INTR'):
5758
cap = getattr(termios, f'V{name}')
5859
if cap < len(cc):
59-
res[name] = cc[cap].decode('latin-1')
60-
return res
60+
ctrl[name] = cc[cap].decode('latin-1')
61+
return ctrl
6162

6263

6364
def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
@@ -110,7 +111,10 @@ def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
110111
# Extract control characters before changing terminal mode
111112
term_ctrl_chars = None
112113
if echo_char:
114+
# Disable canonical mode so we can read char by char
113115
new[3] &= ~termios.ICANON
116+
# Disable IEXTEN so Ctrl+V (LNEXT) is not intercepted
117+
# by the terminal driver and can be handled by our code
114118
new[3] &= ~termios.IEXTEN
115119
term_ctrl_chars = _get_terminal_ctrl_chars(fd)
116120
tcsetattr_flags = termios.TCSAFLUSH
@@ -234,7 +238,7 @@ def __init__(self, stream, echo_char, ctrl_chars, prompt=""):
234238
self.stream = stream
235239
self.echo_char = echo_char
236240
self.prompt = prompt
237-
self.passwd = []
241+
self.password = []
238242
self.cursor_pos = 0
239243
self.eof_pressed = False
240244
self.literal_next = False
@@ -253,20 +257,20 @@ def _refresh_display(self, prev_len=None):
253257
"""Redraw the entire password line with *echo_char*."""
254258
prompt_len = len(self.prompt)
255259
# Use prev_len if given, otherwise current password length
256-
clear_len = prev_len if prev_len is not None else len(self.passwd)
260+
clear_len = prev_len if prev_len is not None else len(self.password)
257261
# Clear the entire line (prompt + password) and rewrite
258262
self.stream.write('\r' + ' ' * (prompt_len + clear_len) + '\r')
259-
self.stream.write(self.prompt + self.echo_char * len(self.passwd))
260-
if self.cursor_pos < len(self.passwd):
261-
self.stream.write('\b' * (len(self.passwd) - self.cursor_pos))
263+
self.stream.write(self.prompt + self.echo_char * len(self.password))
264+
if self.cursor_pos < len(self.password):
265+
self.stream.write('\b' * (len(self.password) - self.cursor_pos))
262266
self.stream.flush()
263267

264268
def _insert_char(self, char):
265269
"""Insert *char* at cursor position."""
266-
self.passwd.insert(self.cursor_pos, char)
270+
self.password.insert(self.cursor_pos, char)
267271
self.cursor_pos += 1
268272
# Only refresh if inserting in middle
269-
if self.cursor_pos < len(self.passwd):
273+
if self.cursor_pos < len(self.password):
270274
self._refresh_display()
271275
else:
272276
self.stream.write(self.echo_char)
@@ -278,45 +282,45 @@ def _handle_move_start(self):
278282

279283
def _handle_move_end(self):
280284
"""Move cursor to end (Ctrl+E)."""
281-
self.cursor_pos = len(self.passwd)
285+
self.cursor_pos = len(self.password)
282286

283287
def _handle_erase(self):
284288
"""Delete character before cursor (Backspace/DEL)."""
285289
if self.cursor_pos <= 0:
286290
return
287-
prev_len = len(self.passwd)
288-
del self.passwd[self.cursor_pos - 1]
291+
prev_len = len(self.password)
292+
del self.password[self.cursor_pos - 1]
289293
self.cursor_pos -= 1
290294
self._refresh_display(prev_len)
291295

292296
def _handle_kill_line(self):
293297
"""Erase entire line (Ctrl+U)."""
294-
prev_len = len(self.passwd)
295-
self.passwd.clear()
298+
prev_len = len(self.password)
299+
self.password.clear()
296300
self.cursor_pos = 0
297301
self._refresh_display(prev_len)
298302

299303
def _handle_kill_forward(self):
300304
"""Kill from cursor to end (Ctrl+K)."""
301-
prev_len = len(self.passwd)
302-
del self.passwd[self.cursor_pos:]
305+
prev_len = len(self.password)
306+
del self.password[self.cursor_pos:]
303307
self._refresh_display(prev_len)
304308

305309
def _handle_erase_word(self):
306310
"""Erase previous word (Ctrl+W)."""
307311
old_cursor = self.cursor_pos
308312
# Skip trailing spaces
309-
while self.cursor_pos > 0 and self.passwd[self.cursor_pos - 1] == ' ':
313+
while self.cursor_pos > 0 and self.password[self.cursor_pos - 1] == ' ':
310314
self.cursor_pos -= 1
311315
# Skip the word
312-
while self.cursor_pos > 0 and self.passwd[self.cursor_pos - 1] != ' ':
316+
while self.cursor_pos > 0 and self.password[self.cursor_pos - 1] != ' ':
313317
self.cursor_pos -= 1
314318
# Remove the deleted portion
315-
prev_len = len(self.passwd)
316-
del self.passwd[self.cursor_pos:old_cursor]
319+
prev_len = len(self.password)
320+
del self.password[self.cursor_pos:old_cursor]
317321
self._refresh_display(prev_len)
318322

319-
def handle(self, char):
323+
def _handle(self, char):
320324
"""Handle a single character input. Returns True if handled."""
321325
self.eof_pressed = False
322326
handler = self._dispatch.get(char)
@@ -325,45 +329,51 @@ def handle(self, char):
325329
return True
326330
return False
327331

332+
def readline(self, input):
333+
"""Read a line of password input with echo character support."""
334+
while True:
335+
char = input.read(1)
336+
337+
# Check for line terminators
338+
if char in ('\n', '\r'):
339+
break
340+
# Handle literal next mode FIRST (Ctrl+V quotes next char)
341+
elif self.literal_next:
342+
self._insert_char(char)
343+
self.literal_next = False
344+
self.eof_pressed = False
345+
# Check if it's the LNEXT character
346+
elif char == self.ctrl['LNEXT']:
347+
self.literal_next = True
348+
self.eof_pressed = False
349+
# Check for special control characters
350+
elif char == self.ctrl['INTR']:
351+
raise KeyboardInterrupt
352+
elif char == self.ctrl['EOF']:
353+
if self.eof_pressed:
354+
break
355+
self.eof_pressed = True
356+
elif char == '\x00':
357+
pass
358+
elif self._handle(char):
359+
# Dispatched to handler
360+
pass
361+
else:
362+
# Insert as normal character
363+
self._insert_char(char)
364+
self.eof_pressed = False
365+
366+
return ''.join(self.password)
367+
328368

329369
def _readline_with_echo_char(stream, input, echo_char, term_ctrl_chars=None,
330370
prompt=""):
331371
"""Read password with echo character and line editing support."""
332372
if term_ctrl_chars is None:
333-
term_ctrl_chars = _POSIX_CTRL_CHARS.copy()
373+
term_ctrl_chars = _POSIX_CTRL_CHARS
334374

335375
editor = _PasswordLineEditor(stream, echo_char, term_ctrl_chars, prompt)
336-
337-
while True:
338-
char = input.read(1)
339-
340-
# Check for line terminators
341-
if char in ('\n', '\r'):
342-
break
343-
# Handle literal next mode FIRST (Ctrl+V quotes next char)
344-
elif editor.literal_next:
345-
editor._insert_char(char)
346-
editor.literal_next = False
347-
editor.eof_pressed = False
348-
# Check if it's the LNEXT character
349-
elif char == editor.ctrl['LNEXT']:
350-
editor.literal_next = True
351-
editor.eof_pressed = False
352-
# Check for special control characters
353-
elif char == editor.ctrl['INTR']:
354-
raise KeyboardInterrupt
355-
elif char == editor.ctrl['EOF']:
356-
if editor.eof_pressed:
357-
break
358-
editor.eof_pressed = True
359-
elif char == '\x00':
360-
pass
361-
# Dispatch to handler or insert as normal character
362-
elif not editor.handle(char):
363-
editor._insert_char(char)
364-
editor.eof_pressed = False
365-
366-
return ''.join(editor.passwd)
376+
return editor.readline(input)
367377

368378

369379
def getuser():

0 commit comments

Comments
 (0)