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

12

Click 是一个流行的 Python 库,用于开发 CLI 应用程序。 Sphinx 是一个流行的库,用于记录 Python 包。 有些人面临的问题 是将这两个工具集成起来,以便它们可以为基于 click 的命令生成 Sphinx 文档。

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

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

2个回答

15

现在你可以使用sphinx-click扩展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中可用。

构建完成后,我们将得到此结果:生成的文档

有关可用选项的更详细信息,请参阅扩展程序的文档


3

装饰命令容器

最近我发现了一个解决这个问题的可能方案,而且似乎很有效,那就是开始定义一个可以应用于类的装饰器。这个想法是,程序员会将命令定义为类的私有成员,而装饰器则基于命令的回调函数创建一个类的公共函数成员。例如,包含命令_bar的类Foo将获得一个新函数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

避免类中出现问题的命令

这个解决方案假设命令在类中是因为我目前正在工作的项目中大部分命令都是定义在yapsy.IPlugin.IPlugin子类中的插件。如果您想将回调函数定义为类实例方法,当您尝试运行CLI时,您可能会遇到一个问题,即click不会向您的命令回调提供self参数。这可以通过使用柯里化来解决,如下所示:

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 documentation


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接