Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

A command without name, in Click

I want to have a command line tool with a usage like this:

$ program <arg>            does something, no command name required
$ program cut <arg>
$ program eat <arg>

The Click code would look like this:

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

@main.command()
@click.argument('arg')
def noname(arg) :
    # does stuff

@main.command()
@click.argument('arg')
def cut(arg) :
    # cuts stuff

@main.command()
@click.argument('arg')
def eat(arg) :
    # eats stuff

My problem is that with this code, there is always a required command name, ie: I need to run $ program noname arg. But I want to be able to run $ program arg.

like image 515
bcusk07q Avatar asked Aug 28 '18 08:08

bcusk07q


2 Answers

click-default-group is doing what you are looking for. It is part of the click-contrib collection.

The advantage of that instead of using invoke_without_command is that it passes the options and arguments flawlessly to the default command, something that is not trivial (or even possible) with the built-in functionality.

Example code:

import click
from click_default_group import DefaultGroup

@click.group(cls=DefaultGroup, default='foo', default_if_no_args=True)
def cli():
    print("group execution")

@cli.command()
@click.option('--config', default=None)
def foo(config):
    click.echo('foo execution')
    if config:
        click.echo(config)

Then, it's possible to call foo command with its option as:

$ program foo --config bar     <-- normal way to call foo
$ program --config bar         <-- foo is called and the option is forwarded. 
                                   Not possible with vanilla Click.

like image 63
edobez Avatar answered Oct 22 '22 19:10

edobez


Your scheme has some challenges because of the ambiguity introduce with the default command. Regardless, here is one way that can be achieved with click. As shown in the test results, the generated help with be less than ideal, but likely OK.

Custom Class:

import click

class DefaultCommandGroup(click.Group):
    """allow a default command for a group"""

    def command(self, *args, **kwargs):
        default_command = kwargs.pop('default_command', False)
        if default_command and not args:
            kwargs['name'] = kwargs.get('name', '<>')
        decorator = super(
            DefaultCommandGroup, self).command(*args, **kwargs)

        if default_command:
            def new_decorator(f):
                cmd = decorator(f)
                self.default_command = cmd.name
                return cmd

            return new_decorator

        return decorator

    def resolve_command(self, ctx, args):
        try:
            # test if the command parses
            return super(
                DefaultCommandGroup, self).resolve_command(ctx, args)
        except click.UsageError:
            # command did not parse, assume it is the default command
            args.insert(0, self.default_command)
            return super(
                DefaultCommandGroup, self).resolve_command(ctx, args)

Using the Custom Class

To use the custom class, pass the cls parameter to the click.group() decorator. Then pass default_command=True for the command which will be the default.

@click.group(cls=DefaultCommandGroup)
def a_group():
    """My Amazing Group"""

@a_group.command(default_command=True)
def a_command():
    """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.command() so that when a command is added we find the default command. In addition we override click.Group.resolve_command() so that we can insert the default command name if the first resolution is unsuccessful.

Test Code:

@click.group(cls=DefaultCommandGroup)
def main():
    pass

@main.command(default_command=True)
@click.argument('arg')
def noname(arg):
    """ does stuff """
    click.echo('default: {}'.format(arg))

@main.command()
@click.argument('arg')
def cut(arg):
    """ cuts stuff """
    click.echo('cut: {}'.format(arg))

@main.command()
@click.argument('arg')
def eat(arg):
    """ eats stuff """
    click.echo('eat: {}'.format(arg))


if __name__ == "__main__":
    commands = (
        'an_arg',
        'cut cut_arg',
        'eat eat_arg',
        '--help',
        'cut --help',
        'eat --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)
            main(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)]
-----------
> an_arg
default: an_arg
-----------
> cut cut_arg
cut: cut_arg
-----------
> eat eat_arg
eat: eat_arg
-----------
> --help
Usage: test.py [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  <>   does stuff
  cut  cuts stuff
  eat  eats stuff
-----------
> cut --help
Usage: test.py cut [OPTIONS] ARG

  cuts stuff

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

  eats stuff

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

Options:
  --help  Show this message and exit.

Commands:
  <>   does stuff
  cut  cuts stuff
  eat  eats stuff
like image 5
Stephen Rauch Avatar answered Oct 22 '22 19:10

Stephen Rauch