Search code examples
pythoncommand-line-argumentspython-click

Commands with multiple common options going into one argument using custom decorator


I would like to make a module that makes it very simple to build click commands that share a lot of options. Those options would be distilled into a single object that is passed into the command. As an illustrative example:

from magic import magic_command
import click

@magic_command('Colored')
@click.option('--color')
def cmd(magic, color):
    pass

The total command would then have many --magic-... options that go into the magic object passed into cmd. I was able to achieve that using the following:

def magic_command(name):
    def decorator(func):
        @click.option('--magic-foo')
        @click.option('--magic-bar')
        def wrapper(magic_foo, magic_bar, **kwargs):
            print(f'initializing Magic with {magic_foo} and {magic_bar}')
            magic = Magic(magic_foo, magic_bar)
            func(magic, **kwargs)

        try:
            wrapper.__click_params__.extend(func.__click_params__)
        except AttributeError:
            pass

        return click.command(f'{name}-Magic')(wrapper)
    return decorator

However, messing with the __click_params__ doesn't seem particularly clean.

The question is somewhat similar to this one, however this approach does not allow me to condense the many magic options into a magic object.

To elaborate, with this approach I would have to do

@magic_command('Colored')
@click.option('--color')
def cmd(magic_foo, magic_bar, color):
    magic = Magic(magic_foo, magic_bar)
    pass

But that means the custom code needs to be aware what magic options there are and how to construct the magic. I guess that can be simplified using **kwargs but still - ideally I'd like to just have a ready magic object passed to cmd.


Solution

  • You can distill multiple options into a single object quite simply by constructing a decorator like:

    Code:

    def magic_options(func):
        @click.option('--magic-bar')
        @click.option('--magic-foo')
        def distill_magic(magic_foo, magic_bar, **kwargs):
            kwargs['magic'] = Magic(magic_foo, magic_bar)
            func(**kwargs)
    
        return distill_magic
    

    Using the decorator

    You can then apply the decorator to the command function like:

    @click.command('Colored-Magic')
    @click.option('--color')
    @magic_options
    def cli(magic, color):
        ...
    

    It needs to be applied to the bare function. This is because the function returned by click.option has been modified by the click framework and it won't work the way you expected.

    Test Code:

    import click
    
    @click.command('Colored-Magic')
    @click.option('--color')
    @magic_options
    def cli(magic, color):
        click.echo(str(magic))
        click.echo(color)
    
    
    class Magic(object):
        def __init__(self, magic_foo, magic_bar):
            self.magic_foo = magic_foo
            self.magic_bar = magic_bar
    
        def __str__(self):
            return "foo: {}  bar: {}".format(self.magic_foo, self.magic_bar)
    
    
    if __name__ == "__main__":
        commands = (
            '--magic-foo fooby --magic-bar barbecue',
            '--magic-foo fooby',
            '--magic-bar barbecue',
            '',
            '--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)]
    -----------
    > --magic-foo fooby --magic-bar barbecue
    foo: fooby  bar: barbecue
    
    -----------
    > --magic-foo fooby
    foo: fooby  bar: None
    
    -----------
    > --magic-bar barbecue
    foo: None  bar: barbecue
    
    -----------
    > 
    foo: None  bar: None
    
    -----------
    > --help
    Usage: test.py [OPTIONS]
    
    Options:
      --color TEXT
      --magic-bar TEXT
      --magic-foo TEXT
      --help            Show this message and exit.