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 ------------- diff --git a/docs/options.md b/docs/options.md index 5c50d7450..9b33a1044 100644 --- a/docs/options.md +++ b/docs/options.md @@ -469,15 +469,131 @@ 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. + +##### 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 + +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: + +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 diff --git a/src/click/core.py b/src/click/core.py index 776a7f5ac..126053558 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 @@ -2583,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 @@ -2618,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 diff --git a/tests/test_options.py b/tests/test_options.py index 50992e2d1..25e649d04 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -2581,6 +2581,823 @@ def cli(flag): assert result.output == repr(expected) +@pytest.mark.parametrize( + ("opts", "args", "expected"), + [ + # #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", + ), + # 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 + """ + + @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 +): + 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 +): + @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) + + +@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: 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"}), + ], + [], + "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 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"}), + ], + [], + "explicit", + id="mixed-bool-and-string-last-wins", + ), + pytest.param( + [ + ("--str-flag", {"flag_value": "named", "default": "explicit"}), + ("--bool-flag", {"flag_value": True, "default": False}), + ], + [], + False, + id="mixed-bool-and-string-last-wins-swapped", + ), + # Empty string default coexisting with ``default=True`` + # 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": ""}), + ], + [], + "", + id="default-true-vs-empty-string-last-wins", + ), + pytest.param( + [ + ("--blank", {"flag_value": "blank", "default": ""}), + ("--upper", {"flag_value": "upper", "default": True}), + ], + [], + "upper", + id="default-true-vs-empty-string-last-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``. + """ + + @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( + ("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 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. + (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 ``--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( + 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. + """ + 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) + + +def test_flag_group_competition_duplicate_option_name(runner): + """The same option name declared twice on the same command is a user + error. + """ + + @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( + ("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"), + [ + 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( + ("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"), [