开发 “TODO” 扩展

本教程的目标是创建一个比在 开发一个 “Hello world” 扩展 中创建的更全面的扩展.虽然该指南仅涉及编写自定义 directive,但本指南添加了多个指令,以及自定义节点,其他配置值和自定义事件处理程序.为此,我们将介绍一个 todo 扩展,它增加了在文档中包含todo条目的功能,并在中心位置收集这些条目.这类似于与Sphinx一起发布的 sphinxext.todo 扩展.

概述

注解

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

我们希望扩展程序将以下内容添加到Sphinx:

  • 一个 todo 指令,包含一些用 “TODO” 标记的内容,如果设置了新的配置值,则只显示在输出中. Todo条目默认不应该在输出中.

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

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

  • 新的指令,称为 todotodolist.

  • 用于表示这些指令的新文档树节点,通常也称为 todotodolist.如果新指令仅产生现有节点可表示的某些内容,则我们不需要新节点.

  • 一个新的配置值 todo_include_todos (配置值名称应以扩展名开头,以保持唯一),控制todo条目是否使其进入输出.

  • 新事件处理程序: 一个用于 doctree-resolved 事件,用于替换todo和todolist节点,另一个用于 env-purge-doc (其原因将在后面介绍).

先决条件

开发一个 “Hello world” 扩展 一样,我们不会通过PyPI分发这个插件,所以我们需要一个Sphinx项目来调用它.您可以使用 sphinx-quickstart 来使用现有项目或创建新项目.

我们假设您使用单独的源(source)和build(build)文件夹.您的扩展文件可以位于项目的任何文件夹中.在我们的例子中,让我们做以下事情:

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

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

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

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

编写扩展

打开 todo.py 并在其中粘贴以下代码,所有这些我们将在稍后详细解释:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
from docutils import nodes
from docutils.parsers.rst import Directive

from sphinx.locale import _
from sphinx.util.docutils import SphinxDirective


class todo(nodes.Admonition, nodes.Element):
    pass


class todolist(nodes.General, nodes.Element):
    pass


def visit_todo_node(self, node):
    self.visit_admonition(node)


def depart_todo_node(self, node):
    self.depart_admonition(node)


class TodolistDirective(Directive):

    def run(self):
        return [todolist('')]


class TodoDirective(SphinxDirective):

    # this enables content in the directive
    has_content = True

    def run(self):
        targetid = 'todo-%d' % self.env.new_serialno('todo')
        targetnode = nodes.target('', '', ids=[targetid])

        todo_node = todo('\n'.join(self.content))
        todo_node += nodes.title(_('Todo'), _('Todo'))
        self.state.nested_parse(self.content, self.content_offset, todo_node)

        if not hasattr(self.env, 'todo_all_todos'):
            self.env.todo_all_todos = []

        self.env.todo_all_todos.append({
            'docname': self.env.docname,
            'lineno': self.lineno,
            'todo': todo_node.deepcopy(),
            'target': targetnode,
        })

        return [targetnode, todo_node]


def purge_todos(app, env, docname):
    if not hasattr(env, 'todo_all_todos'):
        return

    env.todo_all_todos = [todo for todo in env.todo_all_todos
                          if todo['docname'] != docname]


def process_todo_nodes(app, doctree, fromdocname):
    if not app.config.todo_include_todos:
        for node in doctree.traverse(todo):
            node.parent.remove(node)

    # Replace all todolist nodes with a list of the collected todos.
    # Augment each todo with a backlink to the original location.
    env = app.builder.env

    for node in doctree.traverse(todolist):
        if not app.config.todo_include_todos:
            node.replace_self([])
            continue

        content = []

        for todo_info in env.todo_all_todos:
            para = nodes.paragraph()
            filename = env.doc2path(todo_info['docname'], base=None)
            description = (
                _('(The original entry is located in %s, line %d and can be found ') %
                (filename, todo_info['lineno']))
            para += nodes.Text(description, description)

            # Create a reference
            newnode = nodes.reference('', '')
            innernode = nodes.emphasis(_('here'), _('here'))
            newnode['refdocname'] = todo_info['docname']
            newnode['refuri'] = app.builder.get_relative_uri(
                fromdocname, todo_info['docname'])
            newnode['refuri'] += '#' + todo_info['target']['refid']
            newnode.append(innernode)
            para += newnode
            para += nodes.Text('.)', '.)')

            # Insert into the todolist
            content.append(todo_info['todo'])
            content.append(para)

        node.replace_self(content)


