Skip to content

Hide password during typing with a new prompt input #1962

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

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion examples/prompts/get-password.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
from prompt_toolkit import prompt

if __name__ == "__main__":
password = prompt("Password: ", is_password=True)
password = prompt("Password: ", is_password=True, hide_password=True)
print(f"You said: {password}")
24 changes: 21 additions & 3 deletions src/prompt_toolkit/shortcuts/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ class PromptSession(Generic[_T]):
When True (the default), automatically wrap long lines instead of
scrolling horizontally.
:param is_password: Show asterisks instead of the actual typed characters.
:param hide_password: Hide the password, rather than showing asterisks.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to accept password_character here instead of hide_password? It will be more flexible.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Undoubtedly, but from an user perspective hide_password is more clear.

Besides, is there any valuable use-case where the password is displayed as something else, e.g. xxxxx or 11111?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can imagine that some might want to render it as Unicode bullets, because that's more beautiful.

Like password_character="•"

I mainly want to avoid having too many redundant arguments here. We have to repeat them in multiple places and the signature gets pretty long. If at some point, some users want to configure this, we'll have both hide_password and password_character.

Honestly, I'm not sure myself. If there was some prior art in other libraries, that could convince me to go one way or another.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative would be to use both flags, forcing password_character to be an empty string if hide_password is True.
In this way users could either use the flag hide_password if they don't care about how the password is displayed on screen, or the flag password_character if they want to customize the appearance, as youy suggested.

