Oskar Persson
Oskar Persson

Reputation: 6755

Using click.MultiCommand with classmethods

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

Answers (3)

Iguananaut
Iguananaut

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

Iguananaut
Iguananaut

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

Stephen Rauch
Stephen Rauch

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:

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

Upvotes: 4

Related Questions