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>
This commit is contained in:
Semyon Moroz 2025-05-06 15:56:20 +04:00 committed by GitHub
parent 53e6d76aa3
commit bf8bbe9a81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 119 additions and 6 deletions

View file

@ -16,7 +16,7 @@
The :mod:`getpass` module provides two functions: The :mod:`getpass` module provides two functions:
.. function:: getpass(prompt='Password: ', stream=None) .. function:: getpass(prompt='Password: ', stream=None, *, echo_char=None)
Prompt the user for a password without echoing. The user is prompted using Prompt the user for a password without echoing. The user is prompted using
the string *prompt*, which defaults to ``'Password: '``. On Unix, the the string *prompt*, which defaults to ``'Password: '``. On Unix, the
@ -25,6 +25,12 @@ The :mod:`getpass` module provides two functions:
(:file:`/dev/tty`) or if that is unavailable to ``sys.stderr`` (this (:file:`/dev/tty`) or if that is unavailable to ``sys.stderr`` (this
argument is ignored on Windows). argument is ignored on Windows).
The *echo_char* argument controls how user input is displayed while typing.
If *echo_char* is ``None`` (default), input remains hidden. Otherwise,
*echo_char* must be a printable ASCII string and each typed character
is replaced by it. For example, ``echo_char='*'`` will display
asterisks instead of the actual input.
If echo free input is unavailable getpass() falls back to printing If echo free input is unavailable getpass() falls back to printing
a warning message to *stream* and reading from ``sys.stdin`` and a warning message to *stream* and reading from ``sys.stdin`` and
issuing a :exc:`GetPassWarning`. issuing a :exc:`GetPassWarning`.
@ -33,6 +39,9 @@ The :mod:`getpass` module provides two functions:
If you call getpass from within IDLE, the input may be done in the If you call getpass from within IDLE, the input may be done in the
terminal you launched IDLE from rather than the idle window itself. terminal you launched IDLE from rather than the idle window itself.
.. versionchanged:: next
Added the *echo_char* parameter for keyboard feedback.
.. exception:: GetPassWarning .. exception:: GetPassWarning
A :exc:`UserWarning` subclass issued when password input may be echoed. A :exc:`UserWarning` subclass issued when password input may be echoed.

View file

@ -1195,6 +1195,15 @@ getopt
(Contributed by Serhiy Storchaka in :gh:`126390`.) (Contributed by Serhiy Storchaka in :gh:`126390`.)
getpass
-------
* Support keyboard feedback by :func:`getpass.getpass` via the keyword-only
optional argument ``echo_char``. Placeholder characters are rendered whenever
a character is entered, and removed when a character is deleted.
(Contributed by Semyon Moroz in :gh:`77065`.)
graphlib graphlib
-------- --------

View file

