From 058ecb180d377d35c78898f5c30b9a4148bacce5 Mon Sep 17 00:00:00 2001 From: Barry <100205797+barry3406@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:44:27 -0700 Subject: [PATCH] Skip default evaluation and callback invocation during resilient parsing During shell completion, Context.resilient_parsing is set to True. Its documentation states that parsing happens without any interactivity or callback invocation, and that default values are ignored. However, ``Parameter.consume_value`` still called the ``default=`` callable and ``Parameter.process_value`` still invoked the parameter's ``callback``, which could produce unwanted side effects during tab-completion. Now both paths honor ``ctx.resilient_parsing``, matching the docs. --- CHANGES.rst | 5 ++++ src/click/core.py | 21 ++++++++++++--- tests/test_shell_completion.py | 47 ++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index af3a025bed..5d5eeeea6c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,11 @@ Version 8.3.3 Unreleased +- Skip evaluation of parameter defaults and invocation of parameter callbacks + when :attr:`Context.resilient_parsing` is ``True``, such as during shell + completion. This matches the behavior documented for ``resilient_parsing`` + and avoids side effects from expensive ``default=`` callables and ``callback`` + functions while completing. :issue:`2614` - Use :func:`shlex.split` to split pager and editor commands into ``argv`` lists for :class:`subprocess.Popen`, removing ``shell=True``. :issue:`1026` :pr:`1477` :pr:`2775` diff --git a/src/click/core.py b/src/click/core.py index d940dd80e1..08bb622086 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2357,7 +2357,15 @@ def consume_value( source = ParameterSource.DEFAULT_MAP if value is UNSET: - default_value = self.get_default(ctx) + # During resilient parsing (e.g. shell completion), skip evaluating + # the parameter's default. Calling a ``default`` callable could have + # unwanted side effects, and the documentation for + # :attr:`Context.resilient_parsing` states that default values are + # ignored in this mode. + if ctx.resilient_parsing: + default_value = UNSET + else: + default_value = self.get_default(ctx) if default_value is not UNSET: value = default_value source = ParameterSource.DEFAULT @@ -2465,7 +2473,11 @@ def process_value(self, ctx: Context, value: t.Any) -> t.Any: if self.required and self.value_is_missing(value): raise MissingParameter(ctx=ctx, param=self) - if self.callback is not None: + # During resilient parsing (e.g. shell completion), skip invoking the + # parameter's callback. The documentation for + # :attr:`Context.resilient_parsing` states that parsing happens without + # any interactivity or callback invocation. + if self.callback is not None and not ctx.resilient_parsing: # Legacy case: UNSET is not exposed directly to the callback, but converted # to None. if value is UNSET: @@ -3365,7 +3377,10 @@ def process_value(self, ctx: Context, value: t.Any) -> t.Any: if self.is_flag and not self.required and self.is_bool_flag and value is UNSET: value = False - if self.callback is not None: + # Skip callback invocation during resilient parsing (e.g. shell + # completion) to match the documented behavior of + # :attr:`Context.resilient_parsing`. + if self.callback is not None and not ctx.resilient_parsing: value = self.callback(ctx, self, value) return value diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index 20cff238f7..a8447179ef 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -559,3 +559,50 @@ def cli(ctx, config_file): assert not current_warnings, "There should be no warnings to start" _get_completions(cli, args=[], incomplete="") assert not current_warnings, "There should be no warnings after either" + + +def test_default_callable_not_called_during_completion(): + """A callable passed as ``default=`` must not be invoked while shell + completion is running, because ``resilient_parsing`` is set and the + documentation states that default values are ignored in that mode. + """ + calls = [] + + def expensive_default(): + calls.append("default") + return "value" + + cli = Command("cli", params=[Option(["--foo"], default=expensive_default)]) + _get_completions(cli, [], "-") + assert calls == [] + + +def test_callback_not_called_during_completion(): + """A parameter ``callback`` must not fire while shell completion is + running, per the documented behavior of ``resilient_parsing``. + """ + calls = [] + + def cb(ctx, param, value): + calls.append(value) + return value + + cli = Command("cli", params=[Option(["--foo"], default="x", callback=cb)]) + _get_completions(cli, [], "-") + assert calls == [] + + +def test_bool_flag_callback_not_called_during_completion(): + """Boolean flag ``callback`` must not fire during shell completion.""" + calls = [] + + def cb(ctx, param, value): + calls.append(value) + return value + + cli = Command( + "cli", + params=[Option(["--flag/--no-flag"], default=False, callback=cb)], + ) + _get_completions(cli, [], "-") + assert calls == []