Tag*_*agc 6 python documentation command-line-interface python-sphinx python-click
click是用于开发CLI应用程序的流行Python库。sphinx是用于记录Python软件包的流行库。一些人面临的一个问题是集成这两个工具,以便它们可以为基于单击的命令生成Sphinx文档。
我最近遇到了这个问题。我用click.command和装饰了一些函数,click.group向它们添加了文档字符串,然后使用Sphinx的autodoc扩展为其生成了HTML文档。我发现它省略了这些命令的所有文档和参数说明,因为它们在Commandautodoc到达时已被转换为对象。
我如何修改代码,以使命令的文档既可以供最终用户在--helpCLI上运行时使用,又可以供浏览Sphinx生成的文档的人员使用?
您现在可以为此使用 sphinx 扩展sphinx-click。它可以为带有选项和参数描述的嵌套命令生成文档。输出将与您运行时类似--help。
pip install sphinx-click
Run Code Online (Sandbox Code Playgroud)
conf.py文件中启用插件:extensions = ['sphinx_click.ext']
Run Code Online (Sandbox Code Playgroud)
.. click:: module:parser
:prog: hello-world
:show-nested:
Run Code Online (Sandbox Code Playgroud)
有一个简单的click应用程序,它在hello_world模块中定义:
import click
@click.group()
def greet():
"""A sample command group."""
pass
@greet.command()
@click.argument('user', envvar='USER')
def hello(user):
"""Greet a user."""
click.echo('Hello %s' % user)
@greet.command()
def world():
"""Greet the world."""
click.echo('Hello world!')
Run Code Online (Sandbox Code Playgroud)
为了记录所有子命令,我们将使用下面的代码和:show-nested:选项
.. click:: hello_world:greet
:prog: hello-world
:show-nested:
Run Code Online (Sandbox Code Playgroud)
在构建文档之前,sys.path通过安装包setuptools或手动包含它来确保您的模块和任何其他依赖项可用。
构建后,我们将得到: 生成的文档
扩展文档中提供了有关各种可用选项的更多详细信息
装饰命令容器
我最近发现并且似乎有效的解决这个问题的一个可能的解决方案是开始定义一个可以应用于类的装饰器。这个想法是,程序员将命令定义为类的私有成员,装饰器根据命令的回调创建该类的公共函数成员。例如,Foo包含命令的类_bar将获得新功能bar(假设Foo.bar尚不存在)。
此操作保持原始命令不变,因此不应破坏现有代码。由于这些命令是私有的,因此应在生成的文档中省略它们。然而,基于它们的功能应该出现在文档中,因为它们是公开的。
def ensure_cli_documentation(cls):
"""
Modify a class that may contain instances of :py:class:`click.BaseCommand`
to ensure that it can be properly documented (e.g. using tools such as Sphinx).
This function will only process commands that have private callbacks i.e. are
prefixed with underscores. It will associate a new function with the class based on
this callback but without the leading underscores. This should mean that generated
documentation ignores the command instances but includes documentation for the functions
based on them.
This function should be invoked on a class when it is imported in order to do its job. This
can be done by applying it as a decorator on the class.
:param cls: the class to operate on
:return: `cls`, after performing relevant modifications
"""
for attr_name, attr_value in dict(cls.__dict__).items():
if isinstance(attr_value, click.BaseCommand) and attr_name.startswith('_'):
cmd = attr_value
try:
# noinspection PyUnresolvedReferences
new_function = copy.deepcopy(cmd.callback)
except AttributeError:
continue
else:
new_function_name = attr_name.lstrip('_')
assert not hasattr(cls, new_function_name)
setattr(cls, new_function_name, new_function)
return cls
Run Code Online (Sandbox Code Playgroud)
避免类中的命令问题
该解决方案假设命令位于类内部的原因是因为这就是我当前正在处理的项目中定义的大多数命令的方式 - 我将大部分命令加载为包含在yapsy.IPlugin.IPlugin. 如果要将命令的回调定义为类实例方法,则可能会遇到这样的问题:self当您尝试运行 CLI 时,click 不会向命令回调提供参数。这可以通过柯里化回调来解决,如下所示:
class Foo:
def _curry_instance_command_callbacks(self, cmd: click.BaseCommand):
if isinstance(cmd, click.Group):
commands = [self._curry_instance_command_callbacks(c) for c in cmd.commands.values()]
cmd.commands = {}
for subcommand in commands:
cmd.add_command(subcommand)
try:
if cmd.callback:
cmd.callback = partial(cmd.callback, self)
if cmd.result_callback:
cmd.result_callback = partial(cmd.result_callback, self)
except AttributeError:
pass
return cmd
Run Code Online (Sandbox Code Playgroud)
例子
把这一切放在一起:
from functools import partial
import click
from click.testing import CliRunner
from doc_inherit import class_doc_inherit
def ensure_cli_documentation(cls):
"""
Modify a class that may contain instances of :py:class:`click.BaseCommand`
to ensure that it can be properly documented (e.g. using tools such as Sphinx).
This function will only process commands that have private callbacks i.e. are
prefixed with underscores. It will associate a new function with the class based on
this callback but without the leading underscores. This should mean that generated
documentation ignores the command instances but includes documentation for the functions
based on them.
This function should be invoked on a class when it is imported in order to do its job. This
can be done by applying it as a decorator on the class.
:param cls: the class to operate on
:return: `cls`, after performing relevant modifications
"""
for attr_name, attr_value in dict(cls.__dict__).items():
if isinstance(attr_value, click.BaseCommand) and attr_name.startswith('_'):
cmd = attr_value
try:
# noinspection PyUnresolvedReferences
new_function = cmd.callback
except AttributeError:
continue
else:
new_function_name = attr_name.lstrip('_')
assert not hasattr(cls, new_function_name)
setattr(cls, new_function_name, new_function)
return cls
@ensure_cli_documentation
@class_doc_inherit
class FooCommands(click.MultiCommand):
"""
Provides Foo commands.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._commands = [self._curry_instance_command_callbacks(self._calc)]
def list_commands(self, ctx):
return [c.name for c in self._commands]
def get_command(self, ctx, cmd_name):
try:
return next(c for c in self._commands if c.name == cmd_name)
except StopIteration:
raise click.UsageError('Undefined command: {}'.format(cmd_name))
@click.group('calc', help='mathematical calculation commands')
def _calc(self):
"""
Perform mathematical calculations.
"""
pass
@_calc.command('add', help='adds two numbers')
@click.argument('x', type=click.INT)
@click.argument('y', type=click.INT)
def _add(self, x, y):
"""
Print the sum of x and y.
:param x: the first operand
:param y: the second operand
"""
print('{} + {} = {}'.format(x, y, x + y))
@_calc.command('subtract', help='subtracts two numbers')
@click.argument('x', type=click.INT)
@click.argument('y', type=click.INT)
def _subtract(self, x, y):
"""
Print the difference of x and y.
:param x: the first operand
:param y: the second operand
"""
print('{} - {} = {}'.format(x, y, x - y))
def _curry_instance_command_callbacks(self, cmd: click.BaseCommand):
if isinstance(cmd, click.Group):
commands = [self._curry_instance_command_callbacks(c) for c in cmd.commands.values()]
cmd.commands = {}
for subcommand in commands:
cmd.add_command(subcommand)
if cmd.callback:
cmd.callback = partial(cmd.callback, self)
return cmd
@click.command(cls=FooCommands)
def cli():
pass
def main():
print('Example: Adding two numbers')
runner = CliRunner()
result = runner.invoke(cli, 'calc add 1 2'.split())
print(result.output)
print('Example: Printing usage')
result = runner.invoke(cli, 'calc add --help'.split())
print(result.output)
if __name__ == '__main__':
main()
Run Code Online (Sandbox Code Playgroud)
运行main(),我得到这个输出:
def ensure_cli_documentation(cls):
"""
Modify a class that may contain instances of :py:class:`click.BaseCommand`
to ensure that it can be properly documented (e.g. using tools such as Sphinx).
This function will only process commands that have private callbacks i.e. are
prefixed with underscores. It will associate a new function with the class based on
this callback but without the leading underscores. This should mean that generated
documentation ignores the command instances but includes documentation for the functions
based on them.
This function should be invoked on a class when it is imported in order to do its job. This
can be done by applying it as a decorator on the class.
:param cls: the class to operate on
:return: `cls`, after performing relevant modifications
"""
for attr_name, attr_value in dict(cls.__dict__).items():
if isinstance(attr_value, click.BaseCommand) and attr_name.startswith('_'):
cmd = attr_value
try:
# noinspection PyUnresolvedReferences
new_function = copy.deepcopy(cmd.callback)
except AttributeError:
continue
else:
new_function_name = attr_name.lstrip('_')
assert not hasattr(cls, new_function_name)
setattr(cls, new_function_name, new_function)
return cls
Run Code Online (Sandbox Code Playgroud)
通过 Sphinx 运行此操作,我可以在浏览器中查看相关文档: