Reputation: 6755
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)
Upvotes: 1
Views: 2875
Reputation: 23316
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.
Upvotes: 0
Reputation: 23316
@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 ^^
Upvotes: 0
Reputation: 49814
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:
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)
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
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.
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
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
Upvotes: 4