diff --git a/examples/prompts/get-password.py b/examples/prompts/get-password.py index a9c0c27f7..80f3746e5 100755 --- a/examples/prompts/get-password.py +++ b/examples/prompts/get-password.py @@ -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}") diff --git a/src/prompt_toolkit/application/application.py b/src/prompt_toolkit/application/application.py index d93c24398..560807579 100644 --- a/src/prompt_toolkit/application/application.py +++ b/src/prompt_toolkit/application/application.py @@ -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. diff --git a/src/prompt_toolkit/layout/containers.py b/src/prompt_toolkit/layout/containers.py index 99b453477..43dc7c1b8 100644 --- a/src/prompt_toolkit/layout/containers.py +++ b/src/prompt_toolkit/layout/containers.py @@ -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, diff --git a/src/prompt_toolkit/shortcuts/prompt.py b/src/prompt_toolkit/shortcuts/prompt.py index d0732bc13..db18d73e8 100644 --- a/src/prompt_toolkit/shortcuts/prompt.py +++ b/src/prompt_toolkit/shortcuts/prompt.py @@ -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. :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 @@ -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", @@ -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, @@ -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 @@ -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), @@ -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 [])), @@ -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, @@ -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: @@ -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, @@ -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: @@ -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, @@ -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, diff --git a/tests/test_cli.py b/tests/test_cli.py index a876f2993..7f2bb4f1d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,7 @@ from __future__ import annotations +import io from functools import partial import pytest @@ -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 @@ -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, @@ -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")