Sphinx logo

目次

前のトピックへ

Sphinx拡張

次のトピックへ

拡張API

このページ

.. _exttut:

チュートリアル: シンプルな拡張を作成

このセクションではカスタムの拡張の作成について一通り説明していきたいと思います。 ここの説明でカバーするのは、拡張の基本的な書き方と登録の仕方、あとは拡張を作成するのに使用される一般的な機能などです。

サンプルとしては、ドキュメントの中にToDoを書くことができるようになり、指定された場所にすべてのToDoの一覧を出力する、”todo”拡張を扱おうと思います。Sphinxの配布物に含まれる”todo”拡張とほぼ同じものになります。

ビルド・フェーズ

Sphinxのプロジェクトがビルドされる過程で、拡張機能がどのように実行されるのかということを理解することは、拡張機能の開発をするうえで必要不可欠です。この作業は以下のいくつかのフェーズから構成されています。

フェーズ 0: 初期化

このフェーズでは拡張作成者にとって面白いものは何もありません。 ソースディレクトリ内のソースファイルを探索し、拡張機能を初期化します。 保存されたビルド環境があればそれをロードし、なければ新しいビルド環境を作成します。

フェーズ 1: 読み込み

フェーズ 1ではすべてのソースファイルが読み込まれ、パースされます。なお、この後のフェーズは新規のファイルか変更されたファイルに対して実行されます。このフェーズではdocutilsによってディレクティブやロールが処理され、それに対応する関数が呼ばれます。このフェーズの出力は、ソースファイルごとのDOCツリーです。これは、docutilsのノードがツリー上に構成されているものです。すべてのファイルを読み込むまでは完全に解釈できないドキュメントの要素に関しては、一時的なノードが作られます。

ソースを読み込んでいる間は、ラベルや見出し命、説明されているPythonオブジェクト、索引のエントリーなどのめたな情報やクロスファンレスの情報がビルド環境に出力されます。これらの情報は、後で一時的なノードと置き換えられます。

パースされたDOCツリーはすべてのメモリ上で保存しておくことができないため、ディスク上に保存されます。

フェーズ 2: 一貫性チェック

ビルドされたドキュメントの中に、びっくりするようなものがないか、いくつかのチェックを行います。

フェーズ 3: 解決

読み込まれたすべてのドキュメントから収集されたメタデータ、およびクロスリファレンスのデータを使って、一時的なノードを、出力可能なノードに置き換えていきます。例えば、存在するオブジェクトへの参照があればリンクが作成されます。リンク先が存在しないものはシンプルなリテラル(等幅)のノードが作成されます。

フェーズ 4: 書き出し

このフェーズでは参照が解決されたDOCツリーを、HTMLやLaTeXなどの指定された出力フォーマットに変換します。このプロセス中では、docutilsのライターと呼ばれるものがDOCツリーの個々のノードをたどって、出力を行っていきます。

ノート

いくつかのビルダーの中には、この一般的なビルド計画から外れているものもあります。 例えば、外部リンクチェックのビルダーはdoctreeのパースをする以上の情報は不要なので、フェーズ2~4を行いません。

拡張のデザイン

以下のような拡張機能をSphinxに追加したいと考えているとします:

  • “todo”ディレクティブは、”TODO”としてやらなければならないことをコンテンツとして持ち、新しい設定値で表示するように指定されたときだけ表示します。デフォルトではtodoのエントリーは表示されないようにします。
  • “todolist”ディレクティブがあると、全ドキュメントに含まれるTodoの項目を集めて、リストを作成します。

これを実現するためには、Sphinxに以下の項目を追加する必要があるでしょう:

  • todo, todolistと呼ばれる新しいディレクティブ
  • todo, todolistというディレクティブが使用された場合に、それを表現する慣習的な新しいドキュメントツリーのノード。もしも、新しいディレクティブが、既存のノードで表現可能なものだけを生成するのであれば、新しいノードを作成する必要はありません。
  • todo_include_todosという新しい設定値。設定値の名前は、一意性を保つために拡張名から始まる名前にしてください。この設定値はtodoのエントリーが、出力を行うかどうかを判断します
  • 新しいイベントハンドラ。一つはtodoとtodolistのノードを置き換えるための doctree-resolved イベントハンドラで、もう一つは :event:`env-purge-doc`(理由は後で説明します)です。

setup関数

新しい要素は、拡張の中のsetup関数の中で追加していきます。まずは todo.py という新しいPythonのモジュールを作成して、以下のようなsetup関数を追加しましょう:

