ビルドプロセスの拡張

このチュートリアルの目的は、ロールとディレクティブによる構文の拡張で作成したものよりも包括的な拡張機能を作成することです。上記のガイドではカスタムのロールディレクティブの作成についてのみ説明しましたが、このガイドでは、複数のディレクティブの追加、カスタムノード、追加の設定値、カスタムイベントハンドラなど、Sphinx ビルドプロセスへのより複雑な拡張について説明します。

この目的のために、ドキュメントにTODOエントリを含め、それらを一元的に収集する機能を追加する`todo`拡張機能について説明します。これは、Sphinx に付属のsphinx.ext.todo拡張機能に似ています。

概要

注記

この拡張機能の設計を理解するには、重要なオブジェクトビルドフェーズを参照してください。

この拡張機能によって、Sphinx に次の機能を追加します。

  • 「TODO」とマークされたコンテンツを含む`todo`ディレクティブ。新しい設定値が設定されている場合にのみ出力に表示されます。TODOエントリはデフォルトでは出力されません。

  • ドキュメント全体のすべてのTODOエントリのリストを作成する`todolist`ディレクティブ。

そのためには、Sphinx に次の要素を追加する必要があります。

  • `todo`と`todolist`と呼ばれる新しいディレクティブ。

  • これらのディレクティブを表す新しいドキュメントツリーノード。慣例的に`todo`と`todolist`とも呼ばれます。新しいディレクティブが既存のノードで表現できるコンテンツのみを生成する場合、新しいノードは必要ありません。

  • TODOエントリを出力に含めるかどうかを制御する新しい設定値`todo_include_todos`(設定値の名前は、一意性を保つために拡張機能の名前で始める必要があります)。

  • 新しいイベントハンドラ:`todo`ノードと`todolist`ノードを置き換えるためのdoctree-resolvedイベント用、並列ビルドからの中間結果をマージするためのenv-merge-infoイベント用、env-purge-docイベント用(その理由は後述します)。

前提条件

ロールとディレクティブによる構文の拡張と同様に、このプラグインをPyPI経由で配布することはありません。そのため、これを呼び出すSphinxプロジェクトが必要です。既存のプロジェクトを使用するか、**sphinx-quickstart**を使用して新しいプロジェクトを作成できます。

ソースフォルダ(`source`)とビルドフォルダ(`build`)を分けて使用していると想定しています。拡張機能ファイルはプロジェクトの任意のフォルダに配置できます。ここでは、次の手順を実行しましょう。

  1. `source`に`_ext`フォルダを作成します。

  2. `_ext`フォルダに`todo.py`という名前の新しいPythonファイルを作成します。

作成されるフォルダ構造の例を以下に示します。

└── source
    ├── _ext
    │   └── todo.py
    ├── _static
    ├── conf.py
    ├── somefolder
    ├── index.rst
    ├── somefile.rst
    └── someotherfile.rst

拡張機能の作成

