From b24a333522976deeaf675284e59de17887cf54b7 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Mon, 4 May 2026 16:51:20 +0200 Subject: [PATCH 01/15] Cover the case of competing dual boolean flags Refs #3403 --- tests/test_options.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_options.py b/tests/test_options.py index 50992e2d1..cea9c658a 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -2581,6 +2581,34 @@ def cli(flag): assert result.output == repr(expected) +@pytest.mark.parametrize( + ("args", "expected"), + [ + ([], True), + (["--with-xyz"], True), + (["--without-xyz"], False), + # We expect the last one to win. + (["--with-xyz", "--without-xyz"], False), + (["--without-xyz", "--with-xyz"], True), + ], +) +def test_bool_flag_group_competition(runner, args, expected): + """Competing boolean flags sharing a single parameter name. + + Regression test for https://github.com/pallets/click/issues/3403 + """ + + @click.command() + @click.option("--without-xyz", "enable_xyz", flag_value=False) + @click.option("--with-xyz", "enable_xyz", flag_value=True, default=True) + def cli(enable_xyz): + click.echo(repr(enable_xyz), nl=False) + + result = runner.invoke(cli, args) + assert result.exit_code == 0 + assert result.output == repr(expected) + + @pytest.mark.parametrize( ("flag_type", "args", "expect_output"), [ From 87ddb679f96ce01b9accaca1681fc27bbde6262a Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 08:50:32 +0200 Subject: [PATCH 02/15] Add more tests --- tests/test_options.py | 129 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 121 insertions(+), 8 deletions(-) diff --git a/tests/test_options.py b/tests/test_options.py index cea9c658a..156575e05 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -2582,28 +2582,141 @@ def cli(flag): @pytest.mark.parametrize( - ("args", "expected"), + ("opts", "args", "expected"), [ - ([], True), - (["--with-xyz"], True), - (["--without-xyz"], False), - # We expect the last one to win. - (["--with-xyz", "--without-xyz"], False), - (["--without-xyz", "--with-xyz"], True), + # #3403 reproducer: enable/disable pair with explicit ``default=True`` + # on the positive flag, declared after (inner decorator) the negative. + # https://github.com/pallets/click/issues/3403 + pytest.param( + [ + ("--without-xyz", {"flag_value": False}), + ("--with-xyz", {"flag_value": True, "default": True}), + ], + [], + True, + id="3403-reproducer-no-args", + ), + pytest.param( + [ + ("--without-xyz", {"flag_value": False}), + ("--with-xyz", {"flag_value": True, "default": True}), + ], + ["--with-xyz"], + True, + id="3403-reproducer-with-only", + ), + pytest.param( + [ + ("--without-xyz", {"flag_value": False}), + ("--with-xyz", {"flag_value": True, "default": True}), + ], + ["--without-xyz"], + False, + id="3403-reproducer-without-only", + ), + # When both flags are passed, the parser keeps the last value seen. + pytest.param( + [ + ("--without-xyz", {"flag_value": False}), + ("--with-xyz", {"flag_value": True, "default": True}), + ], + ["--with-xyz", "--without-xyz"], + False, + id="3403-reproducer-with-then-without", + ), + pytest.param( + [ + ("--without-xyz", {"flag_value": False}), + ("--with-xyz", {"flag_value": True, "default": True}), + ], + ["--without-xyz", "--with-xyz"], + True, + id="3403-reproducer-without-then-with", + ), ], ) -def test_bool_flag_group_competition(runner, args, expected): +def test_bool_flag_group_competition(runner, opts, args, expected): """Competing boolean flags sharing a single parameter name. Regression test for https://github.com/pallets/click/issues/3403 """ + @click.command() + def cli(enable_xyz): + click.echo(repr(enable_xyz), nl=False) + + for opt_name, opt_kwargs in opts: + cli = click.option(opt_name, "enable_xyz", **opt_kwargs)(cli) + + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + assert result.output == repr(expected) + + +@pytest.mark.parametrize( + ("envvar_value", "args", "expected"), + [ + # An env var on one option in the group provides ENVIRONMENT source, + # which beats any sibling's DEFAULT regardless of explicit-default. + ("1", [], True), + ("0", [], False), + # Command-line still beats the env var. + ("0", ["--with-xyz"], True), + ("1", ["--without-xyz"], False), + ], +) +def test_bool_flag_group_competition_with_envvar( + runner, monkeypatch, envvar_value, args, expected +): + """Env var-sourced value on one member of a feature-switch group beats + a sibling's default. Regression-adjacent to #3403. + """ + monkeypatch.setenv("XYZ", envvar_value) + + @click.command() + @click.option("--without-xyz", "enable_xyz", flag_value=False) + @click.option( + "--with-xyz", + "enable_xyz", + flag_value=True, + default=False, + envvar="XYZ", + ) + def cli(enable_xyz): + click.echo(repr(enable_xyz), nl=False) + + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + assert result.output == repr(expected) + + +@pytest.mark.parametrize( + ("default_map", "args", "expected"), + [ + # ``default_map`` provides DEFAULT_MAP source, beating either default. + ({"enable_xyz": True}, [], True), + ({"enable_xyz": False}, [], False), + # Command-line still beats default_map. + ({"enable_xyz": False}, ["--with-xyz"], True), + ({"enable_xyz": True}, ["--without-xyz"], False), + ], +) +def test_bool_flag_group_competition_with_default_map( + runner, default_map, args, expected +): + """A default_map entry overrides any default in the group, while cmdline + still wins over default_map. + """ + @click.command() @click.option("--without-xyz", "enable_xyz", flag_value=False) @click.option("--with-xyz", "enable_xyz", flag_value=True, default=True) def cli(enable_xyz): click.echo(repr(enable_xyz), nl=False) + result = runner.invoke(cli, args, default_map=default_map) + assert result.exit_code == 0, result.output + assert result.output == repr(expected) result = runner.invoke(cli, args) assert result.exit_code == 0 assert result.output == repr(expected) From a83dde072f37f0b4981b6b355ed0d9ed44816199 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 10:16:28 +0200 Subject: [PATCH 03/15] Test dual flags explicit and non-explicit defaults --- tests/test_options.py | 63 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_options.py b/tests/test_options.py index 156575e05..01ca958bb 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -2719,6 +2719,69 @@ def cli(enable_xyz): assert result.output == repr(expected) result = runner.invoke(cli, args) assert result.exit_code == 0 + + +@pytest.mark.parametrize( + ("default_a", "default_b", "args", "expected"), + [ + # ``default=UNSET`` and an absent ``default`` keyword must produce + # identical behavior. Both options here are bare boolean flags, so + # both auto-derive ``False`` and the first declared wins. + (UNSET, UNSET, [], False), + # ``default=UNSET`` on one side, explicit on the other: the explicit + # one wins regardless of decorator order. + (UNSET, True, [], True), + (True, UNSET, [], True), + (UNSET, False, [], False), + (False, UNSET, [], False), + # ``default=None`` is a real value, distinct from ``UNSET``, and + # remains explicit even when the sibling carries an explicit + # boolean default (3-state). + (None, UNSET, [], None), + (UNSET, None, [], None), + # Explicit ``None`` competing with explicit boolean. The decorator + # order in this test puts ``--b`` first in ``params``, so the value + # carried by ``default_b`` always wins these "both explicit" ties. + (None, True, [], True), + (True, None, [], None), + ], +) +def test_flag_group_unset_vs_none_vs_explicit( + runner, default_a, default_b, args, expected +): + """``UNSET`` as an explicit ``default`` must be indistinguishable from + omitting ``default`` entirely, while ``None`` is a real explicit value. + + Both flags here share the parameter name ``state``: ``--a`` carries + ``flag_value=True`` and ``--b`` carries ``flag_value=False``. The ``--b`` + decorator is written above ``--a``, so it lands first in ``params`` and + keeps the slot in any tie. This exercises the boundary between the + absence-of-default sentinel and a user-chosen ``None`` value, which the + fix relies on to compute :attr:`Parameter._default_explicit`. + """ + a_kwargs = {"flag_value": True} + if default_a is not UNSET: + a_kwargs["default"] = default_a + elif default_a is UNSET: + # Pass UNSET explicitly to verify it's treated as absent. Skip when + # the test wants the absent-keyword case (matches default behavior + # because ``Parameter.__init__`` defaults ``default`` to ``UNSET``). + a_kwargs["default"] = UNSET + + b_kwargs = {"flag_value": False} + if default_b is not UNSET: + b_kwargs["default"] = default_b + elif default_b is UNSET: + b_kwargs["default"] = UNSET + + @click.command() + @click.option("--b", "state", **b_kwargs) + @click.option("--a", "state", **a_kwargs) + def cli(state): + click.echo(repr(state), nl=False) + + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output assert result.output == repr(expected) From effa02496846ef7944388b6a65e88c681f15c5a4 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 10:21:10 +0200 Subject: [PATCH 04/15] Add non boolean dual option tests --- tests/test_options.py | 279 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 278 insertions(+), 1 deletion(-) diff --git a/tests/test_options.py b/tests/test_options.py index 01ca958bb..d84807203 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -2717,8 +2717,285 @@ def cli(enable_xyz): result = runner.invoke(cli, args, default_map=default_map) assert result.exit_code == 0, result.output assert result.output == repr(expected) + + +@pytest.mark.parametrize( + ("opts", "args", "expected"), + [ + # Non-boolean feature switch group: classic --upper/--lower + # pattern. The option with ``default=True`` acts as the default + # via the substitution rule (#3239) for non-boolean ``flag_value``. + pytest.param( + [ + ("--upper", {"flag_value": "upper", "default": True}), + ("--lower", {"flag_value": "lower"}), + ], + [], + "upper", + id="string-default-true-substitutes-to-flag-value", + ), + pytest.param( + [ + ("--upper", {"flag_value": "upper", "default": True}), + ("--lower", {"flag_value": "lower"}), + ], + ["--upper"], + "upper", + id="string-default-true-cmdline-positive", + ), + pytest.param( + [ + ("--upper", {"flag_value": "upper", "default": True}), + ("--lower", {"flag_value": "lower"}), + ], + ["--lower"], + "lower", + id="string-default-true-cmdline-overrides", + ), + # Explicit literal string default beats sibling's absent default. + # Confirms the explicit-beats-absent rule applies regardless of type. + pytest.param( + [ + ("--upper", {"flag_value": "upper", "default": "lower"}), + ("--lower", {"flag_value": "lower"}), + ], + [], + "lower", + id="string-explicit-default-wins-over-absent", + ), + pytest.param( + [ + ("--upper", {"flag_value": "upper"}), + ("--lower", {"flag_value": "lower", "default": "upper"}), + ], + [], + "upper", + id="string-explicit-default-wins-from-second-position", + ), + # Empty string as ``flag_value``: still a legal value, including + # under the ``default=True`` substitution rule. + pytest.param( + [ + ("--empty", {"flag_value": "", "default": True}), + ("--filled", {"flag_value": "filled"}), + ], + [], + "", + id="empty-string-flag-value-default-true", + ), + pytest.param( + [ + ("--empty", {"flag_value": "", "default": True}), + ("--filled", {"flag_value": "filled"}), + ], + ["--empty"], + "", + id="empty-string-flag-value-cmdline", + ), + # Empty string as ``default``: explicit empty string beats + # the sibling's absent default. + pytest.param( + [ + ("--upper", {"flag_value": "upper", "default": ""}), + ("--lower", {"flag_value": "lower"}), + ], + [], + "", + id="empty-string-explicit-default", + ), + # ``flag_value=None`` is a legal flag value: when the option is + # activated, the function receives ``None``. + pytest.param( + [ + ("--none", {"flag_value": None, "default": "fallback"}), + ("--other", {"flag_value": "other"}), + ], + [], + "fallback", + id="none-flag-value-default-fallback", + ), + pytest.param( + [ + ("--none", {"flag_value": None, "default": "fallback"}), + ("--other", {"flag_value": "other"}), + ], + ["--none"], + None, + id="none-flag-value-cmdline-passes-none", + ), + pytest.param( + [ + ("--none", {"flag_value": None, "default": "fallback"}), + ("--other", {"flag_value": "other"}), + ], + ["--other"], + "other", + id="none-flag-value-cmdline-passes-other", + ), + # Explicit ``default=None`` is a real value (not absence) and + # must beat a sibling's absent default. Three-state pattern for + # non-boolean flag groups. + pytest.param( + [ + ("--upper", {"flag_value": "upper", "default": None}), + ("--lower", {"flag_value": "lower"}), + ], + [], + None, + id="explicit-default-none-three-state", + ), + pytest.param( + [ + ("--upper", {"flag_value": "upper", "default": None}), + ("--lower", {"flag_value": "lower"}), + ], + ["--upper"], + "upper", + id="explicit-default-none-cmdline-upper", + ), + pytest.param( + [ + ("--upper", {"flag_value": "upper", "default": None}), + ("--lower", {"flag_value": "lower"}), + ], + ["--lower"], + "lower", + id="explicit-default-none-cmdline-lower", + ), + # Passing ``default=UNSET`` explicitly is the same as not passing + # ``default`` at all, so the sibling's explicit default wins. + pytest.param( + [ + ("--upper", {"flag_value": "upper", "default": UNSET}), + ("--lower", {"flag_value": "lower", "default": "lower"}), + ], + [], + "lower", + id="unset-default-equivalent-to-absent", + ), + pytest.param( + [ + ("--upper", {"flag_value": "upper", "default": UNSET}), + ("--lower", {"flag_value": "lower", "default": "lower"}), + ], + ["--upper"], + "upper", + id="unset-default-cmdline-still-works", + ), + # Neither option has a default: the slot resolves to ``None`` + # because non-boolean flags do not auto-derive a default. + pytest.param( + [ + ("--upper", {"flag_value": "upper"}), + ("--lower", {"flag_value": "lower"}), + ], + [], + None, + id="non-boolean-no-defaults-resolves-to-none", + ), + pytest.param( + [ + ("--upper", {"flag_value": "upper"}), + ("--lower", {"flag_value": "lower"}), + ], + ["--upper"], + "upper", + id="non-boolean-no-defaults-cmdline-still-works", + ), + # Three-flag string group: explicit default wins from any + # position in the decorator stack. + pytest.param( + [ + ("--upper", {"flag_value": "upper"}), + ("--mixed", {"flag_value": "MiXeD", "default": "MiXeD"}), + ("--lower", {"flag_value": "lower"}), + ], + [], + "MiXeD", + id="three-flags-explicit-default-in-middle", + ), + pytest.param( + [ + ("--upper", {"flag_value": "upper"}), + ("--lower", {"flag_value": "lower"}), + ("--default-choice", {"flag_value": "chosen", "default": True}), + ], + [], + "chosen", + id="three-flags-default-true-substitution-last", + ), + # Both options have explicit defaults: the first declared + # keeps the slot, regardless of value type. + pytest.param( + [ + ("--upper", {"flag_value": "upper", "default": "first"}), + ("--lower", {"flag_value": "lower", "default": "second"}), + ], + [], + "first", + id="both-explicit-defaults-string-first-wins", + ), + # Mixed boolean and non-boolean ``flag_value`` in the same group + # is allowed. Both options here carry an explicit default, so the first + # declared wins regardless of value type. The boolean ``default=False`` + # is a literal value (post-#3239), not a sentinel. + pytest.param( + [ + ("--bool-flag", {"flag_value": True, "default": False}), + ("--str-flag", {"flag_value": "named", "default": "explicit"}), + ], + [], + False, + id="mixed-bool-and-string-first-wins", + ), + pytest.param( + [ + ("--str-flag", {"flag_value": "named", "default": "explicit"}), + ("--bool-flag", {"flag_value": True, "default": False}), + ], + [], + "explicit", + id="mixed-bool-and-string-first-wins-swapped", + ), + # Empty string default coexisting with ``default=True`` + # substitution: ``default=""`` is explicit, beats the sibling + # whose default would have substituted to its flag_value. + pytest.param( + [ + ("--upper", {"flag_value": "upper", "default": True}), + ("--blank", {"flag_value": "blank", "default": ""}), + ], + [], + "upper", + id="default-true-vs-empty-string-first-wins", + ), + pytest.param( + [ + ("--blank", {"flag_value": "blank", "default": ""}), + ("--upper", {"flag_value": "upper", "default": True}), + ], + [], + "", + id="default-true-vs-empty-string-first-wins-swapped", + ), + ], +) +def test_flag_group_competition_non_boolean(runner, opts, args, expected): + """Same arbitration rules as :func:`test_bool_flag_group_competition`, + but for feature-switch groups with non-boolean ``flag_value`` (strings, + ``None``, empty strings, mixed types, ``UNSET`` defaults). + """ + + @click.command() + def cli(case): + click.echo(repr(case), nl=False) + + for opt_name, opt_kwargs in opts: + cli = click.option(opt_name, "case", **opt_kwargs)(cli) + result = runner.invoke(cli, args) - assert result.exit_code == 0 + assert result.exit_code == 0, result.output + assert result.output == repr(expected) @pytest.mark.parametrize( From 5a5881cf947e30d2359d50bac7fe16148ba9a53f Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 10:40:45 +0200 Subject: [PATCH 05/15] The last must win --- tests/test_options.py | 56 +++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/tests/test_options.py b/tests/test_options.py index d84807203..ee140ee50 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -2924,29 +2924,29 @@ def cli(enable_xyz): "chosen", id="three-flags-default-true-substitution-last", ), - # Both options have explicit defaults: the first declared - # keeps the slot, regardless of value type. + # Both options have explicit defaults: last-wins, so the option + # declared last keeps the slot, regardless of value type. pytest.param( [ ("--upper", {"flag_value": "upper", "default": "first"}), ("--lower", {"flag_value": "lower", "default": "second"}), ], [], - "first", - id="both-explicit-defaults-string-first-wins", + "second", + id="both-explicit-defaults-string-last-wins", ), # Mixed boolean and non-boolean ``flag_value`` in the same group - # is allowed. Both options here carry an explicit default, so the first - # declared wins regardless of value type. The boolean ``default=False`` - # is a literal value (post-#3239), not a sentinel. + # is allowed. Both options here carry an explicit default, so last-wins + # picks the option declared last regardless of value type. The boolean + # ``default=False`` is a literal value (post-#3239), not a sentinel. pytest.param( [ ("--bool-flag", {"flag_value": True, "default": False}), ("--str-flag", {"flag_value": "named", "default": "explicit"}), ], [], - False, - id="mixed-bool-and-string-first-wins", + "explicit", + id="mixed-bool-and-string-last-wins", ), pytest.param( [ @@ -2954,20 +2954,21 @@ def cli(enable_xyz): ("--bool-flag", {"flag_value": True, "default": False}), ], [], - "explicit", - id="mixed-bool-and-string-first-wins-swapped", + False, + id="mixed-bool-and-string-last-wins-swapped", ), # Empty string default coexisting with ``default=True`` - # substitution: ``default=""`` is explicit, beats the sibling - # whose default would have substituted to its flag_value. + # substitution: ``default=""`` is explicit, ``default=True`` is also + # explicit (and substitutes to the option's ``flag_value``). Last-wins + # picks the option declared last. pytest.param( [ ("--upper", {"flag_value": "upper", "default": True}), ("--blank", {"flag_value": "blank", "default": ""}), ], [], - "upper", - id="default-true-vs-empty-string-first-wins", + "", + id="default-true-vs-empty-string-last-wins", ), pytest.param( [ @@ -2975,8 +2976,8 @@ def cli(enable_xyz): ("--upper", {"flag_value": "upper", "default": True}), ], [], - "", - id="default-true-vs-empty-string-first-wins-swapped", + "upper", + id="default-true-vs-empty-string-last-wins-swapped", ), ], ) @@ -3003,7 +3004,8 @@ def cli(case): [ # ``default=UNSET`` and an absent ``default`` keyword must produce # identical behavior. Both options here are bare boolean flags, so - # both auto-derive ``False`` and the first declared wins. + # both auto-derive ``False`` and last-wins applies (``--a`` is + # processed last); the value is ``False`` either way. (UNSET, UNSET, [], False), # ``default=UNSET`` on one side, explicit on the other: the explicit # one wins regardless of decorator order. @@ -3017,10 +3019,11 @@ def cli(case): (None, UNSET, [], None), (UNSET, None, [], None), # Explicit ``None`` competing with explicit boolean. The decorator - # order in this test puts ``--b`` first in ``params``, so the value - # carried by ``default_b`` always wins these "both explicit" ties. - (None, True, [], True), - (True, None, [], None), + # order in this test puts ``--a`` last in ``params``, so the value + # carried by ``default_a`` wins these "both explicit" ties under + # last-wins. + (None, True, [], None), + (True, None, [], True), ], ) def test_flag_group_unset_vs_none_vs_explicit( @@ -3031,10 +3034,11 @@ def test_flag_group_unset_vs_none_vs_explicit( Both flags here share the parameter name ``state``: ``--a`` carries ``flag_value=True`` and ``--b`` carries ``flag_value=False``. The ``--b`` - decorator is written above ``--a``, so it lands first in ``params`` and - keeps the slot in any tie. This exercises the boundary between the - absence-of-default sentinel and a user-chosen ``None`` value, which the - fix relies on to compute :attr:`Parameter._default_explicit`. + decorator is written above ``--a``, so ``--a`` lands last in ``params`` + and wins under last-wins for "both explicit" ties. This exercises the + boundary between the absence-of-default sentinel and a user-chosen + ``None`` value, which the fix relies on to compute + :attr:`Parameter._default_explicit`. """ a_kwargs = {"flag_value": True} if default_a is not UNSET: From 9c4b0bbfbc8eabf85128c7a327f850039ab39499 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 10:41:55 +0200 Subject: [PATCH 06/15] Track explicit default state --- src/click/core.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/click/core.py b/src/click/core.py index 776a7f5ac..b38702702 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -454,6 +454,12 @@ def __init__( self._close_callbacks: list[t.Callable[[], t.Any]] = [] self._depth = 0 self._parameter_source: dict[str, ParameterSource] = {} + # Tracks whether the option that currently owns each parameter slot in + # :attr:`params` had its ``default`` set explicitly by the user. Used + # to tie-break feature-switch groups where multiple options share a + # parameter name and both fall back to their default value. + # Refs: https://github.com/pallets/click/issues/3403 + self._param_default_explicit: dict[str, bool] = {} self._exit_stack = ExitStack() @property @@ -2197,6 +2203,12 @@ def __init__( self.multiple = multiple self.expose_value = expose_value self.default: t.Any | t.Callable[[], t.Any] | None = default + # Whether the user passed ``default`` explicitly to the constructor. + # Captured before any auto-derived default (like ``False`` for boolean + # flags in :class:`Option`) replaces the :data:`UNSET` sentinel, so it + # remains ``False`` when the default was inferred rather than chosen. + # Refs: https://github.com/pallets/click/issues/3403 + self._default_explicit: bool = default is not UNSET self.is_eager = is_eager self.metavar = metavar self.envvar = envvar From 404bf350345524274ccedf13ac0a2965a536634f Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 10:47:46 +0200 Subject: [PATCH 07/15] Document arbitrage rules --- docs/options.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/options.md b/docs/options.md index 5c50d7450..d3242b0aa 100644 --- a/docs/options.md +++ b/docs/options.md @@ -469,9 +469,11 @@ literally. #### Feature switch groups (multiple flags sharing one variable) -When multiple `flag_value` options target the same parameter -name, `default=True` on one of them marks it as the default -choice. +Several `flag_value` options can target the same parameter name to form a +feature switch group. The user picks one flag on the command line, and the +function receives the corresponding `flag_value`. When the user picks none, +Click falls back to whichever option claims the slot under the arbitration +rules described below. | Definition | Not passed | `--upper` | `--lower` | |--------------------------------------------------------|------------|-----------|-----------| @@ -479,6 +481,22 @@ choice. | `--upper` with `flag_value='upper'`, `default='upper'` | `"upper"` | `"upper"` | `"lower"` | | Both without `default` | `None` | `"upper"` | `"lower"` | +##### Arbitration rules + +Several options can target the same `name` to form a feature switch group. When +they do, only one option's value reaches the function. Arbitration applies +these rules, in order: + +1. **By source.** Whichever option resolved its value from the most explicit + source wins, regardless of decorator order. Any command-line input beats any + default, an environment variable beats a `default_map` entry, and so on. +2. **Within the default tier, explicit beats auto-derived.** An option that + received an explicit `default=` keyword wins over one whose default came + from `default` interpretation. +3. **Otherwise, last declared wins.** When all options in the group resolved + from the same source and tier (all auto-derived defaults, or all explicit + defaults), the option declared last in the source code keeps the slot. + ## Values from Environment Variables To pass in a value from a specific environment variable use `envvar`. From 5c967d782092b80efbcce479f4bc12cb92bdbe44 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 10:48:44 +0200 Subject: [PATCH 08/15] Check duplicate options are detected --- tests/test_options.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_options.py b/tests/test_options.py index ee140ee50..eecd76e06 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -3066,6 +3066,25 @@ def cli(state): assert result.output == repr(expected) +def test_flag_group_competition_duplicate_option_name(runner): + """The same option name declared twice on the same command is a user + error. Click already detects this and raises a ``UserWarning`` when the + command runs, so the arbitration rules never get to "silently picks + one of the two." This test pins that behavior. + """ + + @click.command() + @click.option("--xyz", default="first") + @click.option("--xyz", default="second") + def cli(xyz): + click.echo(repr(xyz), nl=False) + + result = runner.invoke(cli, []) + assert result.exit_code == 1 + assert isinstance(result.exception, UserWarning) + assert "used more than once" in str(result.exception) + + @pytest.mark.parametrize( ("flag_type", "args", "expect_output"), [ From 80c4ed0d4474c86193ed55388ff343d6bb274c4a Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 10:50:19 +0200 Subject: [PATCH 09/15] Test arbitrary sized groups --- tests/test_options.py | 64 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/test_options.py b/tests/test_options.py index eecd76e06..ad3a538b6 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -3085,6 +3085,70 @@ def cli(xyz): assert "used more than once" in str(result.exception) +@pytest.mark.parametrize( + ("opts", "args", "expected"), + [ + pytest.param( + [ + ("--a", {"flag_value": "a"}), + ("--b", {"flag_value": "b"}), + ("--c", {"flag_value": "c"}), + ("--d", {"flag_value": "d"}), + ], + [], + None, + id="four-flags-no-defaults-resolves-to-none", + ), + pytest.param( + [ + ("--a", {"flag_value": "a"}), + ("--b", {"flag_value": "b", "default": "from-b"}), + ("--c", {"flag_value": "c"}), + ("--d", {"flag_value": "d"}), + ], + [], + "from-b", + id="four-flags-only-second-explicit-wins", + ), + pytest.param( + [ + ("--a", {"flag_value": "a"}), + ("--b", {"flag_value": "b", "default": "from-b"}), + ("--c", {"flag_value": "c"}), + ("--d", {"flag_value": "d", "default": "from-d"}), + ], + [], + "from-d", + id="four-flags-two-explicit-last-wins", + ), + pytest.param( + [ + ("--a", {"flag_value": "a"}), + ("--b", {"flag_value": "b"}), + ("--c", {"flag_value": "c"}), + ("--d", {"flag_value": "d"}), + ], + ["--c"], + "c", + id="four-flags-cmdline-beats-everything", + ), + ], +) +def test_flag_group_competition_four_flags(runner, opts, args, expected): + """Arbitration rules applies to groups of any size.""" + + @click.command() + def cli(case): + click.echo(repr(case), nl=False) + + for opt_name, opt_kwargs in opts: + cli = click.option(opt_name, "case", **opt_kwargs)(cli) + + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + assert result.output == repr(expected) + + @pytest.mark.parametrize( ("flag_type", "args", "expect_output"), [ From fd50ce9c318bdcb0804eea25155a13087cb755e1 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 10:51:58 +0200 Subject: [PATCH 10/15] Check last wins for duplicate flags passed in different order --- tests/test_options.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_options.py b/tests/test_options.py index ad3a538b6..b2bea5880 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -3085,6 +3085,29 @@ def cli(xyz): assert "used more than once" in str(result.exception) +@pytest.mark.parametrize( + ("args", "expected"), + [ + (["--with-xyz", "--with-xyz"], True), + (["--without-xyz", "--without-xyz"], False), + (["--with-xyz", "--without-xyz", "--with-xyz"], True), + (["--without-xyz", "--with-xyz", "--without-xyz"], False), + ], +) +def test_flag_group_competition_repeated_cmdline(runner, args, expected): + """Duplicate flags passed in different order to the CLI.""" + + @click.command() + @click.option("--without-xyz", "enable_xyz", flag_value=False) + @click.option("--with-xyz", "enable_xyz", flag_value=True, default=True) + def cli(enable_xyz): + click.echo(repr(enable_xyz), nl=False) + + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + assert result.output == repr(expected) + + @pytest.mark.parametrize( ("opts", "args", "expected"), [ From 2dc265e04dbb337a7e0d6f2824b7149243b57408 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 11:00:07 +0200 Subject: [PATCH 11/15] Implement arbitrage policy --- src/click/core.py | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index b38702702..126053558 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2595,11 +2595,17 @@ def handle_parse_result( :meta private: """ + # Capture the slot's existing state before we mutate + # ``_parameter_source`` so the write decision below can compare our + # incoming source against the source of the option that already wrote + # the slot (if any). + existing_value = ctx.params.get(self.name, UNSET) + existing_source = ctx.get_parameter_source(self.name) + existing_default_explicit = ctx._param_default_explicit.get(self.name, False) + with augment_usage_errors(ctx, param=self): value, source = self.consume_value(ctx, opts) - ctx.set_parameter_source(self.name, source) - # Display a deprecation warning if necessary. if ( self.deprecated @@ -2630,15 +2636,32 @@ def handle_parse_result( # to UNSET, which will be interpreted as a missing value. value = UNSET - # Add parameter's value to the context. - if ( - self.expose_value - # We skip adding the value if it was previously set by another parameter - # targeting the same variable name. This prevents parameters competing for - # the same name to override each other. - and (self.name not in ctx.params or ctx.params[self.name] is UNSET) - ): - ctx.params[self.name] = value + # Arbitrate the slot when several parameters target the same variable + # name (feature-switch groups). See: https://github.com/pallets/click/issues/3403 + slot_empty = existing_value is UNSET + more_explicit = existing_source is not None and source < existing_source + same_source = existing_source is not None and source == existing_source + auto_would_downgrade_explicit = ( + same_source + and source == ParameterSource.DEFAULT + and existing_default_explicit + and not self._default_explicit + ) + is_winner = ( + slot_empty + or more_explicit + or (same_source and not auto_would_downgrade_explicit) + ) + + if is_winner: + ctx.set_parameter_source(self.name, source) + if self.expose_value: + ctx.params[self.name] = value + ctx._param_default_explicit[self.name] = self._default_explicit + elif existing_source is None: + # Nothing has claimed the slot yet. Record at least our source so downstream + # lookups don't return ``None``. + ctx.set_parameter_source(self.name, source) return value, args From 43c42c4977bee3ab632cd10fbea47309c410c9ef Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 12:05:04 +0200 Subject: [PATCH 12/15] Extend tests --- tests/test_options.py | 266 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 246 insertions(+), 20 deletions(-) diff --git a/tests/test_options.py b/tests/test_options.py index b2bea5880..25e649d04 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -2633,11 +2633,194 @@ def cli(flag): True, id="3403-reproducer-without-then-with", ), + # Order-independence: explicit ``default=True`` on the OUTER + # decorator (declared first) must produce the same behavior. + pytest.param( + [ + ("--with-xyz", {"flag_value": True, "default": True}), + ("--without-xyz", {"flag_value": False}), + ], + [], + True, + id="explicit-default-outer-no-args", + ), + pytest.param( + [ + ("--with-xyz", {"flag_value": True, "default": True}), + ("--without-xyz", {"flag_value": False}), + ], + ["--without-xyz"], + False, + id="explicit-default-outer-cmdline-overrides", + ), + # Explicit ``default=False`` on the negative flag wins over the + # auto-derived default of the positive one. Result value is False + # either way, but the assertion still guards source tracking. + pytest.param( + [ + ("--with-xyz", {"flag_value": True}), + ("--without-xyz", {"flag_value": False, "default": False}), + ], + [], + False, + id="explicit-default-false-on-negative", + ), + # Explicit ``default=True`` on the negative flag is unusual but + # legal: post-#3239, it is a literal Python value, not a sentinel. + pytest.param( + [ + ("--with-xyz", {"flag_value": True}), + ("--without-xyz", {"flag_value": False, "default": True}), + ], + [], + True, + id="explicit-default-true-on-negative", + ), + # Both options carry an explicit default: last-wins, so the option + # declared last in the source code keeps the slot. Confirms the + # explicit-beats-auto tie-break does not also promote first-declared + # over a later explicit default. + pytest.param( + [ + ("--with-xyz", {"flag_value": True, "default": True}), + ("--without-xyz", {"flag_value": False, "default": False}), + ], + [], + False, + id="both-explicit-defaults-last-wins", + ), + pytest.param( + [ + ("--without-xyz", {"flag_value": False, "default": False}), + ("--with-xyz", {"flag_value": True, "default": True}), + ], + [], + True, + id="both-explicit-defaults-last-wins-swapped", + ), + # No option has an explicit default: every boolean flag + # auto-derives ``default=False`` regardless of its ``flag_value``, + # so the slot is False either way. Last-wins applies under the hood + # but is not observable because both values are equal. + pytest.param( + [ + ("--with-xyz", {"flag_value": True}), + ("--without-xyz", {"flag_value": False}), + ], + [], + False, + id="both-auto-defaults-positive-first", + ), + pytest.param( + [ + ("--without-xyz", {"flag_value": False}), + ("--with-xyz", {"flag_value": True}), + ], + [], + False, + id="both-auto-defaults-negative-first", + ), + # Explicit ``default=False`` matching the auto-derived value: + # the explicit option still wins the slot. Confirms tracking is + # source-based and not value-based. + pytest.param( + [ + ("--with-xyz", {"flag_value": True, "default": False}), + ("--without-xyz", {"flag_value": False}), + ], + [], + False, + id="explicit-default-matches-auto-still-wins", + ), + # Three-flag group: the explicit default wins regardless of its + # position in the decorator stack. + pytest.param( + [ + ("--auto-a", {"flag_value": True}), + ("--explicit", {"flag_value": False, "default": False}), + ("--auto-b", {"flag_value": True}), + ], + [], + False, + id="three-flags-explicit-in-middle", + ), + pytest.param( + [ + ("--auto-a", {"flag_value": True}), + ("--auto-b", {"flag_value": False}), + ("--explicit", {"flag_value": True, "default": True}), + ], + [], + True, + id="three-flags-explicit-last", + ), + pytest.param( + [ + ("--explicit", {"flag_value": False, "default": False}), + ("--auto-a", {"flag_value": True}), + ("--auto-b", {"flag_value": True}), + ], + [], + False, + id="three-flags-explicit-first", + ), + # Three-state pattern: explicit ``default=None`` on one option + # must beat a sibling's auto-derived ``False``. + pytest.param( + [ + ("--without-xyz", {"flag_value": False}), + ("--with-xyz", {"flag_value": True, "default": None}), + ], + [], + None, + id="explicit-default-none-three-state", + ), + pytest.param( + [ + ("--without-xyz", {"flag_value": False}), + ("--with-xyz", {"flag_value": True, "default": None}), + ], + ["--with-xyz"], + True, + id="explicit-default-none-three-state-with", + ), + pytest.param( + [ + ("--without-xyz", {"flag_value": False}), + ("--with-xyz", {"flag_value": True, "default": None}), + ], + ["--without-xyz"], + False, + id="explicit-default-none-three-state-without", + ), + # Command-line input always beats any default, regardless of + # which option carried the explicit default. + pytest.param( + [ + ("--with-xyz", {"flag_value": True, "default": True}), + ("--without-xyz", {"flag_value": False}), + ], + ["--without-xyz"], + False, + id="cmdline-beats-explicit-default", + ), + pytest.param( + [ + ("--without-xyz", {"flag_value": False, "default": False}), + ("--with-xyz", {"flag_value": True}), + ], + ["--with-xyz"], + True, + id="cmdline-beats-explicit-default-symmetric", + ), ], ) def test_bool_flag_group_competition(runner, opts, args, expected): """Competing boolean flags sharing a single parameter name. + Verifies the arbitration rules between options that target the same + variable name in a feature-switch group. + Regression test for https://github.com/pallets/click/issues/3403 """ @@ -2668,9 +2851,6 @@ def cli(enable_xyz): def test_bool_flag_group_competition_with_envvar( runner, monkeypatch, envvar_value, args, expected ): - """Env var-sourced value on one member of a feature-switch group beats - a sibling's default. Regression-adjacent to #3403. - """ monkeypatch.setenv("XYZ", envvar_value) @click.command() @@ -2704,10 +2884,6 @@ def cli(enable_xyz): def test_bool_flag_group_competition_with_default_map( runner, default_map, args, expected ): - """A default_map entry overrides any default in the group, while cmdline - still wins over default_map. - """ - @click.command() @click.option("--without-xyz", "enable_xyz", flag_value=False) @click.option("--with-xyz", "enable_xyz", flag_value=True, default=True) @@ -2983,8 +3159,7 @@ def cli(enable_xyz): ) def test_flag_group_competition_non_boolean(runner, opts, args, expected): """Same arbitration rules as :func:`test_bool_flag_group_competition`, - but for feature-switch groups with non-boolean ``flag_value`` (strings, - ``None``, empty strings, mixed types, ``UNSET`` defaults). + but for feature-switch groups with non-boolean ``flag_value``. """ @click.command() @@ -3031,14 +3206,6 @@ def test_flag_group_unset_vs_none_vs_explicit( ): """``UNSET`` as an explicit ``default`` must be indistinguishable from omitting ``default`` entirely, while ``None`` is a real explicit value. - - Both flags here share the parameter name ``state``: ``--a`` carries - ``flag_value=True`` and ``--b`` carries ``flag_value=False``. The ``--b`` - decorator is written above ``--a``, so ``--a`` lands last in ``params`` - and wins under last-wins for "both explicit" ties. This exercises the - boundary between the absence-of-default sentinel and a user-chosen - ``None`` value, which the fix relies on to compute - :attr:`Parameter._default_explicit`. """ a_kwargs = {"flag_value": True} if default_a is not UNSET: @@ -3068,9 +3235,7 @@ def cli(state): def test_flag_group_competition_duplicate_option_name(runner): """The same option name declared twice on the same command is a user - error. Click already detects this and raises a ``UserWarning`` when the - command runs, so the arbitration rules never get to "silently picks - one of the two." This test pins that behavior. + error. """ @click.command() @@ -3172,6 +3337,67 @@ def cli(case): assert result.output == repr(expected) +@pytest.mark.parametrize( + ("env", "default_map", "args", "expected"), + [ + # ``auto_envvar_prefix`` produces an ``ENVIRONMENT`` source through a + # different code path than an explicit ``envvar=`` keyword. It still + # must beat any sibling default and be beaten by command-line input. + pytest.param( + {"AUTO_ENABLE_XYZ": "1"}, + None, + [], + True, + id="auto-envvar-prefix-beats-default", + ), + pytest.param( + {"AUTO_ENABLE_XYZ": "1"}, + None, + ["--without-xyz"], + False, + id="auto-envvar-prefix-loses-to-cmdline", + ), + # ``Sentinel.UNSET`` in ``default_map`` must be skipped (#3224 + # carve-out): the lookup falls through to the parameter default. + # Inside a feature switch group, the explicit ``default=True`` on + # ``--with-xyz`` then wins over the sibling's auto-``False``. + pytest.param( + {}, + {"enable_xyz": UNSET}, + [], + True, + id="unset-default-map-falls-through-to-explicit-default", + ), + pytest.param( + {}, + {"enable_xyz": False}, + [], + False, + id="real-default-map-beats-explicit-default", + ), + ], +) +def test_flag_group_competition_envvar_prefix_and_unset_default_map( + runner, monkeypatch, env, default_map, args, expected +): + for name, value in env.items(): + monkeypatch.setenv(name, value) + + @click.command() + @click.option("--without-xyz", "enable_xyz", flag_value=False) + @click.option("--with-xyz", "enable_xyz", flag_value=True, default=True) + def cli(enable_xyz): + click.echo(repr(enable_xyz), nl=False) + + invoke_kwargs = {"auto_envvar_prefix": "AUTO"} + if default_map is not None: + invoke_kwargs["default_map"] = default_map + + result = runner.invoke(cli, args, **invoke_kwargs) + assert result.exit_code == 0, result.output + assert result.output == repr(expected) + + @pytest.mark.parametrize( ("flag_type", "args", "expect_output"), [ From 933ecf815ee7e5943c519b5824122bf71b148797 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 12:05:24 +0200 Subject: [PATCH 13/15] Add changelog --- CHANGES.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 76f2b0066..1e32defa8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -51,6 +51,14 @@ Unreleased commands. :issue:`3107` :pr:`3228` - Add ``click.get_pager_file`` for file-like access to an output pager. :pr:`1572` +- Fix feature switch groups (several ``flag_value`` options sharing one + parameter name) silently dropping an explicit ``default`` when a sibling + option without an explicit default was declared first. Arbitration is now + source-aware: a more explicit :class:`ParameterSource` always wins, and + within ``ParameterSource.DEFAULT``, an option that received an explicit + ``default=`` keyword wins over a sibling whose default was auto-derived. + The 8.3.x first-wins fallback for remaining ties was reverted to the + pre-8.3.x last-wins fallback. :issue:`3403` :pr:`3404` Version 8.3.3 ------------- From d30cd2669ce8d44224e12f29c8756faf0aa89d73 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 7 May 2026 12:29:14 +0200 Subject: [PATCH 14/15] Summarize unittests into documentation examples --- docs/options.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/options.md b/docs/options.md index d3242b0aa..f7e9c4540 100644 --- a/docs/options.md +++ b/docs/options.md @@ -475,11 +475,47 @@ function receives the corresponding `flag_value`. When the user picks none, Click falls back to whichever option claims the slot under the arbitration rules described below. +##### Non-boolean groups + +For non-boolean `flag_value` (strings, enum members, classes, ...), place +`default=True` on the option that should win when no flag is passed. The +substitution rule above resolves it to that option's `flag_value`. Any other +explicit `default` is passed through literally. + | Definition | Not passed | `--upper` | `--lower` | |--------------------------------------------------------|------------|-----------|-----------| | `--upper` with `flag_value='upper'`, `default=True` | `"upper"` | `"upper"` | `"lower"` | | `--upper` with `flag_value='upper'`, `default='upper'` | `"upper"` | `"upper"` | `"lower"` | -| Both without `default` | `None` | `"upper"` | `"lower"` | +| `--upper` with `flag_value='upper'`, `default=None` | `None` | `"upper"` | `"lower"` | +| Neither option carries a `default` | `None` | `"upper"` | `"lower"` | + +The third row is the three-state pattern: the function receives `None` when no +flag is passed, distinguishable from either explicit choice. + +##### Boolean groups + +When `flag_value` is `True` or `False`, the substitution rule does not apply: +`default=True` is the literal Python `True`. To make one flag in an +enable/disable pair the default, set its `default=True` explicitly: + +```python +@click.option("--without-xyz", "enable_xyz", flag_value=False) +@click.option("--with-xyz", "enable_xyz", flag_value=True, default=True) +``` + +| Definition | Not passed | `--with-xyz` | `--without-xyz` | +|---------------------------------------------------------|------------|--------------|-----------------| +| `--with-xyz` with `flag_value=True`, `default=True` | `True` | `True` | `False` | +| `--without-xyz` with `flag_value=False`, `default=False`| `False` | `True` | `False` | +| `--with-xyz` with `flag_value=True`, `default=None` | `None` | `True` | `False` | +| Neither option carries a `default` | `False` | `True` | `False` | + +```{tip} +For most enable/disable cases, the pair form `--with-xyz/--without-xyz` is +shorter and equivalent. The multi-flag group form is useful when the on and off +flags need distinct names without a shared stem, or when each flag needs its +own help text. +``` ##### Arbitration rules From 1fd5b6f2215a76ee083d4a15c97d82e84bc47686 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Fri, 8 May 2026 06:52:53 +0200 Subject: [PATCH 15/15] Document the full option value resolution pipeline --- docs/options.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/docs/options.md b/docs/options.md index f7e9c4540..9b33a1044 100644 --- a/docs/options.md +++ b/docs/options.md @@ -519,6 +519,68 @@ own help text. ##### Arbitration rules +When several options in a group resolve their values simultaneously, only one +wins the parameter slot. The full arbitration policy (source precedence, +explicit-beats-auto tie-break, first-declared fallback) is enumerated under +[Option value resolution](#option-value-resolution). + +## Option value resolution + +This section enumerates the rules Click applies when computing the value +delivered to the decorated function for every option. Rules are listed in the +order they fire during the parsing pipeline. + +### Type inference + +Without an explicit `type=`, Click infers the parameter type at construction: + +1. If `flag_value` is `True` or `False`, the type is {class}`BoolParamType`. +2. If `flag_value` is an `int`, `float`, or `str`, the type is the matching + basic type. +3. If `flag_value` is any other Python object (a class, an enum member, a + `frozenset`, ...), the type is {data}`UNPROCESSED` so the value passes + through unchanged. +4. Otherwise, the type is inferred from `default` if set, falling back to + {class}`StringParamType` when neither hint is available. + +### `default` interpretation + +The literal value passed as `default=` is interpreted differently depending on +whether the option is a flag and what `flag_value` it carries: + +1. `default=UNSET` (the absence sentinel) is treated as if `default` was not + passed at all. It does not count as "the user picked nothing", and it does + not count as an explicit default for arbitration purposes. +2. For a bare boolean flag (no `flag_value`, or `flag_value` of `True` or + `False`), an unset `default` auto-derives to `False`. +3. For a non-boolean flag with a `flag_value`, `default=True` is substituted + with `flag_value`. This is the "activate this flag by default" shorthand. + Any non-`True` `default` is passed through literally. +4. For a boolean flag with `flag_value` set, `default=True` is the literal + Python `True`. The substitution from rule 3 does not apply. +5. `default=None` is always a real explicit value, distinct from `UNSET` + absence. +6. Any other `default` is delivered to the function unchanged after conversion + through the parameter's type. + +### Value sources + +Click resolves the value of every option from the following +sources, in order of decreasing precedence: + +1. **command line input** ({attr}`ParameterSource.COMMANDLINE`), +2. **environment variable** named in `envvar=` or derived from `auto_envvar_prefix` + ({attr}`ParameterSource.ENVIRONMENT`), +3. **`default_map` entry** matching the parameter name on the active {class}`Context` + ({attr}`ParameterSource.DEFAULT_MAP`), +4. **parameter default** ({attr}`ParameterSource.DEFAULT`). + +The first source that produces a value wins. Environment variables and +`default_map` entries set to `Sentinel.UNSET` are skipped, so they fall through +to the next source rather than supplying `UNSET` to the function. + +### Slot arbitration + Several options can target the same `name` to form a feature switch group. When they do, only one option's value reaches the function. Arbitration applies these rules, in order: