ビルドプロセスの拡張¶
このチュートリアルの目的は、ロールとディレクティブによる構文の拡張で作成したものよりも包括的な拡張機能を作成することです。上記のガイドではカスタムのロールとディレクティブの作成についてのみ説明しましたが、このガイドでは、複数のディレクティブの追加、カスタムノード、追加の設定値、カスタムイベントハンドラなど、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`)を分けて使用していると想定しています。拡張機能ファイルはプロジェクトの任意のフォルダに配置できます。ここでは、次の手順を実行しましょう。
`source`に`_ext`フォルダを作成します。
`_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`は単なる「汎用」ノードです。
注意
`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 つのステップが必要です。
`
sys.path.append
` を使用して、`_ext
` ディレクトリを Python パス に追加します。これはファイルの先頭に配置する必要があります。extensions
リストを更新または作成し、拡張機能のファイル名をリストに追加します。
さらに、`todo_include_todos
` 設定値を設定することもできます。上記のように、これはデフォルトで `False
` ですが、明示的に設定できます。
例えば
import os
import sys
sys.path.append(os.path.abspath("./_ext"))
extensions = ['todo']
todo_include_todos = False
これで、プロジェクト全体で拡張機能を使用できるようになりました。例えば
Hello, world
============
.. toctree::
somefile.rst
someotherfile.rst
Hello world. Below is the list of TODOs.
.. todolist::
foo
===
Some intro text here...
.. todo:: Fix this
bar
===
Some more text here...
.. todo:: Fix that
`todo_include_todos
` を `False
` に設定しているため、`todo
` ディレクティブと `todolist
` ディレクティブに対して実際にレンダリングされるものは何もありません。ただし、これを true に切り替えると、前述の出力が表示されます。
参考文献¶
詳細については、docutils のドキュメントと Sphinx API を参照してください。
拡張機能を複数のプロジェクト間で、または他のユーザーと共有する場合は、サードパーティ拡張機能 のセクションを確認してください。