diff --git a/CHANGES.rst b/CHANGES.rst index 7f6c9418c9..0bf7c7d083 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -29,6 +29,10 @@ Unreleased non-shadowed help option names, so ``Try '... -h'`` no longer points to a subcommand option that shadows ``-h``. All surviving names are shown (``-h/--help``). :issue:`2790` :pr:`3208` +- Fix readline functionality on non-Windows platforms. Prompt text is now + passed directly to readline instead of being printed separately, allowing + proper backspace, line editing, and line wrapping behavior. :issue:`2968` + :pr:`2969` Version 8.3.3 ------------- diff --git a/src/click/termui.py b/src/click/termui.py index 6801e30fa4..db418074ef 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -7,10 +7,12 @@ import sys import typing as t from contextlib import AbstractContextManager +from contextlib import redirect_stdout from gettext import gettext as _ from ._compat import isatty from ._compat import strip_ansi +from ._compat import WIN from .exceptions import Abort from .exceptions import UsageError from .globals import resolve_color_default @@ -57,6 +59,26 @@ def hidden_prompt_func(prompt: str) -> str: return getpass.getpass(prompt) +def _readline_prompt(func: t.Callable[[str], str], text: str, err: bool) -> str: + """Call a prompt function, passing the full prompt on non-Windows so + readline can handle line editing and cursor positioning correctly. + + On Windows the prompt is written separately via :func:`echo` for + colorama support, with only the last character passed to *func*. + """ + if WIN: + # Write the prompt separately so that we get nice coloring + # through colorama on Windows. + echo(text[:-1], nl=False, err=err) + # Echo the last character to stdout to work around an issue + # where readline causes backspace to clear the whole line. + return func(text[-1:]) + if err: + with redirect_stdout(sys.stderr): + return func(text) + return func(text) + + def _build_prompt( text: str, suffix: str, @@ -147,12 +169,7 @@ def prompt( def prompt_func(text: str) -> str: f = hidden_prompt_func if hide_input else visible_prompt_func try: - # Write the prompt separately so that we get nice - # coloring through colorama on Windows - echo(text[:-1], nl=False, err=err) - # Echo the last character to stdout to work around an issue where - # readline causes backspace to clear the whole line. - return f(text[-1:]) + return _readline_prompt(f, text, err) except (KeyboardInterrupt, EOFError): # getpass doesn't print a newline if the user aborts input with ^C. # Allegedly this behavior is inherited from getpass(3). @@ -243,12 +260,7 @@ def confirm( while True: try: - # Write the prompt separately so that we get nice - # coloring through colorama on Windows - echo(prompt[:-1], nl=False, err=err) - # Echo the last character to stdout to work around an issue where - # readline causes backspace to clear the whole line. - value = visible_prompt_func(prompt[-1:]).lower().strip() + value = _readline_prompt(visible_prompt_func, prompt, err).lower().strip() except (KeyboardInterrupt, EOFError): raise Abort() from None if value in ("y", "yes"): diff --git a/tests/test_utils.py b/tests/test_utils.py index f5da596337..ec68d307e0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -225,7 +225,38 @@ def f(_): click.echo("interrupted") out, err = capsys.readouterr() - assert out == "Password:\ninterrupted\n" + # On non-Windows, prompt is passed directly to getpass, not echoed separately + assert out == "\ninterrupted\n" + + +@pytest.mark.skipif(WIN, reason="Different behavior on windows.") +@pytest.mark.parametrize( + ("call", "expected_prompt"), + [ + (lambda: click.prompt("Name"), "Name: "), + (lambda: click.prompt("Pw", hide_input=True), "Pw: "), + (lambda: click.prompt("IP", prompt_suffix="."), "IP."), + (lambda: click.confirm("OK"), "OK [y/N]: "), + ], + ids=["prompt", "prompt-hidden", "prompt-custom-suffix", "confirm"], +) +def test_full_prompt_passed_to_readline(monkeypatch, call, expected_prompt): + """On non-Windows, prompt and confirm pass the full prompt text to the + underlying prompt function so readline handles editing correctly. + + https://github.com/pallets/click/issues/2968 + https://github.com/pallets/click/pull/2969 + """ + received = [] + + def capture(text): + received.append(text) + return "y" + + monkeypatch.setattr("click.termui.visible_prompt_func", capture) + monkeypatch.setattr("click.termui.hidden_prompt_func", capture) + call() + assert received == [expected_prompt] def test_prompts_eof(runner): @@ -484,17 +515,21 @@ def emulate_input(text): assert out == "Prompt to stdin with no suffix" assert err == "" + # On non-Windows the full prompt goes through redirect_stdout so + # nothing leaks to stdout when err=True. + # https://github.com/pallets/click/issues/2968 emulate_input("asdlkj\n") click.prompt("Prompt to stderr", err=True) out, err = capfd.readouterr() - assert out == " " - assert err == "Prompt to stderr:" + assert out == "" + assert err == "Prompt to stderr: " + # https://github.com/pallets/click/issues/3019 emulate_input("asdlkj\n") click.prompt("Prompt to stderr with no suffix", prompt_suffix="", err=True) out, err = capfd.readouterr() - assert out == "x" - assert err == "Prompt to stderr with no suffi" + assert out == "" + assert err == "Prompt to stderr with no suffix" emulate_input("y\n") click.confirm("Prompt to stdin") @@ -508,17 +543,19 @@ def emulate_input(text): assert out == "Prompt to stdin with no suffix [y/N]" assert err == "" + # https://github.com/pallets/click/issues/2968 emulate_input("y\n") click.confirm("Prompt to stderr", err=True) out, err = capfd.readouterr() - assert out == " " - assert err == "Prompt to stderr [y/N]:" + assert out == "" + assert err == "Prompt to stderr [y/N]: " + # https://github.com/pallets/click/issues/3019 emulate_input("y\n") click.confirm("Prompt to stderr with no suffix", prompt_suffix="", err=True) out, err = capfd.readouterr() - assert out == "]" - assert err == "Prompt to stderr with no suffix [y/N" + assert out == "" + assert err == "Prompt to stderr with no suffix [y/N]" monkeypatch.setattr(click.termui, "isatty", lambda x: True) monkeypatch.setattr(click.termui, "getchar", lambda: " ")