如何记录使用 Sphinx 的点击命令?

How can I document click commands using Sphinx?

Click is a popular Python library for developing CLI applications with. Sphinx is a popular library for documenting Python packages with. One problem that some have faced 正在集成这两个工具,以便他们可以为其基于点击的命令生成 Sphinx 文档。

我最近 运行 遇到了这个问题。我用 click.commandclick.group 修饰了一些函数,向它们添加了文档字符串,然后使用 Sphinx 的 autodoc 扩展为它们生成了 HTML 文档。我发现它省略了这些命令的所有文档和参数描述,因为在 autodoc 到达它们时它们已经转换为 Command 对象。

如何修改我的代码,使我的命令的文档在 CLI 上 运行 --help 时可供最终用户使用,也可供浏览 Sphinx 生成的文档的人使用?

装饰命令容器

我最近发现并且似乎可行的解决此问题的一个可能方法是开始定义可应用于 classes 的装饰器。这个想法是,程序员将命令定义为 class 的私有成员,装饰器根据命令的回调创建 class 的 public 函数成员。例如,包含命令 _bar 的 class Foo 将获得新函数 bar(假设 Foo.bar 不存在)。

此操作保留原始命令,因此不会破坏现有代码。因为这些命令是私有的,所以在生成的文档中应该省略它们。然而,基于它们的函数应该出现在文档中,因为它们是 public.

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

避免 classes

中的命令出现问题

此解决方案假定命令在 classes 内的原因是因为这就是我当前正在处理的项目中定义我的大部分命令的方式 - 我将我的大部分命令加载为包含在其中的插件yapsy.IPlugin.IPlugin 的子class。如果您想将命令的回调定义为 class 实例方法,您可能会 运行 遇到一个问题,即当您尝试 运行 你的 CLI。这可以通过柯里化回调来解决,如下所示:

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

例子

综合起来:

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

运行 main(),我得到这个输出:

Example: Adding two numbers
1 + 2 = 3

Example: Printing usage
Usage: cli calc add [OPTIONS] X Y

  adds two numbers

Options:
  --help  Show this message and exit.


Process finished with exit code 0

运行 通过 Sphinx,我可以在浏览器中查看相关文档:

您现在可以为此使用 sphinx 扩展 sphinx-click。它可以为带有选项和参数描述的嵌套命令生成文档。输出就像你 运行 --help.

用法

  1. 安装扩展程序
pip install sphinx-click
  1. 在您的 Sphinx conf.py 文件中启用插件:
extensions = ['sphinx_click.ext']
  1. 在文档中必要时使用插件
.. click:: module:parser
   :prog: hello-world
   :show-nested:

例子

有一个简单的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!')

为了记录所有子命令,我们将使用下面的代码和 :show-nested: 选项

.. click:: hello_world:greet
  :prog: hello-world
  :show-nested:

在构建文档之前,通过使用 setuptools 安装包或手动包含它,确保您的模块和任何其他依赖项在 sys.path 中可用。

构建后我们将得到: generated docs

有关各种可用选项的更多详细信息,请参见扩展

documentation