Skip to content
Draft
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
7 changes: 7 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
.. currentmodule:: click

Version 8.2.1
-------------

Unreleased

- Create ``PrompBuilder`` class for customizing prompts display and add ``builder_cls`` parameter to ``prompt`` and ``confirm`` methods. :issue:`2875`

Version 8.2.0
-------------

Expand Down
2 changes: 2 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Decorators
Utilities
---------

.. autoclass:: PromptBuilder

.. autofunction:: echo

.. autofunction:: echo_via_pager
Expand Down
7 changes: 7 additions & 0 deletions docs/prompts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ provided. For instance, the following will only accept floats::

value = click.prompt('Please enter a number', default=42.0)

You can customize how to display and format the prompt via providing
:param:`builder_cls` as an instance of :class:`PromptBuilder`.

Confirmation Prompts
--------------------

Expand All @@ -46,3 +49,7 @@ There is also the option to make the function automatically abort the
execution of the program if it does not return ``True``::

click.confirm('Do you want to continue?', abort=True)

As in `Input Prompts`_ described above, you can customize how to display
and format the prompt via providing :param:`builder_cls` as an instance
of :class:`PromptBuilder`.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "click"
version = "8.2.0.dev"
version = "8.2.1.dev"
description = "Composable command line interface toolkit"
readme = "README.md"
license = {file = "LICENSE.txt"}
Expand Down
119 changes: 102 additions & 17 deletions src/click/termui.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,80 @@ def hidden_prompt_func(prompt: str) -> str:
return getpass.getpass(prompt)


def _build_prompt(
text: str,
suffix: str,
show_default: bool = False,
default: t.Any | None = None,
show_choices: bool = True,
type: ParamType | None = None,
) -> str:
prompt = text
if type is not None and show_choices and isinstance(type, Choice):
prompt += f" ({', '.join(map(str, type.choices))})"
if default is not None and show_default:
prompt = f"{prompt} [{_format_default(default)}]"
return f"{prompt}{suffix}"
class PromptBuilder:
"""Base class for building prompts.

This class is used to build the prompt string that is displayed to
the user. It can be customized by subclassing and overriding any
of the methods: `build`, `add_choices`, and `add_default`,
`should_add_choices`, `should_add_default`.

:param text: The text to display in the prompt.
:param suffix: The suffix to append to the prompt.
:param show_default: Whether to show the default value in the prompt.
:param default: The default value to display in the prompt.
:param show_choices: Whether to show the choices in the prompt.
:param type: The type of the parameter, used to determine choices.

.. versionadded:: 8.2.1
"""

def build(
self,
text: str,
suffix: str,
show_default: bool = False,
default: t.Any | None = None,
show_choices: bool = True,
type: ParamType | None = None,
) -> str:
prompt = (
text
+ self.add_choices(show_choices=show_choices, type=type)
+ self.add_default(show_default=show_default, default=default)
)
return f"{prompt}{suffix}"

def add_choices(
self, show_choices: bool = True, type: ParamType | None = None
) -> str:
if self.should_add_choices(show_choices, type):
# ignoring type as it does not dynamically recognize
# it's valid via above check
return f" ({', '.join(map(str, type.choices))})" # type: ignore
return ""

def add_default(
self, show_default: bool = False, default: t.Any | None = None
) -> str:
if self.should_add_default(show_default, default):
return f" [{_format_default(default)}]"
return ""

def should_add_choices(
self, show_choices: bool = True, type: ParamType | None = None
) -> bool:
return type is not None and show_choices and isinstance(type, Choice)

def should_add_default(
self, show_default: bool = False, default: t.Any | None = None
) -> bool:
return default is not None and show_default

@staticmethod
def validate(value: t.Any) -> None:
"""Check if the `value` class is an instance and an instance of PromptBuilder.

Raises an AssertionError if the `value` class
is not an instance of PromptBuilder."""
try:
if issubclass(value, PromptBuilder):
raise AssertionError(
f"Attempted to use an uninstantiated parameter type ({value})."
)
except TypeError:
# cls is an instance (correct), so issubclass fails.
pass


