8000 gh-77065: Add optional keyword-only argument `echo_char` for `getpass… · python/cpython@bf8bbe9 · GitHub
[go: up one dir, main page]

Skip to content

Commit bf8bbe9

Browse files
donBarbospicnixz
andauthored
gh-77065: Add optional keyword-only argument echo_char for getpass.getpass (#130496)
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
1 parent 53e6d76 commit bf8bbe9

File tree

5 files changed

+119
-6
lines changed

5 files changed

+119
-6
lines changed

Doc/library/getpass.rst

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
The :mod:`getpass` module provides two functions:
1818

19-
.. function:: getpass(prompt='Password: ', stream=None)
19+
.. function:: getpass(prompt='Password: ', stream=None, *, echo_char=None)
2020

2121
Prompt the user for a password without echoing. The user is prompted using
2222
the string *prompt*, which defaults to ``'Password: '``. On Unix, the
@@ -25,6 +25,12 @@ The :mod:`getpass` module provides two functions:
2525
(:file:`/dev/tty`) or if that is unavailable to ``sys.stderr`` (this
2626
argument is ignored on Windows).
2727

28+
The *echo_char* argument controls how user input is displayed while typing.
29+
If *echo_char* is ``None`` (default), input remains hidden. Otherwise,
30+
*echo_char* must be a printable ASCII string and each typed character
31+
is replaced by it. For example, ``echo_char='*'`` will display
32+
asterisks instead of the actual input.
33+
2834
If echo free input is unavailable getpass() falls back to printing
2935
a warning message to *stream* and reading from ``sys.stdin`` and
3036
issuing a :exc:`GetPassWarning`.
@@ -33,6 +39,9 @@ The :mod:`getpass` module provides two functions:
3339
If you call getpass from within IDLE, the input may be done in the
3440
terminal you launched IDLE from rather than the idle window itself.
3541

42+
.. versionchanged:: next
43+
Added the *echo_char* parameter for keyboard feedback.
44+
3645
.. exception:: GetPassWarning
3746

3847
A :exc:`UserWarning` subclass issued when password input may be echoed.

Doc/whatsnew/3.14.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,6 +1195,15 @@ getopt
11951195
(Contributed by Serhiy Storchaka in :gh:`126390`.)
11961196

11971197

1198+
getpass
1199+
-------
1200+
1201+
* Support keyboard feedback by :func:`getpass.getpass` via the keyword-only
1202+
optional argument ``echo_char``. Placeholder characters are rendered whenever
1203+
a character is entered, and removed when a character is deleted.
1204+
(Contributed by Semyon Moroz in :gh:`77065`.)
1205+
1206+
11981207
graphlib
11991208
--------
12001209

Lib/getpass.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Utilities to get a password and/or the current user name.
22
3-
getpass(prompt[, stream]) - Prompt for a password, with echo turned off.
3+
getpass(prompt[, stream[, echo_char]]) - Prompt for a password, with echo
4+
turned off and optional keyboard feedback.
45
getuser() - Get the user name from the environment or password database.
56
67
GetPassWarning - This UserWarning is issued when getpass() cannot prevent
@@ -25,13 +26,15 @@
2526
class GetPassWarning(UserWarning): pass
2627

2728

28-
def unix_getpass(prompt='Password: ', stream=None):
29+
def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
2930
"""Prompt for a password, with echo turned off.
3031
3132
Args:
3233
prompt: Written on stream to ask for the input. Default: 'Password: '
3334
stream: A writable file object to display the prompt. Defaults to
3435
the tty. If no tty is available defaults to sys.stderr.
36+
echo_char: A string used to mask input (e.g., '*'). If None, input is
37+
hidden.
3538
Returns:
3639
The seKr3t input.
3740
Raises:
@@ -40,6 +43,8 @@ def unix_getpass(prompt='Password: ', stream=None):
4043
4144
Always restores terminal settings before returning.
4245
"""
46+
_check_echo_char(echo_char)
47+
4348
passwd = None
4449
with contextlib.ExitStack() as stack:
4550
try:
@@ -68,12 +73,16 @@ def unix_getpass(prompt='Password: ', stream=None):
6873
old = termios.tcgetattr(fd) # a copy to save
6974
new = old[:]
7075
new[3] &= ~termios.ECHO # 3 == 'lflags'
76+
if echo_char:
77+
new[3] &= ~termios.ICANON
7178
tcsetattr_flags = termios.TCSAFLUSH
7279
if hasattr(termios, 'TCSASOFT'):
7380
tcsetattr_flags |= termios.TCSASOFT
7481
try:
7582
termios.tcsetattr(fd, tcsetattr_flags, new)
76-
passwd = _raw_input(prompt, stream, input=input)
83+
passwd = _raw_input(prompt, stream, input=input,
84+
echo_char=echo_char)
85+
7786
finally:
7887
termios.tcsetattr(fd, tcsetattr_flags, old)
7988
stream.flush() # issue7208
@@ -93,10 +102,11 @@ def unix_getpass(prompt='Password: ', stream=None):
93102
return passwd
94103

95104

96-
def win_getpass(prompt='Password: ', stream=None):
105+
def win_getpass(prompt='Password: ', stream=None, *, echo_char=None):
97106
"""Prompt for password with echo off, using Windows getwch()."""
98107
if sys.stdin is not sys.__stdin__:
99108
return fallback_getpass(prompt, stream)
109+
_check_echo_char(echo_char)
100110

101111
for c in prompt:
102112
msvcrt.putwch(c)
@@ -108,9 +118,15 @@ def win_getpass(prompt='Password: ', stream=None):
108118
if c == '\003':
109119
raise KeyboardInterrupt
110120
if c == '\b':
121+
if echo_char and pw:
122+
msvcrt.putch('\b')
123+
msvcrt.putch(' ')
124+
msvcrt.putch('\b')
111125
pw = pw[:-1]
112126
else:
113127
pw = pw + c
128+
if echo_char:
129+
msvcrt.putwch(echo_char)
114130
msvcrt.putwch('\r')
115131
msvcrt.putwch('\n')
116132
return pw
@@ -126,7 +142,14 @@ def fallback_getpass(prompt='Password: ', stream=None):
126142
return _raw_input(prompt, stream)
127143

128144

129-
def _raw_input(prompt="", stream=None, input=None):
145+
def _check_echo_char(echo_char):
146+
# ASCII excluding control characters
147+
if echo_char and not (echo_char.isprintable() and echo_char.isascii()):
148+
raise ValueError("'echo_char' must be a printable ASCII string, "
149+
f"got: {echo_char!r}")
150+
151+
152+
def _raw_input(prompt="", stream=None, input=None, echo_char=None):
130153
# This doesn't save the string in the GNU readline history.
131154
if not stream:
132155
stream = sys.stderr
@@ -143,6 +166,8 @@ def _raw_input(prompt="", stream=None, input=None):
143166
stream.write(prompt)
144167
stream.flush()
145168
# NOTE: The Python C API calls flockfile() (and unlock) during readline.
169+
if echo_char:
170+
return _readline_with_echo_char(stream, input, echo_char)
146171
line = input.readline()
147172
if not line:
148173
raise EOFError
@@ -151,6 +176,35 @@ def _raw_input(prompt="", stream=None, input=None):
151176
return line
152177

153178

179+
def _readline_with_echo_char(stream, input, echo_char):
180+
passwd = ""
181+
eof_pressed = False
182+
while True:
183+
char = input.read(1)
184+
if char == '\n' or char == '\r':
185+
break
186+
elif char == '\x03':
187+
raise KeyboardInterrupt
188+
elif char == '\x7f' or char == '\b':
189+
if passwd:
190+
stream.write("\b \b")
191+
stream.flush()
192+
passwd = passwd[:-1]
193+
elif char == '\x04':
194+
if eof_pressed:
195+
break
196+
else:
197+
eof_pressed = True
198+
elif char == '\x00':
199+
continue
200+
else:
201+
passwd += char
202+
stream.write(echo_char)
203+
stream.flush()
204+
eof_pressed = False
205+
return passwd
206+
207+
154208
def getuser():
155209
"""Get the username from the environment or password database.
156210

Lib/test/test_getpass.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,45 @@ def test_falls_back_to_stdin(self):
161161
self.assertIn('Warning', stderr.getvalue())
162162
self.assertIn('Password:', stderr.getvalue())
163163

164+
def test_echo_char_replaces_input_with_asterisks(self):
165+
mock_result = '*************'
166+
with mock.patch('os.open') as os_open, \
167+
mock.patch('io.FileIO'), \
168+
mock.patch('io.TextIOWrapper') as textio, \
169+
mock.patch('termios.tcgetattr'), \
170+
mock.patch('termios.tcsetattr'), \
171+
mock.patch('getpass._raw_input') as mock_input:
172+
os_open.return_value = 3
173+
mock_input.return_value = mock_result
174+
175+
result = getpass.unix_getpass(echo_char='*')
176+
mock_input.assert_called_once_with('Password: ', textio(),
177+
input=textio(), echo_char='*')
178+
self.assertEqual(result, mock_result)
179+
180+
def test_raw_input_with_echo_char(self):
181+
passwd = 'my1pa$$word!'
182+
mock_input = StringIO(f'{passwd}\n')
183+
mock_output = StringIO()
184+
with mock.patch('sys.stdin', mock_input), \
185+
mock.patch('sys.stdout', mock_output):
186+
result = getpass._raw_input('Password: ', mock_output, mock_input,
187+
'*')
188+
self.assertEqual(result, passwd)
189+
self.assertEqual('Password: ************', mock_output.getvalue())
190+
191+
def test_control_chars_with_echo_char(self):
192+
passwd = 'pass\twd\b'
193+
expect_result = 'pass\tw'
194+
mock_input = StringIO(f'{passwd}\n')
195+
mock_output = StringIO()
196+
with mock.patch('sys.stdin', mock_input), \
197+
mock.patch('sys.stdout', mock_output):
198+
result = getpass._raw_input('Password: ', mock_output, mock_input,
199+
'*')
200+
self.assertEqual(result, expect_result)
201+
self.assertEqual('Password: *******\x08 \x08', mock_output.getvalue())
202+
164203

165204
if __name__ == "__main__":
166205
unittest.main()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add keyword-only optional argument *echo_char* for :meth:`getpass.getpass`
2+
for optional visual keyboard feedback support. Patch by Semyon Moroz.

0 commit comments

Comments
 (0)
0