Skip to content

Commit b6afbf0

Browse files
authored
Hide Sentinel.UNSET values as None in lookup_default() (#3224)
2 parents fa7f035 + 4538229 commit b6afbf0

File tree

3 files changed

+162
-6
lines changed

3 files changed

+162
-6
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Unreleased
44

55
- Fix handling of ``flag_value`` when ``is_flag=False`` to allow such options to be
66
used without an explicit value. :issue:`3084`
7+
- Hide ``Sentinel.UNSET`` values as ``None`` when using ``lookup_default()``.
8+
:issue:`3136` :pr:`3199` :pr:`3202` :pr:`3209` :pr:`3212` :pr:`3224`
79

810
Version 8.3.1
911
--------------

src/click/core.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -706,14 +706,14 @@ def lookup_default(self, name: str, call: bool = True) -> t.Any | None:
706706
Added the ``call`` parameter.
707707
"""
708708
if self.default_map is not None:
709-
value = self.default_map.get(name, UNSET)
709+
value = self.default_map.get(name)
710710

711711
if call and callable(value):
712712
return value()
713713

714714
return value
715715

716-
return UNSET
716+
return None
717717

718718
def fail(self, message: str) -> t.NoReturn:
719719
"""Aborts the execution of the program with a specific error
@@ -2278,9 +2278,12 @@ def get_default(
22782278
.. versionchanged:: 8.0
22792279
Added the ``call`` parameter.
22802280
"""
2281-
value = ctx.lookup_default(self.name, call=False) # type: ignore
2281+
name = self.name
2282+
value = ctx.lookup_default(name, call=False) if name is not None else None
22822283

2283-
if value is UNSET:
2284+
if value is None and not (
2285+
ctx.default_map is not None and name is not None and name in ctx.default_map
2286+
):
22842287
value = self.default
22852288

22862289
if call and callable(value):
@@ -2321,8 +2324,10 @@ def consume_value(
23212324
source = ParameterSource.ENVIRONMENT
23222325

23232326
if value is UNSET:
2324-
default_map_value = ctx.lookup_default(self.name) # type: ignore
2325-
if default_map_value is not UNSET:
2327+
default_map_value = ctx.lookup_default(self.name) # type: ignore[arg-type]
2328+
if default_map_value is not None or (
2329+
ctx.default_map is not None and self.name in ctx.default_map
2330+
):
23262331
value = default_map_value
23272332
source = ParameterSource.DEFAULT_MAP
23282333

tests/test_defaults.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,152 @@ def prefers_red(color):
160160
assert "red" in result.output
161161
result = runner.invoke(prefers_red, ["--green"])
162162
assert "green" in result.output
163+
164+
165+
@pytest.mark.parametrize(
166+
("default_map", "key", "expected"),
167+
[
168+
# Key present in default_map.
169+
({"email": "a@b.com"}, "email", "a@b.com"),
170+
# Key missing from default_map.
171+
({"email": "a@b.com"}, "nonexistent", None),
172+
# No default_map at all / empty default_map.
173+
(None, "anything", None),
174+
({}, "anything", None),
175+
# Falsy values are returned as-is.
176+
({"key": None}, "key", None),
177+
({"key": 0}, "key", 0),
178+
({"key": ""}, "key", ""),
179+
({"key": False}, "key", False),
180+
],
181+
)
182+
def test_lookup_default_returns_hides_sentinel(default_map, key, expected):
183+
"""``lookup_default()`` should return ``None`` for missing keys, not :attr:`UNSET`.
184+
185+
Regression test for https://github.com/pallets/click/issues/3145.
186+
"""
187+
cmd = click.Command("test")
188+
ctx = click.Context(cmd)
189+
if default_map is not None:
190+
ctx.default_map = default_map
191+
assert ctx.lookup_default(key) == expected
192+
193+
194+
def test_lookup_default_callable_in_default_map(runner):
195+
"""A callable in ``default_map`` is invoked with ``call=True``
196+
(the default) and returned as-is with ``call=False``.
197+
198+
Click uses both paths internally:
199+
- ``get_default()`` passes ``call=False``,
200+
- ``resolve_ctx()`` passes ``call=True``.
201+
"""
202+
factory = lambda: "lazy-value" # noqa: E731
203+
204+
# Unit-level: call=True invokes, call=False returns as-is.
205+
cmd = click.Command("test")
206+
ctx = click.Context(cmd)
207+
ctx.default_map = {"name": factory}
208+
assert ctx.lookup_default("name", call=True) == "lazy-value"
209+
assert ctx.lookup_default("name", call=False) is factory
210+
211+
# Integration: the callable is invoked during value resolution.
212+
@click.command()
213+
@click.option("--name", default="original", show_default=True)
214+
@click.pass_context
215+
def cli(ctx, name):
216+
click.echo(f"name={name!r}")
217+
218+
result = runner.invoke(cli, [], default_map={"name": factory})
219+
assert not result.exception
220+
assert "name='lazy-value'" in result.output
221+
222+
# Help rendering gets the callable via call=False, so it
223+
# shows "(dynamic)" rather than invoking it.
224+
result = runner.invoke(cli, ["--help"], default_map={"name": factory})
225+
assert not result.exception
226+
assert "(dynamic)" in result.output
227+
228+
229+
@pytest.mark.parametrize(
230+
("args", "default_map", "expected_value", "expected_source"),
231+
[
232+
# CLI arg wins over everything.
233+
(["--name", "cli"], {"name": "mapped"}, "cli", "COMMANDLINE"),
234+
# default_map overrides parameter default.
235+
([], {"name": "mapped"}, "mapped", "DEFAULT_MAP"),
236+
# Explicit None in default_map still counts as DEFAULT_MAP.
237+
([], {"name": None}, None, "DEFAULT_MAP"),
238+
# Falsy values in default_map are not confused with missing keys.
239+
([], {"name": ""}, "", "DEFAULT_MAP"),
240+
([], {"name": 0}, "0", "DEFAULT_MAP"),
241+
# No default_map falls back to parameter default.
242+
([], None, "original", "DEFAULT"),
243+
],
244+
)
245+
def test_default_map_source(runner, args, default_map, expected_value, expected_source):
246+
"""``get_parameter_source()`` reports the correct origin for a parameter
247+
value across the resolution chain: CLI > default_map > parameter default.
248+
"""
249+
250+
@click.command()
251+
@click.option("--name", default="original")
252+
@click.pass_context
253+
def cli(ctx, name):
254+
source = ctx.get_parameter_source("name")
255+
click.echo(f"name={name!r} source={source.name}")
256+
257+
kwargs = {}
258+
if default_map is not None:
259+
kwargs["default_map"] = default_map
260+
result = runner.invoke(cli, args, **kwargs)
261+
assert not result.exception
262+
assert f"name={expected_value!r}" in result.output
263+
assert f"source={expected_source}" in result.output
264+
265+
266+
def test_lookup_default_override_respected(runner):
267+
"""A subclass override of ``lookup_default()`` should be called by Click
268+
internals, not bypassed by a private method.
269+
270+
Reproduce exactly https://github.com/pallets/click/issues/3145 in which a
271+
subclass that falls back to prefix-based lookup when the parent returns
272+
``None``.
273+
274+
Previous attempts in https://github.com/pallets/click/pr/3199 were entirely
275+
bypassing the user's overridded method.
276+
"""
277+
278+
class CustomContext(click.Context):
279+
def lookup_default(self, name, call=True):
280+
default = super().lookup_default(name, call=call)
281+
282+
if default is not None:
283+
return default
284+
285+
# Prefix-based fallback: look up "app" sub-dict for "app_email".
286+
prefix = name.split("_", 1)[0]
287+
group = getattr(self, "default_map", None) or {}
288+
sub = group.get(prefix)
289+
if isinstance(sub, dict):
290+
return sub.get(name)
291+
return default
292+
293+
@click.command("get-views")
294+
@click.option("--app-email", default="original", show_default=True)
295+
@click.pass_context
296+
def cli(ctx, app_email):
297+
click.echo(f"app_email={app_email!r}")
298+
299+
cli.context_class = CustomContext
300+
default_map = {"app": {"app_email": "prefix@example.com"}}
301+
302+
# resolve_ctx path: the override provides the runtime value.
303+
result = runner.invoke(cli, [], default_map=default_map)
304+
assert not result.exception
305+
assert "app_email='prefix@example.com'" in result.output
306+
307+
# get_default path: the override is also used when
308+
# rendering --help with show_default=True.
309+
result = runner.invoke(cli, ["--help"], default_map=default_map)
310+
assert not result.exception
311+
assert "prefix@example.com" in result.output

0 commit comments

Comments
 (0)