Repository: python/cpython
Pull request #130496: gh-77065: Add optional keyword-only argument `echo_char` for `getpass.getpass`
Author: login: donbarbos
Created: 2025-02-24T03:24:28Z | Merged: 2025-05-06T11:56:21Z



<!-- gh-issue-number: gh-77065 -->
* Issue: gh-77065
<!-- /gh-issue-number -->


<!-- readthedocs-preview cpython-previews start -->
----
📚 Documentation preview 📚: https://cpython-previews--130496.org.readthedocs.build/

<!-- readthedocs-preview cpython-previews end -->


Diff:
diff --git a/Doc/library/getpass.rst b/Doc/library/getpass.rst
index 3b5296f9ec66fa..38b78dc3299a3e 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 767cf9a1f08dc2..973f00b9e70e9d 100644
--- a/Doc/whatsnew/3.14.rst
+++ b/Doc/whatsnew/3.14.rst
@@ -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 ``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 bd0097ced94c5e..f571425e54178a 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 @@
 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 80dda2caaa3331..ab36535a1cfa8a 100644
--- a/Lib/test/test_getpass.py
+++ b/Lib/test/test_getpass.py
@@ -161,6 +161,45 @@ def test_falls_back_to_stdin(self):
             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 00000000000000..65d87e9d727a2c
--- /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.


Reviews:
user: login: picnixz
body: A `mask` is something else for me. So I would either name it "echochar" or "maskchar" or "hidechar", at least with some "char" inside it.

In addition, for termios, you should use `ECHOE` to handle ERASE
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-01T15:29:06Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-01T17:31:03Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-01T17:35:50Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-01T17:45:55Z
user: login: picnixz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-01T17:47:52Z
user: login: picnixz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-01T18:43:18Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-01T18:50:40Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-01T19:06:02Z
user: login: picnixz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-01T19:19:53Z
user: login: picnixz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-01T19:22:16Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-01T19:25:33Z
user: login: picnixz
body: I'm tempted to only allow characters of length 1 for `echochar`, so maybe we can check it. However, for smileys, I don't remember if `len(smiley)` is 1 or 2. Nonetheless, we definitely need tests so please add them.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T17:21:09Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T18:05:08Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T18:08:52Z
user: login: picnixz
body: I still think we at least need tests for Unix. Or if we don't have the infrastructure yet, wait until we have it and then add them.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T19:18:57Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T19:23:46Z
user: login: picnixz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T19:25:12Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T19:26:37Z
user: login: picnixz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T20:11:57Z
user: login: picnixz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T20:13:06Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T06:04:37Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T06:14:51Z
user: login: picnixz
body: Until **tests** are added in this PR, I will not accept it. I have no idea whether the code for Windows will work properly or not. So I would really want tests. Until then, please do not request reviews from me.

~~If the infrastructure is not yet in place, please make it so (either by creating a new issue so that we can work on it or by adding it yourself).~~ NVM, it already exists: https://github.com/python/cpython/pull/130529.
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-23T12:29:04Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T15:21:04Z
user: login: picnixz
body: Can we have tests where we exercise control characters? this is quite important because we want to get the correct feedback
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-30T08:26:45Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T11:09:04Z
user: login: picnixz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-30T11:22:59Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T11:37:49Z
user: login: picnixz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-30T11:41:51Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T11:44:13Z
user: login: gpshead
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T01:14:28Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T04:50:23Z
user: login: hugovk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T10:31:55Z
user: login: hugovk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T10:40:22Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T10:55:27Z
user: login: hugovk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T11:10:53Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T11:12:03Z
user: login: donbarbos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:46:08Z
user: login: picnixz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T14:59:58Z

Review comments:
diff_hunk: @@ -16,7 +16,7 @@
 
 The :mod:`getpass` module provides two functions:
 
-.. function:: getpass(prompt='Password: ', stream=None)
+.. function:: getpass(prompt='Password: ', stream=None, mask=None)
path: Doc/library/getpass.rst
user: login: picnixz
body: ```suggestion
.. function:: getpass(prompt='Password: ', stream=None, *, echochar=None)
```
created_at: 2025-03-01T15:18:33Z
author_association: MEMBER
diff_hunk: @@ -558,6 +558,14 @@ getopt
 * Add support for returning intermixed options and non-option arguments in order.
   (Contributed by Serhiy Storchaka in :gh:`126390`.)
 