`todo.py`を開き、次のコードを貼り付けます。コードの内容については、後で詳しく説明します。

  1from docutils import nodes
  2from docutils.parsers.rst import Directive
  3
  4from sphinx.application import Sphinx
  5from sphinx.locale import _
  6from sphinx.util.docutils import SphinxDirective
  7from sphinx.util.typing import ExtensionMetadata
  8
  9
 10class todo(nodes.Admonition, nodes.Element):
 11    pass
 12
 13
 14class todolist(nodes.General, nodes.Element):
 15    pass
 16
 17
 18def visit_todo_node(self, node):
 19    self.visit_admonition(node)
 20
 21
 22def depart_todo_node(self, node):
 23    self.depart_admonition(node)
 24
 25
 26class TodolistDirective(Directive):
 27    def run(self):
 28        return [todolist('')]
 29
 30
 31class TodoDirective(SphinxDirective):
 32    # this enables content in the directive
 33    has_content = True
 34
 35    def run(self):
 36        targetid = 'todo-%d' % self.env.new_serialno('todo')
 37        targetnode = nodes.target('', '', ids=[targetid])
 38
 39        todo_node = todo('\n'.join(self.content))
 40        todo_node += nodes.title(_('Todo'), _('Todo'))
 41        todo_node += self.parse_content_to_nodes()
 42
 43        if not hasattr(self.env, 'todo_all_todos'):
 44            self.env.todo_all_todos = []
 45
 46        self.env.todo_all_todos.append({
 47            'docname': self.env.docname,
 48            'lineno': self.lineno,
 49            'todo': todo_node.deepcopy(),
 50            'target': targetnode,
 51        })
 52
 53        return [targetnode, todo_node]
 54
 55
 56def purge_todos(app, env, docname):
 57    if not hasattr(env, 'todo_all_todos'):
 58        return
 59
 60    env.todo_all_todos = [
 61        todo for todo in env.todo_all_todos if todo['docname'] != docname
 62    ]
 63
 64
 65def merge_todos(app, env, docnames, other):
 66    if not hasattr(env, 'todo_all_todos'):
 67        env.todo_all_todos = []
 68    if hasattr(other, 'todo_all_todos'):
 69        env.todo_all_todos.extend(other.todo_all_todos)
 70
 71
 72def process_todo_nodes(app, doctree, fromdocname):
 73    if not app.config.todo_include_todos:
 74        for node in doctree.findall(todo):
 75            node.parent.remove(node)
 76
 77    # Replace all todolist nodes with a list of the collected todos.
 78    # Augment each todo with a backlink to the original location.
 79    env = app.builder.env
 80
 81    if not hasattr(env, 'todo_all_todos'):
 82        env.todo_all_todos = []
 83
 84    for node in doctree.findall(todolist):
 85        if not app.config.todo_include_todos:
 86            node.replace_self([])
 87            continue
 88
 89        content = []
 90
 91        for todo_info in env.todo_all_todos:
 92            para = nodes.paragraph()
 93            filename = env.doc2path(todo_info['docname'], base=None)
 94            description = _(
 95                '(The original entry is located in %s, line %d and can be found '
 96            ) % (filename, todo_info['lineno'])
 97            para += nodes.Text(description)
 98
 99            # Create a reference
100            newnode = nodes.reference('', '')
101            innernode = nodes.emphasis(_('here'), _('here'))
102            newnode['refdocname'] = todo_info['docname']
103            newnode['refuri'] = app.builder.get_relative_uri(
104                fromdocname, todo_info['docname']
105            )
106            newnode['refuri'] += '#' + todo_info['target']['refid']
107            newnode.append(innernode)
108            para += newnode
109            para += nodes.Text('.)')
110
111            # Insert into the todolist
112            content.extend((
113                todo_info['todo'],
114                para,
115            ))
116
117        node.replace_self(content)
118
119
120def setup(app: Sphinx) -> ExtensionMetadata:
121    app.add_config_value('todo_include_todos', False, 'html')
122
123    app.add_node(todolist)
124    app.add_node(
125        todo,
126        html=(visit_todo_node, depart_todo_node),
127        latex=(visit_todo_node, depart_todo_node),
128        text=(visit_todo_node, depart_todo_node),
129    )
130
131    app.add_directive('todo', TodoDirective)
132    app.add_directive('todolist', TodolistDirective)
133    app.connect('doctree-resolved', process_todo_nodes)
134    app.connect('env-purge-doc', purge_todos)
135    app.connect('env-merge-info', merge_todos)
136
137    return {
138        'version': '0.1',
139        'env_version': 1,
140        'parallel_read_safe': True,
141        'parallel_write_safe': True,
142    }

これはロールとディレクティブによる構文の拡張で説明したものよりもはるかに拡張性の高い拡張機能ですが、何が起こっているのかを説明するために、各部分を段階的に見ていきます。

ノードクラス

まず、ノードクラスから始めましょう。

 1
 2
 3class todo(nodes.Admonition, nodes.Element):
 4    pass
 5
 6
 7class todolist(nodes.General, nodes.Element):
 8    pass
 9
