Search code examples
pythoncommand-line-interfacepython-click

python-click: dependent options on another option


This question is about the click package: I want to setup my command so that some optional options are dependent on a specific option value and are required based on its value.

Required options:

  1. input (input file)
  2. doe (integer , represents algo name)

Sub options: if doe is

  1. equal to 1 then option generator_string should become required=True
  2. equal to 2 then option number_of_sample_pointsshould become required=True
  3. equal to 3 then option number_of_center_pointsshould become required=True

Valid Examples:

  1. --input ./input.txt --doe 1 --generator_string 1234
  2. --input ./input.txt --doe 2 --number_of_sample_points 3
  3. --input ./input.txt --doe 3 --number_of_center_points 2

CODE:

import click


def check_output(ctx, param, value):
    if value == 1:
        if not ctx.params['generator_string']:
            setOptionAsRequired(ctx, 'generator_string')
    return value


def setOptionAsRequired(ctx, name):
    for p in ctx.command.params:
        if isinstance(p, click.Option) and p.name == name:
            p.required = True


@click.option('--input', required=True, type=click.Path(exists=True) )
@click.option('--doe', required=True, type=int, callback=check_output )
@click.option('--generator_string', required=False, type=str, is_eager=True)
@click.option('--number_of_sample_points', required=False, type=int, is_eager=True)
@click.option('--number_of_center_points', required=False, type=int, is_eager=True)
@click.command(context_settings=dict(max_content_width=800))
def main(input, doe, generator_string, number_of_sample_points, number_of_center_points):
    click.echo('is valid command')

if __name__ == '__main__':
    main()

Solution

  • I would suggest doing that with a custom click.Command class like:

    Custom Class:

    def command_required_option_from_option(require_name, require_map):
    
        class CommandOptionRequiredClass(click.Command):
    
            def invoke(self, ctx):
                require = ctx.params[require_name]
                if require not in require_map:
                    raise click.ClickException(
                        "Unexpected value for --'{}': {}".format(
                            require_name, require))
                if ctx.params[require_map[require].lower()] is None:
                    raise click.ClickException(
                        "With {}={} must specify option --{}".format(
                            require_name, require, require_map[require]))
                super(CommandOptionRequiredClass, self).invoke(ctx)
    
        return CommandOptionRequiredClass
    

    Using the Custom Class

    required_options = {
        1: 'generator_string',
        2: 'number_of_sample_points',
        3: 'number_of_center_points',
    }
    
    @click.command(cls=command_required_option_from_option('doe', required_options))
    ...
    

    How does this work?

    This works because click is a well designed OO framework. The @click.command() decorator usually instantiates a click.Command object but allows this behavior to be over ridden with the cls parameter. So it is a relatively easy matter to inherit from click.Command in our own class and over ride desired methods.

    In this case, we override click.Command.invoke() and then validate that the required option has been set before running the command

    Test Code:

    import click
    
    required_options = {
        1: 'generator_string',
        2: 'number_of_sample_points',
        3: 'number_of_center_points',
    }
    
    @click.command(context_settings=dict(max_content_width=800),
                   cls=command_required_option_from_option('doe', required_options))
    @click.option('--input', required=True,
                  type=click.Path(exists=True))
    @click.option('--doe', required=True, type=int)
    @click.option('--generator_string', required=False, type=str, is_eager=True)
    @click.option('--number_of_sample_points', required=False, type=int,
                  is_eager=True)
    @click.option('--number_of_center_points', required=False, type=int,
                  is_eager=True)
    def main(input, doe, generator_string, number_of_sample_points,
             number_of_center_points):
        click.echo('input: {}'.format(input))
        click.echo('doe: {}'.format(doe))
        click.echo('generator_string: {}'.format(generator_string))
        click.echo('Num of sample_points: {}'.format(number_of_sample_points))
        click.echo('Num of center_points: {}'.format(number_of_center_points))
    
    
    if __name__ == "__main__":
        commands = (
            '--input ./input.txt --doe 0',
            '--input ./input.txt --doe 1',
            '--input ./input.txt --doe 2',
            '--input ./input.txt --doe 3',
            '--input ./input.txt --doe 1 --generator_string 1234',
            '--input ./input.txt --doe 2 --number_of_sample_points 3',
            '--input ./input.txt --doe 3 --number_of_center_points 2',
            '',
            '--help',
        )
    
        import sys, time
    
        time.sleep(1)
        print('Click Version: {}'.format(click.__version__))
        print('Python Version: {}'.format(sys.version))
        for cmd in commands:
            try:
                time.sleep(0.1)
                print('-----------')
                print('> ' + cmd)
                time.sleep(0.1)
                main(cmd.split())
    
            except BaseException as exc:
                if str(exc) != '0' and \
                        not isinstance(exc, (click.ClickException, SystemExit)):
                    raise
    

    Results:

    Click Version: 6.7
    Python Version: 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)]
    -----------
    > --input ./input.txt --doe 0
    Error: Unexpected value for --'doe': 0
    -----------
    > --input ./input.txt --doe 1
    Error: With doe=1 must specify option --generator_string
    -----------
    > --input ./input.txt --doe 2
    Error: With doe=2 must specify option --number_of_sample_points
    -----------
    > --input ./input.txt --doe 3
    Error: With doe=3 must specify option --number_of_center_points
    -----------
    > --input ./input.txt --doe 1 --generator_string 1234
    input: ./input.txt
    doe: 1
    generator_string: 1234
    Num of sample_points: None
    Num of center_points: None
    -----------
    > --input ./input.txt --doe 2 --number_of_sample_points 3
    input: ./input.txt
    doe: 2
    generator_string: None
    Num of sample_points: 3
    Num of center_points: None
    -----------
    > --input ./input.txt --doe 3 --number_of_center_points 2
    input: ./input.txt
    doe: 3
    generator_string: None
    Num of sample_points: None
    Num of center_points: 2
    -----------
    > 
    Usage: test.py [OPTIONS]
    
    Error: Missing option "--input".
    -----------
    > --help
    Usage: test.py [OPTIONS]
    
    Options:
      --input PATH                    [required]
      --doe INTEGER                   [required]
      --generator_string TEXT
      --number_of_sample_points INTEGER
      --number_of_center_points INTEGER
      --help                          Show this message and exit.