+
+getpass
+-------
+
+* Add optional argument *mask* for :meth:`getpass.getpass`.
path: Doc/whatsnew/3.14.rst
user: login: picnixz
body: ```suggestion
* 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.
```
created_at: 2025-03-01T15:23:47Z
author_association: MEMBER
diff_hunk: @@ -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[, mask]]) - Prompt for a password, with echo
+turned off.
path: Lib/getpass.py
user: login: picnixz
body: ```suggestion
getpass(prompt[, stream[, mask]]) - Prompt for a password, with echo
turned off and optional keyboard feedback.
```
created_at: 2025-03-01T15:25:13Z
author_association: MEMBER
diff_hunk: @@ -40,7 +42,7 @@ def unix_getpass(prompt='Password: ', stream=None):
 
     Always restores terminal settings before returning.
     """
-    passwd = None
+    passwd = ""
path: Lib/getpass.py
user: login: picnixz
body: Please revert. A password can be empty.
created_at: 2025-03-01T15:25:44Z
author_association: MEMBER
diff_hunk: @@ -68,17 +70,41 @@ def unix_getpass(prompt='Password: ', stream=None):
                 old = termios.tcgetattr(fd)     # a copy to save
                 new = old[:]
                 new[3] &= ~termios.ECHO  # 3 == 'lflags'
+                if mask:
+                    new[3] &= ~termios.ICANON
path: Lib/getpass.py
user: login: picnixz
body: ```suggestion
                    new[3] |= termios.ICANON
```

Shouldn't it be that way? also, shouldn't you use `ECHOE` so that previous characters are erased as well?
created_at: 2025-03-01T15:27:12Z
author_association: MEMBER
diff_hunk: @@ -68,17 +70,41 @@ def unix_getpass(prompt='Password: ', stream=None):
                 old = termios.tcgetattr(fd)     # a copy to save
                 new = old[:]
                 new[3] &= ~termios.ECHO  # 3 == 'lflags'
+                if mask:
+                    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 not mask:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
+                        return passwd
+
+                    stream.write(prompt)
+                    stream.flush()
+                    while True:
+                        char = input.read(1)
+                        if char == '\n' or char == '\r':
+                            break
+                        if char == '\x03':
+                            raise KeyboardInterrupt
+                        if char == '\x7f' or char == '\b':
+                            if mask and passwd:
+                                stream.write("\b \b" * len(mask))
+                                stream.flush()
+                            passwd = passwd[:-1]
+                        else:
+                            passwd += char
+                            if mask:
+                                stream.write(mask)
+                                stream.flush()
                 finally:
                     termios.tcsetattr(fd, tcsetattr_flags, old)
                     stream.flush()  # issue7208
             except termios.error:
-                if passwd is not None:
+                if passwd:
path: Lib/getpass.py
user: login: picnixz
body: please revert
created_at: 2025-03-01T15:28:02Z
author_association: MEMBER
diff_hunk: @@ -108,9 +134,16 @@ def win_getpass(prompt='Password: ', stream=None):
         if c == '\003':
             raise KeyboardInterrupt
         if c == '\b':
+            if mask and pw:
+                for _ in range(len(mask)):
path: Lib/getpass.py
user: login: picnixz
body: ```suggestion
                for _ in mask:
```
created_at: 2025-03-01T15:28:33Z
author_association: MEMBER
diff_hunk: @@ -40,7 +42,7 @@ def unix_getpass(prompt='Password: ', stream=None):
 
     Always restores terminal settings before returning.
     """
-    passwd = None
+    passwd = ""
path: Lib/getpass.py
user: login: donbarbos
body: yea, but in this case we return an empty string
created_at: 2025-03-01T17:31:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -68,17 +70,41 @@ def unix_getpass(prompt='Password: ', stream=None):
                 old = termios.tcgetattr(fd)     # a copy to save
                 new = old[:]
                 new[3] &= ~termios.ECHO  # 3 == 'lflags'
+                if mask:
+                    new[3] &= ~termios.ICANON
path: Lib/getpass.py
user: login: donbarbos
body: This line is responsible for disabling buffering. when using your proposal mask chars will be displayed only after pressing `Enter`.
created_at: 2025-03-01T17:35:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,7 +42,7 @@ def unix_getpass(prompt='Password: ', stream=None):
 
     Always restores terminal settings before returning.
     """
-    passwd = None
+    passwd = ""
path: Lib/getpass.py
user: login: donbarbos
body: we also using `passwd = ""` in `win_getpass`
created_at: 2025-03-01T17:45:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,7 +42,7 @@ def unix_getpass(prompt='Password: ', stream=None):
 
     Always restores terminal settings before returning.
     """
-    passwd = None
+    passwd = ""
path: Lib/getpass.py
user: login: picnixz
body: > yea, but in this case we return an empty string

