扩展构建过程¶
本教程的目标是创建一个比 使用角色和指令扩展语法 中创建的扩展更全面的扩展。之前的指南仅涵盖编写自定义的 角色 和 指令,而本指南则涵盖对 Sphinx 构建过程进行更复杂的扩展;添加多个指令,以及自定义节点、附加配置值和自定义事件处理程序。
为此,我们将介绍一个 todo
扩展,该扩展添加了在文档中包含 todo 条目的功能,并将这些条目收集在一个中心位置。这类似于 Sphinx 附带的 sphinx.ext.todo
扩展。
概述¶
我们希望扩展向 Sphinx 添加以下内容
一个
todo
指令,包含一些标记为 “TODO” 的内容,并且仅在新配置值设置时才在输出中显示。默认情况下,Todo 条目不应在输出中。一个
todolist
指令,用于创建整个文档中所有 todo 条目的列表。
为此,我们需要向 Sphinx 添加以下元素
新的指令,名为
todo
和todolist
。新的文档树节点来表示这些指令,通常也称为
todo
和todolist
。如果新指令仅生成可由现有节点表示的内容,则我们不需要新节点。一个新的配置值
todo_include_todos
(配置值名称应以扩展名称开头,以保持唯一性),用于控制 todo 条目是否进入输出。新的事件处理程序:一个用于
doctree-resolved
事件,用于替换 todo 和 todolist 节点;一个用于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.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
继承,因为它应该像 note 或 warning 一样处理,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]
这里涵盖了几个重要事项。首先,如您所见,我们现在正在子类化 SphinxDirective
帮助程序类,而不是通常的 Directive
类。这使我们可以使用 self.env
属性访问 构建环境实例。如果没有这个,我们将不得不使用相当繁琐的 self.state.document.settings.env
。然后,为了充当链接目标(来自 TodolistDirective
),TodoDirective
指令除了 todo
节点之外,还需要返回一个目标节点。目标 ID(在 HTML 中,这将是锚点名称)是通过使用 env.new_serialno
生成的,它在每次调用时返回一个新的唯一整数,因此会产生唯一的目标名称。目标节点在没有任何文本(前两个参数)的情况下实例化。
在创建 admonition 节点时,指令的内容主体使用 self.state.nested_parse
进行解析。第一个参数给出内容主体,第二个参数给出内容偏移量。第三个参数给出解析结果的父节点,在我们的例子中是 todo
节点。在此之后,todo
节点被添加到环境中。这是为了能够在整个文档中创建所有 todo 条目的列表,在作者放置 todolist
指令的位置。对于这种情况,使用了环境属性 todo_all_todos
(同样,名称应该是唯一的,因此它以扩展名称为前缀)。当创建新环境时,它不存在,因此指令必须检查并在必要时创建它。有关 todo 条目位置的各种信息与节点的副本一起存储。
在最后一行中,返回应放入文档树的节点:目标节点和 admonition 节点。
指令返回的节点结构如下所示
+--------------------+
| target node |
+--------------------+
+--------------------+
| todo node |
+--------------------+
\__+--------------------+
| admonition title |
+--------------------+
| paragraph |
+--------------------+
| ... |
+--------------------+
事件处理程序
事件处理程序是 Sphinx 最强大的功能之一,它提供了一种挂钩到文档过程任何部分的方法。Sphinx 本身提供了许多事件,如 API 指南 中详细介绍的那样,我们将在此处使用其中的一部分。
让我们看一下上面示例中使用的事件处理程序。首先,用于 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
事件使扩展有机会执行相同的操作。在这里,我们从 todo_all_todos
列表中清除所有 docname 与给定 docname 匹配的 todos。如果文档中还有 todos,它们将在解析期间再次添加。
下一个处理程序,用于 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'):
另一个处理程序属于 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.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 由 sphinx.builders.Builder.get_relative_uri()
构建,它根据使用的构建器创建合适的 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 这是一个布尔值)。如果第三个参数是
'html'
,则如果配置值更改其值,HTML 文档将完全重建。这对于影响读取的配置值是必需的 (构建 阶段 1 (读取))。add_node()
向构建系统添加新的节点类。它还可以为每种受支持的输出格式指定访问者函数。当新节点保留到 阶段 4 (写入) 时,需要这些访问者函数。由于todolist
节点始终在 阶段 3 (解析) 中被替换,因此它不需要任何访问者函数。add_directive()
添加一个新的指令,由名称和类给出。最后,
connect()
向事件添加一个事件处理程序,事件名称由第一个参数给出。事件处理程序函数使用事件文档记录的多个参数调用。
至此,我们的扩展就完成了。
使用扩展¶
与之前一样,我们需要通过在 conf.py
文件中声明扩展来启用它。这里需要两个步骤
使用
sys.path.append
将_ext
目录添加到 Python 路径。这应放在文件的顶部。更新或创建
extensions
列表,并将扩展文件名添加到列表中
此外,我们可能希望设置 todo_include_todos
配置值。如上所述,此值默认为 False
,但我们可以显式设置它。
例如
import sys
from pathlib import Path
sys.path.append(str(Path('_ext').resolve()))
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。
如果您希望在多个项目或与他人共享您的扩展,请查看 第三方扩展 部分。