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

时间:2016-09-08 13:50:17

标签: python documentation command-line-interface python-sphinx python-click

click是一个流行的Python库,用于开发CLI应用程序。 sphinx是一个用于记录Python包的流行库。 One problem that some have faced正在整合这两个工具,以便他们可以为基于点击的命令生成Sphinx文档。

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

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

2 个答案:

答案 0 :(得分: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子类中的插件加载。如果要将命令的回调定义为类实例方法,则可能会遇到一个问题,即当您尝试运行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

答案 1 :(得分:2)

您现在可以为此使用狮身人面像扩展名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:

在构建文档之前,请确保通过sys.path安装软件包或手动添加软件包来确保setuptools中模块和任何其他依赖项都可用。

构建后,我们将获得以下信息: generated docs

扩展名documentation中提供了有关可用选项的更多详细信息