And that's a valid password. Even if it's a weak one, it's still a valid password. So, no need to actually change the current behaviour. Otherwise, let's address it separately.
created_at: 2025-03-01T17:47:52Z
author_association: MEMBER
diff_hunk: @@ -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. If *echochar* is
+   a string, each typed character is replaced with the given string.
+   For example, ``echochar='*'`` will display asterisks instead of the actual
+   input.
path: Doc/library/getpass.rst
user: login: picnixz
body: You should add an `.. versionchanged:: next` with a note like "Add the *echochar* parameter for keyboard feedback."
created_at: 2025-03-01T18:30:48Z
author_association: MEMBER
diff_hunk: @@ -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.
path: Lib/getpass.py
user: login: picnixz
body: ```suggestion
      echochar: A string used to mask input (e.g., '*').  If None, input is
                hidden.
```
created_at: 2025-03-01T18:31:12Z
author_association: MEMBER
diff_hunk: @@ -68,12 +71,37 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
+                        return passwd
+
+                    passwd = ""
path: Lib/getpass.py
user: login: picnixz
body: If the rest fails, we still need to fallback to somehow `passwd = fallback_getpass(prompt, stream)`. I think? Also, the function becomes more and more unreadable with too many nested levels, so maybe we could split this part into a separate function?
created_at: 2025-03-01T18:35:14Z
author_association: MEMBER
diff_hunk: @@ -68,12 +71,37 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
+                        return passwd
+
+                    passwd = ""
+                    stream.write(prompt)
+                    stream.flush()
+                    while True:
+                        char = input.read(1)
+                        if char == '\n' or char == '\r':
+                            break
+                        if char == '\x03':
+                            raise KeyboardInterrupt
+                        if char == '\x7f' or char == '\b':
+                            if echochar and passwd:
+                                stream.write("\b \b" * len(echochar))
+                                stream.flush()
+                            passwd = passwd[:-1]
+                        else:
+                            passwd += char
+                            if echochar:
+                                stream.write(echochar)
+                                stream.flush()
path: Lib/getpass.py
user: login: picnixz
body: ```suggestion
                            stream.write(echochar)
                            stream.flush()
```
created_at: 2025-03-01T18:36:21Z
author_association: MEMBER
diff_hunk: @@ -68,12 +71,37 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
+                        return passwd
+
+                    passwd = ""
+                    stream.write(prompt)
+                    stream.flush()
+                    while True:
+                        char = input.read(1)
+                        if char == '\n' or char == '\r':
+                            break
+                        if char == '\x03':
+                            raise KeyboardInterrupt
+                        if char == '\x7f' or char == '\b':
+                            if echochar and passwd:
+                                stream.write("\b \b" * len(echochar))
+                                stream.flush()
+                            passwd = passwd[:-1]
path: Lib/getpass.py
user: login: picnixz
body: ```suggestion
                            if passwd:
                                stream.write("\b \b" * len(echochar))
                                stream.flush()
                            passwd = passwd[:-1]
```
created_at: 2025-03-01T18:36:29Z
author_association: MEMBER
diff_hunk: @@ -68,12 +71,37 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
+                        return passwd
+
+                    passwd = ""
+                    stream.write(prompt)
+                    stream.flush()
+                    while True:
+                        char = input.read(1)
+                        if char == '\n' or char == '\r':
+                            break
+                        if char == '\x03':
+                            raise KeyboardInterrupt
+                        if char == '\x7f' or char == '\b':
+                            if echochar and passwd:
+                                stream.write("\b \b" * len(echochar))
path: Lib/getpass.py
user: login: picnixz
body: IIUC, this tells termios to remove the last char right?
created_at: 2025-03-01T18:42:13Z
author_association: MEMBER
diff_hunk: @@ -68,12 +71,37 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
+                        return passwd
+
+                    passwd = ""
+                    stream.write(prompt)
+                    stream.flush()
+                    while True:
+                        char = input.read(1)
+                        if char == '\n' or char == '\r':
+                            break
+                        if char == '\x03':
+                            raise KeyboardInterrupt
+                        if char == '\x7f' or char == '\b':
+                            if echochar and passwd:
+                                stream.write("\b \b" * len(echochar))
path: Lib/getpass.py
user: login: donbarbos
body: right
created_at: 2025-03-01T18:50:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -68,12 +71,37 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
+                        return passwd
+
+                    passwd = ""
path: Lib/getpass.py
user: login: donbarbos
body: I'm all for separating this part into a separate function, but what should we call it, maybe `_echochar_input(prompt, stream, input, echochar)`?

regarding fallback, we still have calls to `fallback_getpass` in except blocks. I didn't remove it
created_at: 2025-03-01T19:06:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -68,12 +71,37 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
+                        return passwd
+
+                    passwd = ""
path: Lib/getpass.py
user: login: picnixz
body: Oh yes. Sorry (forget the previous comment)
created_at: 2025-03-01T19:19:53Z
author_association: MEMBER
diff_hunk: @@ -68,12 +71,37 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
+                        return passwd
+
+                    passwd = ""
path: Lib/getpass.py
user: login: picnixz
body: As for how to call it, maybe `_input_with_echochar`? or `_input_with_feedback`
created_at: 2025-03-01T19:22:16Z
author_association: MEMBER
diff_hunk: @@ -68,12 +71,37 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
+                        return passwd
+
+                    passwd = ""
path: Lib/getpass.py
user: login: donbarbos
body: I'll change to `_input_with_echochar`
created_at: 2025-03-01T19:25:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,2 @@
+Add keyword-only optional argument *echochar* for :meth:`getpass.getpass`.
+Patch by Semyon Moroz.
path: Misc/NEWS.d/next/Library/2025-02-24-07-08-11.gh-issue-77065.8uW0Wf.rst
user: login: picnixz
body: ```suggestion
Add keyword-only optional argument *echochar* for :meth:`getpass.getpass`.
for optional visual keyboard feedback support. Patch by Semyon Moroz.
```
created_at: 2025-03-13T17:16:49Z
author_association: MEMBER
diff_hunk: @@ -151,6 +169,33 @@ def _raw_input(prompt="", stream=None, input=None):
     return line
 
 