10
11def visit_todo_node(self, node):
12    self.visit_admonition(node)
13
14

ノードクラスは通常、`docutils.nodes`で定義されている標準のdocutilsクラスを継承する以外は何もしなくても構いません。`todo`は、注記や警告のように処理する必要があるため、`Admonition`を継承します。`todolist`は単なる「汎用」ノードです。

注記

多くの拡張機能は独自のノードクラスを作成する必要はなく、docutilsSphinxによって既に提供されているノードで問題なく動作します。

注意

`conf.py`から離れることなくSphinxを拡張できますが、継承されたノードをそこに宣言すると、明確ではないPickleErrorが発生します。そのため、問題が発生した場合は、継承されたノードを別のPythonモジュールに配置してください。

詳細については、以下を参照してください。

ディレクティブクラス

ディレクティブクラスは、通常docutils.parsers.rst.Directiveから派生したクラスです。ディレクティブインターフェースについてもdocutilsドキュメントで詳しく説明されています。重要なのは、クラスが許可されたマークアップを設定する属性と、ノードのリストを返す`run`メソッドを持つ必要があることです。

まず、`TodolistDirective`ディレクティブを見てみましょう。

1
2
3class TodolistDirective(Directive):
4    def run(self):

これは非常にシンプルで、`todolist`ノードクラスのインスタンスを作成して返します。`TodolistDirective`ディレクティブ自体は、処理する必要のあるコンテンツも引数もありません。それでは、`TodoDirective`ディレクティブを見てみましょう。

 1
 2class TodoDirective(SphinxDirective):
 3    # this enables content in the directive
 4    has_content = True
 5
 6    def run(self):
 7        targetid = 'todo-%d' % self.env.new_serialno('todo')
 8        targetnode = nodes.target('', '', ids=[targetid])
 9
10        todo_node = todo('\n'.join(self.content))
11        todo_node += nodes.title(_('Todo'), _('Todo'))
12        todo_node += self.parse_content_to_nodes()
13
14        if not hasattr(self.env, 'todo_all_todos'):
15            self.env.todo_all_todos = []
16
17        self.env.todo_all_todos.append({
18            'docname': self.env.docname,
19            'lineno': self.lineno,
20            'todo': todo_node.deepcopy(),
21            'target': targetnode,
22        })
23
24        return [targetnode, todo_node]

ここでは、いくつかの重要な点が説明されています。まず、ご覧のとおり、通常のDirectiveクラスではなく、SphinxDirectiveヘルパークラスをサブクラス化しています。これにより、`self.env`プロパティを使用してビルド環境インスタンスにアクセスできます。これがなければ、やや複雑な`self.state.document.settings.env`を使用する必要があります。次に、(`TodolistDirective`からの)リンクターゲットとして機能するために、`TodoDirective`ディレクティブは`todo`ノードに加えてターゲットノードを返す必要があります。ターゲットID(HTMLではアンカー名になります)は、呼び出されるたびに新しい一意の整数を返す`env.new_serialno`を使用して生成されるため、一意のターゲット名になります。ターゲットノードはテキストなし(最初の2つの引数)でインスタンス化されます。

アドモニションノードを作成する際に、ディレクティブのコンテンツ本体は `self.state.nested_parse` を使用して解析されます。最初の引数はコンテンツ本体、2 番目の引数はコンテンツのオフセットです。3 番目の引数は解析結果の親ノードで、この場合は `todo` ノードです。この後、`todo` ノードが環境に追加されます。これは、ドキュメント全体にあるすべての todo エントリのリストを、著者が `todolist` ディレクティブを配置した場所に作成できるようにするために必要です。この場合、環境属性 `todo_all_todos` が使用されます(繰り返しますが、名前は一意である必要があるため、拡張子の名前がプレフィックスとして付けられます)。新しい環境が作成されたときには存在しないため、ディレクティブは必要に応じて存在を確認し、作成する必要があります。todo エントリの場所に関するさまざまな情報が、ノードのコピーと共に保存されます。

