Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------
Expand Down
36 changes: 24 additions & 12 deletions src/click/termui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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"):
Expand Down
55 changes: 46 additions & 9 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand All @@ -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: " ")
Expand Down