def setup(app):
    app.add_config_value('todo_include_todos', False, 'html')

    app.add_node(todolist)
    app.add_node(todo,
                 html=(visit_todo_node, depart_todo_node),
                 latex=(visit_todo_node, depart_todo_node),
                 text=(visit_todo_node, depart_todo_node))

    app.add_directive('todo', TodoDirective)
    app.add_directive('todolist', TodolistDirective)
    app.connect('doctree-resolved', process_todo_nodes)
    app.connect('env-purge-doc', purge_todos)

    return {
        'version': '0.1',
        'parallel_read_safe': True,
        'parallel_write_safe': True,
    }

这是比以下详细介绍的更广泛的扩展 开发一个 “Hello world” 扩展,但是,我们将逐步查看每个部分以解释正在发生的事情.

节点类

让我们从节点类开始:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class todo(nodes.Admonition, nodes.Element):
    pass


class todolist(nodes.General, nodes.Element):
    pass


def visit_todo_node(self, node):
    self.visit_admonition(node)


def depart_todo_node(self, node):
    self.depart_admonition(node)

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

注解

许多扩展不必创建自己的节点类,并且可以使用 docutilsSphinx.

指令类

指令类是通常从“派生”派生的类 docutils.parsers.rst.Directive.指令接口也在 docutils documentation 中详细介绍;重要的是该类应该具有配置允许标记的属性,并且 run 方法,返回节点列表.

首先看一下 TodolistDirective 指令:

1
2
3
4
class TodolistDirective(Directive):

    def run(self):
        return [todolist('')]

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class TodoDirective(SphinxDirective):

    # this enables content in the directive
    has_content = True

    def run(self):
        targetid = 'todo-%d' % self.env.new_serialno('todo')
        targetnode = nodes.target('', '', ids=[targetid])

        todo_node = todo('\n'.join(self.content))
        todo_node += nodes.title(_('Todo'), _('Todo'))
        self.state.nested_parse(self.content, self.content_offset, todo_node)

        if not hasattr(self.env, 'todo_all_todos'):
            self.env.todo_all_todos = []

        self.env.todo_all_todos.append({
            'docname': self.env.docname,
            'lineno': self.lineno,
            'todo': todo_node.deepcopy(),
            'target': targetnode,
        })

        return [targetnode, todo_node]

这里介绍了几个重要的事情.首先,正如您所看到的,我们现在继承子类 SphinxDirective 助手类而不是通常的 Directive 类.这使我们可以使用 self.env 属性访问 build environment instance.没有这个,我们必须使用相当复杂的 self.state.document.settings.env.然后,作为一个链接目标(来自 TodolistDirective), TodoDirective 指令除了 todo 节点外还需要返回一个目标节点.目标ID(在HTML中,这将是锚名称)是使用 env.new_serialno 生成的,它在每次调用时返回一个新的唯一整数,因此会导致唯一的目标名称.目标节点实例化而没有任何文本(前两个参数).

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

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

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

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

事件处理程序

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

让我们看看上面例子中使用的事件处理程序.首先,一个用于 env-purge-doc 事件:

1
2
3
4
5
6
def purge_todos(app, env, docname):
    if not hasattr(env, 'todo_all_todos'):
        return

    env.todo_all_todos = [todo for todo in env.todo_all_todos
                          if todo['docname'] != docname]

