How can I use click.MultiCommand together with commands defined as classmethods?
I'm trying to setup a plugin system for converters where users of the library can provide their own converters. For this system I'm setting up a CLI like the following:
$ myproj convert {converter} INPUT OUTPUT {ARGS}
Each converter is its own class and all inherit from BaseConverter. In the BaseConverter is the most simple Click command which only takes input and output.
For the converters that don't need more than that, they don't have to override that method. If a converter needs more than that, or needs to provide additional documentation, then it needs to be overridden.
With the code below, I get the following error when trying to use the cli:
TypeError: cli() missing 1 required positional argument: 'cls'
conversion/
├── __init__.py
└── backends/
├── __init__.py
├── base.py
├── bar.py
├── baz.py
└── foo.py
# cli.py
from pydoc import locate
import click
from proj.conversion import AVAILABLE_CONVERTERS
class ConversionCLI(click.MultiCommand):
def list_commands(self, ctx):
return sorted(list(AVAILABLE_CONVERTERS))
def get_command(self, ctx, name):
return locate(AVAILABLE_CONVERTERS[name] + '.cli')
#click.command(cls=ConversionCLI)
def convert():
"""Convert files using specified converter"""
pass
# conversion/__init__.py
from django.conf import settings
AVAILABLE_CONVERTERS = {
'bar': 'conversion.backends.bar.BarConverter',
'baz': 'conversion.backends.baz.BazConverter',
'foo': 'conversion.backends.foo.FooConverter',
}
extra_converters = getattr(settings, 'CONVERTERS', {})
AVAILABLE_CONVERTERS.update(extra_converters)
# conversion/backends/base.py
import click
class BaseConverter():
#classmethod
def convert(cls, infile, outfile):
raise NotImplementedError
#classmethod
#click.command()
#click.argument('infile')
#click.argument('outfile')
def cli(cls, infile, outfile):
return cls.convert(infile, outfile)
# conversion/backends/bar.py
from proj.conversion.base import BaseConverter
class BarConverter(BaseConverter):
#classmethod
def convert(cls, infile, outfile):
# do stuff
# conversion/backends/foo.py
import click
from proj.conversion.base import BaseConverter
class FooConverter(BaseConverter):
#classmethod
def convert(cls, infile, outfile, extra_arg):
# do stuff
#classmethod
#click.command()
#click.argument('infile')
#click.argument('outfile')
#click.argument('extra-arg')
def cli(cls, infile, outfile, extra_arg):
return cls.convert(infile, outfile, extra_arg)
To use a classmethod as a click command, you need to be able to populate the cls parameter when invoking the command. That can be done with a custom click.Command class like:
Custom Class:
import click
class ClsMethodClickCommand(click.Command):
def __init__(self, *args, **kwargs):
self._cls = [None]
super(ClsMethodClickCommand, self).__init__(*args, **kwargs)
def main(self, *args, **kwargs):
self._cls[0] = args[0]
return super(ClsMethodClickCommand, self).main(*args[1:], **kwargs)
def invoke(self, ctx):
ctx.params['cls'] = self._cls[0]
return super(ClsMethodClickCommand, self).invoke(ctx)
Using the Custom Class:
class MyClassWithAClickCommand:
#classmethod
#click.command(cls=ClsMethodClickCommand)
....
def cli(cls, ....):
....
And then in the click.Multicommand class you need to populate the _cls attribute since the command.main is not called in this case:
def get_command(self, ctx, name):
# this is hard coded in this example but presumably
# would be done with a lookup via name
cmd = MyClassWithAClickCommand.cli
# Tell the click command which class it is associated with
cmd._cls[0] = MyClassWithAClickCommand
return cmd
How does this work?
This works because click is a well designed OO framework. The #click.command() decorator usually instantiates a click.Command object but allows this behavior to be over ridden with the cls parameter. So it is a relatively easy matter to inherit from click.Command in our own class and over ride desired methods.
In this case, we override click.Command.invoke() and then add the containing class to the ctx.params dict as cls before invoking the command handler.
Test Code:
class MyClassWithAClickCommand:
#classmethod
#click.command(cls=ClsMethodClickCommand)
#click.argument('arg')
def cli(cls, arg):
click.echo('cls: {}'.format(cls.__name__))
click.echo('cli: {}'.format(arg))
class ConversionCLI(click.MultiCommand):
def list_commands(self, ctx):
return ['converter_x']
def get_command(self, ctx, name):
cmd = MyClassWithAClickCommand.cli
cmd._cls[0] = MyClassWithAClickCommand
return cmd
#click.command(cls=ConversionCLI)
def convert():
"""Convert files using specified converter"""
if __name__ == "__main__":
commands = (
'converter_x an_arg',
'converter_x --help',
'converter_x',
'--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)
convert(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)]
-----------
> converter_x an_arg
class: MyClassWithAClickCommand
cli: an_arg
-----------
> converter_x --help
Usage: test.py converter_x [OPTIONS] ARG
Options:
--help Show this message and exit.
-----------
> converter_x
Usage: test.py converter_x [OPTIONS] ARG
Error: Missing argument "arg".
-----------
> --help
Usage: test.py [OPTIONS] COMMAND [ARGS]...
Convert files using specified converter
Options:
--help Show this message and exit.
Commands:
converter_x
-----------
>
Usage: test.py [OPTIONS] COMMAND [ARGS]...
Convert files using specified converter
Options:
--help Show this message and exit.
Commands:
converter_x
#Stephen Rauch's answer was inspirational to me, but didn't quite do it either. While I think it's a more complete answer for the OP, it doesn't quite work the way I wanted insofar as making any arbitrary click command/group work like a classmethod.
It also doesn't work with click's built-in decorators like click.pass_context and click.pass_obj; that's not so much its fault though as that click is really not designed to work on methods--it always passes the context as the first argument, even if that argument should be self/cls.
My use case was I already have a base class for microservices that provides a base CLI for starting them (which generally isn't overridden). But the individual services subclass the base class, so the default main() method on the class is a classmethod, and instantiates an instance of the given subclass.
I wanted to convert the CLI to using click (to make it more extensible) while keeping the existing class structure, but click is really not particularly designed to work with OOP, though this can be worked around.
import click
import types
from functools import update_wrapper, partial
class BoundCommandMixin:
def __init__(self, binding, wrapped, with_context=False, context_arg='ctx'):
self.__self__ = binding
self.__wrapped__ = wrapped
callback = types.MethodType(wrapped.callback, binding)
if with_context:
def context_wrapper(*args, **kwargs):
ctx = obj = click.get_current_context()
if isinstance(with_context, type):
obj = ctx.find_object(with_context)
kwargs[context_arg] = obj
return ctx.invoke(callback, *args, **kwargs)
self.callback = update_wrapper(context_wrapper, callback)
else:
self.callback = callback
def __repr__(self):
wrapped = self.__wrapped__
return f'<bound {wrapped.__class__.__name__} {wrapped.name} of {self.__self__!r}>'
def __getattr__(self, attr):
return getattr(self.__wrapped__, attr)
class classcommand:
_bound_cls_cache = {}
def __new__(cls, command=None, **kwargs):
if command is None:
# Return partially-applied classcommand for use as a decorator
return partial(cls, **kwargs)
else:
# Being used directly as a decorator without arguments
return super().__new__(cls)
def __init__(self, command, with_context=False, context_arg='ctx'):
self.command = command
self.with_context = with_context
self.context_arg = context_arg
def __get__(self, obj, cls=None):
if cls is None:
cls = type(obj)
cmd_type = type(self.command)
bound_cls = self._bound_cls_cache.setdefault(cmd_type,
type('Bound' + cmd_type.__name__, (BoundCommandMixin, cmd_type), {}))
return bound_cls(cls, self.command, self.with_context, self.context_arg)
First it introduces a notion of a "BoundCommand", which is sort of an extension of the notion of a bound method. In fact it just proxies a Command instance, but in fact replaces the command's original .callback attribute with a bound method on the callback, bound to either a class or instance depending on what binding is.
Since click's #pass_context and #pass_obj decorators don't really work with methods, it also provides replacement for the same functionality. If with_context=True the original callback is wrapped in a wrapper that provides the context as a keyword argument ctx (instead of as the first argument). The name of the argument can also be overridden by specifying context_arg.
If with_context=<some type>, the wrapper works the same as click's make_pass_decorator factory for the given type. Note: IIUC if you set with_context=object this is equivalent to #pass_obj.
The second part of this is the decorator class #classcommand, somewhat analogous to #classmethod. It implements a descriptor which simply returns BoundCommands for the wrapped Command.
Here's an example usage:
>>> class Foo:
... #classcommand(with_context=True)
... #click.group(no_args_is_help=False, invoke_without_command=True)
... #click.option('--bar')
... def main(cls, ctx, bar):
... print(cls)
... print(ctx)
... print(bar)
...
>>> Foo.__dict__['main']
<__main__.classcommand object at 0x7f1b471df748>
>>> Foo.main
<bound Group main of <class '__main__.Foo'>>
>>> try:
... Foo.main(['--bar', 'qux'])
... except SystemExit:
... pass
...
<class '__main__.Foo'>
<click.core.Context object at 0x7f1b47229630>
qux
In this example you can still extend the command with sub-commands as simple functions:
>>> #Foo.main.command()
... #click.option('--fred')
... def subcommand(fred):
... print(fred)
...
>>> try:
... Foo.main(['--bar', 'qux', 'subcommand', '--fred', 'flintstone'])
... except SystemExit:
... pass
...
...
<class '__main__.Foo'>
<click.core.Context object at 0x7f1b4715bb38>
qux
flintstone
One possible shortcoming to this is that the sub-commands are not tied to the BoundCommand, but just to the original Group object. So any subclasses of Foo will share the same subcommands as well, and could override each other. For my case this is not a problem, but it's worth considering. I believe a workaround would be possible, e.g. perhaps creating a copy of the original Group for each class it's bound to.
You could similarly implement an #instancecommand decorator for creating commands on instance methods. That's not a use case I have though so it's left as an exercise to the reader ^^
Update: I later came up with yet another solution to this problem, which is sort of a synthesis of my previous solutions, but I think a little bit simpler. I have packaged this solution as a new package objclick which can be used as a drop-in replacement for click like:
import objclick as click
I believe this can be used to solve the OP's problem. For example, to make a command from a "classmethod" you would write:
class BaseConverter():
#classmethod
def convert(cls, infile, outfile):
raise NotImplementedError
#click.classcommand()
#click.argument('infile')
#click.argument('outfile')
def cli(cls, infile, outfile):
return cls.convert(infile, outfile)
where objclick.classcommand provides classmethod-like functionality (it is not necessary to specify classmethod explicitly; in fact currently this will break).
Old answer:
I came up with a different solution to this that I think is much simpler than my previous answer. Since I primarily needed this for click.group(), rather than use click.group() directly I came up with the descriptor+decorator classgroup. It works as a wrapper to click.group(), but creates a new Group instance whose callback is in a sense "bound" to the class on which it was accessed:
import click
from functools import partial, update_wrapper
class classgroup:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
self.callback = None
self.recursion_depth = 0
def __call__(self, callback):
self.callback = callback
return self
def __get__(self, obj, owner=None):
# The recursion_depth stuff is to work around an oddity where
# click.group() uses inspect.getdoc on the callback to get the
# help text for the command if none was provided via help=
# However, inspect.getdoc winds up calling the equivalent
# of getattr(owner, callback.__name__), causing a recursion
# back into this descriptior; in this case we just return the
# wrapped callback itself
self.recursion_depth += 1
if self.recursion_depth > 1:
self.recursion_depth -= 1
return self.callback
if self.callback is None:
return self
if owner is None:
owner = type(obj)
key = '_' + self.callback.__name__
# The Group instance is cached in the class dict
group = owner.__dict__.get(key)
if group is None:
def callback(*args, **kwargs):
return self.callback(owner, *args, **kwargs)
update_wrapper(callback, self.callback)
group = click.group(*self.args, **self.kwargs)(callback)
setattr(owner, key, group)
self.recursion_depth -= 1
return group
Additionally, I added the following decorator based on click's pass_context and pass_obj, but that I think is a little more flexible:
def with_context(func=None, obj_type=None, context_arg='ctx'):
if func is None:
return partial(with_context, obj_type=obj_type, context_arg=context_arg)
def context_wrapper(*args, **kwargs):
ctx = obj = click.get_current_context()
if isinstance(obj_type, type):
obj = ctx.find_object(obj_type)
kwargs[context_arg] = obj
return ctx.invoke(func, *args, **kwargs)
update_wrapper(context_wrapper, func)
return context_wrapper
They can be used together like this:
>>> class Foo:
... #classgroup(no_args_is_help=False, invoke_without_command=True)
... #with_context
... def main(cls, ctx):
... print(cls)
... print(ctx)
... ctx.obj = cls()
... print(ctx.obj)
...
>>> try:
... Foo.main()
... except SystemExit:
... pass
...
<class '__main__.Foo'>
<click.core.Context object at 0x7f8cf4056b00>
<__main__.Foo object at 0x7f8cf4056128>
Subcommands can easily be attached to Foo.main:
>>> #Foo.main.command()
... #with_context(obj_type=Foo, context_arg='foo')
... def subcommand(foo):
... print('subcommand', foo)
...
>>> try:
... Foo.main(['subcommand'])
... except SystemExit:
... pass
...
<class '__main__.Foo'>
<click.core.Context object at 0x7f8ce7a45160>
<__main__.Foo object at 0x7f8ce7a45128>
subcommand <__main__.Foo object at 0x7f8ce7a45128>
Unlike my previous answer, this has the advantage that all subcommands are tied to the class through which they were declared:
>>> Foo.main.commands
{'subcommand': <Command subcommand>}
>>> class Bar(Foo): pass
>>> Bar.main.commands
{}
As an exercise, you could also easily implement a version in which the main on subclasses inherit sub-commands from parent classes, but I don't personally need that.
Using the CLI library click I have an application script app.py with the two sub commands read and write:
#click.group()
#click.pass_context
def cli(ctx):
pass
#cli.command()
#click.pass_context
def read(ctx):
print("read")
#cli.command()
#click.pass_context
def write(ctx):
print("write")
I want to declare a common option --format. I know I can add it as an option to the command group via
#click.group()
#click.option('--format', default='json')
#click.pass_context
def cli(ctx, format):
ctx.obj['format'] = format
But then I cannot give the option after the command, which in my use case is a lot more natural. I want to be able to issue in the shell:
app.py read --format XXX
But with the outlined set-up I get the message Error: no such option: --format. The script only accepts the option before the command.
So my question is: How can I add a common option to both sub commands so that it works as if the option were given to each sub command?
AFAICT, this is not possible with Click. The docs state that:
Click strictly separates parameters between commands and subcommands.
What this means is that options and arguments for a specific command
have to be specified after the command name itself, but before any
other command names.
A possible workaround is writing a common_options decorator. The following example is using the fact that click.option is a function that returns a decorator function which expects to be applied in series. IOW, the following:
#click.option("-a")
#click.option("-b")
def hello(a, b):
pass
is equivalent to the following:
def hello(a, b):
pass
hello = click.option("-a")(click.option("-b")(hello))
The drawback is that you need to have the common argument set on all your subcommands. This can be resolved through **kwargs, which collects keyword arguments as a dict.
(Alternately, you could write a more advanced decorator that would feed the arguments into the context or something like that, but my simple attempt didn't work and i'm not ready to try more advanced approaches. I might edit the answer later and add them.)
With that, we can make a program:
import click
import functools
#click.group()
def cli():
pass
def common_options(f):
options = [
click.option("-a", is_flag=True),
click.option("-b", is_flag=True),
]
return functools.reduce(lambda x, opt: opt(x), options, f)
#cli.command()
#common_options
def hello(**kwargs):
print(kwargs)
# to get the value of b:
print(kwargs["b"])
#cli.command()
#common_options
#click.option("-c", "--citrus")
def world(citrus, a, **kwargs):
print("citrus is", citrus)
if a:
print(kwargs)
else:
print("a was not passed")
if __name__ == "__main__":
cli()
Yes you can add common options to sub commands which can go after the name of the sub command.
You can have options on parent command as well as on sub commands.
Check out below code snippet
import click
from functools import wraps
#click.group()
def cli():
pass
def common_options(f):
#wraps(f)
#click.option('--option1', '-op1', help='Option 1 help text', type=click.FLOAT)
#click.option('--option2', '-op2', help='Option 2 help text', type=click.FLOAT)
def wrapper(*args, **kwargs):
return f(*args, **kwargs)
return wrapper
#cli.group(invoke_without_command=True)
#common_options
#click.pass_context
def parent(ctx, option1, option2):
ctx.ensure_object(dict)
if ctx.invoked_subcommand is None:
click.secho('Parent group is invoked. Perform specific tasks to do!', fg='bright_green')
#parent.command()
#click.option('--sub_option1', '-sop1', help='Sub option 1 help text', type=click.FLOAT)
#common_options
def sub_command1(option1, option2, sub_option1):
click.secho('Perform sub command 1 operations', fg='bright_green')
#parent.command()
#click.option('--sub_option2', '-sop2', help='Sub option 2 help text', type=click.FLOAT)
#common_options
def sub_command2(option1, option2, sub_option2):
click.secho('Perform sub command 2 operations', fg='bright_green')
if __name__ == "__main__":
cli()
Usage
parent --help
=> prints parent group help text with options and sub commands
parent --option1 10 --option2 12
=> Parent group is invoked. Perform specific tasks to do!
parent sub_command1 --help
=> prints sub command 1 help text with options on sub commands
parent sub_command1 --option1 15 --option2 7 --sub_option1 5
=> Perform sub command 1 operations
parent sub_command2 --option1 15 --option2 7 --sub_option2 4
=> Perform sub command 2 operations
Building on my initial question, I'd like to be able to have the body of a parent group run before I run my callback.
I have a case where I'd like to automatically run a common function, check_upgrade(), for most of my click commands and sub-commands, but there are a few cases where I don't want to run it. I was thinking I could have a decorator that one can add (e.g. #bypass_upgrade_check) for commands where check_upgrade() should not run.
For example:
def do_upgrade():
print("Performing upgrade")
bypass_upgrade_check = make_exclude_hook_group(do_upgrade)
#click.group(cls=bypass_upgrade_check())
#click.option('--arg1', default=DFLT_ARG1)
#click.option('--arg2', default=DFLT_ARG2)
#click.pass_context
def cli(ctx, arg1, arg2):
config.call_me_before_upgrade_check(arg1, arg2)
#bypass_upgrade_check
#cli.command()
def top_cmd1():
click.echo('cmd1')
#cli.command()
def top_cmd2():
click.echo('cmd2')
#cli.group()
def sub_cmd_group():
click.echo('sub_cmd_group')
#bypass_upgrade_check
#sub_cmd_group.command()
def sub_cmd1():
click.echo('sub_cmd1')
#sub_cmd_group.command()
def sub_cmd2():
click.echo('sub_cmd2')
I'd like things to function like explained in the initial question, but instead of executing do_upgrade() before executing the body of cli(), I'd like it to call:
cli() --> do_upgrade() --> top_cmd1()
for example. Or for a nested command:
cli() --> sub_cmd_group() --> do_upgrade() --> sub_cmd1()
So I guess another way to phrase the question is: is it possible to have the functionality from the original question, but have the callback be called right before the subcommand itself runs instead of being called before any of the Group blocks run?
The reason I need this is because the arguments passed in to the top-level CLI command indicate the server address to check for an upgrade. I need this information to process do_upgrade(). I can't pass this information directly to do_upgrade() because this server information is also used elsewhere in the application. I can query it from do_upgrade() with something like config.get_server().
In a similar fashion to the original question, one way to solve this is to build a custom decorator that pairs with a custom click.Group class. The added complication is to hook the Command.invoke() instead of the Group.invoke() so that the callback will be invoked immediately preceding the Command.invoke() and thus will be invoked after any Group.invoke():
Custom Decorator Builder:
import click
def make_exclude_hook_command(callback):
""" for any command that is not decorated, call the callback """
hook_attr_name = 'hook_' + callback.__name__
class HookGroup(click.Group):
""" group to hook context invoke to see if the callback is needed"""
def group(self, *args, **kwargs):
""" new group decorator to make sure sub groups are also hooked """
if 'cls' not in kwargs:
kwargs['cls'] = type(self)
return super(HookGroup, self).group(*args, **kwargs)
def command(self, *args, **kwargs):
""" new command decorator to monkey patch command invoke """
cmd = super(HookGroup, self).command(*args, **kwargs)
def hook_command_decorate(f):
# decorate the command
ret = cmd(f)
# grab the original command invoke
orig_invoke = ret.invoke
def invoke(ctx):
"""call the call back right before command invoke"""
parent = ctx.parent
sub_cmd = parent and parent.command.commands[
parent.invoked_subcommand]
if not sub_cmd or \
not isinstance(sub_cmd, click.Group) and \
getattr(sub_cmd, hook_attr_name, True):
# invoke the callback
callback()
return orig_invoke(ctx)
# hook our command invoke to command and return cmd
ret.invoke = invoke
return ret
# return hooked command decorator
return hook_command_decorate
def decorator(func=None):
if func is None:
# if called other than as decorator, return group class
return HookGroup
setattr(func, hook_attr_name, False)
return decorator
Using the decorator builder:
To use the decorator we first need to build the decorator like:
bypass_upgrade = make_exclude_hook_command(do_upgrade)
Then we need to use it as a custom class to click.group() like:
#click.group(cls=bypass_upgrade())
...
And finally, we can decorate any commands or sub-commands to the group that need to not use the callback like:
#bypass_upgrade
#my_group.command()
def my_click_command_without_upgrade():
...
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 the desired methods.
In this case, we build a decorator that sets an attribute on any click function that does not need the callback called. Then in our custom group, we overide both the group() and the command() decorators so that we can we monkey patch invoke() on the command and if the command that is about to be executed has not been decorated, we call the callback.
Test Code:
def do_upgrade():
click.echo("Performing upgrade")
bypass_upgrade = make_exclude_hook_command(do_upgrade)
#click.group(cls=bypass_upgrade())
#click.pass_context
def cli(ctx):
click.echo('cli')
#bypass_upgrade
#cli.command()
def top_cmd1():
click.echo('cmd1')
#cli.command()
def top_cmd2():
click.echo('cmd2')
#cli.group()
def sub_cmd_group():
click.echo('sub_cmd_group')
#bypass_upgrade
#sub_cmd_group.command()
def sub_cmd1():
click.echo('sub_cmd1')
#sub_cmd_group.command()
def sub_cmd2():
click.echo('sub_cmd2')
if __name__ == "__main__":
commands = (
'top_cmd1',
'top_cmd2',
'sub_cmd_group sub_cmd1',
'sub_cmd_group sub_cmd2',
'--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)]
-----------
> sub_cmd_group sub_cmd2
cli
sub_cmd_group
Performing upgrade
sub_cmd2
-----------
> top_cmd1
cli
cmd1
-----------
> top_cmd2
cli
Performing upgrade
cmd2
-----------
> sub_cmd_group sub_cmd1
cli
sub_cmd_group
sub_cmd1
-----------
> sub_cmd_group sub_cmd2
cli
sub_cmd_group
Performing upgrade
sub_cmd2
-----------
> --help
Usage: test.py [OPTIONS] COMMAND [ARGS]...
Options:
--arg1 TEXT
--arg2 TEXT
--help Show this message and exit.
Commands:
sub_cmd_group
top_cmd1
top_cmd2
Is it possible to do something like this with Python Click? I want to use different names for the same click.Group.
import click
class CustomMultiGroup(click.Group):
def group(self, *args, **kwargs):
"""Behaves the same as `click.Group.group()` except if passed
a list of names, all after the first will be aliases for the first.
"""
def decorator(f):
if isinstance(args[0], list):
_args = [args[0][0]] + list(args[1:])
for alias in args[0][1:]:
cmd = super(CustomMultiCommand, self).group(
alias, *args[1:], **kwargs)(f)
cmd.short_help = "Alias for '{}'".format(_args[0])
else:
_args = args
cmd = super(CustomMultiCommand, self).group(
*_args, **kwargs)(f)
return cmd
return decorator
#click.group(cls=CustomMultiGroup)
def mycli():
pass
#cli.group(['my-group', 'my-grp'])
def my_group():
pass
#my_group.command()
def my_command():
pass
I want my command lines to be something like:
mycli my-group my-command
and
mycli my-grp my-command
but reference the same function.
This post is a reference to Python Click multiple command names
click.Group and click.Command behave differently so you need to modify the example to allow the aliasing group to access the aliased group's commands:
Custom Class
This class overides the click.Group.group() method which is used to decorate group functions. It adds the ability to pass a list of group aliases. This class also adds a short help which references the aliased group.
import click
class CustomMultiGroup(click.Group):
def group(self, *args, **kwargs):
"""Behaves the same as `click.Group.group()` except if passed
a list of names, all after the first will be aliases for the first.
"""
def decorator(f):
aliased_group = []
if isinstance(args[0], list):
# we have a list so create group aliases
_args = [args[0][0]] + list(args[1:])
for alias in args[0][1:]:
grp = super(CustomMultiGroup, self).group(
alias, *args[1:], **kwargs)(f)
grp.short_help = "Alias for '{}'".format(_args[0])
aliased_group.append(grp)
else:
_args = args
# create the main group
grp = super(CustomMultiGroup, self).group(*_args, **kwargs)(f)
# for all of the aliased groups, share the main group commands
for aliased in aliased_group:
aliased.commands = grp.commands
return grp
return decorator
Test Code:
#click.group(cls=CustomMultiGroup)
def cli():
pass
#cli.group(['my-group', 'my-grp'])
def my_group():
""" My Sub Command """
pass
#my_group.command('my-command')
def my_command():
click.echo("My Command")
cli('--help'.split())
cli('my-grp --help'.split())
cli('my-group --help'.split())
cli('my-grp my-command'.split())
cli('my-group my-command'.split())
Test Results:
Usage: my_cli [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
my-group My Sub Command
my-grp Alias for 'my-group'
Process finished with exit code 0
The easiest way to have command aliases in Click is using one of the following packages:
click-aliases
Cloup, which also adds several other features other than aliases.
Disclaimer: I'm the author of Cloup.
The two implementations are slightly different.
Cloup stores aliases of a command in the command itself. This allows Cloup to document aliases of a command (also) in the --help of the command itself.
click-aliases always shows aliases of (sub)commands in the "Commands" section of a group. In Cloup, you can enable/disable this behavior according to your preference (by default, aliases are not shown).
import click
#click.group()
#click.option('--username')
def cli1(username):
click.echo(username)
#cli1.command()
def something():
click.echo('howdy')
#click.group()
def cli2():
pass
#cli2.command()
def somethingelse():
click.echo('doody')
cli = click.CommandCollection(sources=[cli1, cli2])
if __name__ == '__main__':
cli()
I would expect that this would allow me to pass --username to something, but when I run this script:
python script.py something --username hi
I get:
Error: no such option: --username
It seems like using the CommandCollection is breaking my options. Has anyone else dealt with this before? There is an open ticket in the click repo for this that hasn't been touched since 2015 and has no solution.
With a bit of new plumbing this can be done.
How??
You can inherit from click.Group and then pass the created class to click.group() like:
#click.group(cls=GroupWithCommandOptions)
In the new class, the options on the group can be applied to the command for parsing, and then during command invocation, the group function can be called with the appropriate options.
New Group Class:
import click
class GroupWithCommandOptions(click.Group):
""" Allow application of options to group with multi command """
def add_command(self, cmd, name=None):
""" Hook the added command and put the group options on the command """
click.Group.add_command(self, cmd, name=name)
# add the group parameters to the command
for param in self.params:
cmd.params.append(param)
# hook the command's invoke with our own
cmd.invoke = self.build_command_invoke(cmd.invoke)
self.invoke_without_command = True
def build_command_invoke(self, original_invoke):
def command_invoke(ctx):
""" insert invocation of group function """
# separate the group parameters
ctx.obj = dict(_params=dict())
for param in self.params:
name = param.name
ctx.obj['_params'][name] = ctx.params[name]
del ctx.params[name]
# call the group function with its parameters
params = ctx.params
ctx.params = ctx.obj['_params']
self.invoke(ctx)
ctx.params = params
# now call (invoke) the original command
original_invoke(ctx)
return command_invoke
Test Code:
# Pass new group class to our group which needs options
#click.group(cls=GroupWithCommandOptions)
#click.option('--username')
def cli1(username):
click.echo(username)
#cli1.command()
def something():
click.echo('howdy')
#click.group()
def cli2():
pass
#cli2.command()
def somethingelse():
click.echo('doody')
cli = click.CommandCollection(sources=[cli1, cli2])
if __name__ == '__main__':
cli('something --username hi'.split())
Results:
hi
howdy