diff --git a/src/click/core.py b/src/click/core.py index f0a624be3..22f3b7a8d 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -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 @@ -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 @@ -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): @@ -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 diff --git a/tests/test_defaults.py b/tests/test_defaults.py index 7dc74f0b7..2226bda5e 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -2,6 +2,7 @@ import click from click import UNPROCESSED +from click._utils import UNSET @pytest.mark.parametrize( @@ -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"