+def _input_with_echochar(prompt="", stream=None, input=None, echochar=""):
path: Lib/getpass.py
user: login: picnixz
body: this function is only called with all its parameters, so there is no need for defaults (and we also don't want defaults as `echochar=""` is not expected)
created_at: 2025-03-13T17:17:57Z
author_association: MEMBER
diff_hunk: @@ -108,9 +119,16 @@ def win_getpass(prompt='Password: ', stream=None):
         if c == '\003':
             raise KeyboardInterrupt
         if c == '\b':
+            if echochar and pw:
+                for _ in echochar:
+                    msvcrt.putwch('\b')
path: Lib/getpass.py
user: login: picnixz
body: Is there a function to write multiple characters for msvcrt?
created_at: 2025-03-13T17:19:32Z
author_association: MEMBER
diff_hunk: @@ -68,12 +71,20 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
path: Lib/getpass.py
user: login: picnixz
body: Why the additioanal `stream.write('\n')`?
created_at: 2025-03-13T17:19:58Z
author_association: MEMBER
diff_hunk: @@ -108,9 +119,16 @@ def win_getpass(prompt='Password: ', stream=None):
         if c == '\003':
             raise KeyboardInterrupt
         if c == '\b':
+            if echochar and pw:
+                for _ in echochar:
+                    msvcrt.putwch('\b')
path: Lib/getpass.py
user: login: donbarbos
body: [It seems](https://docs.python.org/dev/library/msvcrt.html) that there is not
created_at: 2025-03-13T18:05:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -68,12 +71,20 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
path: Lib/getpass.py
user: login: donbarbos
body: It was added because I added `return` (on next line) and there should be a translation of the correction before it (like on 103,104 lines)
created_at: 2025-03-13T18:08:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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.
path: Doc/library/getpass.rst
user: login: picnixz
body: Let's also indicate that `echochar` should be an ASCII character.
created_at: 2025-03-13T19:15:26Z
author_association: MEMBER
diff_hunk: @@ -40,6 +43,10 @@ def unix_getpass(prompt='Password: ', stream=None):
 
     Always restores terminal settings before returning.
     """
+    if echochar and not echochar.isascii():
+        return ValueError(f"Invalid echochar: {echochar}. "
+                          "ASCII character expected.")
path: Lib/getpass.py
user: login: picnixz
body: ```suggestion
        return ValueError(f"'echochar' must be ASCII, got: {echochar!r}")
```
created_at: 2025-03-13T19:15:53Z
author_association: MEMBER
diff_hunk: @@ -68,12 +71,20 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
path: Lib/getpass.py
user: login: picnixz
body: Instead of a `return` you can do something like this:

```py
if echochar:
    passwd = _input_with_echochar(...)
else:
    passwd = _raw_input(...)
```
created_at: 2025-03-13T19:16:42Z
author_association: MEMBER
diff_hunk: @@ -93,10 +108,13 @@ 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)
+    if echochar and not echochar.isascii():
+        return ValueError(f"Invalid echochar: {echochar}. "
+                          "ASCII character expected.")
path: Lib/getpass.py
user: login: picnixz
body: Maybe let's have a single function checking validity of `echochar`.
created_at: 2025-03-13T19:17:39Z
author_association: MEMBER
diff_hunk: @@ -108,9 +126,15 @@ def win_getpass(prompt='Password: ', stream=None):
         if c == '\003':
             raise KeyboardInterrupt
         if c == '\b':
+            if echochar and pw:
+                msvcrt.putwch('\b')
+                msvcrt.putwch(' ')
+                msvcrt.putwch('\b')
path: Lib/getpass.py
user: login: picnixz
body: ```suggestion
                msvcrt.putch('\b')
                msvcrt.putch(' ')
                msvcrt.putch('\b')
```

Those are not wide chars, so we don't need to use `putwch`.
created_at: 2025-03-13T19:18:06Z
author_association: MEMBER
diff_hunk: @@ -93,10 +108,13 @@ 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)
+    if echochar and not echochar.isascii():
+        return ValueError(f"Invalid echochar: {echochar}. "
+                          "ASCII character expected.")
path: Lib/getpass.py
user: login: donbarbos
body: I like it, but I'm still not sure about the condition because '\b' is also ASCII character
created_at: 2025-03-13T19:23:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -93,10 +108,13 @@ 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)
+    if echochar and not echochar.isascii():
+        return ValueError(f"Invalid echochar: {echochar}. "
+                          "ASCII character expected.")
path: Lib/getpass.py
user: login: picnixz
body: You can also exclude some control characters by checking the range then. 
created_at: 2025-03-13T19:25:12Z
author_association: MEMBER
diff_hunk: @@ -68,12 +71,20 @@ 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 not echochar:
+                        passwd = _raw_input(prompt, stream, input=input)
+                        stream.write('\n')
path: Lib/getpass.py
user: login: donbarbos
body: you are right, it's just that initially there was a large structure there (before it was separated into a separate function) and I didn't want to move it further to the right
thanks for reminding me
created_at: 2025-03-13T19:26:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,6 +146,13 @@ def fallback_getpass(prompt='Password: ', stream=None):
     return _raw_input(prompt, stream)
 
 
+def _is_ascii(echochar):
path: Lib/getpass.py
user: login: picnixz
body: Let's name it `_is_valid_echochar` instead
created_at: 2025-03-13T20:05:40Z
author_association: MEMBER
diff_hunk: @@ -126,6 +146,13 @@ def fallback_getpass(prompt='Password: ', stream=None):
     return _raw_input(prompt, stream)
 
 
+def _is_ascii(echochar):
+    # ASCII excluding control characters
+    if echochar and (32 <= ord(echochar) <= 127):
+        return True
+    return False
path: Lib/getpass.py
user: login: picnixz
body: Maybe also use `isprintable()`?
created_at: 2025-03-13T20:05:51Z
author_association: MEMBER
diff_hunk: @@ -40,6 +43,9 @@ def unix_getpass(prompt='Password: ', stream=None):
 
     Always restores terminal settings before returning.
     """
+    if not _is_ascii(echochar):
+        return ValueError(f"'echochar' must be ASCII, got: {echochar!r}")
path: Lib/getpass.py
user: login: picnixz
body: An alternative is to have `_check_echochar(echochar)` which would raise the ValueError.
created_at: 2025-03-13T20:06:43Z
author_association: MEMBER
diff_hunk: @@ -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. If *echochar* is
+   a string, each typed character is replaced with the given string. But this
+   string must be ASCII character. For example, ``echochar='*'`` will display
+   asterisks instead of the actual input.
path: Doc/library/getpass.rst
user: login: picnixz
body: ```suggestion
   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.
```
created_at: 2025-03-13T20:09:55Z
author_association: MEMBER
diff_hunk: @@ -126,6 +146,13 @@ def fallback_getpass(prompt='Password: ', stream=None):
     return _raw_input(prompt, stream)
 
 
+def _is_ascii(echochar):
+    # ASCII excluding control characters
+    if echochar and (32 <= ord(echochar) <= 127):
path: Lib/getpass.py
user: login: picnixz
body: ```suggestion
    if echochar is None or (32 <= ord(echochar) <= 127):
```
created_at: 2025-03-13T20:13:06Z
author_association: MEMBER
diff_hunk: @@ -126,6 +146,13 @@ def fallback_getpass(prompt='Password: ', stream=None):
     return _raw_input(prompt, stream)
 
 
+def _is_ascii(echochar):
+    # ASCII excluding control characters
+    if echochar and (32 <= ord(echochar) <= 127):
path: Lib/getpass.py
user: login: donbarbos
body: `echochar` is None by default. why?
created_at: 2025-03-14T06:04:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,6 +146,13 @@ def fallback_getpass(prompt='Password: ', stream=None):
     return _raw_input(prompt, stream)
 
 
+def _is_ascii(echochar):
+    # ASCII excluding control characters
+    if echochar and (32 <= ord(echochar) <= 127):
path: Lib/getpass.py
user: login: donbarbos
body: It should work only if we pass `echochar` arg
created_at: 2025-03-14T06:14:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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)
path: Lib/getpass.py
user: login: picnixz
body: Can `fallback_getpass` work with an echochar?
created_at: 2025-03-23T12:27:37Z
author_association: MEMBER
diff_hunk: @@ -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)
path: Lib/getpass.py
user: login: donbarbos
body: unfortunately no. we use `fallback_getpass` as a stub if it was not possible to import `termios` and `msvcrt` so it does not support changes in input values and shows the input values
created_at: 2025-03-23T15:21:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -616,6 +616,15 @@ graphlib
   (Contributed by Daniel Pope in :gh:`130914`)
 
 
