Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
21 changes: 18 additions & 3 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions tests/test_shell_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []