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

Hide password during typing with a new prompt input #1962

Open
wants to merge 9 commits into
base: master
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
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}")
2 changes: 1 addition & 1 deletion src/prompt_toolkit/application/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ def __init__(
key_bindings: KeyBindingsBase | None = None,
clipboard: Clipboard | None = None,
full_screen: bool = False,
color_depth: (ColorDepth | Callable[[], ColorDepth | None] | None) = None,
color_depth: ColorDepth | Callable[[], ColorDepth | None] | None = None,
mouse_support: FilterOrBool = False,
enable_page_navigation_bindings: None
| (FilterOrBool) = None, # Can be None, True or False.
Expand Down
4 changes: 1 addition & 3 deletions src/prompt_toolkit/layout/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1464,9 +1464,7 @@ def __init__(
always_hide_cursor: FilterOrBool = False,
cursorline: FilterOrBool = False,
cursorcolumn: FilterOrBool = False,
colorcolumns: (
None | list[ColorColumn] | Callable[[], list[ColorColumn]]
) = None,
colorcolumns: None | list[ColorColumn] | Callable[[], list[ColorColumn]] = None,
align: WindowAlign | Callable[[], WindowAlign] = WindowAlign.LEFT,
style: str | Callable[[], str] = "",
char: None | str | Callable[[], str] = None,
Expand Down
32 changes: 26 additions & 6 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 @@ -519,9 +522,11 @@ def accept(buff: Buffer) -> bool:
enable_history_search=dyncond("enable_history_search"),
validator=DynamicValidator(lambda: self.validator),
completer=DynamicCompleter(
lambda: ThreadedCompleter(self.completer)
if self.complete_in_thread and self.completer
else self.completer
lambda: (
ThreadedCompleter(self.completer)
if self.complete_in_thread and self.completer
else self.completer
)
),
history=self.history,
auto_suggest=DynamicAutoSuggest(lambda: self.auto_suggest),
Expand Down Expand Up @@ -555,13 +560,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 +875,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 +971,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 +1115,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 +1168,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 +1391,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 +1436,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
57 changes: 57 additions & 0 deletions 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,35 @@ 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()

output.stdout.seek(0) # Reset the stream pointer
return output


def _feed_cli_with_input(
text,
editing_mode=EditingMode.EMACS,
Expand Down Expand Up @@ -64,6 +95,32 @@ def _feed_cli_with_input(
return session.default_buffer.document, session.app


def test_visible_password():
password = "secret-value\r"
output = _feed_cli_with_password(password, hide_password=False)
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.
# Sometimes the output is duplicated (why?).
valid_length = len(password.strip())
occasional_length = valid_length * 2
assert len(actual_output) in (valid_length, occasional_length), actual_output


def test_invisible_password():
password = "secret-value\r"
output = _feed_cli_with_password(password, hide_password=True)
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
Loading