Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-77065: Add optional keyword-only argument echochar for getpass.getpass #130496

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
11 changes: 10 additions & 1 deletion Doc/library/getpass.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

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

.. function:: getpass(prompt='Password: ', stream=None)
.. function:: getpass(prompt='Password: ', stream=None, *, echochar=None)

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

The *echochar* argument controls how user input is displayed while typing.
If *echochar* is ``None`` (default), input remains hidden. Otherwise,
*echochar* must be a printable ASCII string and each typed character
is replaced by the former. For example, ``echochar='*'`` will display
asterisks instead of the actual input.

If echo free input is unavailable getpass() falls back to printing
a warning message to *stream* and reading from ``sys.stdin`` and
issuing a :exc:`GetPassWarning`.
Expand All @@ -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
terminal you launched IDLE from rather than the idle window itself.

.. versionchanged:: next
Added the *echochar* parameter for keyboard feedback.

.. exception:: GetPassWarning

A :exc:`UserWarning` subclass issued when password input may be echoed.
Expand Down
9 changes: 9 additions & 0 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,15 @@ getopt
(Contributed by Serhiy Storchaka in :gh:`126390`.)


getpass
-------

* Support keyboard feedback by :func:`getpass.getpass` via the keyword-only
optional argument ``echochar``. Placeholder characters are rendered whenever
a character is entered, and removed when a character is deleted.
(Contributed by Semyon Moroz in :gh:`77065`.)


graphlib
--------

Expand Down
69 changes: 65 additions & 4 deletions Lib/getpass.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""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[, echochar]]) - Prompt for a password, with echo
turned off and optional keyboard feedback.
getuser() - Get the user name from the environment or password database.

GetPassWarning - This UserWarning is issued when getpass() cannot prevent
Expand All @@ -25,13 +26,15 @@
class GetPassWarning(UserWarning): pass


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

Args:
prompt: Written on stream to ask for the input. Default: 'Password: '
stream: A writable file object to display the prompt. Defaults to
the tty. If no tty is available defaults to sys.stderr.
echochar: A string used to mask input (e.g., '*'). If None, input is
hidden.
Returns:
The seKr3t input.
Raises:
Expand All @@ -40,6 +43,8 @@ def unix_getpass(prompt='Password: ', stream=None):

Always restores terminal settings before returning.
"""
_check_echochar(echochar)

passwd = None
with contextlib.ExitStack() as stack:
try:
Expand Down Expand Up @@ -68,12 +73,18 @@ def unix_getpass(prompt='Password: ', stream=None):
old = termios.tcgetattr(fd) # a copy to save
new = old[:]
new[3] &= ~termios.ECHO # 3 == 'lflags'
if echochar:
new[3] &= ~termios.ICANON
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)
if echochar:
passwd = _input_with_echochar(prompt, stream, input,
echochar)
else:
passwd = _raw_input(prompt, stream, input=input)
finally:
termios.tcsetattr(fd, tcsetattr_flags, old)
stream.flush() # issue7208
Expand All @@ -93,10 +104,11 @@ def unix_getpass(prompt='Password: ', stream=None):
return passwd


def win_getpass(prompt='Password: ', stream=None):
def win_getpass(prompt='Password: ', stream=None, *, echochar=None):
"""Prompt for password with echo off, using Windows getwch()."""
if sys.stdin is not sys.__stdin__:
return fallback_getpass(prompt, stream)
_check_echochar(echochar)

for c in prompt:
msvcrt.putwch(c)
Expand All @@ -108,9 +120,15 @@ def win_getpass(prompt='Password: ', stream=None):
if c == '\003':
raise KeyboardInterrupt
if c == '\b':
if echochar and pw:
msvcrt.putch('\b')
msvcrt.putch(' ')
msvcrt.putch('\b')
pw = pw[:-1]
else:
pw = pw + c
if echochar:
msvcrt.putwch(echochar)
msvcrt.putwch('\r')
msvcrt.putwch('\n')
return pw
Expand All @@ -126,6 +144,13 @@ def fallback_getpass(prompt='Password: ', stream=None):
return _raw_input(prompt, stream)


def _check_echochar(echochar):
# ASCII excluding control characters
if echochar and not (echochar.isprintable() and echochar.isascii()):
raise ValueError("'echochar' must be a printable ASCII string, "
f"got: {echochar!r}")


def _raw_input(prompt="", stream=None, input=None):
# This doesn't save the string in the GNU readline history.
if not stream:
Expand All @@ -151,6 +176,42 @@ def _raw_input(prompt="", stream=None, input=None):
return line


def _input_with_echochar(prompt, stream, input, echochar):
if not stream:
stream = sys.stderr
if not input:
input = sys.stdin
prompt = str(prompt)
stream.write(prompt)
stream.flush()
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(echochar)
stream.flush()
eof_pressed = False
return passwd


def getuser():
"""Get the username from the environment or password database.

Expand Down
38 changes: 38 additions & 0 deletions Lib/test/test_getpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,44 @@ def test_falls_back_to_stdin(self):
self.assertIn('Warning', stderr.getvalue())
self.assertIn('Password:', stderr.getvalue())

def test_echochar_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._input_with_echochar') as mock_input:
os_open.return_value = 3
mock_input.return_value = mock_result

result = getpass.unix_getpass(echochar='*')
mock_input.assert_called_once_with('Password: ', textio(), textio(), '*')
self.assertEqual(result, mock_result)

def test_input_with_echochar(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._input_with_echochar('Password: ', mock_output,
mock_input, '*')
self.assertEqual(result, passwd)
self.assertEqual('Password: ************', mock_output.getvalue())

def test_control_chars_with_echochar(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._input_with_echochar('Password: ', mock_output,
mock_input, '*')
self.assertEqual(result, expect_result)
self.assertEqual('Password: *******\x08 \x08', mock_output.getvalue())


if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add keyword-only optional argument *echochar* for :meth:`getpass.getpass`
for optional visual keyboard feedback support. Patch by Semyon Moroz.
Loading