Skip to content

Commit 70c673d

Browse files
Fix eagerness of help option generated by help_option_names (#2811)
Co-authored-by: Andreas Backx <[email protected]>
1 parent 273fb90 commit 70c673d

3 files changed

Lines changed: 160 additions & 6 deletions

File tree

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ Unreleased
1818
forced on Windows. :issue:`2606`.
1919
- More robust bash version check, fixing problem on Windows with git-bash.
2020
:issue:`2638`
21+
- Cache the help option generated by the ``help_option_names`` setting to
22+
respect its eagerness. :pr:`2811`
23+
2124

2225
Version 8.1.7
2326
-------------

src/click/core.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
if t.TYPE_CHECKING:
4141
import typing_extensions as te
4242

43+
from .decorators import HelpOption
4344
from .shell_completion import CompletionItem
4445

4546
F = t.TypeVar("F", bound=t.Callable[..., t.Any])
@@ -116,9 +117,16 @@ def iter_params_for_processing(
116117
invocation_order: t.Sequence["Parameter"],
117118
declaration_order: t.Sequence["Parameter"],
118119
) -> t.List["Parameter"]:
119-
"""Given a sequence of parameters in the order as should be considered
120-
for processing and an iterable of parameters that exist, this returns
121-
a list in the correct order as they should be processed.
120+
"""Returns all declared parameters in the order they should be processed.
121+
122+
The declared parameters are re-shuffled depending on the order in which
123+
they were invoked, as well as the eagerness of each parameters.
124+
125+
The invocation order takes precedence over the declaration order. I.e. the
126+
order in which the user provided them to the CLI is respected.
127+
128+
This behavior and its effect on callback evaluation is detailed at:
129+
https://click.palletsprojects.com/en/stable/advanced/#callback-evaluation-order
122130
"""
123131

124132
def sort_key(item: "Parameter") -> t.Tuple[bool, float]:
@@ -1223,6 +1231,7 @@ def __init__(
12231231
self.options_metavar = options_metavar
12241232
self.short_help = short_help
12251233
self.add_help_option = add_help_option
1234+
self._help_option: t.Optional[HelpOption] = None
12261235
self.no_args_is_help = no_args_is_help
12271236
self.hidden = hidden
12281237
self.deprecated = deprecated
@@ -1288,16 +1297,26 @@ def get_help_option(self, ctx: Context) -> t.Optional["Option"]:
12881297
"""Returns the help option object.
12891298
12901299
Unless ``add_help_option`` is ``False``.
1300+
1301+
.. versionchanged:: 8.1.8
1302+
The help option is now cached to avoid creating it multiple times.
12911303
"""
12921304
help_options = self.get_help_option_names(ctx)
12931305

12941306
if not help_options or not self.add_help_option:
12951307
return None
12961308

1297-
# Avoid circular import.
1298-
from .decorators import HelpOption
1309+
# Cache the help option object in private _help_option attribute to
1310+
# avoid creating it multiple times. Not doing this will break the
1311+
# callback odering by iter_params_for_processing(), which relies on
1312+
# object comparison.
1313+
if self._help_option is None:
1314+
# Avoid circular import.
1315+
from .decorators import HelpOption
1316+
1317+
self._help_option = HelpOption(help_options)
12991318

1300-
return HelpOption(help_options)
1319+
return self._help_option
13011320

13021321
def make_parser(self, ctx: Context) -> OptionParser:
13031322
"""Creates the underlying option parser for this command."""

tests/test_commands.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,138 @@ def test_group_add_command_name(runner):
305305
assert result.exit_code == 0
306306

307307

308+
@pytest.mark.parametrize(
309+
("invocation_order", "declaration_order", "expected_order"),
310+
[
311+
# Non-eager options.
312+
([], ["-a"], ["-a"]),
313+
(["-a"], ["-a"], ["-a"]),
314+
([], ["-a", "-c"], ["-a", "-c"]),
315+
(["-a"], ["-a", "-c"], ["-a", "-c"]),
316+
(["-c"], ["-a", "-c"], ["-c", "-a"]),
317+
([], ["-c", "-a"], ["-c", "-a"]),
318+
(["-a"], ["-c", "-a"], ["-a", "-c"]),
319+
(["-c"], ["-c", "-a"], ["-c", "-a"]),
320+
(["-a", "-c"], ["-a", "-c"], ["-a", "-c"]),
321+
(["-c", "-a"], ["-a", "-c"], ["-c", "-a"]),
322+
# Eager options.
323+
([], ["-b"], ["-b"]),
324+
(["-b"], ["-b"], ["-b"]),
325+
([], ["-b", "-d"], ["-b", "-d"]),
326+
(["-b"], ["-b", "-d"], ["-b", "-d"]),
327+
(["-d"], ["-b", "-d"], ["-d", "-b"]),
328+
([], ["-d", "-b"], ["-d", "-b"]),
329+
(["-b"], ["-d", "-b"], ["-b", "-d"]),
330+
(["-d"], ["-d", "-b"], ["-d", "-b"]),
331+
(["-b", "-d"], ["-b", "-d"], ["-b", "-d"]),
332+
(["-d", "-b"], ["-b", "-d"], ["-d", "-b"]),
333+
# Mixed options.
334+
([], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
335+
(["-a"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
336+
(["-b"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
337+
(["-c"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-c", "-a"]),
338+
(["-d"], ["-a", "-b", "-c", "-d"], ["-d", "-b", "-a", "-c"]),
339+
(["-a", "-b"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
340+
(["-b", "-a"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
341+
(["-d", "-c"], ["-a", "-b", "-c", "-d"], ["-d", "-b", "-c", "-a"]),
342+
(["-c", "-d"], ["-a", "-b", "-c", "-d"], ["-d", "-b", "-c", "-a"]),
343+
(["-a", "-b", "-c", "-d"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
344+
(["-b", "-d", "-a", "-c"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
345+
([], ["-b", "-d", "-e", "-a", "-c"], ["-b", "-d", "-e", "-a", "-c"]),
346+
(["-a", "-d"], ["-b", "-d", "-e", "-a", "-c"], ["-d", "-b", "-e", "-a", "-c"]),
347+
(["-c", "-d"], ["-b", "-d", "-e", "-a", "-c"], ["-d", "-b", "-e", "-c", "-a"]),
348+
],
349+
)
350+
def test_iter_params_for_processing(
351+
invocation_order, declaration_order, expected_order
352+
):
353+
parameters = {
354+
"-a": click.Option(["-a"]),
355+
"-b": click.Option(["-b"], is_eager=True),
356+
"-c": click.Option(["-c"]),
357+
"-d": click.Option(["-d"], is_eager=True),
358+
"-e": click.Option(["-e"], is_eager=True),
359+
}
360+
361+
invocation_params = [parameters[opt_id] for opt_id in invocation_order]
362+
declaration_params = [parameters[opt_id] for opt_id in declaration_order]
363+
expected_params = [parameters[opt_id] for opt_id in expected_order]
364+
365+
assert (
366+
click.core.iter_params_for_processing(invocation_params, declaration_params)
367+
== expected_params
368+
)
369+
370+
371+
def test_help_param_priority(runner):
372+
"""Cover the edge-case in which the eagerness of help option was not
373+
respected, because it was internally generated multiple times.
374+
375+
See: https://github.com/pallets/click/pull/2811
376+
"""
377+
378+
def print_and_exit(ctx, param, value):
379+
if value:
380+
click.echo(f"Value of {param.name} is: {value}")
381+
ctx.exit()
382+
383+
@click.command(context_settings={"help_option_names": ("--my-help",)})
384+
@click.option("-a", is_flag=True, expose_value=False, callback=print_and_exit)
385+
@click.option(
386+
"-b", is_flag=True, expose_value=False, callback=print_and_exit, is_eager=True
387+
)
388+
def cli():
389+
pass
390+
391+
# --my-help is properly called and stop execution.
392+
result = runner.invoke(cli, ["--my-help"])
393+
assert "Value of a is: True" not in result.stdout
394+
assert "Value of b is: True" not in result.stdout
395+
assert "--my-help" in result.stdout
396+
assert result.exit_code == 0
397+
398+
# -a is properly called and stop execution.
399+
result = runner.invoke(cli, ["-a"])
400+
assert "Value of a is: True" in result.stdout
401+
assert "Value of b is: True" not in result.stdout
402+
assert "--my-help" not in result.stdout
403+
assert result.exit_code == 0
404+
405+
# -a takes precedence over -b and stop execution.
406+
result = runner.invoke(cli, ["-a", "-b"])
407+
assert "Value of a is: True" not in result.stdout
408+
assert "Value of b is: True" in result.stdout
409+
assert "--my-help" not in result.stdout
410+
assert result.exit_code == 0
411+
412+
# --my-help is eager by default so takes precedence over -a and stop
413+
# execution, whatever the order.
414+
for args in [["-a", "--my-help"], ["--my-help", "-a"]]:
415+
result = runner.invoke(cli, args)
416+
assert "Value of a is: True" not in result.stdout
417+
assert "Value of b is: True" not in result.stdout
418+
assert "--my-help" in result.stdout
419+
assert result.exit_code == 0
420+
421+
# Both -b and --my-help are eager so they're called in the order they're
422+
# invoked by the user.
423+
result = runner.invoke(cli, ["-b", "--my-help"])
424+
assert "Value of a is: True" not in result.stdout
425+
assert "Value of b is: True" in result.stdout
426+
assert "--my-help" not in result.stdout
427+
assert result.exit_code == 0
428+
429+
# But there was a bug when --my-help is called before -b, because the
430+
# --my-help option created by click via help_option_names is internally
431+
# created twice and is not the same object, breaking the priority order
432+
# produced by iter_params_for_processing.
433+
result = runner.invoke(cli, ["--my-help", "-b"])
434+
assert "Value of a is: True" not in result.stdout
435+
assert "Value of b is: True" not in result.stdout
436+
assert "--my-help" in result.stdout
437+
assert result.exit_code == 0
438+
439+
308440
def test_unprocessed_options(runner):
309441
@click.command(context_settings=dict(ignore_unknown_options=True))
310442
@click.argument("args", nargs=-1, type=click.UNPROCESSED)

0 commit comments

Comments
 (0)