From fe043eea24728d31d4178aa4180c41a0c0d23729 Mon Sep 17 00:00:00 2001 From: Rodion Steshenko Date: Tue, 17 Feb 2026 16:10:23 -0500 Subject: [PATCH] Fix lookup_default returning Sentinel.UNSET instead of None When lookup_default is called and the parameter name is not found in the default_map (or when default_map is None), the method returned the internal Sentinel.UNSET value instead of None. This is a regression introduced in 8.3.0 that breaks downstream code which checks the return value against None. Also updated internal callers (get_default, consume_value) to check for None instead of UNSET since lookup_default no longer leaks the sentinel. Fixes #3145 --- src/click/core.py | 9 ++++++--- tests/test_context.py | 46 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index 6adc65ccd6..c4ed7b136a 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -708,12 +708,15 @@ def lookup_default(self, name: str, call: bool = True) -> t.Any | None: if self.default_map is not None: value = self.default_map.get(name, UNSET) + if value is UNSET: + return None + if call and callable(value): return value() return value - return UNSET + return None def fail(self, message: str) -> t.NoReturn: """Aborts the execution of the program with a specific error @@ -2280,7 +2283,7 @@ def get_default( """ value = ctx.lookup_default(self.name, call=False) # type: ignore - if value is UNSET: + if value is None: value = self.default if call and callable(value): @@ -2322,7 +2325,7 @@ def consume_value( if value is UNSET: default_map_value = ctx.lookup_default(self.name) # type: ignore - if default_map_value is not UNSET: + if default_map_value is not None: value = default_map_value source = ParameterSource.DEFAULT_MAP diff --git a/tests/test_context.py b/tests/test_context.py index e35c532516..3939b8b681 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -780,3 +780,49 @@ def test_propagate_opt_prefixes(): ctx = click.Context(click.Command("test2"), parent=parent) assert ctx._opt_prefixes == {"-", "--", "!"} + + +def test_lookup_default_returns_none_when_not_in_map(): + """lookup_default should return None, not a sentinel, when the name + is not found in the default_map. Regression test for #3145.""" + cmd = click.Command("test") + ctx = click.Context(cmd, info_name="test") + ctx.default_map = {"other_param": "value"} + + # When the parameter is not in the default_map, should return None + result = ctx.lookup_default("missing_param") + assert result is None, f"Expected None, got {result!r} ({type(result)})" + + result_no_call = ctx.lookup_default("missing_param", call=False) + assert result_no_call is None, f"Expected None, got {result_no_call!r}" + + +def test_lookup_default_returns_none_when_no_default_map(): + """lookup_default should return None when default_map is None.""" + cmd = click.Command("test") + ctx = click.Context(cmd, info_name="test") + assert ctx.default_map is None + + result = ctx.lookup_default("any_param") + assert result is None, f"Expected None, got {result!r} ({type(result)})" + + +def test_lookup_default_returns_value_when_in_map(): + """lookup_default should return the value when found in default_map.""" + cmd = click.Command("test") + ctx = click.Context(cmd, info_name="test") + ctx.default_map = {"my_param": "my_value"} + + assert ctx.lookup_default("my_param") == "my_value" + + +def test_lookup_default_calls_callable_when_call_true(): + """lookup_default should call callable defaults when call=True.""" + cmd = click.Command("test") + ctx = click.Context(cmd, info_name="test") + ctx.default_map = {"my_param": lambda: "computed"} + + assert ctx.lookup_default("my_param", call=True) == "computed" + # call=False should return the callable itself + result = ctx.lookup_default("my_param", call=False) + assert callable(result)