+getpass
path: Doc/whatsnew/3.14.rst
user: login: picnixz
body: Can you put this one before the graphlib section now?
created_at: 2025-03-30T08:16:29Z
author_association: MEMBER
diff_hunk: @@ -126,6 +144,12 @@ 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(f"'echochar' must be ASCII, got: {echochar!r}")
path: Lib/getpass.py
user: login: picnixz
body: ```suggestion
        raise ValueError("'echochar' must be a printable ASCII string,
                         f"got: {echochar!r}")
```

Maybe like that?
created_at: 2025-03-30T08:17:50Z
author_association: MEMBER
diff_hunk: @@ -151,6 +175,33 @@ 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 = ""
+    while True:
+        char = input.read(1)
+        if char == '\n' or char == '\r':
+            break
+        if char == '\x03':
path: Lib/getpass.py
user: login: picnixz
body: how should we handle `^D`? and/or nullbytes?
created_at: 2025-03-30T08:23:57Z
author_association: MEMBER
diff_hunk: @@ -151,6 +175,33 @@ 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 = ""
+    while True:
+        char = input.read(1)
+        if char == '\n' or char == '\r':
+            break
+        if char == '\x03':
+            raise KeyboardInterrupt
+        if char == '\x7f' or char == '\b':
path: Lib/getpass.py
user: login: picnixz
body: `\x7f` is the DEL character, but AFAIR, `DEL` deletes the character on the *right*, not the left. So it shouldn't matter here? (`\b` is backspace so it's fine though).
created_at: 2025-03-30T08:26:06Z
author_association: MEMBER
diff_hunk: @@ -151,6 +175,33 @@ 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 = ""
+    while True:
+        char = input.read(1)
+        if char == '\n' or char == '\r':
+            break
+        if char == '\x03':
+            raise KeyboardInterrupt
+        if char == '\x7f' or char == '\b':
path: Lib/getpass.py
user: login: donbarbos
body: as far as i know the character that will be sent depends on the context. for example when i press Backspace on my keyboard i get `\x07` so i will leave handle of this char
created_at: 2025-03-30T11:09:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,6 +175,33 @@ 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 = ""
+    while True:
+        char = input.read(1)
+        if char == '\n' or char == '\r':
+            break
+        if char == '\x03':
+            raise KeyboardInterrupt
+        if char == '\x7f' or char == '\b':
path: Lib/getpass.py
user: login: picnixz
body: Ah right, actually Wikipedia tells me that:

