扩展构建过程

本教程的目标是创建一个比使用角色和指令扩展语法中创建的更全面的扩展。该指南只涵盖了如何编写自定义的角色指令,而本指南涵盖了对Sphinx构建过程的更复杂扩展;添加多个指令,以及自定义节点、额外的配置值和自定义事件处理程序。

为此,我们将介绍一个todo扩展,它增加了在文档中包含待办事项条目并将它们集中收集的能力。这类似于与Sphinx一起分发的sphinx.ext.todo扩展。

概述

注意

要理解此扩展的设计,请参阅重要对象构建阶段

我们希望该扩展为Sphinx添加以下内容

  • 一个todo指令,包含标记为“TODO”的内容,并且只有在设置了新的配置值时才在输出中显示。默认情况下,待办事项条目不应出现在输出中。

  • 一个todolist指令,用于创建文档中所有待办事项条目的列表。

为此,我们需要向Sphinx添加以下元素

  • 新的指令,称为todotodolist

  • 新的文档树节点来表示这些指令,通常也称为todotodolist。如果新指令只产生可以用现有节点表示的内容,我们就不需要新节点。

  • 一个新的配置值todo_include_todos(配置值名称应以扩展名开头,以保持唯一性),它控制待办事项条目是否进入输出。

  • 新的事件处理程序:一个用于doctree-resolved事件,用于替换待办事项和待办事项列表节点;一个用于env-merge-info,用于合并并行构建的中间结果;一个用于env-purge-doc(其原因将在后面介绍)。

先决条件

使用角色和指令扩展语法一样,我们不会通过PyPI分发此插件,因此我们再次需要一个Sphinx项目来调用它。您可以使用现有项目,也可以使用sphinx-quickstart创建一个新项目。

我们假设您使用单独的源 (source) 和构建 (build) 文件夹。您的扩展文件可以位于项目的任何文件夹中。在我们的例子中,让我们执行以下操作

  1. source中创建一个_ext文件夹

  2. _ext文件夹中创建一个新的Python文件,名为todo.py

这是一个您可能获得的文件夹结构示例

└── 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    }

这个扩展比使用角色和指令扩展语法中详细介绍的要广泛得多,但是,我们将逐步查看每个部分以解释发生了什么。

节点类

让我们从节点类开始

 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指令

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与给定名称匹配的待办事项。如果文档中仍有待办事项,它们将在解析期间再次添加。

下一个处理程序,用于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事件在阶段3(解析)结束时,为每个即将写入的文档发出,并允许在该文档上进行自定义解析。我们为此事件编写的处理程序稍微复杂一些。如果todo_include_todos配置值(我们稍后将描述)为false,则所有todotodolist节点都将从文档中删除。如果不是,todo节点将保持原样。 todolist节点将被替换为待办事项条目列表,并附带指向其来源位置的反向链接。列表项由todo条目中的节点和即时创建的docutils节点组成:每个条目一个段落,包含显示位置的文本,以及一个带有反向引用的链接(包含斜体节点的引用节点)。引用URI由sphinx.builders.Builder.get_relative_uri()构建,该函数根据使用的构建器创建合适的URI,并附加待办事项节点(目标)的ID作为锚点名称。

setup 函数

之前所述,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知道它应该识别新的配置值todo_include_todos,其默认值为False(这也告诉Sphinx它是一个布尔值)。

    如果第三个参数是'html',则当配置值改变时,HTML文档将被完全重建。这对于影响读取的配置值(构建阶段1(读取))是必需的。

  • add_node()向构建系统添加一个新的节点类。它还可以为每种支持的输出格式指定访问函数。当新节点保留到阶段4(写入)时,这些访问函数是必需的。由于todolist节点总是在阶段3(解析)中被替换,因此它不需要任何访问函数。

  • add_directive()添加一个新的指令,由名称和类给出。

  • 最后,connect()将一个事件处理程序添加到由第一个参数给出的事件。事件处理程序函数会调用几个参数,这些参数在事件文档中有所说明。

至此,我们的扩展就完成了。

使用扩展

和以前一样,我们需要在conf.py文件中声明来启用扩展。这里需要两个步骤

  1. 使用sys.path.append_ext目录添加到Python路径。这应该放在文件顶部。

  2. 更新或创建extensions列表,并将扩展文件名添加到列表中

此外,我们可能希望设置todo_include_todos配置值。如上所述,这默认为False,但我们可以显式设置它。

例如

import sys
from pathlib import Path

sys.path.append(str(Path('_ext').resolve()))

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,所以我们实际上不会看到todotodolist指令的任何渲染输出。但是,如果我们将此切换为true,我们将看到前面描述的输出。

延伸阅读

有关更多信息,请参阅docutils文档和Sphinx API

如果您希望在多个项目或与他人共享您的扩展,请查看第三方扩展部分。