Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Version 8.4.1

Unreleased

- ``get_parameter_source()`` is available during eager callbacks and type
conversion again. :issue:`3458`
- Zsh completion scripts parse correctly on Windows. :issue:`3277`
- Shell completion of `Choice` `Enum` values produces a valid completion
result. :issue:`3015`
Expand Down
14 changes: 9 additions & 5 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2609,6 +2609,11 @@ def handle_parse_result(
with augment_usage_errors(ctx, param=self):
value, source = self.consume_value(ctx, opts)

# Record the source before processing so eager callbacks and type
# conversion can inspect it. Restored after arbitration if this
# option loses a feature-switch group.
ctx.set_parameter_source(self.name, source)
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restores 8.3.3 timing so get_parameter_source() works inside convert / eager callbacks (#3458).

#3404 moved this to the end of handle_parse_result, which caused the regression.


# Display a deprecation warning if necessary.
if (
self.deprecated
Expand Down Expand Up @@ -2654,14 +2659,13 @@ def handle_parse_result(
)

if is_winner:
ctx.set_parameter_source(self.name, source)
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Winner already has the right source from the early write. Only params need updating here.

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)
elif existing_source is not None:
# Lost arbitration; restore the winning option's source.
ctx.set_parameter_source(self.name, existing_source)
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This option already wrote its provisional source above; if it loses arbitration, put the winner’s source back so _parameter_source matches ctx.param.

# else: keep the provisional source recorded before process_value.

return value, args

Expand Down
123 changes: 123 additions & 0 deletions tests/test_defaults.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import os

import pytest

import click
from click import UNPROCESSED
from click._utils import UNSET
from click.core import ParameterSource


@pytest.mark.parametrize(
Expand Down Expand Up @@ -265,6 +268,126 @@ def cli(ctx, name):
assert f"source={expected_source}" in result.output


def test_parameter_source_during_paramtype_convert(runner):
"""``get_parameter_source()`` is available during ``ParamType.convert``.

Uses the reproducer from https://github.com/pallets/click/issues/3458.
"""

class Source(click.ParamType):
name = "source"

def convert(self, value, param, ctx):
return {
"value": value,
"source": ctx.get_parameter_source(param.name),
}

@click.command()
@click.option("--default", type=Source(), default="/tmp/file")
@click.option("--nodefault", type=Source())
def cli(default, nodefault):
click.echo(f"default: {default}")
click.echo(f"nodefault: {nodefault}")

result = runner.invoke(cli, [])
assert not result.exception
assert "default: {'value': '/tmp/file', 'source': " in result.output
assert "'source': None}" not in result.output.split("default:")[1].split("\n")[0]
assert (
result.output == "default: {'value': '/tmp/file', 'source': "
f"{ParameterSource.DEFAULT!r}}}\nnodefault: None\n"
)

result = runner.invoke(cli, ["--default", "cli", "--nodefault", "also"])
assert not result.exception
assert (
"default: {'value': 'cli', 'source': "
f"{ParameterSource.COMMANDLINE!r}}}" in result.output
)
assert (
"nodefault: {'value': 'also', 'source': "
f"{ParameterSource.COMMANDLINE!r}}}" in result.output
)


def test_parameter_source_during_eager_callback(runner):
"""``get_parameter_source()`` is available during eager callbacks.

Regression test for https://github.com/pallets/click/issues/3458.
"""

def eager_cb(ctx, param, value):
source = ctx.get_parameter_source(param.name)
click.echo(f"callback source={source.name if source else None}")

@click.command()
@click.option(
"--flag/--no-flag",
default=False,
is_eager=True,
callback=eager_cb,
expose_value=False,
)
def cli():
source = click.get_current_context().get_parameter_source("flag")
click.echo(f"final source={source.name}")

result = runner.invoke(cli, [])
assert not result.exception
assert "callback source=DEFAULT" in result.output
assert "final source=DEFAULT" in result.output

result = runner.invoke(cli, ["--flag"])
assert not result.exception
assert "callback source=COMMANDLINE" in result.output
assert "final source=COMMANDLINE" in result.output


def test_flask_debug_env_not_stomped_by_default_flag(runner, monkeypatch):
"""Eager callback must not overwrite env when the flag used its default.

Covers the Flask ``_set_debug`` pattern (pallets/flask#6025). Regression test
for https://github.com/pallets/click/issues/3458.
"""

monkeypatch.delenv("APP_DEBUG", raising=False)

def set_debug(ctx, param, value):
source = ctx.get_parameter_source(param.name)
if source is not None and source in (
ParameterSource.DEFAULT,
ParameterSource.DEFAULT_MAP,
):
return None
os.environ["APP_DEBUG"] = "1" if value else "0"
return value

@click.command()
@click.option(
"--debug/--no-debug",
default=False,
is_eager=True,
expose_value=False,
callback=set_debug,
)
def cli():
click.echo(f"APP_DEBUG={os.environ.get('APP_DEBUG', '')}")

monkeypatch.setenv("APP_DEBUG", "1")
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert result.output.strip() == "APP_DEBUG=1"

result = runner.invoke(cli, ["--debug"])
assert result.exit_code == 0
assert result.output.strip() == "APP_DEBUG=1"

result = runner.invoke(cli, ["--no-debug"])
assert result.exit_code == 0
assert result.output.strip() == "APP_DEBUG=0"


def test_lookup_default_override_respected(runner):
"""A subclass override of ``lookup_default()`` should be called by Click
internals, not bypassed by a private method.
Expand Down
62 changes: 62 additions & 0 deletions tests/test_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -2895,6 +2895,68 @@ def cli(enable_xyz):
assert result.output == repr(expected)


@pytest.mark.parametrize(
("opts", "args", "invoke_kwargs", "expected_value", "expected_source"),
[
# https://github.com/pallets/click/issues/3458
pytest.param(
[
("--without-xyz", {"flag_value": False}),
("--with-xyz", {"flag_value": True, "default": True}),
],
[],
{},
True,
"DEFAULT",
id="explicit-default-wins",
),
pytest.param(
[
("--without-xyz", {"flag_value": False}),
("--with-xyz", {"flag_value": True, "default": True}),
],
["--without-xyz"],
{},
False,
"COMMANDLINE",
id="cmdline-wins",
),
pytest.param(
[
("--without-xyz", {"flag_value": False}),
("--with-xyz", {"flag_value": True, "default": True}),
],
["--without-xyz"],
{"default_map": {"enable_xyz": True}},
False,
"COMMANDLINE",
id="loser-default-map-restores-winner-source",
),
],
)
def test_bool_flag_group_parameter_source(
runner, opts, args, invoke_kwargs, expected_value, expected_source
):
"""``get_parameter_source()`` stays correct for feature-switch groups.

Regression test for https://github.com/pallets/click/issues/3458.
"""

@click.command()
@click.pass_context
def cli(ctx, enable_xyz):
source = ctx.get_parameter_source("enable_xyz")
click.echo(f"value={enable_xyz!r} source={source.name}")

for opt_name, opt_kwargs in opts:
cli = click.option(opt_name, "enable_xyz", **opt_kwargs)(cli)

result = runner.invoke(cli, args, **invoke_kwargs)
assert result.exit_code == 0, result.output
assert f"value={expected_value!r}" in result.output
assert f"source={expected_source}" in result.output


@pytest.mark.parametrize(
("opts", "args", "expected"),
[
Expand Down
Loading