Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is it possible to add a global argument for all subcommands in Click based interfaces?

I am using Click under a virtualenv and use the entry_point directive in setuptools to map the root to a function called dispatch.

My tool exposes two subcommands serve and config, I am using an option on the top level group to ensure that the user always passes a --path directive. However the usage turns out as follows:

mycommand --path=/tmp serve

both the serve and config sub commands need to ensure that the user always passes a path in and ideally I would like to present the cli as:

mycommand serve /tmp` or `mycommand config validate /tmp

current Click based implemenation is as follows:

# cli root

@click.group()
@click.option('--path', type=click.Path(writable=True))
@click.version_option(__version__)
@click.pass_context
def dispatch(ctx, path):
    """My project description"""
    ctx.obj = Project(path="config.yaml")

# serve

@dispatch.command()
@pass_project
def serve(project):
    """Starts WSGI server using the configuration"""
    print "hello"

# config

@dispatch.group()
@pass_project
def config(project):
    """Validate or initalise a configuration file"""
    pass

@config.command("validate")
@pass_project
def config_validate(project):
    """Reports on the validity of a configuration file"""
    pass

@config.command("init")
@pass_project
def config_init(project):
    """Initialises a skeleton configuration file"""
    pass

Is this possible without adding the path argument to each sub command?

like image 471
Devraj Avatar asked Sep 10 '15 05:09

Devraj


1 Answers

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

Custom Class:

import click

class GroupArgForCommands(click.Group):
    """Add special argument on group to front of command list"""

    def __init__(self, *args, **kwargs):
        super(GroupArgForCommands, self).__init__(*args, **kwargs)
        cls = GroupArgForCommands.CommandArgument

        # gather the special arguments
        self._cmd_args = {
            a.name: a for a in self.params if isinstance(a, cls)}

        # strip out the special arguments
        self.params = [a for a in self.params if not isinstance(a, cls)]

        # hook the original add_command method
        self._orig_add_command = click.Group.add_command.__get__(self)

    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'] = GroupArgForCommands.CommandArgument

        def decorator(f):
            click.argument(*param_decls, **attrs)(f)
            return f

        return decorator

    def add_command(self, cmd, name=None):

        # hook add_command for any sub groups
        if hasattr(cmd, 'add_command'):
            cmd._orig_add_command = cmd.add_command
            cmd.add_command = GroupArgForCommands.add_command.__get__(cmd)
            cmd.cmd_args = self._cmd_args

        # call original add_command
        self._orig_add_command(cmd, name)

        # if this command's callback has desired parameters add them
        import inspect
        args = inspect.signature(cmd.callback)
        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 @GroupArgForCommands.command_argument decorator for special arguments, and then add a parameter of the same name as the special argument to any commands as needed.

@click.group(cls=GroupArgForCommands)
@GroupArgForCommands.command_argument('special')
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 GroupArgForCommands implements a command_argument() method. This method is used as a decorator when adding a special argument instead of using click.argument()

Test Code:

def process_path_to_project(ctx, cmd, value):
    """param callback example to convert path to project"""
    # Use 'path' to construct a project.
    # For this example we will just annotate and pass through
    return 'converted {}'.format(value)


@click.group(cls=GroupArgForCommands)
@GroupArgForCommands.command_argument('path',
                                      callback=process_path_to_project)
def dispatch():
    """My project description"""


@dispatch.command()
def serve(path):
    """Starts WSGI server using the configuration"""
    click.echo('serve {}'.format(path))

@dispatch.group()
def config():
    """Validate or initalise a configuration file"""
    pass

@config.command("validate")
def config_validate():
    """Reports on the validity of a configuration file"""
    click.echo('config_validate')


@config.command("init")
def config_init(path):
    """Initialises a skeleton configuration file"""
    click.echo('config_init {}'.format(path))



if __name__ == "__main__":
    commands = (
        'config init a_path',
        'config init',
        'config validate a_path',
        'config validate',
        'config a_path',
        'config',
        'serve a_path',
        'serve',
        'config init --help',
        'config validate --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)
            dispatch(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)]
-----------
> config init a_path
config_init converted a_path
-----------
> config init
Usage: test.py config init [OPTIONS] PATH

Error: Missing argument "path".
-----------
> config validate a_path
Usage: test.py config validate [OPTIONS]

Error: Got unexpected extra argument (a_path)
-----------
> config validate
config_validate
-----------
> config a_path
Usage: test.py config [OPTIONS] COMMAND [ARGS]...

Error: No such command "a_path".
-----------
> config
Usage: test.py config [OPTIONS] COMMAND [ARGS]...

  Validate or initalise a configuration file

Options:
  --help  Show this message and exit.

Commands:
  init      Initialises a skeleton configuration file
  validate  Reports on the validity of a configuration...
-----------
> serve a_path
serve converted a_path
-----------
> serve
Usage: test.py serve [OPTIONS] PATH

Error: Missing argument "path".
-----------
> config init --help
Usage: test.py config init [OPTIONS] PATH

  Initialises a skeleton configuration file

Options:
  --help  Show this message and exit.
-----------
> config validate --help
Usage: test.py config validate [OPTIONS]

  Reports on the validity of a configuration file

Options:
  --help  Show this message and exit.
-----------
> 
Usage: test.py [OPTIONS] COMMAND [ARGS]...

  My project description

Options:
  --help  Show this message and exit.

Commands:
  config  Validate or initalise a configuration file
  serve   Starts WSGI server using the configuration
like image 112
Stephen Rauch Avatar answered Sep 21 '22 04:09

Stephen Rauch