def setup(app):
    app.add_config_value('todo_include_todos', False, False)

    app.add_node(todolist)
    app.add_node(todo,
                 html=(visit_todo_node, depart_todo_node),
                 latex=(visit_todo_node, depart_todo_node),
                 text=(visit_todo_node, depart_todo_node))

    app.add_directive('todo', TodoDirective)
    app.add_directive('todolist', TodolistDirective)
    app.connect('doctree-resolved', process_todo_nodes)
    app.connect('env-purge-doc', purge_todos)

この関数のなかで参照されているクラスと関数の中にはまだ説明していないものもあります。呼ばれているものが個々に何をしているか、というのを説明していきます:

  • add_config_value() メソッドはSphinxに対して新しい設定値である todo_include_todosを追加するように指示して、 conf.py の中に書けるようにします。このオプションのデフォルト値はFalseになります。また、この設定値がブーリアンの値を取るということもSphinxに知らせます。

    もしも3番目の引数がTrueの場合には、設定値が変更された場合にすべてのドキュメントが再読み込みされます。この引数は、設定値がフェーズ.1の読み込みに対して影響を与えるかどうかを指定するのに必要です。

  • add_node() メソッドは、ビルドシステムに対して新しいノードクラスを追加します。このメソッドはサポートする出力形式ごとにビジター関数を定義することができます。これらのビジター関数は新しいノードがフェーズ.4まで残っている場合に必要になります。todolistはフェーズ.3までにすべて置き換えられてしまうため、ビジターを指定する必要はありません。
  • add_directive() メソッドは、指定された名前とクラスから、新しいディレクティブを追加します。

    ハンドラー関数は後で作成します。

  • 最後に、 connect() メソッドは、最初の引数に指定されたイベントの名前に対する、イベントハンドラを追加します。イベントハンドラの関数は、ドキュメントに関する引数をいくつか伴って呼び出されます。

ノードクラス

それではノードクラスを実装していきます:

from docutils import nodes

class todo(nodes.Admonition, nodes.Element):
    pass

class todolist(nodes.General, nodes.Element):
    pass

def visit_todo_node(self, node):
    self.visit_admonition(node)

def depart_todo_node(self, node):
    self.depart_admonition(node)

ノードクラスは docutils.nodes の中で定義されているdocutilsの標準クラスを継承する以外には何もやる必要はありません。todonotewarningのように使用されなければならないため、Admonitionクラスを定義しています。todolistは単なる”一般”ノードです。

ディレクティブクラス

ディレクティブクラスは、通常はdocutils.parsers.rst.Directiveクラスを継承しています。Docutils 0.4にはクラスベースのディレクティブのインタフェースがなかったため、Sphinxはsphinx.util.compat.Directiveというクラスを実装しています。これを継承して実装すると、Docutils 0.4でも、0.5以上でも同じように動作します。このディレクティブのインタフェースはdocutilsのドキュメントの詳細までカバーしています。また、ディレクティブのインタフェースとして必須なのは、ノードのリストを返すrunメソッドを定義していることになります。

todolistディレクティブはきわめてシンプルです::

from sphinx.util.compat import Directive

class TodolistDirective(Directive):

def run(self):
return [todolist(‘’)]

todolistノードクラスのインスタンスを作って返しています。todolistディレクティブでは、コンテンツも引数も取り扱う必要はありません。

todoディレクティブのクラスは以下のようになります:

from sphinx.util.compat import make_admonition

class TodoDirective(Directive):

    # この変数をセットすると、ディレクティブの中にコンテンツを書けるようになります。
    has_content = True

    def run(self):
        env = self.state.document.settings.env

        targetid = "todo-%s" % env.index_num
        env.index_num += 1
        targetnode = nodes.target('', '', ids=[targetid])

        ad = make_admonition(todo, self.name, [_('Todo')], self.options,
                             self.content, self.lineno, self.content_offset,
                             self.block_text, self.state, self.state_machine)

        if not hasattr(env, 'todo_all_todos'):
            env.todo_all_todos = []
        env.todo_all_todos.append({
            'docname': env.docname,
            'lineno': self.lineno,
            'todo': ad[0].deepcopy(),
            'target': targetnode,
        })

        return [targetnode] + ad

拡張機能の作成にあたって重要なことがこのクラスでカバーされています。まず最初に、見てわかるように、self.state.document.settings.envを通じて、ビルド環境のインスタンスを参照することができるということです。

次に、todolistからのリンクターゲットとして動作するために、todoディレクティブがtodoノードだけでなく、ターゲットとなるノードを返さなければならないという点です。ターゲットのIDはenv.index_numを使用して作成されます。この数値はディレクティブが呼びだしの間は永続化されていて、ユニークなターゲット名を作成するのに使用することができます。ターゲットIDはHTMLではアンカー名として使用されます。ターゲットノードはインスタンス化されるときにはテキスト(最初の二つの引数)を持ちません。