> Terminal emulators may produce DEL when ← Backspace key or Control+← Backspace or Control+? are typed. 

So maybe let's keep this here.
created_at: 2025-03-30T11:22:59Z
author_association: MEMBER
diff_hunk: @@ -151,6 +175,33 @@ 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 = ""
+    while True:
+        char = input.read(1)
+        if char == '\n' or char == '\r':
+            break
+        if char == '\x03':
path: Lib/getpass.py
user: login: donbarbos
body: I checked what the behavior is if you don't pass `echochar` and there you need to double-click `^D` to trigger the input interrupt. so I made the same behavior.

about nullbytes I don't know how to reproduce it so I decided to ignore such input
created_at: 2025-03-30T11:37:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,6 +175,33 @@ 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 = ""
+    while True:
+        char = input.read(1)
+        if char == '\n' or char == '\r':
+            break
+        if char == '\x03':
path: Lib/getpass.py
user: login: picnixz
body: It could be from another stream. Here, input is an arbitrary stream. Mocking streams, receiving from connection or just finding embedded null bytes from somewhere.
created_at: 2025-03-30T11:41:51Z
author_association: MEMBER
diff_hunk: @@ -151,6 +175,33 @@ 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 = ""
+    while True:
+        char = input.read(1)
+        if char == '\n' or char == '\r':
+            break
+        if char == '\x03':
path: Lib/getpass.py
user: login: donbarbos
body: Got it, but is it okay to ignore it?
created_at: 2025-03-30T11:44:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,6 +176,42 @@ def _raw_input(prompt="", stream=None, input=None):
     return line
 
 
+def _input_with_echochar(prompt, stream, input, echochar):
path: Lib/getpass.py
user: login: gpshead
body: rather than duplicating so much of _raw_input's logic, I suggest making a _readline_with_echochar() function and just conditionally calling that vs the existing input.readline() call within _raw_input().
created_at: 2025-05-05T01:13:20Z
author_association: MEMBER
diff_hunk: @@ -151,6 +176,42 @@ def _raw_input(prompt="", stream=None, input=None):
     return line
 
 
+def _input_with_echochar(prompt, stream, input, echochar):
path: Lib/getpass.py
user: login: donbarbos
body: sounds reasonable, please check my diff
created_at: 2025-05-05T04:50:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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)
path: Doc/library/getpass.rst
user: login: hugovk
body: Let's add an underscore throughout for readability and accessibility:

```suggestion
.. function:: getpass(prompt='Password: ', stream=None, *, echo_char=None)
```
created_at: 2025-05-06T10:27:32Z
author_association: MEMBER
diff_hunk: @@ -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
path: Doc/library/getpass.rst
user: login: hugovk
body: ```suggestion
   is replaced by it. For example, ``echochar='*'`` will display
```
created_at: 2025-05-06T10:33:20Z
author_association: MEMBER
diff_hunk: @@ -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)
path: Doc/library/getpass.rst
user: login: donbarbos
body: thank you. left `echochar` only in function names for tests

