Search code examples
pythoncommand-line-interfacepython-click

How do I dynamically invoke a Command's Subcommands Multiple Times?


My Click 7.0 application has one group, having multiple commands, called by the main cli function like so:

Code:

import sys
import click

@click.group()
def cli():
    """This is cli helptext"""
    click.echo('cli called')

@cli.group(chain=True, no_args_is_help=False)
@click.option('-r', '--repeat', default=1, type=click.INT, help='repeat helptext')
def chainedgroup(repeat):
    """This is chainedgroup helptext"""

    top = sys.argv[2]
    bottom = sys.argv[3:]
    click.echo('chainedgroup code called')

    for _ in range(repeat):
        chainedgroup.main(bottom, top, standalone_mode=False)

@chainedgroup.command()
def command1():
    """This is command1 helptext"""
    click.echo('command1 called')

@chainedgroup.command()
@click.option('-o', '--option')
def command2(option):
    """This is command2 helptext"""
    click.echo('command2 called with {0}'.format(option))

Run:

$ testcli chainedgroup --repeat 2 command1
$ testcli chainedgroup -r 3 command1 command2 -o test

Expected Result:

cli called
chainedgroup code called
command1 called
command1 called
----------
cli called
chainedgroup code called
command1 called
command2 called with test
command1 called
command2 called with test
command1 called
command2 called with test

Actual Result:

Case #1 gives me a Missing command error, while case #2 ends in a RecursionError.

I'm sure I was sure Command.main() is the correct method to call. What am I doing wrong?


Solution

  • If you create a custom click.Group class you can override the invoke() method to call the commands more than once.

    Custom Class:

    class RepeatMultiCommand(click.Group):
        def invoke(self, ctx):
            old_callback = self.callback
    
            def new_callback(*args, **kwargs):
                # only call the group callback once
                if repeat_number == 0:
                    return old_callback(*args, **kwargs)
            self.callback = new_callback
    
            # call invoke the desired number of times
            for repeat_number in range(ctx.params['repeat']):
                new_ctx = copy.deepcopy(ctx)
                super(RepeatMultiCommand, self).invoke(new_ctx)
    
            self.callback = old_callback
    

    To use the Custom Class:

    Pass the .group() decorator the custom class with the cls parameter like:

    @cli.group(chain=True, no_args_is_help=False, cls=RepeatMultiCommand)
    @click.option('-r', '--repeat', default=1, type=click.INT,
                  help='repeat helptext')
    def chainedgroup(repeat):
        ....
    

    How does this work?

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

    In this case, we override click.Group.invoke(). In our invoke(), we hook the group callback so that we can have it only be called once, and we then call the super().invoke() a repeat number of times.

    Test Code:

    import click
    import copy
    import sys
    
    @click.group()
    def cli():
        """This is cli helptext"""
        click.echo('cli called')
    
    
    @cli.group(chain=True, no_args_is_help=False, cls=RepeatMultiCommand)
    @click.option('-r', '--repeat', default=1, type=click.INT,
                  help='repeat helptext')
    def chainedgroup(repeat):
        """This is chainedgroup helptext"""
        click.echo('chainedgroup code called')
    
    
    @chainedgroup.command()
    def command1():
        """This is command1 helptext"""
        click.echo('command1 called')
    
    
    @chainedgroup.command()
    @click.option('-o', '--option')
    def command2(option):
        """This is command2 helptext"""
        click.echo('command2 called with {0}'.format(option))
    
    
    if __name__ == "__main__":
        commands = (
            'chainedgroup --repeat 2 command1',
            'chainedgroup -r 3 command1 command2 -o test',
            'chainedgroup command1',
            'chainedgroup --help',
            '--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
    

    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)]
    -----------
    > chainedgroup --repeat 2 command1
    cli called
    chainedgroup code called
    command1 called
    command1 called
    -----------
    > chainedgroup -r 3 command1 command2 -o test
    cli called
    chainedgroup code called
    command1 called
    command2 called with test
    command1 called
    command2 called with test
    command1 called
    command2 called with test
    -----------
    > chainedgroup command1
    cli called
    chainedgroup code called
    command1 called
    -----------
    > chainedgroup --help
    cli called
    Usage: test.py chainedgroup [OPTIONS] COMMAND1 [ARGS]... [COMMAND2
                                [ARGS]...]...
    
      This is chainedgroup helptext
    
    Options:
      -r, --repeat INTEGER  repeat helptext
      --help                Show this message and exit.
    
    Commands:
      command1  This is command1 helptext
      command2  This is command2 helptext
    -----------
    > --help
    Usage: test.py [OPTIONS] COMMAND [ARGS]...
    
      This is cli helptext
    
    Options:
      --help  Show this message and exit.
    
    Commands:
      chainedgroup  This is chainedgroup helptext