Require an option only if a choice is made when using click

Question:

When using click I know how to define a multiple choice option. I also know how to set an option as a required one. But, how can I indicate that an option B is required only if the value of option A is foo?

Here’s an example:

import click

@click.command()
@click.option('--output',
              type=click.Choice(['stdout', 'file']), default='stdout')
@click.option('--filename', type=click.STRING)
def main(output, filename):
    print("output: " + output)
    if output == 'file':
        if filename is None:
            print("filename must be provided!")
        else:
            print("filename: " + str(filename))

if __name__ == "__main__":
    main()

If the output option is stdout, then filename is not needed. However, if the user chooses output to be file, then the other option filename must be provided. Is this pattern supported by click?

At the beginning of the function I can add something like:

if output == 'file' and filename is None:
    raise ValueError('When output is "file", a filename must be provided')

But I am interested whether there’s a nicer/cleaner solution.

Asked By: Dror

||

Answers:

In the particular case of this example, I think an easier method would be to get rid of --output, and simply assume stdout if --filename is not specified and if --filename is specified, then use it instead of stdout.

But assuming this is a contrived example, you can inherit from click.Option to allow hooking into the click processing:

Custom Class:

class OptionRequiredIf(click.Option):

    def full_process_value(self, ctx, value):
        value = super(OptionRequiredIf, self).full_process_value(ctx, value)

        if value is None and ctx.params['output'] == 'file':
            msg = 'Required if --output=file'
            raise click.MissingParameter(ctx=ctx, param=self, message=msg)
        return value

Using Custom Class:

To use the custom class, pass it as the cls argument to the option decorator like:

@click.option('--filename', type=click.STRING, cls=OptionRequiredIf)

Test Code:

import click

@click.command()
@click.option('--output',
              type=click.Choice(['stdout', 'file']), default='stdout')
@click.option('--filename', type=click.STRING, cls=OptionRequiredIf)
def main(output, filename):
    print("output: " + output)
    if output == 'file':
        if filename is None:
            print("filename must be provided!")
        else:
            print("filename: " + str(filename))


main('--output=file'.split())

Results:

Usage: test.py [OPTIONS]

Error: Missing option "--filename".  Required if --output=file
Answered By: Stephen Rauch

I extended the answer by Stephen, and made it more generic:

class OptionRequiredIf(click.Option):
    """
    Option is required if the context has `option` set to `value`
    """

    def __init__(self, *a, **k):
        try:
            option = k.pop('option')
            value  = k.pop('value')
        except KeyError:
            raise(KeyError("OptionRequiredIf needs the option and value "
                           "keywords arguments"))

        click.Option.__init__(self, *a, **k)
        self._option = option
        self._value = value

    def process_value(self, ctx, value):
        value = super(OptionRequiredIf, self).process_value(ctx, value)
        if value is None and ctx.params[self._option] == self._value:
            msg = 'Required if --{}={}'.format(self._option, self._value)
            raise click.MissingParameter(ctx=ctx, param=self, message=msg)
        return value

Usage example:

@click.option('--email', type=click.STRING,
              help='Settings for sending emails.',
              option='output', value='email', cls=OptionRequiredIf)

I was inspired by this answer

Answered By: Dror

You can do the same thing with a custom validation callback:

import click


def required_with_output(ctx, param, value):
    if ctx.params.get("output") != "stdout" and value is None:
        raise click.BadParameter("--output requires --filename")
    return value


@click.command()
@click.option(
    "--output",
    type=click.Choice(["stdout", "file"]),
    default="stdout",
)
@click.option("--filename", callback=required_with_output)
def main(output, filename):
    print("output: " + output)
    if output == "file":
        if filename is None:
            print("filename must be provided!")
        else:
            print("filename: " + str(filename))


if __name__ == "__main__":
    main()

I think this is a little simpler.

Answered By: larsks