开发 “TODO” 插件

本教程的目的是创建一个比 开发一个 “Hello world” 插件 中创建的更全面的插件。那个指南只是涵盖了编写一个自定义的 指令,而本指南增加了多个指令,以及自定义节点、额外的配置值和自定义事件处理程序。为此,我们将介绍 todo 插件,它可以在文档中加入 todo 项,并将其收集在一个中心位置。这与 Sphinx 的 sphinxext.todo 插件类似。

概述

备注

要了解这个插件的设计,请参考 重要对象构建阶段

我们希望这个插件能给 Sphinx 增加以下内容:

  • 一个 todo 指令,包含一些标有 “TODO” 的内容,只有在设置了新的配置值后才会在输出中显示。默认情况下,Todo 条目不应该出现在输出中。

  • 一个 todolist 指令,创建了整个文档中所有 todo 条目的列表。

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

  • 新指令,称为 todotodolist

  • 新建文档树节点来代表这些指令,传统上也称为 todotodolist。如果新指令只产生一些可由现有节点表示的内容,我们就不需要新的节点。

  • 一个新的配置值 todo_include_todos (配置值的名字应该以扩展名开始,以便保持唯一),控制 todo 条目是否进入输出。

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

前提条件

开发一个 “Hello world” 插件 一样,我们不会通过 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.locale import _
  5from sphinx.util.docutils import SphinxDirective
  6
  7
  8class todo(nodes.Admonition, nodes.Element):
  9    pass
 10
 11
 12class todolist(nodes.General, nodes.Element):
 13    pass
 14
 15
 16def visit_todo_node(self, node):
 17    self.visit_admonition(node)
 18
 19
 20def depart_todo_node(self, node):
 21    self.depart_admonition(node)
 22
 23
 24class TodolistDirective(Directive):
 25
 26    def run(self):
 27        return [todolist('')]
 28
 29
 30class TodoDirective(SphinxDirective):
 31
 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        self.state.nested_parse(self.content, self.content_offset, todo_node)
 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 = [todo for todo in env.todo_all_todos
 61                          if todo['docname'] != docname]
 62
 63
 64def merge_todos(app, env, docnames, other):
 65    if not hasattr(env, 'todo_all_todos'):
 66        env.todo_all_todos = []
 67    if hasattr(other, 'todo_all_todos'):
 68        env.todo_all_todos.extend(other.todo_all_todos)
 69
 70
 71def process_todo_nodes(app, doctree, fromdocname):
 72    if not app.config.todo_include_todos:
 73        for node in doctree.traverse(todo):
 74            node.parent.remove(node)
 75
 76    # Replace all todolist nodes with a list of the collected todos.
 77    # Augment each todo with a backlink to the original location.
 78    env = app.builder.env
 79
 80    if not hasattr(env, 'todo_all_todos'):
 81        env.todo_all_todos = []
 82
 83    for node in doctree.traverse(todolist):
 84        if not app.config.todo_include_todos:
 85            node.replace_self([])
 86            continue
 87
 88        content = []
 89
 90        for todo_info in env.todo_all_todos:
 91            para = nodes.paragraph()
 92            filename = env.doc2path(todo_info['docname'], base=None)
 93            description = (
 94                _('(The original entry is located in %s, line %d and can be found ') %
 95                (filename, todo_info['lineno']))
 96            para += nodes.Text(description, description)
 97
 98            # Create a reference
 99            newnode = nodes.reference('', '')
100            innernode = nodes.emphasis(_('here'), _('here'))
101            newnode['refdocname'] = todo_info['docname']
102            newnode['refuri'] = app.builder.get_relative_uri(
103                fromdocname, todo_info['docname'])
104            newnode['refuri'] += '#' + todo_info['target']['refid']
105            newnode.append(innernode)
106            para += newnode
107            para += nodes.Text('.)', '.)')
108
109            # Insert into the todolist
110            content.append(todo_info['todo'])
111            content.append(para)
112
113        node.replace_self(content)
114
115
116def setup(app):
117    app.add_config_value('todo_include_todos', False, 'html')
118
119    app.add_node(todolist)
120    app.add_node(todo,
121                 html=(visit_todo_node, depart_todo_node),
122                 latex=(visit_todo_node, depart_todo_node),
123                 text=(visit_todo_node, depart_todo_node))
124
125    app.add_directive('todo', TodoDirective)
126    app.add_directive('todolist', TodolistDirective)
127    app.connect('doctree-resolved', process_todo_nodes)
128    app.connect('env-purge-doc', purge_todos)
129    app.connect('env-merge-info', merge_todos)
130
131    return {
132        'version': '0.1',
133        'parallel_read_safe': True,
134        'parallel_write_safe': True,
135    }

这比 开发一个 “Hello world” 插件 中详细介绍的扩展要广泛得多,然而,我们将逐步查看每一块,解释发生了什么。

节点类

让我们从节点类开始:

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

节点类通常不需要做任何事情,除了继承 docutils.nodes 中定义的标准 docutils 类。todo 继承自 Admonition,因为它应该像注释或警告一样被处理,todolist 只是一个 “通用” 节点。

备注

许多扩展将不必创建他们自己的节点类,并与已经由 docutilsSphinx 提供的节点顺利工作。

注意

