From 684b3f5b5150761ec1c930bef300eb6169f483d8 Mon Sep 17 00:00:00 2001 From: Yuri Shevtsov Date: Tue, 19 May 2026 01:48:00 +0000 Subject: [PATCH] Add help parameter to arguments Closes #2983 Co-authored-by: Rowlando13 <67291205+Rowlando13@users.noreply.github.com> Co-authored-by: Kevin Deldycke --- CHANGES.rst | 2 + docs/arguments.md | 5 ++- docs/documentation.md | 12 +++--- src/click/core.py | 44 ++++++++++++++++++++- tests/test_arguments.py | 88 +++++++++++++++++++++++++++++++++++++++++ tests/test_info_dict.py | 40 +++++++++++++++++++ 6 files changed, 184 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4de49f482d..5814f3b132 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,8 @@ Unreleased - Supported versions of Windows enable ANSI terminal styles by default. 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` Version 8.4.2 diff --git a/docs/arguments.md b/docs/arguments.md index 90d37c1569..81b331bd5d 100644 --- a/docs/arguments.md +++ b/docs/arguments.md @@ -10,10 +10,13 @@ Arguments are: * Are positional in nature. * Similar to a limited version of {ref}`options ` that can take an arbitrary number of inputs -* {ref}`Documented manually `. +* Can take an optional `help` string shown in the ``Positional arguments`` + section of the help page, or be {ref}`documented in the command docstring + `. Useful and often used kwargs are: +* `help`: Help text for the argument. * `default`: Passes a default. * `nargs`: Sets the number of arguments. Set to -1 to take an arbitrary number. diff --git a/docs/documentation.md b/docs/documentation.md index 73a0ea75f1..1215c85e50 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -15,7 +15,7 @@ Simple example: .. click:example:: @click.command() - @click.argument('name') + @click.argument('name', help='The name to print') @click.option('--count', default=1, help='number of greetings') def hello(name: str, count: int): """This script prints hello and a name one or more times.""" @@ -113,8 +113,10 @@ The help epilog is printed at the end of the help and is useful for showing exam ## Documenting Arguments -{class}`click.argument` does not take a `help` parameter. This follows the Unix Command Line Tools convention of using arguments only for necessary things and documenting them in the command help text -by name. This should then be done via the docstring. +{class}`click.argument` accepts an optional `help` parameter that is shown in +the ``Positional arguments`` section of the help page. You can still document +arguments in the command docstring, especially when you want to describe them +in the main help text by name. A brief example: @@ -122,7 +124,7 @@ A brief example: .. click:example:: @click.command() - @click.argument('filename') + @click.argument('filename', help='The file to print.') def touch(filename): """Print FILENAME.""" click.echo(filename) @@ -131,7 +133,7 @@ A brief example: invoke(touch, args=['--help']) ``` -Or more explicitly: +Or more explicitly in the docstring: ```{eval-rst} .. click:example:: diff --git a/src/click/core.py b/src/click/core.py index b422bd49f2..a6c9c6421a 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1172,11 +1172,13 @@ def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: - :meth:`format_usage` - :meth:`format_help_text` + - :meth:`format_arguments` - :meth:`format_options` - :meth:`format_epilog` """ self.format_usage(ctx, formatter) self.format_help_text(ctx, formatter) + self.format_arguments(ctx, formatter) self.format_options(ctx, formatter) self.format_epilog(ctx, formatter) @@ -1203,13 +1205,25 @@ def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: opts = [] for param in self.get_params(ctx): rv = param.get_help_record(ctx) - if rv is not None: + if rv is not None and not isinstance(param, Argument): opts.append(rv) if opts: with formatter.section(_("Options")): formatter.write_dl(opts) + def format_arguments(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the arguments that have a help record into the formatter.""" + args = [] + for param in self.get_params(ctx): + rv = param.get_help_record(ctx) + if rv is not None and isinstance(param, Argument): + args.append(rv) + + if args: + with formatter.section(_("Positional arguments")): + formatter.write_dl(args) + def format_epilog(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes the epilog into the formatter if it exists.""" if self.epilog: @@ -3450,6 +3464,11 @@ class Argument(Parameter): and are required by default. All parameters are passed onwards to the constructor of :class:`Parameter`. + + :param help: the help string. + + .. versionchanged:: 8.5 + Added the ``help`` parameter. """ param_type_name = "argument" @@ -3458,6 +3477,7 @@ def __init__( self, param_decls: cabc.Sequence[str], required: bool | None = None, + help: str | None = None, **attrs: t.Any, ) -> None: # Auto-detect the requirement status of the argument if not explicitly set. @@ -3473,8 +3493,24 @@ def __init__( if "multiple" in attrs: raise TypeError("__init__() got an unexpected keyword argument 'multiple'.") + deprecated = attrs.get("deprecated", False) + + if help: + help = inspect.cleandoc(help) + + if deprecated: + label = _format_deprecated_label(deprecated) + help = f"{help} {label}" if help else label + + self.help = help + super().__init__(param_decls, required=required, **attrs) + def to_info_dict(self) -> dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict.update(help=self.help) + return info_dict + @property def human_readable_name(self) -> str: if self.metavar is not None: @@ -3517,6 +3553,12 @@ def _parse_decls( def get_usage_pieces(self, ctx: Context) -> list[str]: return [self.make_metavar(ctx)] + def get_help_record(self, ctx: Context) -> tuple[str, str] | None: + if self.help is None: + return None + + return self.make_metavar(ctx), self.help + def get_error_hint(self, ctx: Context | None) -> str: if ctx is not None: return f"'{self.make_metavar(ctx)}'" diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 60152820d9..f4b452336a 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -295,6 +295,50 @@ def cli(f): assert result.output == "test\n" +def test_argument_help(runner): + @click.command() + @click.argument("name", help="The name to print") + @click.option("--count", default=1, help="number of greetings") + def cli(name, count): + pass + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0, result.output + assert "Positional arguments:" in result.output + assert "NAME" in result.output + assert "The name to print" in result.output + assert "Options:" in result.output + assert "number of greetings" in result.output + assert result.output.index("Positional arguments:") < result.output.index( + "Options:" + ) + + +def test_argument_help_options_only_no_arguments_section(runner): + @click.command() + @click.option("--count", default=1, help="number of greetings") + def cli(count): + pass + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0, result.output + assert "Positional arguments:" not in result.output + assert "Options:" in result.output + assert "number of greetings" in result.output + + +def test_argument_help_optional_metavar(runner): + @click.command() + @click.argument("name", required=False, default="", help="The name to print") + def cli(name): + pass + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0, result.output + assert "[NAME]" in result.output + assert "The name to print" in result.output + + def test_deprecated_usage(runner): @click.command() @click.argument("f", required=False, deprecated=True) @@ -332,6 +376,50 @@ def cli(foo): assert result.output.splitlines()[0] == f"Usage: cli [OPTIONS] {expected}" +@pytest.mark.parametrize( + ("deprecated", "expected_label"), + [(True, "(DEPRECATED)"), ("use g instead", "(DEPRECATED: use g instead)")], +) +def test_deprecated_usage_help_record(runner, deprecated, expected_label): + @click.command() + @click.argument("f", required=False, deprecated=deprecated, help="path to the file") + def cli(f): + click.echo(f) + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0, result.output + assert "Positional arguments:" in result.output + assert "[F!]" in result.output + assert f"path to the file {expected_label}" in result.output + + +def test_deprecated_usage_help_record_without_help(runner): + @click.command() + @click.argument("f", required=False, deprecated=True) + def cli(f): + click.echo(f) + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0, result.output + # Deprecation alone produces a help row with just the deprecation label. + assert "Positional arguments:" in result.output + assert "(DEPRECATED)" in result.output + + +@pytest.mark.parametrize( + ("deprecated", "expected"), + [(True, "(DEPRECATED)"), ("USE B INSTEAD", "(DEPRECATED: USE B INSTEAD)")], +) +@pytest.mark.parametrize("help_text", ["", None]) +def test_deprecated_empty_help_no_leading_space(help_text, deprecated, expected): + """An argument with empty or missing help text must not gain a stray leading + space before the deprecation label. + """ + arg = click.Argument(["foo"], required=False, help=help_text, deprecated=deprecated) + ctx = click.Context(click.Command("cli")) + assert arg.get_help_record(ctx)[1] == expected + + @pytest.mark.parametrize("deprecated", [True, "USE B INSTEAD"]) def test_deprecated_warning(runner, deprecated): @click.command() diff --git a/tests/test_info_dict.py b/tests/test_info_dict.py index 20fe68cc13..b434c3fc58 100644 --- a/tests/test_info_dict.py +++ b/tests/test_info_dict.py @@ -40,6 +40,7 @@ "multiple": False, "default": None, "envvar": None, + "help": None, }, ) NUMBER_OPTION = ( @@ -273,3 +274,42 @@ class TestType(click.ParamType): pass assert TestType().to_info_dict()["name"] == "TestType" + + +@pytest.mark.parametrize( + ("help_in", "help_out"), + [ + pytest.param(None, None, id="None"), + pytest.param("", "", id="empty"), + pytest.param("single line", "single line", id="single-line"), + pytest.param( + "\n first line\n second line\n ", + "first line\nsecond line", + id="multi-line", + ), + ], +) +def test_argument_to_info_dict_help(help_in, help_out): + arg = click.Argument(["name"], help=help_in) + assert arg.to_info_dict()["help"] == help_out + + +def test_argument_to_info_dict_nargs(): + arg = click.Argument(["files"], nargs=-1, help="files to process") + info = arg.to_info_dict() + assert info["nargs"] == -1 + assert info["help"] == "files to process" + + +def test_command_to_info_dict_multiple_arguments(): + @click.command() + @click.argument("src", help="source path") + @click.argument("dst", help="destination path") + def cli(src, dst): + pass + + ctx = click.Context(cli) + params = cli.to_info_dict(ctx)["params"] + args = [p for p in params if p["param_type_name"] == "argument"] + assert [p["name"] for p in args] == ["src", "dst"] + assert [p["help"] for p in args] == ["source path", "destination path"]