对构建过程进行扩展¶
本教程的目标是创建一个更全面的扩展,相比 使用角色和指令扩展语法 中创建的那个。上篇那个指南仅涵盖了编写自定义 role 和 directive,而本指南涵盖了对 Sphinx 构建过程的更复杂扩展;添加多个指令,以及自定义节点、额外的配置值和自定义事件处理程序。
为此,我们将完成一个 todo 扩展,它增加了在文档中包含待办事项条目的功能,并将其集中收集在一个地方。这类似于随Sphinx分发的 sphinx.ext.todo 扩展。
概述¶
我们希望扩展将以下内容添加到Sphinx中:
一个
todo指令,其包含一些标记为“TODO”的内容,只有在置位了新的配置值时才显示在输出中。默认情况下,待办事项条目不应出现在输出中。一个
todolist指令,用于创建整个文档中所有待办事项条目的列表。
为此,我们需要在 Sphinx 中添加以下元素:
新指令,称为
todo和todolist。表示这些指令的新文档树节点,通常也称为
todo和todolist。如果新指令只产生一些可由现有节点表示的内容,我们就不需要新节点。一个新的配置值
todo_include_todos(配置值名称应以扩展的名字开头,以保持唯一性),用于控制todo条目是否放入输出中。新的事件处理程序:一个用于
doctree-resolved事件,以替换 todo 和 todolist 节点,一个用于env-merge-info事件以合并来自并行构建的中间结果,还有一个用于env-purge-doc事件(稍后将介绍其原因)。
系统需求¶
与 使用角色和指令扩展语法 一样,我们不会通过PyPI分发此扩展,因此我们再次需要一个Sphinx项目来调用它。您可以使用现有项目或使用 sphinx-quickstart 创建一个新项目。
我们假设您使用的是单独的 source( source )和 build( 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.current_document.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 }
这是一个比 使用角色和指令扩展语法 中介绍的扩展更深入的扩展,但是,我们将逐步查看每个部分以解释发生了什么。
node 类
让我们从节点类开始:
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
Node类除了从 docutils.nodes 中定义的标准的docutils类继承以外,通常不需要做其它任何事情。 todo 继承自 adminion 因为它应该像注释或警告那样处理;todolist 只是一个“常规”节点。
注意
重要的是要知道,虽然您可以在不离开 conf.py 的情况下扩展 Sphinx,但如果您在那里声明了一个继承的节点,您将遇到一个不明显的 PickleError。因此,如果出现问题,请确保将继承的节点放入单独的 Python 模块中。
有关更多详细信息,请参见:
指令类
指令类通常是从 docutils.parsers.rst.Directive 派生的类。指令接口在 docutils documentation 中也有详细介绍;重要的是该类应具有一些属性,用于配置允许的标记;以及 run 方法,以返回节点列表。
首先看一下 TodolistDirective 指令:
1class TodolistDirective(Directive):
2 def run(self):
3 return [todolist('')]
它非常简单,创建并返回我们 todolist 节点类的一个实例。 TodolistDirective 指令本身没有需要处理的内容或参数。这将我们带到了 TodoDirective 指令:
1class TodoDirective(SphinxDirective):
2 # this enables content in the directive
3 has_content = True
4
5 def run(self):
6 targetid = 'todo-%d' % self.env.new_serialno('todo')
7 targetnode = nodes.target('', '', ids=[targetid])
8
9 todo_node = todo('\n'.join(self.content))
10 todo_node += nodes.title(_('Todo'), _('Todo'))
11 todo_node += self.parse_content_to_nodes()
12
13 if not hasattr(self.env, 'todo_all_todos'):
14 self.env.todo_all_todos = []
15
16 self.env.todo_all_todos.append({
17 'docname': self.env.current_document.docname,
18 'lineno': self.lineno,
19 'todo': todo_node.deepcopy(),
20 'target': targetnode,
21 })
22
23 return [targetnode, todo_node]
这里涵盖了几个重要的内容。首先,如您所见,我们现在正在子类化 SphinxDirective 帮助类,而不是通常的 Directive 类。这使我们能够使用 self.env 属性访问 构建环境实例。没有这个,我们将不得不使用相当复杂的 self.state.document.settings.env。然后,为了充当链接目标(来自 TodolistDirective), TodoDirective 指令除了需要返回 todo 节点以外,还需要返回一个目标节点。目标 ID(在 HTML 中,这将是锚名称)是通过使用 env.new_serialno 生成的,它在每次调用时返回一个新的唯一整数,因此会导致唯一的目标名称。目标节点在没有任何文本的情况下实例化(前两个参数)。
在创建警告节点时,使用 self.parse_content_to_nodes() 解析指令的内容主体。随后,todo 节点被添加到环境中。这是为了能够在作者放置 todolist 指令的位置创建整个文档中所有待办事项条目的列表。对于这种情况,使用环境属性 todo_all_todos (同样,名称应该是唯一的,所以它以扩展的名字为前缀)。当创建新环境时它不存在,因此指令必须检查并在必要时创建它。有关待办事项条目位置的各种信息与节点的副本一起存储。
在最后一行中,将返回应该放入文档树中的节点:目标节点和警告节点。
指令返回的节点结构如下所示:
+--------------------+
| 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
7 ]
由于我们将源文件中的信息存储在环境中,这是持久的,所以当源文件更改时,它可能会过期。因此,在读取每个源文件之前,环境中对它的记录都会被清除,并且 env-purge-doc 事件为扩展提供了执行相同操作的机会。在这里,我们从 todo_all_todos 中清除docname与给定todo匹配的todo。如果文档中还剩下todo,则在解析期间将再次添加它们。
下一个处理程序,用于 env-merge-info 事件,在并行构建期间使用。由于在并行构建期间所有线程都有自己的 env,因此需要合并多个 todo_all_todos 列表:
1def merge_todos(app, env, docnames, other):
2 if not hasattr(env, 'todo_all_todos'):
3 env.todo_all_todos = []
4 if hasattr(other, 'todo_all_todos'):
5 env.todo_all_todos.extend(other.todo_all_todos)
另一个处理程序属于 doctree-resolved 事件:
1def process_todo_nodes(app, doctree, fromdocname):
2 if not app.config.todo_include_todos:
3 for node in doctree.findall(todo):
4 node.parent.remove(node)
5
6 # Replace all todolist nodes with a list of the collected todos.
7 # Augment each todo with a backlink to the original location.
8 env = app.env
9
10 if not hasattr(env, 'todo_all_todos'):
11 env.todo_all_todos = []
12
13 for node in doctree.findall(todolist):
14 if not app.config.todo_include_todos:
15 node.replace_self([])
16 continue
17
18 content = []
19
20 for todo_info in env.todo_all_todos:
21 para = nodes.paragraph()
22 filename = env.doc2path(todo_info['docname'], base=None)
23 description = _(
24 '(The original entry is located in %s, line %d and can be found '
25 ) % (filename, todo_info['lineno'])
26 para += nodes.Text(description)
27
28 # Create a reference
29 newnode = nodes.reference('', '')
30 innernode = nodes.emphasis(_('here'), _('here'))
31 newnode['refdocname'] = todo_info['docname']
32 newnode['refuri'] = app.builder.get_relative_uri(
33 fromdocname, todo_info['docname']
34 )
35 newnode['refuri'] += '#' + todo_info['target']['refid']
36 newnode.append(innernode)
37 para += newnode
38 para += nodes.Text('.)')
39
40 # Insert into the todolist
41 content.extend((
42 todo_info['todo'],
43 para,
44 ))
45
46 node.replace_self(content)
doctree-resolved 事件在 phase 3 (resolving) 结束时即将编写的每个文档发出,并允许对该文档进行自定义解析。我们为此事件编写的处理程序更为复杂。如果 todo_include_todos 配置值(我们将很快描述它)为 false,则从文档中删除所有 todo 和 todolist 节点。如果不是, todo 节点就会保持原样。 todolist 节点被待办事项条目的列表替换,并带有指向其来源位置的反向链接。列表项由 todo 条目中的节点和动态创建的 docutils 节点组成:每个条目包含一个段落,其中包含提供位置的文本,以及一个带有反向引用的链接(包含斜体节点的引用节点)。引用 URI 由 sphinx.builders.Builder.get_relative_uri() 构建,它根据所使用的生成器创建适当的 URI,并将待办事项节点(目标)的 ID 作为锚名称附加。
setup 函数
如 previously 所述,setup 函数是必须的,用于将指令插入到 Sphinx 中。但是,我们还使用它来连接扩展的其他部分。让我们来看看我们的 setup 函数:
1def setup(app: Sphinx) -> ExtensionMetadata:
2 app.add_config_value('todo_include_todos', False, 'html')
3
4 app.add_node(todolist)
5 app.add_node(
6 todo,
7 html=(visit_todo_node, depart_todo_node),
8 latex=(visit_todo_node, depart_todo_node),
9 text=(visit_todo_node, depart_todo_node),
10 )
11
12 app.add_directive('todo', TodoDirective)
13 app.add_directive('todolist', TodolistDirective)
14 app.connect('doctree-resolved', process_todo_nodes)
15 app.connect('env-purge-doc', purge_todos)
16 app.connect('env-merge-info', merge_todos)
17
18 return {
19 'version': '0.1',
20 'env_version': 1,
21 'parallel_read_safe': True,
22 'parallel_write_safe': True,
23 }
此函数中的调用引用了我们之前添加的类和函数。各个调用的作用如下:
add_config_value()让 Sphinx 知道它应该识别新的 config valuetodo_include_todos,其默认值为False(这也告诉 Sphinx 它是一个布尔值)。如果第三个参数是
'html',则如果配置值更改其值,则会完全重建 HTML 文档。这对于影响读取(构建 phase 1 (reading))的配置值是必需的。add_node()将一个新的 node class 添加到构建系统中。它还可以为每个支持的输出格式指定访问者函数。当新节点保持到 phase 4 (writing) 时,需要这些访问者函数。由于todolist节点始终在 phase 3 (resolving) 中被替换,因此它不需要任何访问者函数。add_directive()添加一个新 指令,并指定其指令名和类名。最后,
connect()将 event handler 添加到由第一个参数指定名称的事件。调用事件处理程序函数时使用了多个随事件一起记录的参数。
至此,我们的扩展完成了。
使用扩展¶
和以前一样,我们需要通过在 conf.py 文件中声明它来启用扩展。这里有两个必要的步骤:
使用sys.path.append增加
_ext目录到 Python path 中。 这应该放在文件的顶部。更新或创建
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接口。
如果您希望在多个项目或与其他人共享您的扩展,请查看 第三方插件 部分。