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
36 changes: 24 additions & 12 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,20 @@ def ensure_object(self, object_type: type[V]) -> V:
self.obj = rv = object_type()
return rv

def _default_map_has(self, name: str | None) -> bool:
"""Check if :attr:`default_map` contains a real value for ``name``.

Returns ``False`` when the key is absent, the map is ``None``,
``name`` is ``None``, or the stored value is the internal
:data:`UNSET` sentinel.
"""
return (
name is not None
and self.default_map is not None
and name in self.default_map
and self.default_map[name] is not UNSET
)

@t.overload
def lookup_default(
self, name: str, call: t.Literal[True] = True
Expand All @@ -705,15 +719,17 @@ def lookup_default(self, name: str, call: bool = True) -> t.Any | None:
.. versionchanged:: 8.0
Added the ``call`` parameter.
"""
if self.default_map is not None:
value = self.default_map.get(name)
if not self._default_map_has(name):
return None

if call and callable(value):
return value()
# Assert to make the type checker happy.
assert self.default_map is not None
value = self.default_map[name]

return value
if call and callable(value):
return value()

return None
return value

def fail(self, message: str) -> t.NoReturn:
"""Aborts the execution of the program with a specific error
Expand Down Expand Up @@ -2281,9 +2297,7 @@ def get_default(
name = self.name
value = ctx.lookup_default(name, call=False) if name is not None else None

if value is None and not (
ctx.default_map is not None and name is not None and name in ctx.default_map
):
if value is None and not ctx._default_map_has(name):
value = self.default

if call and callable(value):
Expand Down Expand Up @@ -2325,9 +2339,7 @@ def consume_value(

if value is UNSET:
default_map_value = ctx.lookup_default(self.name) # type: ignore[arg-type]
if default_map_value is not None or (
ctx.default_map is not None and self.name in ctx.default_map
):
if default_map_value is not None or ctx._default_map_has(self.name):
value = default_map_value
source = ParameterSource.DEFAULT_MAP

Expand Down
20 changes: 20 additions & 0 deletions tests/test_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import click
from click import UNPROCESSED
from click._utils import UNSET


@pytest.mark.parametrize(
Expand Down Expand Up @@ -354,3 +355,22 @@ def cli(value):
result = runner.invoke(cli, args, **kwargs)
assert result.exit_code == 0
assert result.output == repr(expected)


def test_unset_in_default_map(runner):
"""An ``UNSET`` value in ``default_map`` should be treated as if
the key is absent, and so fallback to the parameter's own default.

Refs: https://github.com/pallets/click/pull/3224#issuecomment-3968643305
"""

@click.command(
context_settings={"default_map": {"port": UNSET}},
)
@click.option("--port", default=8000)
def cli(port):
click.echo(f"port={port}")

result = runner.invoke(cli, [])
assert result.exit_code == 0
assert result.output.strip() == "port=8000"
Loading