7

Clickは、CLI アプリケーションを開発するための一般的な Python ライブラリです。Sphinxは、Python パッケージをドキュメント化するための一般的なライブラリです。一部の人が直面した問題の 1 つは、これら 2 つのツールを統合して、クリックベースのコマンドの Sphinx ドキュメントを生成できるようにすることです。

最近この問題に遭遇しました。click.command関数のいくつかをandで装飾し、それらに docstring を追加してから、Sphinx の拡張機能click.groupを使用してそれらの HTML ドキュメントを生成しました。autodoc私が見つけたのは、これらのコマンドのすべてのドキュメントと引数の説明が省略されていることです。これらのコマンドはCommand、autodoc がそれらに到達するまでにオブジェクトに変換されていたためです。

--helpCLI で実行するエンド ユーザーと、Sphinx で生成されたドキュメントを参照するユーザーの両方がコマンドのドキュメントを利用できるように、コードを変更するにはどうすればよいですか?

4

2 に答える 2

2

コマンド コンテナーの装飾

私が最近発見したこの問題の解決策の 1 つは、クラスに適用できるデコレータを定義することから始めることです。プログラマーがコマンドをクラスのプライベート メンバーとして定義し、デコレーターがコマンドのコールバックに基づいてクラスのパブリック関数メンバーを作成するという考え方です。たとえば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.IPluginselfコマンドのコールバックをクラス インスタンス メソッドとして定義する場合、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 で実行すると、ブラウザでドキュメントを表示できます。

スフィンクスのドキュメント

于 2016-09-08T13:50:17.017 に答える