diff --git a/CHANGES.rst b/CHANGES.rst index 062596b79..3ff5a48b6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 ------------- diff --git a/docs/api.rst b/docs/api.rst index 68c5a482a..c6eefdc41 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -41,6 +41,8 @@ Decorators Utilities --------- +.. autoclass:: PromptBuilder + .. autofunction:: echo .. autofunction:: echo_via_pager diff --git a/docs/prompts.rst b/docs/prompts.rst index 93ad52d4b..74ecec844 100644 --- a/docs/prompts.rst +++ b/docs/prompts.rst @@ -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 -------------------- @@ -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`. diff --git a/pyproject.toml b/pyproject.toml index a8f3655c9..d9a030e0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/src/click/termui.py b/src/click/termui.py index dcbb22216..c370ce703 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -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: @@ -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. @@ -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. @@ -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 @@ -153,7 +225,7 @@ 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 ) @@ -161,7 +233,7 @@ def prompt_func(text: str) -> str: 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: @@ -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). @@ -213,6 +286,12 @@ 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``. @@ -220,7 +299,13 @@ def confirm( .. 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, diff --git a/tests/test_utils.py b/tests/test_utils.py index 9adab7798..491e2b639 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -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\nyes!\n" + + result = runner.invoke(test, input="\n") + assert not result.exception + assert result.output == "Foo (bar | baz) <>: \nyes!\n" + + result = runner.invoke(test, input="baz\n") + assert not result.exception + assert result.output == "Foo (bar | baz) <>: baz\nno :(\n" + + def test_confirm_repeat(runner): cli = click.Command( "cli", params=[click.Option(["--a/--no-a"], default=None, prompt=True)]