@ -1,6 +1,7 @@
"""Utilities to get a password and/or the current user name. """Utilities to get a password and/or the current user name.
getpass(prompt[, stream]) - Prompt for a password, with echo turned off. getpass(prompt[, stream[, echo_char]]) - Prompt for a password, with echo
turned off and optional keyboard feedback.
getuser() - Get the user name from the environment or password database. getuser() - Get the user name from the environment or password database.
GetPassWarning - This UserWarning is issued when getpass() cannot prevent GetPassWarning - This UserWarning is issued when getpass() cannot prevent
@ -25,13 +26,15 @@ __all__ = ["getpass","getuser","GetPassWarning"]
class GetPassWarning(UserWarning): pass class GetPassWarning(UserWarning): pass
def unix_getpass(prompt='Password: ', stream=None): def unix_getpass(prompt='Password: ', stream=None, *, echo_char=None):
"""Prompt for a password, with echo turned off. """Prompt for a password, with echo turned off.
Args: Args:
prompt: Written on stream to ask for the input. Default: 'Password: ' prompt: Written on stream to ask for the input. Default: 'Password: '
stream: A writable file object to display the prompt. Defaults to stream: A writable file object to display the prompt. Defaults to
the tty. If no tty is available defaults to sys.stderr. the tty. If no tty is available defaults to sys.stderr.
echo_char: A string used to mask input (e.g., '*'). If None, input is
hidden.
Returns: Returns:
The seKr3t input. The seKr3t input.
Raises: Raises:
@ -40,6 +43,8 @@ def unix_getpass(prompt='Password: ', stream=None):
Always restores terminal settings before returning. Always restores terminal settings before returning.
""" """
_check_echo_char(echo_char)
passwd = None passwd = None
with contextlib.ExitStack() as stack: with contextlib.ExitStack() as stack:
try: try:
@ -68,12 +73,16 @@ def unix_getpass(prompt='Password: ', stream=None):
old = termios.tcgetattr(fd) # a copy to save old = termios.tcgetattr(fd) # a copy to save
new = old[:] new = old[:]
new[3] &= ~termios.ECHO # 3 == 'lflags' new[3] &= ~termios.ECHO # 3 == 'lflags'
if echo_char:
new[3] &= ~termios.ICANON
tcsetattr_flags = termios.TCSAFLUSH tcsetattr_flags = termios.TCSAFLUSH
if hasattr(termios, 'TCSASOFT'): if hasattr(termios, 'TCSASOFT'):
tcsetattr_flags |= termios.TCSASOFT tcsetattr_flags |= termios.TCSASOFT
try: try:
termios.tcsetattr(fd, tcsetattr_flags, new) termios.tcsetattr(fd, tcsetattr_flags, new)
passwd = _raw_input(prompt, stream, input=input) passwd = _raw_input(prompt, stream, input=input,
echo_char=echo_char)
finally: finally:
termios.tcsetattr(fd, tcsetattr_flags, old) termios.tcsetattr(fd, tcsetattr_flags, old)
stream.flush() # issue7208 stream.flush() # issue7208
@ -93,10 +102,11 @@ def unix_getpass(prompt='Password: ', stream=None):
return passwd return passwd
def win_getpass(prompt='Password: ', stream=None): def win_getpass(prompt='Password: ', stream=None, *, echo_char=None):
"""Prompt for password with echo off, using Windows getwch().""" """Prompt for password with echo off, using Windows getwch()."""
if sys.stdin is not sys.__stdin__: if sys.stdin is not sys.__stdin__:
return fallback_getpass(prompt, stream) return fallback_getpass(prompt, stream)
_check_echo_char(echo_char)
for c in prompt: for c in prompt:
msvcrt.putwch(c) msvcrt.putwch(c)
@ -108,9 +118,15 @@ def win_getpass(prompt='Password: ', stream=None):
if c == '\003': if c == '\003':
raise KeyboardInterrupt raise KeyboardInterrupt
if c == '\b': if c == '\b':
if echo_char and pw:
msvcrt.putch('\b')
msvcrt.putch(' ')
msvcrt.putch('\b')
pw = pw[:-1] pw = pw[:-1]
else: else:
pw = pw + c pw = pw + c
if echo_char:
msvcrt.putwch(echo_char)
msvcrt.putwch('\r') msvcrt.putwch('\r')
msvcrt.putwch('\n') msvcrt.putwch('\n')
return pw return pw
@ -126,7 +142,14 @@ def fallback_getpass(prompt='Password: ', stream=None):
return _raw_input(prompt, stream) return _raw_input(prompt, stream)
def _raw_input(prompt="", stream=None, input=None): def _check_echo_char(echo_char):
# ASCII excluding control characters
if echo_char and not (echo_char.isprintable() and echo_char.isascii()):
raise ValueError("'echo_char' must be a printable ASCII string, "
f"got: {echo_char!r}")
def _raw_input(prompt="", stream=None, input=None, echo_char=None):
# This doesn't save the string in the GNU readline history. # This doesn't save the string in the GNU readline history.
if not stream: if not stream:
stream = sys.stderr stream = sys.stderr
@ -143,6 +166,8 @@ def _raw_input(prompt="", stream=None, input=None):
stream.write(prompt) stream.write(prompt)
stream.flush() stream.flush()
# NOTE: The Python C API calls flockfile() (and unlock) during readline. # NOTE: The Python C API calls flockfile() (and unlock) during readline.
if echo_char:
return _readline_with_echo_char(stream, input, echo_char)
line = input.readline() line = input.readline()
if not line: if not line:
raise EOFError raise EOFError
@ -151,6 +176,35 @@ def _raw_input(prompt="", stream=None, input=None):
return line 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:
break
else:
eof_pressed = True
elif char == '\x00':
continue
else:
passwd += char
stream.write(echo_char)
stream.flush()
eof_pressed = False
return passwd
def getuser(): def getuser():
"""Get the username from the environment or password database. """Get the username from the environment or password database.

View file

@ -161,6 +161,45 @@ class UnixGetpassTest(unittest.TestCase):
self.assertIn('Warning', stderr.getvalue()) self.assertIn('Warning', stderr.getvalue())
self.assertIn('Password:', stderr.getvalue()) self.assertIn('Password:', stderr.getvalue())
def test_echo_char_replaces_input_with_asterisks(self):
mock_result = '*************'
with mock.patch('os.open') as os_open, \
mock.patch('io.FileIO'), \
mock.patch('io.TextIOWrapper') as textio, \
mock.patch('termios.tcgetattr'), \
mock.patch('termios.tcsetattr'), \
mock.patch('getpass._raw_input') as mock_input:
os_open.return_value = 3
mock_input.return_value = mock_result
result = getpass.unix_getpass(echo_char='*')
mock_input.assert_called_once_with('Password: ', textio(),
input=textio(), echo_char='*')
self.assertEqual(result, mock_result)
def test_raw_input_with_echo_char(self):
passwd = 'my1pa$$word!'
mock_input = StringIO(f'{passwd}\n')
mock_output = StringIO()
with mock.patch('sys.stdin', mock_input), \
mock.patch('sys.stdout', mock_output):
result = getpass._raw_input('Password: ', mock_output, mock_input,
'*')
self.assertEqual(result, passwd)
self.assertEqual('Password: ************', mock_output.getvalue())
def test_control_chars_with_echo_char(self):
passwd = 'pass\twd\b'
expect_result = 'pass\tw'
mock_input = StringIO(f'{passwd}\n')
mock_output = StringIO()
with mock.patch('sys.stdin', mock_input), \
mock.patch('sys.stdout', mock_output):
result = getpass._raw_input('Password: ', mock_output, mock_input,
'*')
self.assertEqual(result, expect_result)
self.assertEqual('Password: *******\x08 \x08', mock_output.getvalue())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -0,0 +1,2 @@
Add keyword-only optional argument *echo_char* for :meth:`getpass.getpass`
for optional visual keyboard feedback support. Patch by Semyon Moroz.