Multi-value options override the value returned from `callback` when not all specified
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 17.7k
- Forks
- 2.3k
- Avg merge
- 1d 30m
- Merged PRs (30d)
- 18
Description
I discovered this when I wanted to add a flag_value-style option to shortcut a custom str parameter option.
I want to have 2 options:
- one that accepts a parameter of any
strvalue like the name of a file or something - another that is a flag that fetches the value from a remote server instead of me specifying it
Since these both represent the same thing, my initial thought was just to pass them under the same parameter name to the function, which seems to almost work.
the problem seems to be that if the "custom" value is not specified while the "flag" option is set, then click will use the flag_value from the "flag" as the default value for the "custom" value.
And click seems to process unspecified options last (which I can see why this was done), but since "custom" is not specified, it processes the default after the "flag" option has already set a value for the function parameter that they share.
# bad.py
import click
SENTINEL = "$_fetch"
def fetch():
# some logic dependent on an external resource
# return requests.get("...").json()["value")
print('resource fetched')
return "foo"
def callback(ctx, param, value):
if value is SENTINEL:
return fetch()
return value
@click.command()
@click.option("--custom", "custom")
@click.option("--fetch", "custom", flag_value=SENTINEL, callback=callback)
def cli(custom):
print(f'custom={custom}')
if __name__ == '__main__':
cli()
results in the SENTINEL internal value being passed to the function when --fetch is specified alone:
# call with just --custom <arg>, get <arg>, as expected
$ python bad.py --custom bar
custom=bar
# call with just --fetch, get SENTINEL, the internal value that should not ever appear in the values
$ python bad.py --fetch
resource fetched
custom=$_fetch
# call with --custom <arg> first and --fetch after, get the fetched resource, as expected
$ python bad.py --custom bar --fetch
resource fetched
custom=foo
It's that last use-case that really rubs the dirt in; paradoxically, I have to specify the longer option I was trying to avoid using in order to get my "shortcut" flag option to work.
However, we can use a workaround to fix it, sort of, by passing the same callback to both options:
# bad.py
# ..
@click.option("--custom", "custom", callback=callback)
@click.option("--fetch", "custom", flag_value=SENTINEL, callback=callback)
# ..
results in fetch being called twice when --fetch is the last specified option:
# call with just --custom <arg>, get <arg>, as expected
$ python bad.py --custom bar
custom=bar
# call with just --fetch, get foo, but the external resource is pinged twice
$ python bad.py --fetch
resource fetched
resource fetched
custom=foo
# call with --custom <arg> first and --fetch after, get the fetched resource, as expected, resource is pinged once
$ python bad.py --custom bar --fetch
resource fetched
custom=foo
I could have some caching mechanism to avoid the repeated call to the external resource, but that should not be necessary, I should not have to add the callback argument to both options.
I can write a custom Option class that resolved this though:
# good.py
import click
class FixOption(click.Option):
def handle_parse_result(self, ctx, opts, args):
val = super().handle_parse_result(ctx, opts, args)
# even though opts is type-hinted as non-mutable, this still works
opts[self.name] = val[0]
return val
SENTINEL = "$_fetch"
def fetch():
# some logic dependent on an external resource
# return requests.get("...").json()["value")
print('resource fetched')
return "foo"
def callback(ctx, param, value):
if value is SENTINEL:
return fetch()
return value
@click.command()
@click.option("--custom", "custom")
@click.option("--fetch", "custom", flag_value=SENTINEL, callback=callback, cls=FixOption)
def cli(custom):
print(f'custom={custom}')
if __name__ == '__main__':
cli()
But I'd prefer not to have to keep a whole class around just for this, especially when it does work without one, just only if I pass both my options.
Environment:
- Python version: 3.12.5
- Click version: 8.1.7
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by running the bad.py reproducer with --fetch and with --custom followed by --fetch. Read Click's Option.handle_parse_result behavior and the callback processing for options sharing a parameter name. Done means the fetch callback returns the fetched value when --fetch is used alone, without requiring a callback on --custom or invoking the external fetch more than once.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 45/100