由于我们将源文件中的信息存储在持久的环境中,因此当源文件发生更改时,它可能会过时.因此,在读取每个源文件之前,清除环境的记录,并且 env-purge-doc 事件为扩展提供了执行相同操作的机会.在这里,我们清除所有todos,其docname与 todo_all_todos 列表中的给定匹配.如果文档中还有待办事项,则在解析期间将再次添加它们.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def process_todo_nodes(app, doctree, fromdocname):
    if not app.config.todo_include_todos:
        for node in doctree.traverse(todo):
            node.parent.remove(node)

    # Replace all todolist nodes with a list of the collected todos.
    # Augment each todo with a backlink to the original location.
    env = app.builder.env

    for node in doctree.traverse(todolist):
        if not app.config.todo_include_todos:
            node.replace_self([])
            continue

        content = []

        for todo_info in env.todo_all_todos:
            para = nodes.paragraph()
            filename = env.doc2path(todo_info['docname'], base=None)
            description = (
                _('(The original entry is located in %s, line %d and can be found ') %
                (filename, todo_info['lineno']))
            para += nodes.Text(description, description)

            # Create a reference
            newnode = nodes.reference('', '')
            innernode = nodes.emphasis(_('here'), _('here'))
            newnode['refdocname'] = todo_info['docname']
            newnode['refuri'] = app.builder.get_relative_uri(
                fromdocname, todo_info['docname'])
            newnode['refuri'] += '#' + todo_info['target']['refid']
            newnode.append(innernode)
            para += newnode
            para += nodes.Text('.)', '.)')

            # Insert into the todolist
            content.append(todo_info['todo'])
            content.append(para)

        node.replace_self(content)

doctree-resolved 事件在以下结尾发出 阶段3(解析) 并允许自定义解析.我们为此事件编写的处理程序涉及更多.如果 todo_include_todos 配置值(我们将在稍后描述)为false,则从文档中删除所有 todotodolist 节点.如果没有, todo 节点就会停留在哪里以及它们如何. todolist 节点被todo条目列表替换,并带有到它们来自的位置的反向链接.列表项由 todo 条目中的节点和动态创建的docutils节点组成:每个条目的段落,包含给出位置的文本,以及包含斜体节点的链接(包含斜体节点的参考节点)反向引用.引用URI由以下构建 sphinx.builders.Builder.get_relative_uri(), 它根据使用的构建器创建合适的URI,并附加待办事项节点(目标)的ID作为锚名称.

setup 函数

如上所述 之前的, setup 函数是一个要求,用于将指令插入Sphinx.但是,我们也使用它来连接扩展的其他部分.让我们看看我们的 setup 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def setup(app):
    app.add_config_value('todo_include_todos', False, 'html')

    app.add_node(todolist)
    app.add_node(todo,
                 html=(visit_todo_node, depart_todo_node),
                 latex=(visit_todo_node, depart_todo_node),
                 text=(visit_todo_node, depart_todo_node))

    app.add_directive('todo', TodoDirective)
    app.add_directive('todolist', TodolistDirective)
    app.connect('doctree-resolved', process_todo_nodes)
    app.connect('env-purge-doc', purge_todos)

    return {
        'version': '0.1',
        'parallel_read_safe': True,
        'parallel_write_safe': True,
    }

此函数中的调用是指我们之前添加的类和函数.个人电话的作用如下:

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

    如果第三个参数是 'html',如果配置值改变了它的值,HTML文档将完全重建.这对于影响读取的配置值是必需的(build:ref:phase 1(reading)<build-phases>).

  • add_node() 为构建系统添加了一个新的 node class.它还可以为每种支持的输出格式指定访问者功能.当新节点停留时,需要这些访问者函数 阶段4(写入).由于 todolist 节点总是被替换为 phase 3(resolving),它不需要任何节点.

  • add_directive() 添加一个新的*指令*,由name和class给出.

  • 最后,:meth:~Sphinx.connect事件处理程序 添加到名称由第一个参数给出的事件中.使用事件记录的几个参数调用事件处理函数.

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

使用扩展

和以前一样,我们需要通过在我们的 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 文档和 开发扩展.