def _format_default(default: t.Any) -> t.Any:
Expand All @@ -91,6 +151,7 @@ def prompt(
show_default: bool = True,
err: bool = False,
show_choices: bool = True,
builder_cls: PromptBuilder | None = None,
) -> t.Any:
"""Prompts a user for input. This is a convenience function that can
be used to prompt a user for input later.
Expand Down Expand Up @@ -118,6 +179,12 @@ def prompt(
For example if type is a Choice of either day or week,
show_choices is true and text is "Group by" then the
prompt will be "Group by (day, week): ".
:param builder_cls: A custom prompt builder class. If not provided,
:class:`PromptBuilder` will be used. This is useful for
customizing the prompt format.

.. versionchanged:: 8.2.1
The ``builder_cls`` parameter.

.. versionadded:: 8.0
``confirmation_prompt`` can be a custom string.
Expand All @@ -132,6 +199,11 @@ def prompt(
Added the `err` parameter.

"""
if builder_cls is None:
builder_cls = PromptBuilder()

if __debug__:
PromptBuilder.validate(builder_cls)

def prompt_func(text: str) -> str:
f = hidden_prompt_func if hide_input else visible_prompt_func
Expand All @@ -153,15 +225,15 @@ def prompt_func(text: str) -> str:
if value_proc is None:
value_proc = convert_type(type, default)

prompt = _build_prompt(
prompt = builder_cls.build(
text, prompt_suffix, show_default, default, show_choices, type
)

if confirmation_prompt:
if confirmation_prompt is True:
confirmation_prompt = _("Repeat for confirmation")

confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix)
confirmation_prompt = builder_cls.build(confirmation_prompt, prompt_suffix)

while True:
while True:
Expand Down Expand Up @@ -198,6 +270,7 @@ def confirm(
prompt_suffix: str = ": ",
show_default: bool = True,
err: bool = False,
builder_cls: PromptBuilder | None = None,
) -> bool:
"""Prompts for confirmation (yes/no question).

Expand All @@ -213,14 +286,26 @@ def confirm(
:param show_default: shows or hides the default value in the prompt.
:param err: if set to true the file defaults to ``stderr`` instead of
``stdout``, the same as with echo.
:param builder_cls: A custom prompt builder class. If not provided,
:class:`PromptBuilder` will be used. This is useful for
customizing the prompt format.

.. versionadded:: 8.2.1
The ``builder_cls`` parameter.

.. versionchanged:: 8.0
Repeat until input is given if ``default`` is ``None``.

.. versionadded:: 4.0
Added the ``err`` parameter.
"""
prompt = _build_prompt(
if builder_cls is None:
builder_cls = PromptBuilder()

if __debug__:
PromptBuilder.validate(builder_cls)

prompt = builder_cls.build(
text,
prompt_suffix,
show_default,
Expand Down
40 changes: 40 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,46 @@ def test_no():
assert result.output == "Foo [Y/n]: n\nno :(\n"


def test_prompts_custom_builder(runner):
class CustomBuilder(click.termui.PromptBuilder):
def add_choices(self, show_choices=True, type=None):
if self.should_add_choices(show_choices, type):
# ignoring type as it does not dynamically recognize
# it's valid via above check
return f" ({' | '.join(map(str, type.choices))})" # type: ignore

def add_default(self, show_default=False, default=None):
if self.should_add_default(show_default, default):
return f" <<{default}>>"

@click.command()
def test():
if (
click.prompt(
"Foo",
type=click.Choice(["bar", "baz"]),
default="bar",
builder_cls=CustomBuilder(),
)
== "bar"
):
click.echo("yes!")
else:
click.echo("no :(")

result = runner.invoke(test, input="bar\n")
assert not result.exception
assert result.output == "Foo (bar | baz) <<bar>>: bar\nyes!\n"

result = runner.invoke(test, input="\n")
assert not result.exception
assert result.output == "Foo (bar | baz) <<bar>>: \nyes!\n"

result = runner.invoke(test, input="baz\n")
assert not result.exception
assert result.output == "Foo (bar | baz) <<bar>>: baz\nno :(\n"


def test_confirm_repeat(runner):
cli = click.Command(
"cli", params=[click.Option(["--a/--no-a"], default=None, prompt=True)]
Expand Down
Loading