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
35 changes: 26 additions & 9 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,22 @@ 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:
"""Internal method that returns :data:`UNSET` when no default is
found, allowing callers to distinguish "not set" from ``None``.

:meta private:
"""
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 @@ -702,18 +718,19 @@ def lookup_default(self, name: str, call: bool = True) -> t.Any | None:
:param call: If the default is a callable, call it. Disable to
return the callable instead.

.. versionchanged:: 8.3.2
Returns ``None`` instead of an internal sentinel value when
no default is found, restoring the pre-8.3.0 public behavior.

.. 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()
value = self._lookup_default(name, call=call)

return value
if value is UNSET:
return None

return UNSET
return value

def fail(self, message: str) -> t.NoReturn:
"""Aborts the execution of the program with a specific error
Expand Down Expand Up @@ -2278,7 +2295,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 +2338,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
48 changes: 48 additions & 0 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,3 +780,51 @@ def test_propagate_opt_prefixes():
ctx = click.Context(click.Command("test2"), parent=parent)

assert ctx._opt_prefixes == {"-", "--", "!"}


def test_lookup_default_returns_none_not_sentinel():
"""``lookup_default`` must return ``None`` – not the internal
``UNSET`` sentinel – when the parameter has no entry in the default
map. Regression test for #3145.
"""
cmd = click.Command("test")

# Case 1: no default_map at all
ctx = click.Context(cmd, info_name="test")
assert ctx.lookup_default("missing") is None
assert ctx.lookup_default("missing", call=False) is None

# Case 2: default_map exists but does not contain the parameter
ctx = click.Context(cmd, info_name="test", default_map={"other": "value"})
assert ctx.lookup_default("missing") is None
assert ctx.lookup_default("missing", call=False) is None

# Case 3: default_map contains the parameter – should still work
ctx = click.Context(cmd, info_name="test", default_map={"present": "hello"})
assert ctx.lookup_default("present") == "hello"
assert ctx.lookup_default("present", call=False) == "hello"

# Case 4: default_map contains a callable
ctx = click.Context(cmd, info_name="test", default_map={"func": lambda: "computed"})
assert ctx.lookup_default("func", call=True) == "computed"

# Case 5: subclass that overrides lookup_default (like in the issue report)
class CustomContext(click.Context):
def lookup_default(self, name, call=True):
default = super().lookup_default(name, call=call)
if default is not None:
return default
# Fall back to a prefix-based lookup
prefix = name.split("_", 1)[0]
group = getattr(self, "default_map", None) or {}
sub = group.get(prefix)
if isinstance(sub, dict):
return sub.get(name)
return None

ctx = CustomContext(cmd, info_name="test")
ctx.default_map = {"app": {"app_email": "test@example.com"}}
# The key "app_email" is not at the top level, so lookup_default
# returns None, and the subclass falls through to the prefix logic.
result = ctx.lookup_default("app_email")
assert result == "test@example.com"
Loading