Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 317
Zulan Avatar asked May 17 '19 11:05

Zulan


3 Answers

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.
like image 145
Stephen Rauch Avatar answered Oct 24 '22 18:10

Stephen Rauch


Changing some "magic attrs" of a function in a decorator is perfectly normal: functools.wraps does just that. So you can:

  1. Use @wraps
  2. Use @click.option "after" @wraps
  3. define magic options as a list, and parse args/kwargs using values in that list.
from functools import wraps, WRAPPER_ASSIGNMENTS

DEFAULT_MAGIC_OPTIONS = ('--magic-foo', '--magic-bar')


def magic_command(name, magic_options=DEFAULT_MAGIC_OPTIONS):
    magic_options = magic_options or []
    magic_kwarg_names = [opt.split('--', 1)[1].replace('-', '_') for opt in magic_options]

    def decorator(func):
        @wraps(func, assigned=WRAPPER_ASSIGNMENTS+('__click_params__', ))
        def wrapper(*args, **kwargs):
            num_used_magic_args = min(len(magic_kwarg_names), len(args))

            magic_args = args[:num_used_magic_args]

            # If you want magic options to be "args only", then:
            # * you can raise TypeError if num_used_magic_args != len(magic_kwarg_names)
            # * you should not calculate `magic_kwargs`
            magic_kwargs = {}
            for kwarg_name in magic_kwarg_names[num_used_magic_args:]:
                if kwarg_name in kwargs:
                    magic_kwargs[kwarg_name] = kwargs.pop(kwarg_name)

            print(f'Initializing Magic with args={magic_args}, kwargs={magic_kwargs}')
            magic = Magic(*magic_args, **magic_kwargs)
            return func(magic, *args[num_used_magic_args:], **kwargs)

        for magic_option in magic_options[::-1]:  # Reverse order, to have proper positional arguments
            wrapper = click.option(magic_option)(wrapper)

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

    return decorator

Usage:

@magic_command('Colored')
@click.option('--color')  # Note: wrapper will be properly updated 
# with this @click.option, but related argument will not be passed
# into `Magic(...)` initialization.
# If you want `color` to be passed into `Magic`: specify it as one 
# of the items in `magic_options` argument of magic_command decorator:
# `@magic_command('Colored', magic_options=DEFAULT_MAGIC_OPTIONS+('color', ))`
# AND remove it from function definition here (keep only `magic`)
def cmd(magic, color):
    assert isinstance(magic, Magic)
    pass
like image 35
imposeren Avatar answered Oct 24 '22 20:10

imposeren


I don't know if it's possible to do what you want without using click internals but, there must be a way, right?

Anyway, here is a solution that makes use of another decorator. This one is put just above the function and its function is to group the magic_* parameters.

def magic_command(f):
    f = click.option('--magic-bar')(f)
    f = click.option('--magic-foo')(f)
    f = click.command()(f)
    return f

def group_magic_args(f):
    def new_f(magic_foo, magic_bar, *args, **kwargs):
        magic = Magic(magic_foo, magic_bar)
        f(magic=magic, *args, **kwargs)
    return new_f

You would use the new decorator like this:

@magic_command
@click.option('--color')
@group_magic_args
def cmd(magic, color):
    pass
like image 41
araraonline Avatar answered Oct 24 '22 18:10

araraonline