From 047adef258fc25566163ffc3efd14effc0ef7352 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Fri, 31 Jul 2026 15:02:01 +0400 Subject: [PATCH] Preserve declaration order of help option names Co-authored-by: Jochen Sprickerhof --- CHANGES.md | 2 ++ src/click/core.py | 15 +++++++++---- tests/test_commands.py | 50 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f7ff22b32..fc440996c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -63,6 +63,8 @@ Unreleased {class}`ParamType` takes a second optional type parameter describing the input value it accepts (`ParamType[int, str]` for a type converting strings to integers), defaulting to `Any`. {pr}`3407` +- {meth}`Command.get_help_option_names` returns the help option names in the + order they were declared. {pr}`3728` ## Version 8.4.2 diff --git a/src/click/core.py b/src/click/core.py index f29dc4651..de129ec2c 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1175,11 +1175,18 @@ def collect_usage_pieces(self, ctx: Context) -> list[str]: return rv def get_help_option_names(self, ctx: Context) -> list[str]: - """Returns the names for the help option.""" - all_names = set(ctx.help_option_names) + """Returns the names for the help option. + + Drops duplicates and names already reserved by another parameter. Order of + :attr:`Context.help_option_names` is preserved, so the result is stable. + + .. versionchanged:: 8.5.0 + Names keep their declaration order. + """ + all_names = dict.fromkeys(ctx.help_option_names) for param in self.params: - all_names.difference_update(param.opts) - all_names.difference_update(param.secondary_opts) + for name in (*param.opts, *param.secondary_opts): + all_names.pop(name, None) return list(all_names) def get_help_option(self, ctx: Context) -> Option | None: diff --git a/tests/test_commands.py b/tests/test_commands.py index 35ba65caa..c2de826b1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -419,6 +419,56 @@ def test_iter_params_for_processing( ) +@pytest.mark.parametrize( + ("help_option_names", "params", "expected"), + [ + # A single default name is returned unchanged. + (["--help"], [], ["--help"]), + # Declaration order is preserved verbatim, whatever it is. + (["-h", "--help"], [], ["-h", "--help"]), + (["--help", "-h"], [], ["--help", "-h"]), + (["--help", "-h", "-?"], [], ["--help", "-h", "-?"]), + (["-?", "--help", "-h"], [], ["-?", "--help", "-h"]), + # Duplicate names collapse to their first occurrence, keeping order. + (["--help", "--help"], [], ["--help"]), + (["-h", "--help", "-h"], [], ["-h", "--help"]), + # A name already claimed by another parameter's option is dropped. + (["-h", "--help"], [["--help"]], ["-h"]), + (["-h", "--help"], [["-h", "--verbose"]], ["--help"]), + # Both options of a feature-switch flag are removed too. + (["--shout", "--help"], [["--shout/--no-shout"]], ["--help"]), + (["--no-shout", "--help"], [["--shout/--no-shout"]], ["--help"]), + # Filtering every name out yields an empty list. + (["-h", "--help"], [["-h"], ["--help"]], []), + # Deduplication and conflict removal combine, order still preserved. + (["-h", "--help", "-h", "--assist"], [["--assist"]], ["-h", "--help"]), + ], + ids=[ + "default", + "order-short-long", + "order-long-short", + "order-three", + "order-three-shuffled", + "dedupe-adjacent", + "dedupe-spread", + "conflict-long", + "conflict-short", + "conflict-flag-on", + "conflict-flag-off", + "all-removed", + "dedupe-and-conflict", + ], +) +def test_get_help_option_names(help_option_names, params, expected): + """Check help option names are deduplicated but keep stable order. + + https://github.com/pallets/click/pull/3728 + """ + cli = click.Command("cli", params=[click.Option(decls) for decls in params]) + ctx = click.Context(cli, help_option_names=help_option_names) + assert cli.get_help_option_names(ctx) == expected + + def test_help_param_priority(runner): """Cover the edge-case in which the eagerness of help option was not respected, because it was internally generated multiple times.