Как я могу документировать команды кликов с помощью Sphinx?

Click — это популярная библиотека Python для разработки приложений CLI. Sphinx — популярная библиотека для документирования пакетов Python. Одна из проблем, с которой столкнулись некоторые, — это интеграция этих двух инструментов, чтобы они могли генерировать документацию Sphinx. для их команд, основанных на кликах.

Недавно я столкнулся с этой проблемой. Я украсил некоторые из своих функций click.command и click.group, добавил к ним строки документации, а затем сгенерировал для них HTML-документацию, используя расширение Sphinx autodoc. Я обнаружил, что в нем отсутствует вся документация и описания аргументов для этих команд, потому что они были преобразованы в Command объекты к тому времени, когда к ним добрался autodoc.

Как я могу изменить свой код, чтобы сделать документацию по моим командам доступной как для конечных пользователей, когда они запускают --help в CLI, так и для людей, просматривающих документацию, сгенерированную Sphinx?


person Tagc    schedule 08.09.2016    source источник


Ответы (2)


Для этого вы можете использовать расширение 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:

Перед сборкой документации убедитесь, что ваш модуль и любые дополнительные зависимости доступны в sys.path, либо установив пакет с setuptools, либо включив его вручную.

После сборки мы получим следующее: сгенерированные документы

Более подробная информация о различных доступных параметрах содержится в документации расширения.

person Daria Kharlan    schedule 01.03.2019

Украшение командных контейнеров

Одно из возможных решений этой проблемы, которое я недавно обнаружил и, похоже, работает, состоит в том, чтобы начать с определения декоратора, который можно применять к классам. Идея состоит в том, что программист определяет команды как закрытые члены класса, а декоратор создает публичную функцию-член класса, основанную на обратном вызове команды. Например, класс 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. Если вы хотите определить обратные вызовы для команд как методы экземпляра класса, вы можете столкнуться с проблемой, когда click не предоставляет параметр self для ваших обратных вызовов команд, когда вы пытаетесь запустить 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

person Tagc    schedule 08.09.2016