开发一个 “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          |
     +--------------------+
     | ...                |
     +--------------------+

事件处理者

Event handlers are one of Sphinx’s most powerful features, providing a way to do hook into any part of the documentation process. There are many events provided by Sphinx itself, as detailed in the API guide, and we’re going to use a subset of them here.

Let’s look at the event handlers used in the above example. First, the one for the env-purge-doc event:

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]

Since we store information from source files in the environment, which is persistent, it may become out of date when the source file changes. Therefore, before each source file is read, the environment’s records of it are cleared, and the env-purge-doc event gives extensions a chance to do the same. Here we clear out all todos whose docname matches the given one from the todo_all_todos list. If there are todos left in the document, they will be added again during parsing.

The next handler, for the env-merge-info event, is used during parallel builds. As during parallel builds all threads have their own env, there’s multiple todo_all_todos lists that need to be merged:

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)

The other handler belongs to the doctree-resolved event:

 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)

The doctree-resolved event is emitted at the end of phase 3 (resolving) and allows custom resolving to be done. The handler we have written for this event is a bit more involved. If the todo_include_todos config value (which we’ll describe shortly) is false, all todo and todolist nodes are removed from the documents. If not, todo nodes just stay where and how they are. todolist nodes are replaced by a list of todo entries, complete with backlinks to the location where they come from. The list items are composed of the nodes from the todo entry and docutils nodes created on the fly: a paragraph for each entry, containing text that gives the location, and a link (reference node containing an italic node) with the backreference. The reference URI is built by sphinx.builders.Builder.get_relative_uri() which creates a suitable URI depending on the used builder, and appending the todo node’s (the target’s) ID as the anchor name.

The setup function

As noted previously, the setup function is a requirement and is used to plug directives into Sphinx. However, we also use it to hook up the other parts of our extension. Let’s look at our setup function:

 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    }

The calls in this function refer to the classes and functions we added earlier. What the individual calls do is the following:

  • add_config_value() lets Sphinx know that it should recognize the new config value todo_include_todos, whose default value should be False (this also tells Sphinx that it is a boolean value).

    If the third argument was 'html', HTML documents would be full rebuild if the config value changed its value. This is needed for config values that influence reading (build phase 1 (reading)).

  • add_node() adds a new node class to the build system. It also can specify visitor functions for each supported output format. These visitor functions are needed when the new nodes stay until phase 4 (writing). Since the todolist node is always replaced in phase 3 (resolving), it doesn’t need any.

  • add_directive() adds a new directive, given by name and class.

  • Finally, connect() adds an event handler to the event whose name is given by the first argument. The event handler function is called with several arguments which are documented with the event.

With this, our extension is complete.

Using the extension

As before, we need to enable the extension by declaring it in our conf.py file. There are two steps necessary here:

  1. Add the _ext directory to the Python path using sys.path.append. This should be placed at the top of the file.

  2. Update or create the extensions list and add the extension file name to the list

In addition, we may wish to set the todo_include_todos config value. As noted above, this defaults to False but we can set it explicitly.

For example:

import os
import sys

sys.path.append(os.path.abspath("./_ext"))

extensions = ['todo']

todo_include_todos = False

You can now use the extension throughout your project. For example:

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

Because we have configured todo_include_todos to False, we won’t actually see anything rendered for the todo and todolist directives. However, if we toggle this to true, we will see the output described previously.

Further reading

For more information, refer to the docutils documentation and 为 Sphinx 开发插件.