diff --git a/CHANGES.md b/CHANGES.md index dec59ad91..df5771d69 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,10 @@ Unreleased Colorama is no longer a dependency and is not used. {issue}`2986` {pr}`3505` - {class}`Argument` accepts a `help` parameter, and help output includes a `Positional arguments` section when argument help is available. {issue}`2983` {pr}`3473` +- Add {func}`custom_version_option`, a `--version` option whose output is + produced by a callback, covering cases {func}`version_option` intentionally + does not. The feature set of {func}`version_option` is now frozen; see + [discussion #3527](https://github.com/pallets/click/discussions/3527). {pr}`3581` ## Version 8.4.2 diff --git a/docs/api.md b/docs/api.md index 6dc8ee84c..df62cf5df 100644 --- a/docs/api.md +++ b/docs/api.md @@ -41,6 +41,10 @@ classes and functions. .. autofunction:: version_option ``` +```{eval-rst} +.. autofunction:: custom_version_option +``` + ```{eval-rst} .. autofunction:: help_option ``` diff --git a/src/click/__init__.py b/src/click/__init__.py index 64be7e0c3..2d9918bce 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -18,6 +18,7 @@ from .decorators import argument as argument from .decorators import command as command from .decorators import confirmation_option as confirmation_option +from .decorators import custom_version_option as custom_version_option from .decorators import group as group from .decorators import help_option as help_option from .decorators import make_pass_decorator as make_pass_decorator diff --git a/src/click/decorators.py b/src/click/decorators.py index db6a45ebb..cc42e5d42 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -440,6 +440,16 @@ def version_option( :func:`importlib.metadata.packages_distributions`, so e.g. ``PIL`` resolves to the ``Pillow`` distribution. + .. note:: + The parameters and message variables accepted by this option are + frozen: no new slots will be added, to keep the common case simple + and predictable. If you need values it does not expose, such as a + file path, the Python version, or git metadata, use + :func:`custom_version_option` to render the output yourself. + + Rationale: `discussion #3527 + `_. + :param version: The version number to show. If not provided, Click will try to detect it. :param param_decls: One or more option names. Defaults to the single @@ -548,6 +558,48 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: return option(*param_decls, **kwargs) +def custom_version_option( + callback: t.Callable[[Context], str], + *param_decls: str, + **kwargs: t.Any, +) -> t.Callable[[FC], FC]: + """Add a ``--version`` option whose output is produced by ``callback``. + + This is the customizable companion to :func:`version_option`. Where + :func:`version_option` is intentionally limited to a fixed message and + a small set of values, this option calls ``callback`` to build the + whole string to print. Use it when you need values that + :func:`version_option` does not expose, such as a file path, the + Python version, or git metadata. + + :param callback: Called with the current :class:`Context` when the + option is invoked. Its return value is printed, then the program + exits. + :param param_decls: One or more option names. Defaults to the single + value ``--version``. + :param kwargs: Extra arguments are passed to :func:`option`. + + .. versionadded:: 8.5.0 + """ + + def show_version(ctx: Context, param: Parameter, value: bool) -> None: + if not value or ctx.resilient_parsing: + return + + echo(callback(ctx), color=ctx.color) + ctx.exit() + + if not param_decls: + param_decls = ("--version",) + + kwargs.setdefault("is_flag", True) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("is_eager", True) + kwargs.setdefault("help", _("Show the version and exit.")) + kwargs["callback"] = show_version + return option(*param_decls, **kwargs) + + def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: """Pre-configured ``--help`` option which immediately prints the help page and exits the program. diff --git a/tests/test_basic.py b/tests/test_basic.py index 34d22e792..00bbb3b05 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -858,3 +858,26 @@ def cli(): result = runner.invoke(cli, ["--version"]) assert result.exit_code != 0 assert "not installed" in str(result.exception) + + +@pytest.mark.parametrize("args", [["--version"], ["-V"]]) +def test_custom_version_option(runner, args): + @click.command() + @click.custom_version_option(lambda ctx: "custom 9.9.9", "-V", "--version") + def cli(): + pass + + result = runner.invoke(cli, args) + assert result.exit_code == 0 + assert result.output == "custom 9.9.9\n" + + +def test_custom_version_option_receives_context(runner): + @click.command() + @click.custom_version_option(lambda ctx: f"{ctx.info_name} 1.0") + def cli(): + pass + + result = runner.invoke(cli, ["--version"], prog_name="mytool") + assert result.exit_code == 0 + assert result.output == "mytool 1.0\n"