diff --git a/src/click/core.py b/src/click/core.py index 6adc65ccd..30295cdde 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -702,9 +702,29 @@ 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 internal sentinel when no default + is found in the default map. + .. versionchanged:: 8.0 Added the ``call`` parameter. """ + value = self._lookup_default(name, call) + # Normalize UNSET to None for public API compatibility. + # Internal code uses _lookup_default directly to distinguish + # between "no value" (UNSET) and "explicitly set to None". + if value is UNSET: + return None + return value + + def _lookup_default(self, name: str, call: bool = True) -> t.Any: + """Internal method to get default from default_map. + + Returns UNSET sentinel if no default is found. Use lookup_default() + for public API which normalizes UNSET to None. + + :meta private: + """ if self.default_map is not None: value = self.default_map.get(name, UNSET) @@ -2278,7 +2298,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 @@ -2321,7 +2341,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 diff --git a/tests/test_defaults.py b/tests/test_defaults.py index 31eee844b..1b4a49314 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -110,3 +110,59 @@ def prefers_red(color): assert "red" in result.output result = runner.invoke(prefers_red, ["--green"]) assert "green" in result.output + + +def test_lookup_default_returns_none_not_sentinel(runner): + """Test that lookup_default returns None when parameter not in default_map. + + Regression test for issue #3145. + """ + + @click.command() + @click.option("--email") + @click.pass_context + def cmd(ctx, email): + # When key not in default_map, lookup_default should return None + result = ctx.lookup_default("nonexistent") + assert result is None, f"Expected None, got {result!r}" + click.echo("OK") + + result = runner.invoke(cmd) + assert result.exit_code == 0 + assert "OK" in result.output + + +def test_lookup_default_returns_none_with_empty_default_map(runner): + """Test that lookup_default returns None even when default_map exists but key missing.""" + + @click.command() + @click.option("--name", default="test") + @click.pass_context + def cmd(ctx, name): + # Set default_map but query for nonexistent key + ctx.default_map = {"other_param": "value"} + result = ctx.lookup_default("missing_key") + assert result is None, f"Expected None, got {result!r}" + click.echo("OK") + + result = runner.invoke(cmd) + assert result.exit_code == 0 + assert "OK" in result.output + + +def test_lookup_default_still_returns_actual_defaults(runner): + """Test that lookup_default still returns actual values from default_map.""" + + @click.command() + @click.option("--name") + @click.pass_context + def cmd(ctx, name): + ctx.default_map = {"email": "test@example.com"} + # Should return the actual default when present + result = ctx.lookup_default("email") + assert result == "test@example.com" + click.echo("OK") + + result = runner.invoke(cmd) + assert result.exit_code == 0 + assert "OK" in result.output