Developing a “TODO” extension

The objective of this tutorial is to create a more comprehensive extension than that created in 开发了一个 “Hello world” 扩展. Whereas that guide just covered writing a custom directive, this guide adds multiple directives, along with custom nodes, additional config values and custom event handlers. To this end, we will cover a todo extension that adds capabilities to include todo entries in the documentation, and to collect these in a central place. This is similar the sphinxext.todo extension distributed with Sphinx.

概述

备注

To understand the design of this extension, refer to 重要对象 and 构建阶段.

我们希望插件将以下内容添加到Sphinx中:

  • A todo directive, containing some content that is marked with “TODO” and only shown in the output if a new config value is set. Todo entries should not be in the output by default.

  • A todolist directive that creates a list of all todo entries throughout the documentation.

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

  • 新指令,称为“todo”和“todolist”。

  • 表示这些指令的新文档树节点,通常也称为“todo”和“todolist”。如果新指令只产生一些可由现有节点表示的内容,我们就不需要新节点。

  • 一个新的配置值“todo”include_todos`(配置值名称应以扩展名开头,以保持唯一性),用于控制todo条目是否将其放入输出中。

  • New event handlers: one for the doctree-resolved event, to replace the todo and todolist nodes, one for env-merge-info to merge intermediate results from parallel builds, and one for env-purge-doc (the reason for that will be covered later).

系统需求

As with 开发了一个 “Hello world” 扩展, we will not be distributing this plugin via PyPI so once again we need a Sphinx project to call this from. You can use an existing project or create a new one using sphinx-quickstart.

我们假设您使用的是单独的 source(:file:source)和build(:file:build)文件夹。 您的扩展文件可以在项目的任何文件夹中。 在我们的情况下,让我们执行以下操作:

  1. 创建了 _ext folder in source

  2. Create a new Python file in the _ext folder called todo.py

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

└── source
    ├── _ext
    │   └── todo.py
    ├── _static
    ├── conf.py
    ├── somefolder
    ├── index.rst
    ├── somefile.rst
    └── someotherfile.rst

编写扩展

Open todo.py and paste the following code in it, all of which we will explain in detail shortly:

  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        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 if todo['docname'] != docname]
 61
 62
 63def merge_todos(app, env, docnames, other):
 64    if not hasattr(env, 'todo_all_todos'):
 65        env.todo_all_todos = []
 66    if hasattr(other, 'todo_all_todos'):
 67        env.todo_all_todos.extend(other.todo_all_todos)
 68
 69
 70def process_todo_nodes(app, doctree, fromdocname):
 71    if not app.config.todo_include_todos:
 72        for node in doctree.findall(todo):
 73            node.parent.remove(node)
 74
 75    # Replace all todolist nodes with a list of the collected todos.
 76    # Augment each todo with a backlink to the original location.
 77    env = app.builder.env
 78
 79    if not hasattr(env, 'todo_all_todos'):
 80        env.todo_all_todos = []
 81
 82    for node in doctree.findall(todolist):
 83        if not app.config.todo_include_todos:
 84            node.replace_self([])
 85            continue
 86
 87        content = []
 88
 89        for todo_info in env.todo_all_todos:
 90            para = nodes.paragraph()
 91            filename = env.doc2path(todo_info['docname'], base=None)
 92            description = _(
 93                '(The original entry is located in %s, line %d and can be found '
 94            ) % (filename, todo_info['lineno'])
 95            para += nodes.Text(description)
 96
 97            # Create a reference
 98            newnode = nodes.reference('', '')
 99            innernode = nodes.emphasis(_('here'), _('here'))
100            newnode['refdocname'] = todo_info['docname']
101            newnode['refuri'] = app.builder.get_relative_uri(fromdocname, todo_info['docname'])
102            newnode['refuri'] += '#' + todo_info['target']['refid']
103            newnode.append(innernode)
104            para += newnode
105            para += nodes.Text('.)')
106
107            # Insert into the todolist
108            content.extend((
109                todo_info['todo'],
110                para,
111            ))
112
113        node.replace_self(content)
114
115
116def setup(app: Sphinx) -> ExtensionMetadata:
117    app.add_config_value('todo_include_todos', False, 'html')
118
119    app.add_node(todolist)
120    app.add_node(
121        todo,
122        html=(visit_todo_node, depart_todo_node),
123        latex=(visit_todo_node, depart_todo_node),
124        text=(visit_todo_node, depart_todo_node),
125    )
126
127    app.add_directive('todo', TodoDirective)
128    app.add_directive('todolist', TodolistDirective)
129    app.connect('doctree-resolved', process_todo_nodes)
130    app.connect('env-purge-doc', purge_todos)
131    app.connect('env-merge-info', merge_todos)
132
133    return {
134        'version': '0.1',
135        'env_version': 1,
136        'parallel_read_safe': True,
137        'parallel_write_safe': True,
138    }

This is far more extensive extension than the one detailed in 开发了一个 “Hello world” 扩展, however, we will will look at each piece step-by-step to explain what’s happening.

The node classes

Let’s start with the node classes:

 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

除了从mod:docutils.nodes文件. ``todo``继承自``adminion``因为它应该像注释或警告那样处理,`todolist`只是一个“常规”节点。

备注

Many extensions will not have to create their own node classes and work fine with the nodes already provided by docutils and Sphinx.

注意

It is important to know that while you can extend Sphinx without leaving your conf.py, if you declare an inherited node right there, you’ll hit an unobvious PickleError. So if something goes wrong, please make sure that you put inherited nodes into a separate Python module.

For more details, see:

The directive classes

A directive class is a class deriving usually from docutils.parsers.rst.Directive. The directive interface is also covered in detail in the docutils documentation; the important thing is that the class should have attributes that configure the allowed markup, and a run method that returns a list of nodes.

Looking first at the TodolistDirective directive:

1
2
3class TodolistDirective(Directive):
4    def run(self):

It’s very simple, creating and returning an instance of our todolist node class. The TodolistDirective directive itself has neither content nor arguments that need to be handled. That brings us to the TodoDirective directive:

 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        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]

Several important things are covered here. First, as you can see, we’re now subclassing the SphinxDirective helper class instead of the usual Directive class. This gives us access to the build environment instance using the self.env property. Without this, we’d have to use the rather convoluted self.state.document.settings.env. Then, to act as a link target (from TodolistDirective), the TodoDirective directive needs to return a target node in addition to the todo node. The target ID (in HTML, this will be the anchor name) is generated by using env.new_serialno which returns a new unique integer on each call and therefore leads to unique target names. The target node is instantiated without any text (the first two arguments).

On creating admonition node, the content body of the directive are parsed using self.state.nested_parse. The first argument gives the content body, and the second one gives content offset. The third argument gives the parent node of parsed result, in our case the todo node. Following this, the todo node is added to the environment. This is needed to be able to create a list of all todo entries throughout the documentation, in the place where the author puts a todolist directive. For this case, the environment attribute todo_all_todos is used (again, the name should be unique, so it is prefixed by the extension name). It does not exist when a new environment is created, so the directive must check and create it if necessary. Various information about the todo entry’s location are stored along with a copy of the node.

在最后一行中,将返回应该放入doctree中的节点:目标节点和警告节点。

指令返回的节点结构如下所示:

+--------------------+
| target node        |
+--------------------+
+--------------------+
| todo node          |
+--------------------+
  \__+--------------------+
     | admonition title   |
     +--------------------+
     | paragraph          |
     +--------------------+
     | ...                |
     +--------------------+

The event handlers

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 if todo['docname'] != docname]
6

由于我们将源文件中的信息存储在环境中,这是持久的,所以当源文件更改时,它可能会过期。因此,在读取每个源文件之前,环境中对它的记录都会被清除,并且:event:`env purge doc`事件为扩展提供了执行相同操作的机会。在这里,我们从“todo”列表中清除docname与给定todo匹配的todo。如果文档中还剩下todo,则在解析期间将再次添加它们。

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:

1    if not hasattr(env, 'todo_all_todos'):
2        env.todo_all_todos = []
3    if hasattr(other, 'todo_all_todos'):
4        env.todo_all_todos.extend(other.todo_all_todos)
5

The other handler belongs to the doctree-resolved event:

 1    if not app.config.todo_include_todos:
 2        for node in doctree.findall(todo):
 3            node.parent.remove(node)
 4
 5    # Replace all todolist nodes with a list of the collected todos.
 6    # Augment each todo with a backlink to the original location.
 7    env = app.builder.env
 8
 9    if not hasattr(env, 'todo_all_todos'):
10        env.todo_all_todos = []
11
12    for node in doctree.findall(todolist):
13        if not app.config.todo_include_todos:
14            node.replace_self([])
15            continue
16
17        content = []
18
19        for todo_info in env.todo_all_todos:
20            para = nodes.paragraph()
21            filename = env.doc2path(todo_info['docname'], base=None)
22            description = _(
23                '(The original entry is located in %s, line %d and can be found '
24            ) % (filename, todo_info['lineno'])
25            para += nodes.Text(description)
26
27            # Create a reference
28            newnode = nodes.reference('', '')
29            innernode = nodes.emphasis(_('here'), _('here'))
30            newnode['refdocname'] = todo_info['docname']
31            newnode['refuri'] = app.builder.get_relative_uri(fromdocname, todo_info['docname'])
32            newnode['refuri'] += '#' + todo_info['target']['refid']
33            newnode.append(innernode)
34            para += newnode
35            para += nodes.Text('.)')
36
37            # Insert into the todolist
38            content.extend((
39                todo_info['todo'],
40                para,
41            ))
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.

setup 功能

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: 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    }

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

  • :方法:~Sphinx.add_config_值`让Sphinx知道它应该识别新的*config值*``todo_include_todos``,它的默认值应该是`False`(这也告诉Sphinx它是一个布尔值)。

    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.

  • :方法:`~Sphinx.add_指令`添加一个由名称和类给定的新*指令*。

  • 最后,:meth:`~Sphinx.connect`将*event handler*添加到由第一个参数指定名称的事件。调用事件处理程序函数时使用了多个随事件一起记录的参数。

With this, our extension is complete.

使用扩展

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

  1. 使用sys.path.append将`_ext`目录将新增到 Python 路径_中。 这应该放在文件的顶部。

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

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.

例如:

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

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.

延伸阅读

有关更多信息,请参考 docutils 文档和:doc:/ extdev / index。