Skip to content

Commit 437e1e3

Browse files
committed
Temporarily provide a fake context to the callback to hide UNSET values as None
Fix: #3136
1 parent ea70da4 commit 437e1e3

3 files changed

Lines changed: 143 additions & 1 deletion

File tree

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Unreleased
1111
the ``Context.invoke()`` method. :issue:`3066` :issue:`3065` :pr:`3068`
1212
- Fix conversion of ``Sentinel.UNSET`` happening too early, which caused incorrect
1313
behavior for multiple parameters using the same name. :issue:`3071` :pr:`3079`
14+
- Hide ``Sentinel.UNSET`` values as ``None`` when looking up for other parameters
15+
through the context inside parameter callbacks. :issue:`3136` :pr:`3137`
1416
- Fix rendering when ``prompt`` and ``confirm`` parameter ``prompt_suffix`` is
1517
empty. :issue:`3019` :pr:`3021`
1618
- When ``Sentinel.UNSET`` is found during parsing, it will skip calls to

src/click/core.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2440,7 +2440,37 @@ def process_value(self, ctx: Context, value: t.Any) -> t.Any:
24402440
# to None.
24412441
if value is UNSET:
24422442
value = None
2443-
value = self.callback(ctx, self, value)
2443+
2444+
# Search for parameters with UNSET values in the context.
2445+
unset_keys = {k: None for k, v in ctx.params.items() if v is UNSET}
2446+
# No UNSET values, call the callback as usual.
2447+
if not unset_keys:
2448+
value = self.callback(ctx, self, value)
2449+
2450+
# Legacy case: provide a temporarily manipulated context to the callback
2451+
# to hide UNSET values as None.
2452+
#
2453+
# Refs:
2454+
# https://github.com/pallets/click/issues/3136
2455+
# https://github.com/pallets/click/pull/3137
2456+
else:
2457+
# Add another layer to the context stack to clearly hint that the
2458+
# context is temporarily modified.
2459+
with ctx:
2460+
# Update the context parameters to replace UNSET with None.
2461+
ctx.params.update(unset_keys)
2462+
# Feed these fake context parameters to the callback.
2463+
value = self.callback(ctx, self, value)
2464+
# Restore the UNSET values in the context parameters.
2465+
ctx.params.update(
2466+
{
2467+
k: UNSET
2468+
for k in unset_keys
2469+
# Only restore keys that are present and still None, in case
2470+
# the callback modified other parameters.
2471+
if k in ctx.params and ctx.params[k] is None
2472+
}
2473+
)
24442474

24452475
return value
24462476

tests/test_context.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,116 @@ def test_make_pass_meta_decorator_doc():
181181
assert "passes the test value" in pass_value.__doc__
182182

183183