created_at: 2025-05-06T10:55:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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)
path: Doc/library/getpass.rst
user: login: hugovk
body: Let's update those too: we need to read them as well, and if we search for the name, they will also show up. 
created_at: 2025-05-06T11:10:51Z
author_association: MEMBER
diff_hunk: @@ -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)
path: Doc/library/getpass.rst
user: login: donbarbos
body: done
created_at: 2025-05-06T11:12:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,9 +126,15 @@ def win_getpass(prompt='Password: ', stream=None):
         if c == '\003':
             raise KeyboardInterrupt
         if c == '\b':
+            if echochar and pw:
+                msvcrt.putwch('\b')
+                msvcrt.putwch(' ')
+                msvcrt.putwch('\b')
path: Lib/getpass.py
user: login: donbarbos
body: @picnixz I apologize again, as I was so eager to add this to 3.14, but I only have Windows now

the thing is that putch does not accept control characters, so you need to use putwch.
you can easily check this by simply trying to erase the character, and it will immediately give you an error:
```
msvcrt.putch('\b')
~~~~~~~~~~~~~^^^^^^
TypeError: putch() argument must be a byte string of length 1, not str
```
created_at: 2025-05-15T14:46:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,9 +126,15 @@ def win_getpass(prompt='Password: ', stream=None):
         if c == '\003':
             raise KeyboardInterrupt
         if c == '\b':
+            if echochar and pw:
+                msvcrt.putwch('\b')
+                msvcrt.putwch(' ')
+                msvcrt.putwch('\b')
path: Lib/getpass.py
user: login: picnixz
body: Oh my bad!
created_at: 2025-05-15T14:59:57Z
author_association: MEMBER

PR comments:
user: login: donbarbos
created_at: 2025-02-27T21:37:04Z
body: @picnixz sorry for choosing you, I just haven't found anyone else from the core team who is active yet (I looked at who is involved in issue and experts table). could you review?
author_association: CONTRIBUTOR
user: login: picnixz
created_at: 2025-03-01T15:17:25Z
body: You can ping me for anything that is remotely related to security and cryptography
author_association: MEMBER
user: login: bedevere-app[bot]
created_at: 2025-03-01T15:29:10Z
body: <!-- changes-requested -->
A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated.

Once you have made the requested changes, please leave a comment on this pull request containing the phrase `I have made the requested changes; please review again`. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.
<!-- /changes-requested -->



author_association: NONE
user: login: donbarbos
created_at: 2025-03-01T17:40:51Z
body: @picnixz I chose `mask` because it was used in the issue examples and also it seems that `*char` assumes one character while our argument can take arbitrary length. But it's not very important for me.
As for using keyword-only argument, I don't quite understand why that is.

author_association: CONTRIBUTOR
user: login: picnixz
created_at: 2025-03-01T17:50:11Z
body: >  As for using keyword-only argument, I don't quite understand why that is.

Because if you were to use it, you would write `getpass(mask="*")`, likely not changing the prompt nor the stream value. And keyword-only arguments is good for future compatibility (e.g., if we want to add more options, we are not held back by the order of the arguments)
author_association: MEMBER
user: login: picnixz
created_at: 2025-03-01T17:53:37Z
body: > I chose mask because it was used in the issue examples and also it seems that *char 

Usually, you expect 1 character indeed. I don't think we should write something else than 1 char (or maybe we do but it's because of colors?) I would not expect *words* to be put as placeholders. Maybe colorized characters, which in this case are allowed to be strings of length > 1, but not real words.

Also, what we are echoing is really the corresponding password *character*. For *each* character, we use that placeholder. `mask` is really not a good idea because it could hint that we would (bitwise) mask the password (namely perform some `&` operation). But this is something we can think of at the end.
author_association: MEMBER
user: login: donbarbos
created_at: 2025-03-01T18:14:56Z
body: I have made the requested changes; please review again
author_association: CONTRIBUTOR
user: login: bedevere-app[bot]
created_at: 2025-03-01T18:15:00Z
body: Thanks for making the requested changes!

@picnixz: please review the changes made to this pull request.

author_association: NONE
user: login: donbarbos
created_at: 2025-03-01T18:33:25Z
body: @picnixz I just thought maybe we also should to add `, *, echochar=None` to `getpass.fallback_getpass` to have a unified interface
author_association: CONTRIBUTOR
user: login: picnixz
created_at: 2025-03-01T19:22:31Z
body: I'll review more tomorrow
author_association: MEMBER
user: login: donbarbos
created_at: 2025-03-02T18:30:53Z
body: @picnixz I think this is ready
author_association: CONTRIBUTOR
user: login: picnixz
created_at: 2025-03-02T18:41:20Z
body: Then I'll review more tomorrow xD or maybe this night. I'm a bit tired now
author_association: MEMBER
user: login: donbarbos
created_at: 2025-03-07T07:54:57Z
body: @picnixz could you review? (all your suggestions were accepted)
author_association: CONTRIBUTOR
user: login: picnixz
created_at: 2025-03-07T16:25:28Z
body: I am travelling so I will review this when I'm back next week (I want to review this on my PC so that I can easily check other things at the same time). Sorry!
author_association: MEMBER
user: login: donbarbos
created_at: 2025-03-13T17:27:58Z
body: length of the emoji can be even longer if it is compound.

