From 65b4c48f44c8dd1eb1adb9202efbb2e79db5e561 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Mon, 2 Mar 2026 16:26:39 +0400 Subject: [PATCH 1/5] Check effect of `UNSET` in `default_map` Reproduces https://github.com/pallets/click/pull/3224#issuecomment-3968643305 --- tests/test_defaults.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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" From 82553aabaf06455a3add27e64fb939fe1edf009d Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Mon, 2 Mar 2026 16:30:27 +0400 Subject: [PATCH 2/5] Treat `UNSET` in a `default_map` as absent --- src/click/core.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index f0a624be3..d6a9424b6 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -708,6 +708,11 @@ 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) + # Hide the UNSET sentinel from the caller, as it is an + # implementation detail. Treat it the same as "key not found". + if value is UNSET: + return None + if call and callable(value): return value() @@ -2282,7 +2287,10 @@ def get_default( 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 + ctx.default_map is not None + and name is not None + and name in ctx.default_map + and ctx.default_map[name] is not UNSET ): value = self.default @@ -2326,7 +2334,9 @@ 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 + ctx.default_map is not None + and self.name in ctx.default_map + and ctx.default_map.get(self.name) is not UNSET ): value = default_map_value source = ParameterSource.DEFAULT_MAP From bc42caf909f2c40c81406e4c159888d535719b38 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Mon, 2 Mar 2026 16:32:43 +0400 Subject: [PATCH 3/5] Fix typing --- src/click/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/click/core.py b/src/click/core.py index d6a9424b6..402290209 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2336,7 +2336,7 @@ def consume_value( if default_map_value is not None or ( ctx.default_map is not None and self.name in ctx.default_map - and ctx.default_map.get(self.name) is not UNSET + and ctx.default_map[self.name] is not UNSET # type: ignore[index] ): value = default_map_value source = ParameterSource.DEFAULT_MAP From caac49c32893c14cfa142ce92947eea38735fc18 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Mon, 2 Mar 2026 16:35:52 +0400 Subject: [PATCH 4/5] Add `_default_map_has` utility --- src/click/core.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index 402290209..f3d7651ca 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -720,6 +720,20 @@ def lookup_default(self, name: str, call: bool = True) -> t.Any | None: return None + 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 + ) + def fail(self, message: str) -> t.NoReturn: """Aborts the execution of the program with a specific error message. @@ -2286,12 +2300,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 - and ctx.default_map[name] is not UNSET - ): + if value is None and not ctx._default_map_has(name): value = self.default if call and callable(value): @@ -2333,11 +2342,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 - and ctx.default_map[self.name] is not UNSET # type: ignore[index] - ): + if default_map_value is not None or ctx._default_map_has(self.name): value = default_map_value source = ParameterSource.DEFAULT_MAP From 219830be89a550ae96ce57e63e47c4323f7bebef Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Mon, 2 Mar 2026 16:39:40 +0400 Subject: [PATCH 5/5] Make `_default_map_has` the single source of truth --- src/click/core.py | 47 ++++++++++++++++++++++------------------------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index f3d7651ca..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,34 +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) - - # Hide the UNSET sentinel from the caller, as it is an - # implementation detail. Treat it the same as "key not found". - if value is UNSET: - return None - - if call and callable(value): - return value() - - return value + if not self._default_map_has(name): + return None - return None + # Assert to make the type checker happy. + assert self.default_map is not None + value = self.default_map[name] - def _default_map_has(self, name: str | None) -> bool: - """Check if :attr:`default_map` contains a real value for ``name``. + if call and callable(value): + return value() - 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 - ) + return value def fail(self, message: str) -> t.NoReturn: """Aborts the execution of the program with a specific error