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

def _lookup_default(self, name: str, call: bool = True) -> t.Any | object:
"""Internal method for looking up defaults. Returns Sentinel.UNSET
if not found, for use by internal library code that needs to
distinguish between "not set" and "set to None".

:param name: Name of the parameter.
:param call: If the default is a callable, call it. Disable to
return the callable instead.

.. versionadded:: 8.3
"""
if self.default_map is not None:
value = self.default_map.get(name, UNSET)

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

return value

return UNSET

@t.overload
def lookup_default(
self, name: str, call: t.Literal[True] = True
Expand All @@ -704,16 +725,13 @@ 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, UNSET)

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

return value

return UNSET
.. versionchanged:: 8.3
Do not return the internal ``Sentinel.UNSET`` sentinel value.
Return ``None`` instead.
"""
value = self._lookup_default(name, call)
return None if value is UNSET else value

def fail(self, message: str) -> t.NoReturn:
"""Aborts the execution of the program with a specific error
Expand Down Expand Up @@ -2278,7 +2296,7 @@ def get_default(
.. versionchanged:: 8.0
Added the ``call`` parameter.
"""
value = ctx.lookup_default(self.name, call=False) # type: ignore
value = ctx._lookup_default(self.name, call=False) # type: ignore

if value is UNSET:
value = self.default
Expand Down Expand Up @@ -2321,7 +2339,7 @@ def consume_value(
source = ParameterSource.ENVIRONMENT

if value is UNSET:
default_map_value = ctx.lookup_default(self.name) # type: ignore
default_map_value = ctx._lookup_default(self.name) # type: ignore
if default_map_value is not UNSET:
value = default_map_value
source = ParameterSource.DEFAULT_MAP
Expand Down
58 changes: 58 additions & 0 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,64 @@ def cli(ctx, option):
assert rv.return_value == expect


def test_lookup_default_returns_none_for_missing_keys():
"""Regression test for #3145: lookup_default should return None,
not the internal Sentinel.UNSET, when a key is not found.

In Click 8.3.0 and 8.3.1, lookup_default incorrectly returned
the internal UNSET sentinel instead of None, breaking the public API.
"""
ctx = click.Context(click.Command("test"))

# Test 1: No default_map at all
assert ctx.lookup_default("missing") is None

# Test 2: default_map exists but key is missing
ctx.default_map = {"other": "value"}
assert ctx.lookup_default("missing") is None

# Test 3: default_map has the key
ctx.default_map = {"key": "value"}
assert ctx.lookup_default("key") == "value"

# Test 4: default_map has key with None value
ctx.default_map = {"key": None}
assert ctx.lookup_default("key") is None


def test_lookup_default_with_callable():
"""Test that lookup_default calls callable defaults."""

def get_default():
return "computed_value"

ctx = click.Context(click.Command("test"))
ctx.default_map = {"key": get_default}

# With call=True (default), should return the computed value
assert ctx.lookup_default("key") == "computed_value"

# With call=False, should return the callable itself
assert ctx.lookup_default("key", call=False) is get_default


def test_lookup_default_internal_returns_unset():
"""Test that the internal _lookup_default method returns UNSET
for internal library use. This is important for code that needs
to distinguish between 'not set' and 'set to None'.
"""
from click.core import UNSET

ctx = click.Context(click.Command("test"))

# Internal method should return UNSET when key is missing
assert ctx._lookup_default("missing") is UNSET

# Even with no default_map
ctx.default_map = None
assert ctx._lookup_default("missing") is UNSET


def test_propagate_opt_prefixes():
parent = click.Context(click.Command("test"))
parent._opt_prefixes = {"-", "--", "!"}
Expand Down
Loading