diff options
author | Semyon Moroz <donbarbos@proton.me> | 2025-05-06 15:56:20 +0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-05-06 14:56:20 +0300 |
commit | bf8bbe9a813dd9fc2dd14be06df172b7d26ca1af (patch) | |
tree | bdee42507770fa676f4096347272baf97f7c16aa | |
parent | 53e6d76aa30eb760fb8ff788815f22a0e6c101cd (diff) | |
download | cpython-bf8bbe9a813dd9fc2dd14be06df172b7d26ca1af.tar.gz cpython-bf8bbe9a813dd9fc2dd14be06df172b7d26ca1af.zip |
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>
-rw-r--r-- | Doc/library/getpass.rst | 11 | ||||
-rw-r--r-- | Doc/whatsnew/3.14.rst | 9 | ||||
-rw-r--r-- | Lib/getpass.py | 64 | ||||
-rw-r--r-- | Lib/test/test_getpass.py | 39 | ||||
-rw-r--r-- | Misc/NEWS.d/next/Library/2025-02-24-07-08-11.gh-issue-77065.8uW0Wf.rst | 2 |
5 files changed, 119 insertions, 6 deletions
diff --git a/Doc/library/getpass.rst b/Doc/library/getpass.rst index 3b5296f9ec6..38b78dc3299 100644 --- a/Doc/library/getpass.rst +++ b/Doc/library/getpass.rst @@ -16,7 +16,7 @@ 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 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 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 a warning message to *stream* and reading from ``sys.stdin`` and 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 terminal you launched IDLE from rather than the idle window itself. + .. versionchanged:: next + Added the *echo_char* parameter for keyboard feedback. + .. exception:: GetPassWarning A :exc:`UserWarning` subclass issued when password input may be echoed. diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 9fe14c592bd..8a80f7fe341 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -1195,6 +1195,15 @@ getopt (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 -------- diff --git a/Lib/getpass.py b/Lib/getpass.py index bd0097ced94..f571425e541 100644 --- a/Lib/getpass.py +++ b/Lib/getpass.py @@ -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[, 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. GetPassWarning - This UserWarning is issued when getpass() cannot prevent @@ -25,13 +26,15 @@ __all__ = ["getpass","getuser","GetPassWarning"] 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. 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. + echo_char: A string used to mask input (e.g., '*'). If None, input is + hidden. Returns: The seKr3t input. Raises: @@ -40,6 +43,8 @@ def unix_getpass(prompt='Password: ', stream=None): Always restores terminal settings before returning. """ + _check_echo_char(echo_char) + passwd = None with contextlib.ExitStack() as stack: try: @@ -68,12 +73,16 @@ def unix_getpass(prompt='Password: ', stream=None): old = termios.tcgetattr(fd) # a copy to save new = old[:] new[3] &= ~termios.ECHO # 3 == 'lflags' + if echo_char: + 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) + passwd = _raw_input(prompt, stream, input=input, + echo_char=echo_char) + finally: termios.tcsetattr(fd, tcsetattr_flags, old) stream.flush() # issue7208 @@ -93,10 +102,11 @@ def unix_getpass(prompt='Password: ', stream=None): 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().""" if sys.stdin is not sys.__stdin__: return fallback_getpass(prompt, stream) + _check_echo_char(echo_char) for c in prompt: msvcrt.putwch(c) @@ -108,9 +118,15 @@ def win_getpass(prompt='Password: ', stream=None): if c == '\003': raise KeyboardInterrupt if c == '\b': + if echo_char and pw: + msvcrt.putch('\b') + msvcrt.putch(' ') + msvcrt.putch('\b') pw = pw[:-1] else: pw = pw + c + if echo_char: + msvcrt.putwch(echo_char) msvcrt.putwch('\r') msvcrt.putwch('\n') return pw @@ -126,7 +142,14 @@ def fallback_getpass(prompt='Password: ', stream=None): 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. if not stream: stream = sys.stderr @@ -143,6 +166,8 @@ def _raw_input(prompt="", stream=None, input=None): stream.write(prompt) stream.flush() # 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() if not line: raise EOFError @@ -151,6 +176,35 @@ def _raw_input(prompt="", stream=None, input=None): 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(): """Get the username from the environment or password database. diff --git a/Lib/test/test_getpass.py b/Lib/test/test_getpass.py index 80dda2caaa3..ab36535a1cf 100644 --- a/Lib/test/test_getpass.py +++ b/Lib/test/test_getpass.py @@ -161,6 +161,45 @@ class UnixGetpassTest(unittest.TestCase): self.assertIn('Warning', 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__": unittest.main() diff --git a/Misc/NEWS.d/next/Library/2025-02-24-07-08-11.gh-issue-77065.8uW0Wf.rst b/Misc/NEWS.d/next/Library/2025-02-24-07-08-11.gh-issue-77065.8uW0Wf.rst new file mode 100644 index 00000000000..65d87e9d727 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-02-24-07-08-11.gh-issue-77065.8uW0Wf.rst @@ -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. |