Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/arguments.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ Arguments are:
* Are positional in nature.
* Similar to a limited version of {ref}`options <options>` that
can take an arbitrary number of inputs
* {ref}`Documented manually <documenting-arguments>`.
* Can take an optional `help` string shown in the ``Positional arguments``
section of the help page, or be {ref}`documented in the command docstring
<documenting-arguments>`.

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.

Expand Down
12 changes: 7 additions & 5 deletions docs/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -113,16 +113,18 @@ 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:

```{eval-rst}
.. click:example::

@click.command()
@click.argument('filename')
@click.argument('filename', help='The file to print.')
def touch(filename):
"""Print FILENAME."""
click.echo(filename)
Expand All @@ -131,7 +133,7 @@ A brief example:
invoke(touch, args=['--help'])
```

Or more explicitly:
Or more explicitly in the docstring:

```{eval-rst}
.. click:example::
Expand Down
44 changes: 43 additions & 1 deletion src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -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]:
Comment thread
kdeldycke marked this conversation as resolved.
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:
Expand Down Expand Up @@ -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)}'"
Expand Down
88 changes: 88 additions & 0 deletions tests/test_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
40 changes: 40 additions & 0 deletions tests/test_info_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"multiple": False,
"default": None,
"envvar": None,
"help": None,
},
)
NUMBER_OPTION = (
Expand Down Expand Up @@ -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"]
Loading