最後の行では、doctree に配置されるノード、つまりターゲットノードとアドモニションノードが返されます。

ディレクティブが返すノード構造は次のようになります。

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

イベントハンドラ

イベントハンドラは、Sphinx の最も強力な機能の 1 つであり、ドキュメント作成プロセスのあらゆる部分にフックする方法を提供します。API ガイド に詳述されているように、Sphinx 自体によって多くのイベントが提供されており、ここではそのサブセットを使用します。

上記の例で使用されているイベントハンドラを見てみましょう。まず、env-purge-doc イベントのハンドラです。

1def purge_todos(app, env, docname):
2    if not hasattr(env, 'todo_all_todos'):
3        return
4
5    env.todo_all_todos = [
6        todo for todo in env.todo_all_todos if todo['docname'] != docname

ソースファイルからの情報を永続的な環境に保存するため、ソースファイルが変更されると情報が古くなる可能性があります。そのため、各ソースファイルが読み取られる前に、環境のレコードがクリアされ、env-purge-doc イベントは拡張機能に同じことを行う機会を与えます。ここでは、docname が指定されたものと一致するすべての todo を `todo_all_todos` リストからクリアします。ドキュメントに todo が残っている場合は、解析中に再度追加されます。

次のハンドラである env-merge-info イベントのハンドラは、並列ビルド中に使用されます。並列ビルド中はすべてのスレッドが独自の `env` を持つため、マージする必要がある複数の `todo_all_todos` リストがあります。

1
2def merge_todos(app, env, docnames, other):
3    if not hasattr(env, 'todo_all_todos'):
4        env.todo_all_todos = []
5    if hasattr(other, 'todo_all_todos'):

もう 1 つのハンドラは、doctree-resolved イベントに属します。

 1
 2def process_todo_nodes(app, doctree, fromdocname):
 3    if not app.config.todo_include_todos:
 4        for node in doctree.findall(todo):
 5            node.parent.remove(node)
 6
 7    # Replace all todolist nodes with a list of the collected todos.
 8    # Augment each todo with a backlink to the original location.
 9    env = app.builder.env
10
11    if not hasattr(env, 'todo_all_todos'):
12        env.todo_all_todos = []
13
14    for node in doctree.findall(todolist):
15        if not app.config.todo_include_todos:
16            node.replace_self([])
17            continue
18
19        content = []
20
21        for todo_info in env.todo_all_todos:
22            para = nodes.paragraph()
23            filename = env.doc2path(todo_info['docname'], base=None)
24            description = _(
25                '(The original entry is located in %s, line %d and can be found '
26            ) % (filename, todo_info['lineno'])
27            para += nodes.Text(description)
28
29            # Create a reference
30            newnode = nodes.reference('', '')
31            innernode = nodes.emphasis(_('here'), _('here'))
32            newnode['refdocname'] = todo_info['docname']
33            newnode['refuri'] = app.builder.get_relative_uri(
34                fromdocname, todo_info['docname']
35            )
36            newnode['refuri'] += '#' + todo_info['target']['refid']
37            newnode.append(innernode)
38            para += newnode
39            para += nodes.Text('.)')
40
41            # Insert into the todolist
42            content.extend((
43                todo_info['todo'],

doctree-resolved イベントは、フェーズ 3(解決) の最後に発行され、カスタムの解決を実行できるようにします。このイベント用に作成したハンドラは、少し複雑です。`todo_include_todos` 設定値(後ほど説明します)が false の場合、すべての `todo` ノードと `todolist` ノードがドキュメントから削除されます。そうでない場合、`todo` ノードはそのままで、そのまま残ります。`todolist` ノードは、todo エントリのリストに置き換えられ、元の場所へのバックリンクが完備されます。リスト項目は、`todo` エントリのノードと、その場で作成された docutils ノードで構成されます。各エントリの段落には、場所を示すテキストと、逆参照を含むリンク(イタリックノードを含む参照ノード)が含まれています。参照 URI は、使用されているビルダーに応じて適切な URI を作成する sphinx.builders.Builder.get_relative_uri() によって構築され、todo ノード(ターゲット)の ID がアンカー名として追加されます。

`setup` 関数

前述 のように、`setup` 関数は必須であり、ディレクティブを Sphinx に接続するために使用されます。ただし、拡張機能の他の部分を接続するためにも使用します。`setup` 関数を見てみましょう。

 1
 2        node.replace_self(content)
 3
 4
 5def setup(app: Sphinx) -> ExtensionMetadata:
 6    app.add_config_value('todo_include_todos', False, 'html')
 7
 8    app.add_node(todolist)
 9    app.add_node(
10        todo,
11        html=(visit_todo_node, depart_todo_node),
12        latex=(visit_todo_node, depart_todo_node),
13        text=(visit_todo_node, depart_todo_node),
14    )
15
16    app.add_directive('todo', TodoDirective)
17    app.add_directive('todolist', TodolistDirective)
18    app.connect('doctree-resolved', process_todo_nodes)
19    app.connect('env-purge-doc', purge_todos)
20    app.connect('env-merge-info', merge_todos)
21
22    return {
23        'version': '0.1',
24        'env_version': 1,
25        'parallel_read_safe': True,
26        'parallel_write_safe': True,
27    }

この関数の呼び出しは、前に追加したクラスと関数を参照しています。個々の呼び出しの動作は次のとおりです。

  • add_config_value() は、Sphinx に新しい *設定値* `todo_include_todos` を認識させることができます。デフォルト値は `False` です(これは Sphinx にブール値であることも伝えます)。

    3 番目の引数が `'html'` だった場合、設定値が変更されると HTML ドキュメントが完全に再構築されます。これは、読み取り(ビルド フェーズ 1(読み取り))に影響を与える設定値に必要です。

  • add_node() は、ビルドシステムに新しい *ノードクラス* を追加します。また、サポートされている各出力形式のビジター関数を指定することもできます。これらのビジター関数は、新しいノードが フェーズ 4(書き込み) まで残る場合に必要です。`todolist` ノードは常に フェーズ 3(解決) で置き換えられるため、何も必要ありません。

  • add_directive() は、名前とクラスで指定された新しい *ディレクティブ* を追加します。

  • 最後に、connect() は、最初の引数で名前が指定されたイベントに *イベントハンドラ* を追加します。イベントハンドラ関数は、イベントと共にドキュメント化されているいくつかの引数を使用して呼び出されます。

これで、拡張機能は完成です。

拡張機能の使用

前と同様に、`conf.py` ファイルで宣言することで拡張機能を有効にする必要があります。ここでは 2 つのステップが必要です。

  1. `sys.path.append` を使用して、`_ext` ディレクトリを Python パス に追加します。これはファイルの先頭に配置する必要があります。

  2. extensions リストを更新または作成し、拡張機能のファイル名をリストに追加します。

さらに、`todo_include_todos` 設定値を設定することもできます。上記のように、これはデフォルトで `False` ですが、明示的に設定できます。

例えば

import os
import sys

sys.path.append(os.path.abspath("./_ext"))

extensions = ['todo']

todo_include_todos = False

これで、プロジェクト全体で拡張機能を使用できるようになりました。例えば

index.rst
Hello, world
============

.. toctree::
   somefile.rst
   someotherfile.rst

Hello world. Below is the list of TODOs.

.. todolist::
somefile.rst
foo
===

Some intro text here...

.. todo:: Fix this
someotherfile.rst
bar
===

Some more text here...

.. todo:: Fix that

`todo_include_todos` を `False` に設定しているため、`todo` ディレクティブと `todolist` ディレクティブに対して実際にレンダリングされるものは何もありません。ただし、これを true に切り替えると、前述の出力が表示されます。

参考文献

詳細については、docutils のドキュメントと Sphinx API を参照してください。

拡張機能を複数のプロジェクト間で、または他のユーザーと共有する場合は、サードパーティ拡張機能 のセクションを確認してください。