diff --git a/CHANGELOG.md b/CHANGELOG.md index 16ace5e4..74e5acc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ prompt is displayed. - **get_rprompt**: override to populate right prompt - **pre_prompt**: hook method that is called before the prompt is displayed, but after `prompt-toolkit` event loop has started + - **read_secret**: read secrets like passwords without displaying them to the terminal - New settables: - **max_column_completion_results**: (int) the maximum number of completion results to display in a single column diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e3fe682a..b17e0e99 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3391,6 +3391,24 @@ def read_input( return self._read_raw_input(prompt, temp_session) + def read_secret( + self, + prompt: str = '', + ) -> str: + """Read a secret from stdin without displaying the value on the screen. + + :param prompt: prompt to display to user + :return: the secret read from stdin with all trailing new lines removed + :raises EOFError: if the input stream is closed or the user signals EOF (e.g., Ctrl+D) + :raises Exception: any other exceptions raised by prompt() + """ + temp_session: PromptSession[str] = PromptSession( + input=self.main_session.input, + output=self.main_session.output, + ) + + return self._read_raw_input(prompt, temp_session, is_password=True) + def _process_alerts(self) -> None: """Background worker that processes queued alerts and dynamic prompt updates.""" while True: diff --git a/examples/README.md b/examples/README.md index 45153c0f..43928cda 100644 --- a/examples/README.md +++ b/examples/README.md @@ -77,8 +77,8 @@ each: - Shows how cmd2's built-in `run_pyscript` command can provide advanced Python scripting of cmd2 applications - [read_input.py](https://github.com/python-cmd2/cmd2/blob/main/examples/read_input.py) - - Demonstrates the various ways to call `cmd2.Cmd.read_input()` for input history and tab - completion + - Demonstrates the various ways to call `cmd2.Cmd.read_input()` and `cmd2.Cmd.read_secret()` for + input history, tab completion, and password masking - [remove_builtin_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/remove_builtin_commands.py) - Shows how to remove any built-in cmd2 commands you do not want to be present in your cmd2 application diff --git a/examples/read_input.py b/examples/read_input.py index 7c534749..05426484 100755 --- a/examples/read_input.py +++ b/examples/read_input.py @@ -1,6 +1,7 @@ #!/usr/bin/env python -"""A simple example demonstrating the various ways to call cmd2.Cmd.read_input() for input history and tab completion. +"""A simple example demonstrating the various ways to call cmd2.Cmd.read_input() and cmd2.Cmd.read_secret(). +These methods can be used to read input from stdin with optional history, tab completion, or password masking. It also demonstrates how to use the cmd2.Cmd.select method. """ @@ -97,6 +98,19 @@ def do_custom_parser(self, _) -> None: else: self.custom_history.append(input_str) + @cmd2.with_category(EXAMPLE_COMMANDS) + def do_read_password(self, _) -> None: + """Call read_secret to read a password without displaying it while being typed. + + WARNING: Password will be displayed for verification after it is typed. + """ + self.poutput("The input will not be displayed on the screen") + try: + password = self.read_secret("Password: ") + self.poutput(f"You entered: {password}") + except EOFError: + pass + def do_eat(self, arg): """Example of using the select method for reading multiple choice input. diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 01a3bef1..3f27fa12 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -2236,6 +2236,29 @@ def test_read_input_eof(base_app, monkeypatch) -> None: base_app.read_input("Prompt> ") +def test_read_secret(base_app, monkeypatch): + """Test read_secret passes is_password=True to _read_raw_input.""" + with mock.patch.object(base_app, '_read_raw_input') as mock_reader: + mock_reader.return_value = "my_secret" + + secret = base_app.read_secret("Secret: ") + + assert secret == "my_secret" + # Verify it called _read_raw_input with is_password=True + args, kwargs = mock_reader.call_args + assert args[0] == "Secret: " + assert kwargs['is_password'] is True + + +def test_read_secret_eof(base_app, monkeypatch): + """Test that read_secret passes up EOFErrors.""" + read_raw_mock = mock.MagicMock(name='_read_raw_input', side_effect=EOFError) + monkeypatch.setattr("cmd2.Cmd._read_raw_input", read_raw_mock) + + with pytest.raises(EOFError): + base_app.read_secret("Secret: ") + + def test_read_input_passes_all_arguments_to_resolver(base_app): mock_choices = ["choice1", "choice2"] mock_provider = mock.MagicMock(name="provider")