1

How can I achieve the following synopsis using the Python click library?

Usage: app CMD [OPTIONS] [FOO] [BAR]
       app CMD [OPTIONS] [FOOBAR]

I can't figure out whether I am able to pass two different sets of named argument for the same command based on the number of given arguments. That is, if only one argument was passed it's foobar, but if two arguments were passed, they are foo and bar.

The code representation of such implementation would look something like this (provided you could use function overload, which you can't)

@click.command()
@click.argument('foo', required=False)
@click.argument('bar', required=False)
def cmd(foo, bar):
    # ...

@click.command()
@click.argument('foobar', required=False)
def cmd(foobar):
    # ...
4
  • Why not just test to see if bar is None? Commented Mar 10, 2021 at 2:11
  • I wanted to be able to specify different help/description and/or requirements (variable type) for those two different scenarios. It is possible with always having two and checking for None but the ux on that one would be meh and thus being the last resort Commented Mar 10, 2021 at 2:15
  • In this context does it make sense that required=False? Commented Mar 10, 2021 at 2:32
  • Yes as they are still optional — just like in the synopsis Commented Mar 10, 2021 at 8:43

1 Answer 1

1

You can add multiple command handlers with a different number of arguments for each by creating a custom click.Command class. There is some ambiguity around which of the command handlers would best be called if parameters are not strictly required, but that can be mostly dealt with by using the first signature that fits the command line passed.

Custom Class

class AlternateArgListCmd(click.Command):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.alternate_arglist_handlers = [(self, super())]
        self.alternate_self = self

    def alternate_arglist(self, *args, **kwargs):
        from click.decorators import command as cmd_decorator

        def decorator(f):
            command = cmd_decorator(*args, **kwargs)(f)
            self.alternate_arglist_handlers.append((command, command))

            # verify we have no options defined and then copy options from base command
            options = [o for o in command.params if isinstance(o, click.Option)]
            if options:
                raise click.ClickException(
                    f'Options not allowed on {type(self).__name__}: {[o.name for o in options]}')
            command.params.extend(o for o in self.params if isinstance(o, click.Option))
            return command

        return decorator

    def make_context(self, info_name, args, parent=None, **extra):
        """Attempt to build a context for each variant, use the first that succeeds"""
        orig_args = list(args)
        for handler, handler_super in self.alternate_arglist_handlers:
            args[:] = list(orig_args)
            self.alternate_self = handler
            try:
                return handler_super.make_context(info_name, args, parent, **extra)
            except click.UsageError:
                pass
            except:
                raise

        # if all alternates fail, return the error message for the first command defined
        args[:] = orig_args
        return super().make_context(info_name, args, parent, **extra)

    def invoke(self, ctx):
        """Use the callback for the appropriate variant"""
        if self.alternate_self.callback is not None:
            return ctx.invoke(self.alternate_self.callback, **ctx.params)
        return super().invoke(ctx)

    def format_usage(self, ctx, formatter):
        """Build a Usage for each variant"""
        prefix = "Usage: "
        for _, handler_super in self.alternate_arglist_handlers:
            pieces = handler_super.collect_usage_pieces(ctx)
            formatter.write_usage(ctx.command_path, " ".join(pieces), prefix=prefix)
            prefix = " " * len(prefix)

Using the Custom Class:

To use the custom class, pass it as the cls argument to the click.command decorator like:

@click.command(cls=AlternateArgListCmd)
@click.argument('foo')
@click.argument('bar')
def cli(foo, bar):
    ...

Then use the alternate_arglist() decorator on the command to add another command handler with different arguments.

@cli.alternate_arglist()
@click.argument('foobar')
def cli_one_param(foobar):
    ...

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 the desired methods.

In this case we add a new decorator method: alternate_arglist(), and override three methods: make_context(), invoke() & format_usage(). The overridden make_context() method checks to see which of the command handler variants matches the number of args passed, the overridden invoke() method is used to call the appropriate command handler variant and the overridden format_usage() is used to create the help message showing the various usages.

Test Code:

import click


@click.command(cls=AlternateArgListCmd)
@click.argument('foo')
@click.argument('bar')
@click.argument('baz')
@click.argument('bing', required=False)
@click.option('--an-option', default='empty')
def cli(foo, bar, baz, bing, an_option):
    """Best Command Ever!"""
    if bing is not None:
        click.echo(f'foo bar baz bing an-option: {foo} {bar} {baz} {bing} {an_option}')
    else:
        click.echo(f'foo bar baz an-option: {foo} {bar} {baz} {an_option}')


@cli.alternate_arglist()
@click.argument('foo')
@click.argument('bar')
def cli_two_param(foo, bar, an_option):
    click.echo(f'foo bar an-option: {foo} {bar} {an_option}')


@cli.alternate_arglist()
@click.argument('foobar', required=False)
def cli_one_param(foobar, an_option):
    click.echo(f'foobar an-option: {foobar} {an_option}')


if __name__ == "__main__":
    commands = (
        '',
        'p1',
        'p1 p2 --an-option=optional',
        'p1 p2 p3',
        'p1 p2 p3 p4 --an-option=optional',
        'p1 p2 p3 p4 p5',
        '--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)
            cli(cmd.split())

        except BaseException as exc:
            if str(exc) != '0' and \
                    not isinstance(exc, (click.ClickException, SystemExit)):
                raise

Test Results:

Click Version: 7.1.2
Python Version: 3.8.5 (tags/v3.8.5:580fbb0, Jul 20 2020, 15:57:54) [MSC v.1924 64 bit (AMD64)]
-----------
>
foobar an-option: None empty
-----------
> p1
foobar an-option: p1 empty
-----------
> p1 p2 --an-option=optional
foo bar an-option: p1 p2 optional
-----------
> p1 p2 p3
foo bar baz an-option: p1 p2 p3 empty
-----------
> p1 p2 p3 p4 --an-option=optional
foo bar baz bing an-option: p1 p2 p3 p4 optional
-----------
> p1 p2 p3 p4 p5
Usage: test_code.py [OPTIONS] FOO BAR BAZ [BING]
       test_code.py [OPTIONS] FOO BAR
       test_code.py [OPTIONS] [FOOBAR]
Try 'test_code.py --help' for help.

Error: Got unexpected extra argument (p5)
-----------
> --help
Usage: test_code.py [OPTIONS] FOO BAR BAZ [BING]
       test_code.py [OPTIONS] FOO BAR
       test_code.py [OPTIONS] [FOOBAR]

  Best Command Ever!

Options:
  --an-option TEXT
  --help            Show this message and exit.
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.