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

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

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

重要なオブジェクト

あなたが拡張を書くときに使用するAPIを持つ、いくつかの鍵となるオブジェクトがあります。それらは次のとおりです:

アプリケーション

アプリケーションオブジェクト(通常 app と呼ばれる)は Sphinx のインスタンスです。これは高位の機能をコントロールします。例えば、拡張のセットアップや、イベントのディスパッチ、アウトプットの生成(ロギング)等です。

もし、環境オブジェクトがあれば、 env.app のようにアプリケーションが提供されます。

Environment

ビルド環境オブジェクト(通常 env と呼ばれる)は BuildEnvironment のインスタンスです。これはドキュメントソースのパースを行い、ドキュメントセットに関する全てのメタデータを保持し、それらをビルド後にディスクにシリアライズする責任を持っています。

環境オブジェクトはメタデータにアクセスするためのAPIや、参照を解決するAPIなどを持っています。それはまた、拡張から情報のキャッシュとして使われ、それによって漸進的な再ビルドができます。

もしアプリケーションかビルダーのオブジェクトがあれば、 app.envbuilder.env のように環境オブジェクトが提供されます。

ビルダー

ビルダーオブジェクト(通常 builder と呼ばれる)は Builder のサブクラスのインスタンスです。各ビルダークラスはパースしたドキュメントを使って、出力フォーマットに変換したり、データ処理したりします(例えば、外部リンクのチェックなど)。

もしアプリケーションオブジェクトがあれば、 app.builder のようにビルダーオブジェクトが提供されます。

Config

コンフィグオブジェクト(通常 config と呼ばれる)は conf.py の設定値を属性として提供します。これは Config のインスタンスです。

コンフィグオブジェクトは app.configenv.config として提供されます。

ビルド・フェーズ

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

フェーズ 0: 初期化

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

フェーズ 1: 読み込み

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

docutilsによって提供されるノードがあります。これらは docutilsのドキュメント. で説明されています。追加のノードはSphinxによって提供され、 ドキュメントはここ にあります。

ソースを読み込んでいる間は、ラベルや見出し名、説明されている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 イベントハンドラで、もう一つは env-purge-doc (理由は後で説明します)です。

setup関数

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

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

    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)

    return {'version': '0.1'}   # identifies the version of our extension

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

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

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

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

    後ほど、 todotodolist という2つのnodeクラスを作成します。

  • 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は単なる”一般”ノードです。

注釈

多くの拡張は独自のノードクラスを実装する必要はなく、 docutilsSphinx が提供するノードを使えば良いでしょう。

ディレクティブクラス

directiveクラスは通常 docutils.parsers.rst.Directive から派生します。ディレクティブのインタフェースの詳細は docutils documentation でも述べられています。重要なこととして、そのクラスはマークアップに許される設定のための属性と、ノードのリストを返す run メソッドを持たせるということです。

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

from docutils.parsers.rst import Directive

class TodolistDirective(Directive):

    def run(self):
        return [todolist('')]

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

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

from sphinx.util.compat import make_admonition
from sphinx.locale import _

class TodoDirective(Directive):

    # this enables content in the directive
    has_content = True

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

        targetid = "todo-%d" % env.new_serialno('todo')
        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.new_serialno を使用して作成されます(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 イベントは、拡張機能の中でそのような作業を行うのに適した場所になります。ここでは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)

    # Replace all todolist nodes with a list of the collected todos.
    # Augment each todo with a backlink to the original location.
    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)

            # Create a reference
            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('.)', '.)')

            # Insert into the todolist
            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がアンカー名として追加されています。