7

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

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

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

4

2 回答 2

10

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

用法

  1. 安装扩展
pip install sphinx-click
  1. conf.py在 Sphinx文件中启用插件:
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:

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

构建后我们会得到这个: 生成的文档

扩展的文档中提供了有关各种可用选项的更多详细信息

于 2019-03-01T14:40:18.697 回答
2

装饰命令容器

我最近发现并且似乎可行的这个问题的一个可能的解决方案是开始定义一个可以应用于类的装饰器。这个想法是程序员将命令定义为类的私有成员,并且装饰器创建基于命令回调的类的公共函数成员。例如,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

避免类中的命令问题

这个解决方案假设命令在类内部的原因是因为这就是我的大多数命令在我当前正在处理的项目中定义的方式 - 我将我的大部分命令加载为包含在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

例子

把这一切放在一起:

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 运行它,我可以在浏览器中查看它的文档:

狮身人面像文档

于 2016-09-08T13:50:17.017 回答