Skip to content

Commit 987174b

Browse files
yorickvPkdeldycke
andcommitted
Fix readline backspace and line-wrapping on non-Windows
Apply fix to prompt() and confirm(), which were covered in #1836, #2093 and #3019 Co-authored-by: Kevin Deldycke <kevin@deldycke.com>
1 parent 25edc1e commit 987174b

3 files changed

Lines changed: 74 additions & 21 deletions

File tree

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ Released 2026-04-20
7070
- Change :class:`ParameterSource` to an :class:`~enum.IntEnum` and reorder
7171
its members from most to least explicit, so values can be compared to
7272
check whether a parameter was explicitly provided. :issue:`2879` :pr:`3248`
73+
- Fix readline functionality on non-Windows platforms. Prompt text is now
74+
passed directly to readline instead of being printed separately, allowing
75+
proper backspace, line editing, and line wrapping behavior. :issue:`2968`
76+
:pr:`2969`
7377

7478
Version 8.3.2
7579
-------------

src/click/termui.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
import sys
88
import typing as t
99
from contextlib import AbstractContextManager
10+
from contextlib import redirect_stdout
1011
from gettext import gettext as _
1112

1213
from ._compat import isatty
1314
from ._compat import strip_ansi
15+
from ._compat import WIN
1416
from .exceptions import Abort
1517
from .exceptions import UsageError
1618
from .globals import resolve_color_default
@@ -57,6 +59,26 @@ def hidden_prompt_func(prompt: str) -> str:
5759
return getpass.getpass(prompt)
5860

5961