:param editing_mode: ``EditingMode.VI`` or ``EditingMode.EMACS``.
:param vi_mode: `bool`, if True, Identical to ``editing_mode=EditingMode.VI``.
:param complete_while_typing: `bool` or
Expand Down Expand Up @@ -335,10 +336,10 @@ class PromptSession(Generic[_T]):
"lexer",
"completer",
"complete_in_thread",
"is_password",
"editing_mode",
"key_bindings",
"is_password",
"hide_password",
"bottom_toolbar",
"style",
"style_transformation",
Expand Down Expand Up @@ -377,6 +378,7 @@ def __init__(
multiline: FilterOrBool = False,
wrap_lines: FilterOrBool = True,
is_password: FilterOrBool = False,
hide_password: bool = False,
vi_mode: bool = False,
editing_mode: EditingMode = EditingMode.EMACS,
complete_while_typing: FilterOrBool = True,
Expand Down Expand Up @@ -435,6 +437,7 @@ def __init__(
self.completer = completer
self.complete_in_thread = complete_in_thread
self.is_password = is_password
self.hide_password = hide_password
self.key_bindings = key_bindings
self.bottom_toolbar = bottom_toolbar
self.style = style
Expand Down Expand Up @@ -555,13 +558,17 @@ def _create_layout(self) -> Layout:
def display_placeholder() -> bool:
return self.placeholder is not None and self.default_buffer.text == ""

password_character = "" if self.hide_password else "*"

all_input_processors = [
HighlightIncrementalSearchProcessor(),
HighlightSelectionProcessor(),
ConditionalProcessor(
AppendAutoSuggestion(), has_focus(default_buffer) & ~is_done
),
ConditionalProcessor(PasswordProcessor(), dyncond("is_password")),
ConditionalProcessor(
PasswordProcessor(char=password_character), dyncond("is_password")
),
DisplayMultipleCursors(),
# Users can insert processors here.
DynamicProcessor(lambda: merge_processors(self.input_processors or [])),
Expand Down Expand Up @@ -866,6 +873,7 @@ def prompt(
completer: Completer | None = None,
complete_in_thread: bool | None = None,
is_password: bool | None = None,
hide_password: bool | None = None,
key_bindings: KeyBindingsBase | None = None,
bottom_toolbar: AnyFormattedText | None = None,
style: BaseStyle | None = None,
Expand Down Expand Up @@ -961,6 +969,8 @@ class itself. For these, passing in ``None`` will keep the current
self.complete_in_thread = complete_in_thread
if is_password is not None:
self.is_password = is_password
if hide_password is not None:
self.hide_password = hide_password
if key_bindings is not None:
self.key_bindings = key_bindings
if bottom_toolbar is not None:
Expand Down Expand Up @@ -1103,6 +1113,7 @@ async def prompt_async(
completer: Completer | None = None,
complete_in_thread: bool | None = None,
is_password: bool | None = None,
hide_password: bool | None = None,
key_bindings: KeyBindingsBase | None = None,
bottom_toolbar: AnyFormattedText | None = None,
style: BaseStyle | None = None,
Expand Down Expand Up @@ -1155,6 +1166,8 @@ async def prompt_async(
self.complete_in_thread = complete_in_thread
if is_password is not None:
self.is_password = is_password
if hide_password is not None:
self.hide_password = hide_password
if key_bindings is not None:
self.key_bindings = key_bindings
if bottom_toolbar is not None:
Expand Down Expand Up @@ -1376,6 +1389,7 @@ def prompt(
completer: Completer | None = None,
complete_in_thread: bool | None = None,
is_password: bool | None = None,
hide_password: bool | None = None,
key_bindings: KeyBindingsBase | None = None,
bottom_toolbar: AnyFormattedText | None = None,
style: BaseStyle | None = None,
Expand Down Expand Up @@ -1420,7 +1434,11 @@ def prompt(
"""
# The history is the only attribute that has to be passed to the
# `PromptSession`, it can't be passed into the `prompt()` method.
session: PromptSession[str] = PromptSession(history=history)
# The `hide_password` is needed by the layout, so must be provided
# in the init as well.
session: PromptSession[str] = PromptSession(
history=history, hide_password=hide_password or False
)

return session.prompt(
message,
Expand Down
58 changes: 57 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import io
from functools import partial

import pytest
Expand All @@ -18,6 +19,7 @@
from prompt_toolkit.key_binding.bindings.named_commands import prefix_meta
from prompt_toolkit.key_binding.key_bindings import KeyBindings
from prompt_toolkit.output import DummyOutput
from prompt_toolkit.output.plain_text import PlainTextOutput
from prompt_toolkit.shortcuts import PromptSession


Expand All @@ -29,6 +31,33 @@ def _history():
return h


def _feed_cli_with_password(text, check_line_ending=True, hide_password=False):
"""
Create a Prompt, feed it with a given user input considered
to be a password, and then return the CLI output to the caller.

This returns an Output object.
"""

# If the given text doesn't end with a newline, the interface won't finish.
if check_line_ending:
assert text.endswith("\r")

output = PlainTextOutput(stdout=io.StringIO())

with create_pipe_input() as inp:
inp.send_text(text)
session = PromptSession(
input=inp,
output=output,
is_password=True,
hide_password=hide_password,
)

_ = session.prompt()
return output


def _feed_cli_with_input(
text,
editing_mode=EditingMode.EMACS,
Expand All @@ -42,7 +71,7 @@ def _feed_cli_with_input(
Create a Prompt, feed it with the given user input and return the CLI
object.

This returns a (result, Application) tuple.
This returns a (Document, Application, Output) tuple.
"""
# If the given text doesn't end with a newline, the interface won't finish.
if check_line_ending:
Expand All @@ -64,6 +93,33 @@ def _feed_cli_with_input(
return session.default_buffer.document, session.app


def test_visible_password():
# Both the `result` and the `cli.current_buffer` displays the password in plain-text,
# but that's not what the user sees on screen.
password = "secret-value\r"
output = _feed_cli_with_password(password, hide_password=False)
output.stdout.seek(0) # Reset the stream pointer
actual_output = output.stdout.read().strip()

# Test that the string is made up only of `*` characters
assert actual_output == "*" * len(actual_output), actual_output

# Test that the string is long as much as the original password,
# minus the needed carriage return.
assert actual_output == "*" * len(password.strip()), actual_output


def test_invisible_password():
password = "secret-value\r"
output = _feed_cli_with_password(password, hide_password=True)
output.stdout.seek(0) # Reset the stream pointer
actual_output = output.stdout.read().strip()

# Test that, if the `hide_password` flag is set to True,
# then then prompt won't display anything in the output.
assert actual_output == "", actual_output


def test_simple_text_input():
# Simple text input, followed by enter.
result, cli = _feed_cli_with_input("hello\r")
Expand Down