and it seems there is no objective reason to limit the length for `echochar`

I also opened a separate issue #130524 for testing this module as they don't cover much
author_association: CONTRIBUTOR
user: login: picnixz
created_at: 2025-03-13T17:38:59Z
body: > and it seems there is no objective reason to limit the length for echochar

There is. Because the purpose of this feature is visual feedback to help *counting* the number of characters. However, it doesn't seem useful to have a text being replaced. No CLI would do that honestly... For instance, Java uses a single character as well. And usually people would want to use `echochar='*'`, not some fancy string. I really don't want to support non-ASCII string or string that could mess up the input. What if I use `echochar='\b'` for instance? or some control character instead? In sudo, it's hardcoded with `*`: https://github.com/sudo-project/sudo/blob/a4f31cff2b43cc0e5ad6b5c19ef5dd9a64bd883c/src/tgetpass.c#L416. Also, for non-ASCII characters (smileys for instance), we also need to have a fallback in case the encoding fails in the user's locale I think (not everything is encoded similarly depending on the locale).

So to ease our life, I would advise accepting for now simple ASCII characters.
author_association: MEMBER
user: login: donbarbos
created_at: 2025-03-13T18:23:35Z
body: yeah, it looks funny when we use '\b' as `echochar` but I don't think it's a problem, it looks like a feature :)

but if you suggest to support only ASCII characters at least for now I guess we can use `.isascii` method, right?
author_association: CONTRIBUTOR
user: login: donbarbos
created_at: 2025-03-13T18:46:20Z
body: @picnixz maybe we should use `UnicodeWarning` if echochar and not echochar.isascii()? or just raise `ValueError`?
author_association: CONTRIBUTOR
user: login: picnixz
created_at: 2025-03-13T19:07:24Z
body: > just raise ValueError

Yes, we should just validate `echochar` before doing anything, and then raise `ValueError` if it's not ascii. Then use it normally.
author_association: MEMBER
user: login: donbarbos
created_at: 2025-03-13T19:56:33Z
body: cc @picnixz 

EDIT: I thought maybe I could do the `echochar` check the other way around, that is, allow everything except `ord(echochar)<32`

(I'll continue tomorrow)

author_association: CONTRIBUTOR
user: login: bedevere-app[bot]
created_at: 2025-03-23T12:29:08Z
body: <!-- changes-requested -->
A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated.

Once you have made the requested changes, please leave a comment on this pull request containing the phrase `I have made the requested changes; please review again`. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.
<!-- /changes-requested -->



author_association: NONE
user: login: picnixz
created_at: 2025-03-23T12:30:11Z
body: NVM, I forgot that there was an open PR for that. However it hadn't advanced since then. However, I would want to have tests for Unix.
author_association: MEMBER
user: login: donbarbos
created_at: 2025-03-25T23:18:34Z
body: I have made the requested changes; please review again
author_association: CONTRIBUTOR
user: login: bedevere-app[bot]
created_at: 2025-03-25T23:18:39Z
body: Thanks for making the requested changes!

@picnixz: please review the changes made to this pull request.

author_association: NONE
user: login: donbarbos
created_at: 2025-03-29T14:15:51Z
body: @picnixz, I added test cases, what do you think about them?
author_association: CONTRIBUTOR
user: login: donbarbos
created_at: 2025-03-30T13:39:44Z
body: > Can we have tests where we exercise control characters? this is quite important because we want to get the correct feedback

I added test case with control chars `\t` and `\b`. but `mock_output.getvalue()` have `***\x08 \x08` in the end of string without removing `*` unlike the real console where `*` is removed by itself and `\x08` is not displayed
author_association: CONTRIBUTOR
user: login: donbarbos
created_at: 2025-04-19T11:25:29Z
body: > > Can we have tests where we exercise control characters? this is quite important because we want to get the correct feedback
> 
> I added test case with control chars `\t` and `\b`. but `mock_output.getvalue()` have `***\x08 \x08` in the end of string without removing `*` unlike the real console where `*` is removed by itself and `\x08` is not displayed

cc @picnixz
author_association: CONTRIBUTOR
user: login: donbarbos
created_at: 2025-05-04T16:32:35Z
body: @picnixz It seems that all the tests that you requested already exist, but maybe it will be possible to add more since there is already an issue for covering the module with tests

I would like to have time to accept before feature freeze :-)
author_association: CONTRIBUTOR
user: login: picnixz
created_at: 2025-05-04T16:36:58Z
body: I am travelling (I'm back after the beta release) so I won't merge anything on mobile. I'll defer this to @gpshead, sorry but it passed under my radar :( (in the future, a request for review is more likely to be noticed)
author_association: MEMBER
user: login: donbarbos
created_at: 2025-05-06T11:58:50Z
body: Thank you everyone!
author_association: CONTRIBUTOR