@@ -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