Admonition(勧告)は標準のdocutils関数(docutilsのバージョン間の互換性のためにSphinxでラップしてある)を使って作成します。最初の引数はノードのクラスで、ここではtodoを設定しています。3番目の引数はAdmonitionのタイトルです。argumentsを使用して、ユーザ定義の名前になります。make_admonitionから返されたものはノードのリストになります。

todoノードが環境に追加されました。これは全ドキュメントのToDoのエントリーのリストを作成できるようにするために必要なものです。ここで作ったリストはtodolistディレクティブが置かれているところに出力されます。この場合、環境の属性のtodo_all_todosが使用されます。繰り返しになりますが、名前の重複を避けるために、属性名の頭には拡張名を設定します。新しい環境が作成されたときにはまだ存在していないため、ディレクティブの中では必要に応じてあるかどうかチェックを行い、作成する必要があります。ToDoエントリーの位置に関するさまざまな情報がノードのコピーの中に保存されます。

最後の行では、作成したターゲットノードと、AdmonitionノードをDOCツリーの中に配置するために、returnで返しています。

ディレクティブが返すノード構造は以下のようになっています:

+--------------------+
| target node        |
+--------------------+
+--------------------+
| todo node          |
+--------------------+
  \__+--------------------+
     | admonition title   |
     +--------------------+
     | paragraph          |
     +--------------------+
     | ...                |
     +--------------------+

イベントハンドラ

最後に、イベントハンドラを見ていきます。最初に見るのは env-purge-doc イベントです:

def purge_todos(app, env, docname):
    if not hasattr(env, 'todo_all_todos'):
        return
    env.todo_all_todos = [todo for todo in env.todo_all_todos
                          if todo['docname'] != docname]

ソースファイルの中から情報を取り出し、環境の中に格納しましたが、これは永続化されます。そのため、ソースファイルが変更されると古い情報になってしまう可能性があります。そのため、それぞれのソースファイルを読み込む前に、環境の記録をクリアしています。 env-purge-doc イベントは、拡張機能の中でそのような作業を行うのに適した場所になりmさう。ここではtodo_all_todosのリストの中の項目のうち、ドキュメントの名前(docname)がマッチしたものを削除しています。もしもドキュメント内のToDoが残っていたとしたら、パース時に重複して追加されてしまいます。

もう一つ doctree-resolved イベントに関連したハンドラが定義されています。このイベントはフェーズ.3が完了したところで発生(emit)します。解決処理を独自に実装することができるようになります:

def process_todo_nodes(app, doctree, fromdocname):
    if not app.config.todo_include_todos:
        for node in doctree.traverse(todo):
            node.parent.remove(node)

    # すべての todolist ノードを、収集したtodoのリストと置き換えます
    # それぞれの項目には、元の定義された位置へのリンクを追加します
    env = app.builder.env

    for node in doctree.traverse(todolist):
        if not app.config.todo_include_todos:
            node.replace_self([])
            continue

        content = []

        for todo_info in env.todo_all_todos:
            para = nodes.paragraph()
            filename = env.doc2path(todo_info['docname'], base=None)
            description = (
                _('(The original entry is located in %s, line %d and can be found ') %
                (filename, todo_info['lineno']))
            para += nodes.Text(description, description)

            # 参照の作成
            newnode = nodes.reference('', '')
            innernode = nodes.emphasis(_('here'), _('here'))
            newnode['refdocname'] = todo_info['docname']
            newnode['refuri'] = app.builder.get_relative_uri(
                fromdocname, todo_info['docname'])
            newnode['refuri'] += '#' + todo_info['target']['refid']
            newnode.append(innernode)
            para += newnode
            para += nodes.Text('.)', '.)')

            # ToDoリストへの追加
            content.append(todo_info['todo'])
            content.append(para)

        node.replace_self(content)

このコードは多少込み入っています。もしも新しい設定値である"todo_include_todos"がfalseの場合には、すべてのtodoおよび、todolistのノードをドキュメントから削除します。

trueの場合にはtodoのノードはその場に保持されます。todolistノードはtodoのエントリーのリストに置き換えられ、定義された場所への逆リンクが張られます。リストアイテムはtodoエントリーのノードの内容から作成され、その場でdocutilsのノードが作成されます。エントリーごとに段落が作られます。段落の中には定義された位置を表すテキストと逆参照のためのリンクが含まれます。また参照はイタリック体のノードの中に定義されます。参照のリンクはapp.builder.get_relative_uri関数によって作成されます。これはビルダーごとに適したURIを作成します。リンクには、ノードのターゲットのIDがアンカー名として追加されています。