Search code examples
pythoncommand-line-interfacepython-click

With python click, how can I avoid duplicating argument code used by multiple subcommands


I have a group of subcommands that all operate on a list of URLs that can optionally be passed as an argument. How can I assign this argument to the group instead to avoid duplicating the argument definition on every subcommand?

Current code:

from config import site_list

@click.group()
def cli():
    pass

@cli.command()
@cli.argument('sites', nargs=-1)
def subcommand_one():
    if sites:
        site_list = sites
    etc...

@cli.command()
@cli.argument('sites', nargs=-1)
def subcommand_two():
    if sites:
        site_list = sites
    etc...

Example invocation:

$ python sites.py subcommand_one www.example.com www.example2.com

I tried moving the argument decorator to the group like this:

@click.group()
@click.argument('sites', nargs=-1)
def cli(sites):
    if sites:
        site_list = sites

But then I would get this error:

$ python sites.py subcommand_one
Usage: sites.py [OPTIONS] [SITES] COMMAND [ARGS]...
Try "sites.py --help" for help.

Error: Missing command.

Solution

  • If there is a specific nargs = -1 argument that you would like to decorate only onto the group, but be applicable to all commands as needed, you can do that with some of extra plumbing like:

    This answer is inspired by this answer.

    Custom Class

    class GroupNArgsForCommands(click.Group):
        """Add special arguments on group"""
    
        def __init__(self, *args, **kwargs):
            super(GroupNArgsForCommands, self).__init__(*args, **kwargs)
            cls = GroupNArgsForCommands.CommandArgument
    
            # gather the special arguments for later
            self._cmd_args = {
                a.name: a for a in self.params if isinstance(a, cls)}
    
            # strip out the special arguments from self
            self.params = [a for a in self.params if not isinstance(a, cls)]
    
        class CommandArgument(click.Argument):
            """class to allow us to find our special arguments"""
    
        @staticmethod
        def command_argument(*param_decls, **attrs):
            """turn argument type into type we can find later"""
    
            assert 'cls' not in attrs, "Not designed for custom arguments"
            attrs['cls'] = GroupNArgsForCommands.CommandArgument
    
            def decorator(f):
                click.argument(*param_decls, **attrs)(f)
                return f
    
            return decorator
    
        def group(self, *args, **kwargs):
            # any derived groups need to be the same type
            kwargs['cls'] = GroupNArgsForCommands
    
            def decorator(f):
                grp = super(GroupNArgsForCommands, self).group(
                    *args, **kwargs)(f)
                self.add_command(grp)
    
                # any sub commands need to hook the same special args
                grp._cmd_args = self._cmd_args
    
                return grp
    
            return decorator
    
        def add_command(self, cmd, name=None):
    
            # call original add_command
            super(GroupNArgsForCommands, self).add_command(cmd, name)
    
            # if this command's callback has desired parameters add them
            import inspect
            args = inspect.signature(cmd.callback)
            if len(args.parameters):
                for arg_name in reversed(list(args.parameters)):
                    if arg_name in self._cmd_args:
                        cmd.params[:] = [self._cmd_args[arg_name]] + cmd.params
    

    Using the Custom Class:

    To use the custom class, pass the cls parameter to the click.group() decorator, use the @GroupNArgsForCommands.command_argument decorator for the special argument, and then add a parameter of the same name as the special argument to any commands as needed.

    @click.group(cls=GroupNArgsForCommands)
    @GroupNArgsForCommands.command_argument('special', nargs=-1)
    def a_group():
        """My project description"""
    
    @a_group.command()
    def a_command(special):
        """a command under the group"""
    

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

    In this case we over ride click.Group.add_command() so that when a command is added we can examine the command callback parameters to see if they have the same name as any of our special arguments. If they match, the argument is added to the command's arguments just as if it had been decorated directly.

    In addition GroupNArgsForCommands implements a command_argument() method. This method is used as a decorator when adding the special argument instead of using click.argument()

    Test Class

    import click
    
    @click.group(cls=GroupNArgsForCommands)
    @GroupNArgsForCommands.command_argument('sites', nargs=-1)
    def cli():
        click.echo("cli group")
    
    @cli.command()
    def command_one(sites):
        click.echo("command_one: {}".format(sites))
    
    @cli.group()
    def subcommand():
        click.echo("subcommand group")
    
    @subcommand.command()
    def one():
        click.echo("subcommand_one")
    
    @subcommand.command()
    def two(sites):
        click.echo("subcommand_two: {}".format(sites))
    
    if __name__ == "__main__":
        commands = (
            'command_one site1 site2',
            'command_one site1',
            'command_one',
            'subcommand',
            'subcommand one site1 site2',
            'subcommand one site1',
            'subcommand one',
            'subcommand two site1 site2',
            'subcommand two site1',
            'subcommand two',
            '--help',
            'command_one --help',
            'subcommand --help',
            'subcommand one --help',
            'subcommand two --help',
            '',
        )
    
        import sys, time
    
        time.sleep(1)
        print('Click Version: {}'.format(click.__version__))
        print('Python Version: {}'.format(sys.version))
        for command in commands:
            try:
                time.sleep(0.1)
                print('-----------')
                print('> ' + command)
                time.sleep(0.1)
                cli(command.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)]
    -----------
    > command_one site1 site2
    cli group
    command_one: ('site1', 'site2')
    -----------
    > command_one site1
    cli group
    command_one: ('site1',)
    -----------
    > command_one
    cli group
    command_one: ()
    -----------
    > subcommand
    cli group
    Usage: test.py subcommand [OPTIONS] COMMAND [ARGS]...
    
    Options:
      --help  Show this message and exit.
    
    Commands:
      one
      two
    -----------
    > subcommand one site1 site2
    Usage: test.py subcommand one [OPTIONS]
    
    Error: Got unexpected extra arguments (site1 site2)
    cli group
    subcommand group
    -----------
    > subcommand one site1
    cli group
    subcommand group
    Usage: test.py subcommand one [OPTIONS]
    
    Error: Got unexpected extra argument (site1)
    -----------
    > subcommand one
    cli group
    subcommand group
    subcommand_one
    -----------
    > subcommand two site1 site2
    cli group
    subcommand group
    subcommand_two: ('site1', 'site2')
    -----------
    > subcommand two site1
    cli group
    subcommand group
    subcommand_two: ('site1',)
    -----------
    > subcommand two
    cli group
    subcommand group
    subcommand_two: ()
    -----------
    > --help
    Usage: test.py [OPTIONS] COMMAND [ARGS]...
    
    Options:
      --help  Show this message and exit.
    
    Commands:
      command_one
      subcommand
    -----------
    > command_one --help
    cli group
    Usage: test.py command_one [OPTIONS] [SITES]...
    
    Options:
      --help  Show this message and exit.
    -----------
    > subcommand --help
    cli group
    Usage: test.py subcommand [OPTIONS] COMMAND [ARGS]...
    
    Options:
      --help  Show this message and exit.
    
    Commands:
      one
      two
    -----------
    > subcommand one --help
    cli group
    subcommand group
    Usage: test.py subcommand one [OPTIONS]
    
    Options:
      --help  Show this message and exit.
    -----------
    > subcommand two --help
    cli group
    subcommand group
    Usage: test.py subcommand two [OPTIONS] [SITES]...
    
    Options:
      --help  Show this message and exit.
    -----------
    > 
    Usage: test.py [OPTIONS] COMMAND [ARGS]...
    
    Options:
      --help  Show this message and exit.
    
    Commands:
      command_one
      subcommand