62+
def _readline_prompt(func: t.Callable[[str], str], text: str, err: bool) -> str:
63+
"""Call a prompt function, passing the full prompt on non-Windows so
64+
readline can handle line editing and cursor positioning correctly.
65+
66+
On Windows the prompt is written separately via :func:`echo` for
67+
colorama support, with only the last character passed to *func*.
68+
"""
69+
if WIN:
70+
# Write the prompt separately so that we get nice coloring
71+
# through colorama on Windows.
72+
echo(text[:-1], nl=False, err=err)
73+
# Echo the last character to stdout to work around an issue
74+
# where readline causes backspace to clear the whole line.
75+
return func(text[-1:])
76+
if err:
77+
with redirect_stdout(sys.stderr):
78+
return func(text)
79+
return func(text)
80+
81+
6082
def _build_prompt(
6183
text: str,
6284
suffix: str,
@@ -147,12 +169,7 @@ def prompt(
147169
def prompt_func(text: str) -> str:
148170
f = hidden_prompt_func if hide_input else visible_prompt_func
149171
try:
150-
# Write the prompt separately so that we get nice
151-
# coloring through colorama on Windows
152-
echo(text[:-1], nl=False, err=err)
153-
# Echo the last character to stdout to work around an issue where
154-
# readline causes backspace to clear the whole line.
155-
return f(text[-1:])
172+
return _readline_prompt(f, text, err)
156173
except (KeyboardInterrupt, EOFError):
157174
# getpass doesn't print a newline if the user aborts input with ^C.
158175
# Allegedly this behavior is inherited from getpass(3).
@@ -243,12 +260,7 @@ def confirm(
243260

244261
while True:
245262
try:
246-
# Write the prompt separately so that we get nice
247-
# coloring through colorama on Windows
248-
echo(prompt[:-1], nl=False, err=err)
249-
# Echo the last character to stdout to work around an issue where
250-
# readline causes backspace to clear the whole line.
251-
value = visible_prompt_func(prompt[-1:]).lower().strip()
263+
value = _readline_prompt(visible_prompt_func, prompt, err).lower().strip()
252264
except (KeyboardInterrupt, EOFError):
253265
raise Abort() from None
254266
if value in ("y", "yes"):

tests/test_utils.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,38 @@ def f(_):
225225
click.echo("interrupted")
226226

227227
out, err = capsys.readouterr()
228-
assert out == "Password:\ninterrupted\n"
228+
# On non-Windows, prompt is passed directly to getpass, not echoed separately
229+
assert out == "\ninterrupted\n"
230+
231+
232+
@pytest.mark.skipif(WIN, reason="Different behavior on windows.")
233+
@pytest.mark.parametrize(
234+
("call", "expected_prompt"),
235+
[
236+
(lambda: click.prompt("Name"), "Name: "),
237+
(lambda: click.prompt("Pw", hide_input=True), "Pw: "),
238+
(lambda: click.prompt("IP", prompt_suffix="."), "IP."),
239+
(lambda: click.confirm("OK"), "OK [y/N]: "),
240+
],
241+
ids=["prompt", "prompt-hidden", "prompt-custom-suffix", "confirm"],
242+
)
243+
def test_full_prompt_passed_to_readline(monkeypatch, call, expected_prompt):
244+
"""On non-Windows, prompt and confirm pass the full prompt text to the
245+
underlying prompt function so readline handles editing correctly.
246+
247+
https://github.com/pallets/click/issues/2968
248+
https://github.com/pallets/click/pull/2969
249+
"""
250+
received = []
251+
252+
def capture(text):
253+
received.append(text)
254+
return "y"
255+
256+
monkeypatch.setattr("click.termui.visible_prompt_func", capture)
257+
monkeypatch.setattr("click.termui.hidden_prompt_func", capture)
258+
call()
259+
assert received == [expected_prompt]
229260

230261

231262
def test_prompts_eof(runner):
@@ -484,17 +515,21 @@ def emulate_input(text):
484515
assert out == "Prompt to stdin with no suffix"
485516
assert err == ""
486517

518+
# On non-Windows the full prompt goes through redirect_stdout so
519+
# nothing leaks to stdout when err=True.
520+
# https://github.com/pallets/click/issues/2968
487521
emulate_input("asdlkj\n")
488522
click.prompt("Prompt to stderr", err=True)
489523
out, err = capfd.readouterr()
490-
assert out == " "
491-
assert err == "Prompt to stderr:"
524+
assert out == ""
525+
assert err == "Prompt to stderr: "
492526

527+
# https://github.com/pallets/click/issues/3019
493528
emulate_input("asdlkj\n")
494529
click.prompt("Prompt to stderr with no suffix", prompt_suffix="", err=True)
495530
out, err = capfd.readouterr()
496-
assert out == "x"
497-
assert err == "Prompt to stderr with no suffi"
531+
assert out == ""
532+
assert err == "Prompt to stderr with no suffix"
498533

499534
emulate_input("y\n")
500535
click.confirm("Prompt to stdin")
@@ -508,17 +543,19 @@ def emulate_input(text):
508543
assert out == "Prompt to stdin with no suffix [y/N]"
509544
assert err == ""
510545

546+
# https://github.com/pallets/click/issues/2968
511547
emulate_input("y\n")
512548
click.confirm("Prompt to stderr", err=True)
513549
out, err = capfd.readouterr()
514-
assert out == " "
515-
assert err == "Prompt to stderr [y/N]:"
550+
assert out == ""
551+
assert err == "Prompt to stderr [y/N]: "
516552

553+
# https://github.com/pallets/click/issues/3019
517554
emulate_input("y\n")
518555
click.confirm("Prompt to stderr with no suffix", prompt_suffix="", err=True)
519556
out, err = capfd.readouterr()
520-
assert out == "]"
521-
assert err == "Prompt to stderr with no suffix [y/N"
557+
assert out == ""
558+
assert err == "Prompt to stderr with no suffix [y/N]"
522559

523560
monkeypatch.setattr(click.termui, "isatty", lambda x: True)
524561
monkeypatch.setattr(click.termui, "getchar", lambda: " ")

0 commit comments

Comments
 (0)