将click.MultiCommand与类方法一起使用

时间:2019-03-18 00:17:13

标签: python command-line-interface python-click

如何将click.MultiCommand与定义为类方法的命令一起使用?

我正在尝试为转换器建立一个插件系统,库的用户可以提供自己的转换器。对于此系统,我将设置如下的CLI:

$ myproj convert {converter} INPUT OUTPUT {ARGS}

每个转换器都是其自己的类,并且都从BaseConverter继承。在BaseConverter中,最简单的Click命令仅接受输入和输出。

对于不需要更多功能的转换器,它们不必重写该方法。如果转换器需要的更多,或者需要提供其他文档,则需要对其进行覆盖。

使用以下代码,尝试使用cli时出现以下错误:

  

TypeError:cli()缺少1个必需的位置参数:“ 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)

3 个答案:

答案 0 :(得分:0)

要使用classmethod作为单击命令,您需要在调用命令时填充cls参数。可以使用自定义click.Command类,例如:

自定义类别:

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, ....):
        ....

然后在click.Multicommand类中,您需要填充_cls属性,因为在这种情况下不会调用command.main

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

这是如何工作的?

之所以可行,是因为click是一个设计良好的OO框架。 @click.command()装饰器通常会实例化click.Command对象,但允许使用cls参数来覆盖此行为。因此,在我们自己的类中继承click.Command并超越所需的方法是相对容易的事情。

在这种情况下,我们覆盖click.Command.invoke(),然后在调用命令处理程序之前将包含的类作为ctx.params添加到cls字典中。

测试代码:

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

答案 1 :(得分:0)

@Stephen Rauch的answer对我来说是鼓舞人心的,但也没有做到。虽然我认为这是OP的更完整的答案,但它不能像我想要的那样有效地工作,只要使任何单击命令/组像classmethod一样工作即可。

它也不适用于Click的内置装饰器,例如click.pass_contextclick.pass_obj;但这并不是它的错,尽管该点击实际上并不是设计用于方法上的,它始终始终将上下文作为第一个参数传递,即使该参数应为self/cls。 / p>

我的用例是我已经有了微服务的基类,它提供了启动它们的基本CLI(通常不会被覆盖)。但是各个服务的子类都是基类,因此该类的默认main()方法是classmethod,并实例化给定子类的实例。

我想在保留现有类结构的同时将CLI转换为使用click(使其更具扩展性),但是click并不是专门为与OOP设计而设计的,尽管可以解决此问题。

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)

首先,它引入了“ BoundCommand”的概念,这是对bound方法的概念的扩展。实际上,它只是代理Command实例,但实际上是使用回调上的绑定方法替换了命令的原始.callback属性,该绑定方法取决于binding是什么而绑定到类或实例上

由于click的@pass_context@pass_obj装饰器实际上无法与方法配合使用,因此它还可以替代相同的功能。如果with_context=True原始回调被包装在包装器中,该包装器将上下文作为关键字参数ctx(而不是第一个参数)提供。通过指定context_arg也可以覆盖参数名称。

如果为with_context=<some type>,则包装器的作用与给定类型的click的make_pass_decorator工厂相同。注意:如果设置了with_context=object,则IIUC与@pass_obj等效。

第二部分是装饰器类@classcommand,有点类似于@classmethod。它实现了一个描述符,该描述符只是为包装的Command返回BoundCommands。

这是一个用法示例:

>>> 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

在此示例中,您仍然可以使用简单功能的子命令扩展命令:

>>> @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

一个可能的缺点是子命令不绑定到BoundCommand,而仅绑定到原始的Group对象。因此Foo的任何子类也将共享相同的子命令,并且可以相互覆盖。就我而言,这不是问题,但值得考虑。我相信有一种解决方法是可能的,例如也许为绑定到的每个类创建原始组的副本。

您可以类似地实现一个@instancecommand装饰器,以在实例方法上创建命令。我不是用例,所以留给读者练习^^

答案 2 :(得分:0)

更新:后来我想出了另一个解决方案,该解决方案是对以前解决方案的综合,但我认为要简单一些。我已经将此解决方案打包为一个新的软件包objclick,可以像以下这样直接替代click

import objclick as click

我相信这可以用来解决OP的问题。例如,要从“类方法”发出命令,您将编写:

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)

其中objclick.classcommand提供类方法的功能(不必显式指定classmethod;实际上,目前这会中断)。


旧答案:

我想出了一个不同的解决方案,我认为它比my previous answer简单得多。由于我主要需要click.group()使用它,而不是直接使用click.group(),所以我想到了描述符+装饰器classgroup。它用作click.group()的包装,但是创建了一个新的Group实例,该实例的回调在某种意义上是“绑定”到访问它的类的:

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

此外,我基于click的pass_contextpass_obj添加了以下装饰器,但我认为它稍微灵活一些:

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

它们可以像这样一起使用:

>>> 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>

子命令可以轻松附加到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>

与我先前的答案不同,它的优点是所有子命令都与声明它们的类绑定在一起:

>>> Foo.main.commands                                                                                                                                                                            
{'subcommand': <Command subcommand>}
>>> class Bar(Foo): pass                                                                                                                                                                         
>>> Bar.main.commands                                                                                                                                                                            
{}

作为练习,您还可以轻松实现一个版本,其中子类上的main继承父类的子命令,但我个人不需要。