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
.
You can distill multiple options into a single object quite simply by constructing a decorator like:
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
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.
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
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.
Changing some "magic attrs" of a function in a decorator is perfectly normal:
functools.wraps
does just that. So you can:
@wraps
@click.option
"after" @wraps
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
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With