Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ Unreleased
[discussion #3527](https://github.com/pallets/click/discussions/3527). {pr}`3581`
- `style()` and `secho()` no longer silently drop the 256-color index `0`
(black) passed as `fg` or `bg`, and now validate color arguments. {pr}`3677`
- The automatic help option stores its value under the reserved name
`_click_default_help` instead of `help`, so a parameter named `help` no
longer breaks parsing. The new name is visible in
{meth}`Command.to_info_dict` output. Parameters that overwrite each other's
value trigger a warning: an argument sharing its name with another
parameter, or any parameter claiming the reserved name. Options may still
share a name to compete for the same value (feature switches).
{issue}`2819` {pr}`3678`

## Version 8.4.2

Expand Down
2 changes: 1 addition & 1 deletion docs/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ The default placeholder variable ([meta variable](https://en.wikipedia.org/wiki/

## Help Parameter Customization

Help parameters are automatically added by Click for any command. The default is `--help` but can be overridden by the context setting {attr}`~Context.help_option_names`. Click also performs automatic conflict resolution on the default help parameter, so if a command itself implements a parameter named `help` then the default help will not be run.
Help parameters are automatically added by Click for any command. The default is `--help` but can be overridden by the context setting {attr}`~Context.help_option_names`. Click resolves conflicts automatically: an option reusing one of the help option names takes it over, and the default help parameter is dropped once all its names are taken. A parameter merely named `help`, like `click.argument("help")`, does not disable it: both keep working independently.

This example changes the default parameters to `-h` and `--help`
instead of just `--help`:
Expand Down
46 changes: 44 additions & 2 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
F = t.TypeVar("F", bound="t.Callable[..., t.Any]")
V = t.TypeVar("V")

# Reserved storage name of the automatic help option. No user parameter is
# expected to claim it.
_HELP_OPTION_STORAGE_NAME = "_click_default_help"


def _complete_visible_commands(
ctx: Context, incomplete: str
Expand Down Expand Up @@ -1119,6 +1123,37 @@ def get_params(self, ctx: Context) -> list[Parameter]:
stacklevel=3,
)

# Options may deliberately share a storage name to compete for
# the same value (feature switches), but an argument sharing a
# name silently overwrites the other parameter's value.
names_counter = Counter(param.name for param in params)
duplicate_names = (
name for name, count in names_counter.items() if count > 1
)

for duplicate_name in duplicate_names:
sharers = [param for param in params if param.name == duplicate_name]

if help_option in sharers:
warnings.warn(
(
f"The name {duplicate_name!r} is reserved for the "
"automatic help option. Give the parameter a "
"different name."
),
stacklevel=3,
)
elif any(isinstance(param, Argument) for param in sharers):
warnings.warn(
(
f"The name {duplicate_name!r} is used by an argument "
"and another parameter. They will overwrite each "
"other's value during parsing. Give each parameter "
"a unique name."
),
stacklevel=3,
)

return params

def format_usage(self, ctx: Context, formatter: HelpFormatter) -> None:
Expand Down Expand Up @@ -1153,6 +1188,11 @@ def get_help_option(self, ctx: Context) -> Option | None:

Skipped if :attr:`add_help_option` is ``False``.

.. versionchanged:: 8.5.0
The help option stores its value under the reserved name
``_click_default_help``, so a parameter named ``help`` no
longer breaks parsing.

.. versionchanged:: 8.1.8
The help option is now cached to avoid creating it multiple times.
"""
Expand All @@ -1169,8 +1209,10 @@ def get_help_option(self, ctx: Context) -> Option | None:
# Avoid circular import.
from .decorators import help_option

# Apply help_option decorator and pop resulting option
help_option(*help_option_names)(self)
# The help option never exposes its value, so it uses a reserved
# storage name, keeping it clear of user parameters (like an
# argument named "help") that would otherwise clobber its value.
help_option(*help_option_names, _HELP_OPTION_STORAGE_NAME)(self)
self._help_option = self.params.pop() # type: ignore[assignment]

return self._help_option
Expand Down
82 changes: 82 additions & 0 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,88 @@ def cli():
assert result.exit_code == 0


@pytest.mark.parametrize(
("help_names", "params", "args", "expected_output"),
[
(["--help"], [], [], "\n"),
(["--help"], [click.Argument(["help"])], ["value"], "value\n"),
(
["--help"],
[click.Option(["--assist", "help"])],
["--assist", "value"],
"value\n",
),
(["--man"], [click.Option(["--foo", "man"])], ["--foo", "value"], "value\n"),
],
ids=["no-collision", "argument", "option", "custom-flags"],
)
def test_param_named_help(runner, help_names, params, args, expected_output):
"""User parameters never clash with the automatic help option, which
stores its value under the reserved ``_click_default_help`` name.

https://github.com/pallets/click/issues/2819
"""
cli = click.Command(
"cli",
context_settings={"help_option_names": help_names},
params=params,
callback=lambda **kwargs: click.echo(next(iter(kwargs.values()), None)),
)

ctx = click.Context(cli, help_option_names=help_names)
help_option = cli.get_help_option(ctx)
assert help_option is not None
assert help_option.name == "_click_default_help"

result = runner.invoke(cli, args)
assert result.output == expected_output
assert result.exit_code == 0

result = runner.invoke(cli, [help_names[0]])
assert "Show this message and exit." in result.output
assert result.exit_code == 0


def test_param_squatting_help_option_name(runner):
"""Claiming the reserved storage name of the automatic help option
triggers a warning.
"""

@click.command()
@click.argument("_click_default_help")
def cli(_click_default_help):
click.echo(_click_default_help)

with pytest.warns(UserWarning, match="reserved for the automatic help option"):
result = runner.invoke(cli, ["value"])

# The collision still breaks parsing, but no longer silently.
assert result.exit_code == 2


def test_option_reusing_help_flag(runner):
"""An option reusing the ``--help`` flag replaces the automatic help
option entirely.

https://github.com/pallets/click/issues/2819
"""

@click.command()
@click.option("--help", default="default value")
def cli(help):
click.echo(help)

assert cli.get_help_option(click.Context(cli)) is None

result = runner.invoke(cli, [])
assert result.output == "default value\n"
assert result.exit_code == 0

result = runner.invoke(cli, ["--help", "custom"])
assert result.output == "custom\n"
assert result.exit_code == 0


def test_repr():
@click.command()
def command():
Expand Down
2 changes: 1 addition & 1 deletion tests/test_info_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
HELP_OPTION = (
None,
{
"name": "help",
"name": "_click_default_help",
"param_type_name": "option",
"opts": ["--help"],
"secondary_opts": [],
Expand Down
36 changes: 36 additions & 0 deletions tests/test_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -3324,6 +3324,42 @@ def cli(xyz):
runner.invoke(cli, [])


def test_argument_sharing_param_name_warning(runner):
"""An argument sharing its storage name with an option makes both
silently overwrite each other's value, which triggers a warning.
"""

@click.command()
@click.option("--foo", "target")
@click.argument("target")
def cli(target):
click.echo(target, nl=False)

with pytest.warns(UserWarning, match="'target' is used by an argument"):
result = runner.invoke(cli, ["--foo", "from_option", "from_argument"])

# The value parsed by the option is lost to the argument's.
assert result.output == "from_argument"
assert result.exit_code == 0


def test_options_sharing_name_no_warning(runner):
"""Options deliberately sharing a storage name (feature switches) are
exempt from the duplicate-name warning. Warnings are errors in this
suite, so a clean run proves none fired.
"""

@click.command()
@click.option("--upper", "transformation", flag_value="upper")
@click.option("--lower", "transformation", flag_value="lower")
def cli(transformation):
click.echo(transformation, nl=False)

result = runner.invoke(cli, ["--upper"])
assert result.output == "upper"
assert result.exit_code == 0


@pytest.mark.parametrize(
("args", "expected"),
[
Expand Down
Loading