需要知道的是,虽然你可以在不离开 conf.py 的情况下扩展 Sphinx,但如果你在那里声明一个继承的节点,你会遇到一个不明显的 PickleError。所以如果出了问题,请确保你把继承的节点放到一个单独的 Python 模块中。

更多细节见:

指令类

指令类是一个通常从 docutils.parsers.rst.Directive 派生出来的类。指令接口在 docutils 文档 中也有详细介绍;重要的是,该类应该有配置允许标记的属性,以及返回节点列表的 run 方法。

首先看一下 TodolistDirective 指令。

1class TodolistDirective(Directive):
2
3    def run(self):
4        return [todolist('')]

这很简单,创建并返回我们的 todolist 节点类的实例。TodolistDirective 指令本身既没有内容也没有需要处理的参数。这给我们带来了 TodoDirective 指令:

 1class TodoDirective(SphinxDirective):
 2
 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        self.state.nested_parse(self.content, self.content_offset, todo_node)
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 属性访问 build environment 实例。如果没有这个,我们将不得不使用相当复杂的 self.state.document.settings.env。然后,为了充当链接目标(来自 TodolistDirective ),TodoDirective 指令需要在 todo 节点之外返回一个目标节点。目标 ID(在 HTML 中,这将是锚的名称)是通过使用 env.new_serialno 来生成的,它在每次调用时都会返回一个新的唯一的整数,因此导致了唯一的目标名称。目标节点被实例化,没有任何文本(前两个参数)。

在创建告诫节点时,指令的内容体使用 self.state.nested_parse 进行解析。第一个参数给出内容主体,第二个参数给出内容偏移。第三个参数给出解析结果的父节点,在我们的例子中是 todo 节点。之后,“todo” 节点被添加到环境中。这是需要的,以便能够在作者放置 todolist 指令的地方,创建整个文档中所有 todo 条目的列表。在这种情况下,环境属性 todo_all_todos 被使用(同样,这个名字应该是唯一的,所以它以扩展名为前缀)。 当一个新环境被创建时,它并不存在,所以指令必须检查并在必要时创建它。关于 todo 条目的位置的各种信息与节点的副本一起被存储。

在最后一行,应该被放入 doctree 中的节点被返回:目标节点和训诫节点。

该指令返回的节点结构看起来像这样

+--------------------+
| 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 = [todo for todo in env.todo_all_todos
6                          if todo['docname'] != docname]

由于我们将源文件的信息存储在环境中,这是持久性的,当源文件发生变化时,这些信息可能会过时。因此,在读取每个源文件之前,环境的记录会被清除,并且 env-purge-doc 事件会让插件有机会做同样的事情。在这里,我们从 todo_all_todos 列表中清除所有 docname 与给定 todos 匹配的 todos。如果文档中还有待办事项,它们将在解析过程中再次添加。

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.traverse(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.builder.env
 9
10    if not hasattr(env, 'todo_all_todos'):
11        env.todo_all_todos = []
12
13    for node in doctree.traverse(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, 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            newnode['refuri'] += '#' + todo_info['target']['refid']
35            newnode.append(innernode)
36            para += newnode
37            para += nodes.Text('.)', '.)')
38
39            # Insert into the todolist
40            content.append(todo_info['todo'])
41            content.append(para)
42
43        node.replace_self(content)

doctree-resolved 事件在 phase 3 (resolving) 的末尾触发,并允许自定义解析。我们为这个事件编写的处理程序更复杂一些。如果 todo_include_todos 配置值(我们将很快描述)为 false,所有 todotodolist 节点将从文档中删除。如果没有,todo 节点就会原地不动。todolist 节点被一列 todo 条目所取代,并附有指向它们来源位置的反向链接。列表项由 todo 条目中的节点和动态创建的 docutils 节点组成:每个条目都有段落,其中包含给出位置的文本,以及带有反向引用的链接(引用节点包含一个斜体节点)。引用 URI 是由 sphinx.builders.Builder.get_relative_uri() 构建的,它根据使用的构建器创建合适的 URI,并将 todo 节点(目标)的 ID 附加为锚的名称。

setup 函数

如前所述 previouslysetup 函数是必需的,用于将指令插入 Sphinx。但是,我们也使用它来连接插件的其他部分。让我们看看 setup 函数:

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

这个函数中的调用引用了我们之前添加的类和函数。独立回调的作用如下:

  • add_config_value() 让 Sphinx 知道它应该识别新的 config value todo_include_todos,其默认值应该是 False (这也告诉 Sphinx 它是一个布尔值)。

    如果第三个参数是 'html',如果配置值改变了它的值,HTML 文档将被完全重建。这需要配置影响读取的值(build phase 1 (reading))。

  • add_node() 向构建系统中添加新的 node class。它还可以为每种支持的输出格式指定访问者函数。当新的节点停留到 phase 4 (writing) 时,需要这些 visitor 函数。因为 todolist 节点总是被替换为 phase 3 (resolving),所以它不需要任何。

  • add_directive() 添加新的 directive,由名称和类给出。

  • 最后,connect() 为事件添加 event handler,其名称由第一个参数给出。事件处理函数被调用时带有几个参数,这些参数被记录在事件中。

有了这个,我们的插件就完成了。

使用插件

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

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

  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,我们实际上不会看到 todotodolist 指令的任何呈现。然而,如果我们将此设置为 true,我们将看到前面描述的输出。

进一步的阅读

更多信息,请参考 docutils documentation 和 为 Sphinx 开发插件