开发“TODO”扩展

本教程的目标是创建一个比 开发“Hello World”扩展 . 而这本指南只是涵盖了写一个习惯 directive 本指南添加了多个指令,以及自定义节点、其他配置值和自定义事件处理程序。为此,我们将涵盖 todo 扩展,它添加了在文档中包含TODO项并在中心位置收集这些项的功能。这和 sphinxext.todo 扩展与Sphinx分布。

概述

备注

要了解此扩展的设计,请参阅 重要对象建造阶段 .

我们希望扩展名为sphinx添加以下内容:

  • A todo 指令,其中包含一些标记为“todo”的内容,并且仅在设置新的配置值时显示在输出中。默认情况下,TODO项不应在输出中。

  • A 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. 创建一个 _ext 文件夹在 source

  2. 在中创建新的python文件 _ext 文件夹名为 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        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        'parallel_read_safe': True,
136        'parallel_write_safe': True,
137    }

这比 开发“Hello World”扩展 不过,我们将一步一步地研究每一件事情,以解释正在发生的事情。

节点类

让我们从节点类开始:

 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类。 docutils.nodes . todo 继承自 Admonition 因为它应该像注释或警告一样处理, todolist 只是一个“常规”节点。

备注

许多扩展不必创建自己的节点类,并且可以很好地使用已由提供的节点 docutilsSphinx

注意

重要的是要知道,虽然您可以扩展Sphinx而无需离开您的 conf.py ,如果您在那里声明一个继承的节点,您将遇到一个不明显的 PickleError 。因此,如果出现问题,请确保将继承的节点放入单独的Python模块中。

有关详细信息,请参阅:

指令类

指令类通常是从 docutils.parsers.rst.Directive . 指令接口也在 docutils documentation ;重要的是类应该具有配置允许标记的属性,以及 run 返回节点列表的方法。

先看一下 TodolistDirective 指令:

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

它非常简单,创建并返回 todolist 节点类。这个 TodolistDirective 指令本身既没有需要处理的内容,也没有需要处理的参数。这就把我们带到 TodoDirective 指令:

 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]

这里介绍了一些重要的事情。首先,如您所见,我们现在将 SphinxDirective 帮助程序类而不是通常的 Directive 班级。这使我们能够访问 build environment instance 使用 self.env 财产。如果没有这个,我们将不得不使用相当复杂的 self.state.document.settings.env . 然后,作为链接目标(从 TodolistDirectiveTodoDirective 指令需要返回除 todo 节点。通过使用 env.new_serialno 它会在每次调用时返回一个新的唯一整数,从而导致唯一的目标名称。目标节点被实例化,没有任何文本(前两个参数)。

在创建警告节点时,使用 self.state.nested_parse . 第一个参数给出内容体,第二个参数给出内容偏移量。第三个参数给出了解析结果的父节点,在我们的示例中, todo 节点。在这之后, todo 节点将添加到环境中。这需要能够在文档的整个过程中,在作者放置 todolist 指令。对于这种情况,环境属性 todo_all_todos 使用(同样,名称应该是唯一的,所以它以扩展名为前缀)。它在创建新环境时不存在,因此该指令必须检查并在必要时创建它。有关TODO项位置的各种信息与节点的副本一起存储。

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

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

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

事件处理程序

事件处理程序是Sphinx最强大的功能之一,它提供了一种钩住文档流程任何部分的方法。Sphinx本身提供了许多事件,详情见 the API guide ,我们将在这里使用它们的一个子集。

让我们看看上面例子中使用的事件处理程序。首先,为 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 if todo['docname'] != docname]
6

由于我们将源文件中的信息存储在环境中(这是持久的),因此当源文件更改时,它可能会过时。因此,在读取每个源文件之前,环境的记录都会被清除,并且 env-purge-doc 事件给扩展一个机会来做同样的事情。在这里,我们清除所有文档名与给定文档名匹配的待办事项 todo_all_todos 名单。如果文档中还有待办事项,则在分析过程中将再次添加这些待办事项。

下一个处理程序 env-merge-info 事件,在并行生成期间使用。在并行构建期间,所有线程都有自己的线程 env ,有多个 todo_all_todos 需要合并的列表:

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

另一个处理程序属于 doctree-resolved 事件:

 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)

这个 doctree-resolved 事件在结束时发出 phase 3 (resolving) 并允许自定义解析。我们为这个事件编写的处理程序有点复杂。如果 todo_include_todos 配置值(稍后我们将描述)为假,全部 todotodolist 节点将从文档中删除。如果不是, todo 节点只停留在它们的位置和方式。 todolist 节点被一个TODO条目列表所取代,其中包含到它们来自的位置的反向链接。列表项由来自 todo 即时创建的entry和docutils节点:每个条目的一个段落,包含给出位置的文本,以及一个带有backreference的链接(引用节点包含斜体节点)。引用URI由 sphinx.builders.Builder.get_relative_uri() 它根据使用的构建器创建一个合适的URI,并附加todo节点(目标的)ID作为锚定名称。

这个 setup 功能

如所指出的 previously , the 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        'parallel_read_safe': True,
21        'parallel_write_safe': True,
22    }

此函数中的调用引用了我们前面添加的类和函数。个人电话的作用如下:

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

    如果第三个论点是 'html' ,如果配置值更改了HTML文档的值,则HTML文档将被完全重新生成。这对于影响读取(构建)的配置值是必需的 phase 1 (reading)

  • add_node() 添加新的 节点类 到构建系统。它还可以为每个支持的输出格式指定访问者函数。当新节点停留到 phase 4 (writing) . 自从 todolist 节点总是在中替换 phase 3 (resolving) 不需要。

  • add_directive() 添加新的 指令 ,由名称和类提供。

  • 最后, connect() 添加一个 事件处理程序 其名称由第一个参数给定的事件。调用事件处理程序函数时使用了几个随事件一起记录的参数。

有了这个,我们的扩展就完成了。

使用扩展名

和以前一样,我们需要通过在 conf.py 文件。这里需要两个步骤:

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

  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_todosFalse ,我们不会实际看到 todotodolist 指令。但是,如果我们将其切换为true,我们将看到前面描述的输出。

进一步阅读

有关详细信息,请参阅 docutils 文件和 Sphinx扩展API .