如何将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)
答案 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_context
和click.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_context
和pass_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
继承父类的子命令,但我个人不需要。