184+
def test_hiding_of_unset_sentinel_in_callbacks():
185+
"""Fix: https://github.com/pallets/click/issues/3136"""
186+
187+
def inspect_other_params(ctx, param, value):
188+
"""A callback that inspects other parameters' values via the context."""
189+
assert click.get_current_context() is ctx
190+
click.echo(f"callback.my_arg: {ctx.params.get('my_arg')!r}")
191+
click.echo(f"callback.my_opt: {ctx.params.get('my_opt')!r}")
192+
click.echo(f"callback.my_callback: {ctx.params.get('my_callback')!r}")
193+
194+
click.echo(f"callback.param: {param!r}")
195+
click.echo(f"callback.value: {value!r}")
196+
197+
return "hard-coded"
198+
199+
class ParameterInternalCheck(Option):
200+
"""An option that checks internal state during processing."""
201+
202+
def process_value(self, ctx, value):
203+
"""Check that UNSET values are hidden as None in ctx.params within the
204+
callback, and then properly restored afterwards.
205+
"""
206+
assert click.get_current_context() is ctx
207+
click.echo(f"before_process.my_arg: {ctx.params.get('my_arg')!r}")
208+
click.echo(f"before_process.my_opt: {ctx.params.get('my_opt')!r}")
209+
click.echo(f"before_process.my_callback: {ctx.params.get('my_callback')!r}")
210+
211+
value = super().process_value(ctx, value)
212+
213+
assert click.get_current_context() is ctx
214+
click.echo(f"after_process.my_arg: {ctx.params.get('my_arg')!r}")
215+
click.echo(f"after_process.my_opt: {ctx.params.get('my_opt')!r}")
216+
click.echo(f"after_process.my_callback: {ctx.params.get('my_callback')!r}")
217+
218+
return value
219+
220+
def change_other_params(ctx, param, value):
221+
"""A callback that modifies other parameters' values via the context."""
222+
assert click.get_current_context() is ctx
223+
click.echo(f"before_change.my_arg: {ctx.params.get('my_arg')!r}")
224+
click.echo(f"before_change.my_opt: {ctx.params.get('my_opt')!r}")
225+
click.echo(f"before_change.my_callback: {ctx.params.get('my_callback')!r}")
226+
227+
click.echo(f"before_change.param: {param!r}")
228+
click.echo(f"before_change.value: {value!r}")
229+
230+
ctx.params["my_arg"] = "changed"
231+
# Reset to None parameters that where not UNSET to see they are not forced back
232+
# to UNSET.
233+
ctx.params["my_callback"] = None
234+
235+
return value
236+
237+
@click.command
238+
@click.argument("my-arg", required=False)
239+
@click.option("--my-opt")
240+
@click.option("--my-callback", callback=inspect_other_params)
241+
@click.option("--check-internal", cls=ParameterInternalCheck)
242+
@click.option(
243+
"--modifying-callback", cls=ParameterInternalCheck, callback=change_other_params
244+
)
245+
@click.pass_context
246+
def cli(ctx, my_arg, my_opt, my_callback, check_internal, modifying_callback):
247+
click.echo(f"cli.my_arg: {my_arg!r}")
248+
click.echo(f"cli.my_opt: {my_opt!r}")
249+
click.echo(f"cli.my_callback: {my_callback!r}")
250+
click.echo(f"cli.check_internal: {check_internal!r}")
251+
click.echo(f"cli.modifying_callback: {modifying_callback!r}")
252+
253+
runner = click.testing.CliRunner()
254+
result = runner.invoke(cli)
255+
256+
assert result.stdout.splitlines() == [
257+
# Values of other parameters within the callback are None, not UNSET.
258+
"callback.my_arg: None",
259+
"callback.my_opt: None",
260+
"callback.my_callback: None",
261+
"callback.param: <Option my_callback>",
262+
"callback.value: None",
263+
# Previous UNSET values were not altered by the callback.
264+
"before_process.my_arg: Sentinel.UNSET",
265+
"before_process.my_opt: Sentinel.UNSET",
266+
"before_process.my_callback: 'hard-coded'",
267+
"after_process.my_arg: Sentinel.UNSET",
268+
"after_process.my_opt: Sentinel.UNSET",
269+
"after_process.my_callback: 'hard-coded'",
270+
# Changes on other parameters within the callback are restored afterwards.
271+
"before_process.my_arg: Sentinel.UNSET",
272+
"before_process.my_opt: Sentinel.UNSET",
273+
"before_process.my_callback: 'hard-coded'",
274+
"before_change.my_arg: None",
275+
"before_change.my_opt: None",
276+
"before_change.my_callback: 'hard-coded'",
277+
"before_change.param: <ParameterInternalCheck modifying_callback>",
278+
"before_change.value: None",
279+
"after_process.my_arg: 'changed'",
280+
"after_process.my_opt: Sentinel.UNSET",
281+
"after_process.my_callback: None",
282+
# Unset values within the main command are UNSET, but hidden as None.
283+
"cli.my_arg: 'changed'",
284+
"cli.my_opt: None",
285+
"cli.my_callback: None",
286+
"cli.check_internal: None",
287+
"cli.modifying_callback: None",
288+
]
289+
assert not result.stderr
290+
assert not result.exception
291+
assert result.exit_code == 0
292+
293+
184294
def test_context_pushing():
185295
rv = []
186296

0 commit comments

Comments
 (0)