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

3

9

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.

Joaquinajoash answered 17/5, 2019 at 11:53 Comment(0)
F
8

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.
Flavor answered 26/5, 2019 at 21:51 Comment(0)
D
2

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
Dutybound answered 25/5, 2019 at 6:4 Comment(0)
U
1

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
Upholstery answered 25/5, 2019 at 8:15 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.