擴展建置流程

本教學的目標是建立比使用角色和指令擴展語法中建立的更全面的擴充功能。 雖然該指南僅涵蓋編寫自訂的角色指令,但本指南涵蓋了對 Sphinx 建置流程更複雜的擴展;新增多個指令,以及自訂節點、額外的組態值和自訂事件處理常式。

為此,我們將涵蓋一個 todo 擴充功能,該功能新增在文件中包含 todo 條目的功能,並將這些條目集中收集在一個地方。 這與 Sphinx 隨附的sphinx.ext.todo擴充功能類似。

概觀

注意

要了解此擴充功能的設計,請參閱重要物件建置階段

我們希望擴充功能為 Sphinx 新增以下內容

  • 一個 todo 指令,包含一些標記為 “TODO” 的內容,並且僅在新組態值設定時才顯示在輸出中。 預設情況下,Todo 條目不應出現在輸出中。

  • 一個 todolist 指令,用於建立整個文件中所有 todo 條目的列表。

為此,我們需要將以下元素新增到 Sphinx

  • 新的指令,稱為 todotodolist

  • 新的文件樹狀節點來表示這些指令,通常也稱為 todotodolist。 如果新指令僅產生一些可由現有節點表示的內容,我們就不需要新的節點。

  • 一個新的組態值 todo_include_todos (組態值名稱應以擴充功能名稱開頭,以保持唯一性),用於控制 todo 條目是否進入輸出。

  • 新的事件處理常式:一個用於 doctree-resolved 事件,以取代 todo 和 todolist 節點,一個用於 env-merge-info 以合併來自平行建置的中間結果,以及一個用於 env-purge-doc (稍後將介紹其原因)。

先決條件

使用角色和指令擴展語法一樣,我們將不會透過 PyPI 分發此外掛程式,因此我們再次需要一個 Sphinx 專案來從中呼叫。 您可以使用現有的專案,或使用 sphinx-quickstart 建立一個新的專案。

我們假設您正在使用單獨的原始碼 (source) 和建置 (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 並貼上以下程式碼,我們將在稍後詳細解釋所有程式碼

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

這是一個比使用角色和指令擴展語法中詳細介紹的擴充功能更廣泛的擴充功能,但是,我們將逐步查看每個部分以解釋正在發生的事情。

節點類別

讓我們從節點類別開始

 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.nodes 中定義的標準 docutils 類別繼承即可。 todoAdmonition 繼承,因為它應該像註釋或警告一樣處理,todolist 只是一個「通用」節點。

注意

許多擴充功能將不必建立自己的節點類別,並且可以使用 docutilsSphinx 提供的節點正常運作。

注意

重要的是要知道,雖然您可以在不離開 conf.py 的情況下擴展 Sphinx,但如果您在那裡宣告一個繼承的節點,您將遇到一個不明顯的 PickleError。 因此,如果出現問題,請確保將繼承的節點放入單獨的 Python 模組中。

如需更多詳細資訊,請參閱

指令類別

指令類別通常是從 docutils.parsers.rst.Directive 派生的類別。 docutils 文件中也詳細介紹了指令介面; 重要的是該類別應具有配置允許標記的屬性,以及一個返回節點列表的 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        todo_node += self.parse_content_to_nodes()
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 屬性存取 建置環境實例。 如果沒有這個,我們將不得不使用相當複雜的 self.state.document.settings.env。 然後,為了充當連結目標 (來自 TodolistDirective),除了 todo 節點之外,TodoDirective 指令還需要傳回目標節點。 目標 ID (在 HTML 中,這將是錨點名稱) 是透過使用 env.new_serialno 產生的,它在每次呼叫時傳回一個新的唯一整數,因此會產生唯一的目標名稱。 目標節點在沒有任何文字 (前兩個參數) 的情況下被實例化。

在建立 admonition 節點時,指令的內容主體使用 self.state.nested_parse 進行解析。 第一個參數給出內容主體,第二個參數給出內容偏移量。 第三個參數給出已解析結果的父節點,在我們的例子中是 todo 節點。 之後,todo 節點會新增到環境中。 這是必要的,以便能夠在作者放置 todolist 指令的位置建立整個文件中所有 todo 條目的列表。 在這種情況下,使用環境屬性 todo_all_todos (同樣,名稱應該是唯一的,因此它以擴充功能名稱作為前綴)。 當建立新環境時,它不存在,因此指令必須檢查並在必要時建立它。 關於 todo 條目位置的各種資訊與節點的副本一起儲存。

在最後一行中,傳回應放入文件樹狀結構中的節點:目標節點和 admonition 節點。

指令傳回的節點結構如下所示

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

由於我們將來自原始碼檔案的資訊儲存在環境中 (環境是持久性的),因此當原始碼檔案變更時,它可能會過時。 因此,在讀取每個原始碼檔案之前,環境中關於它的記錄會被清除,而 env-purge-doc 事件讓擴充功能有機會執行相同的操作。 在這裡,我們從 todo_all_todos 列表中清除所有 docname 與給定 docname 匹配的 todos。 如果文件中還有 todos,它們將在解析期間再次新增。

下一個處理常式,用於 env-merge-info 事件,用於平行建置期間。 由於在平行建置期間,所有執行緒都有自己的 env,因此需要合併多個 todo_all_todos 列表

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

另一個處理常式屬於 doctree-resolved 事件

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

doctree-resolved 事件在階段 3 (解析)結束時發出,並允許執行自訂解析。 我們為此事件編寫的處理常式稍微複雜一些。 如果 todo_include_todos 組態值 (我們將在稍後描述) 為 false,則所有 todotodolist 節點都會從文件中移除。 如果不是,todo 節點只會保持原位和原樣。 todolist 節點會被 todo 條目的列表取代,其中包含指向其來源位置的反向連結。 列表項目由來自 todo 條目的節點和動態建立的 docutils 節點組成:每個條目一個段落,其中包含提供位置的文字,以及一個帶有反向參考的連結 (包含斜體節點的參考節點)。 參考 URI 是由 sphinx.builders.Builder.get_relative_uri() 建構的,它會根據使用的建置器建立合適的 URI,並將 todo 節點 (目標) 的 ID 附加為錨點名稱。

setup 函數

先前所述,setup 函數是一個要求,用於將指令插入 Sphinx。 但是,我們也使用它來連接擴充功能的其他部分。 讓我們看看我們的 setup 函數

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

此函數中的呼叫引用了我們先前新增的類別和函數。 個別呼叫的作用如下

  • add_config_value() 讓 Sphinx 知道它應該識別新的組態值 todo_include_todos,其預設值應為 False (這也告訴 Sphinx 它是一個布林值)。

    如果第三個參數是 'html',則如果組態值變更其值,HTML 文件將會完整重建。 這對於影響讀取 (建置階段 1 (讀取)) 的組態值是必要的。

  • add_node() 將新的節點類別新增到建置系統。 它還可以為每種支援的輸出格式指定訪問者函數。 當新節點保留到階段 4 (寫入)時,需要這些訪問者函數。 由於 todolist 節點始終在階段 3 (解析)中被取代,因此它不需要任何訪問者函數。

  • add_directive() 新增一個新的指令,由名稱和類別給出。

  • 最後,connect() 將一個事件處理常式新增到由第一個參數給出的名稱的事件。 事件處理常式函數使用多個參數呼叫,這些參數記錄在事件中。

有了這個,我們的擴充功能就完成了。

使用擴充功能

與之前一樣,我們需要透過在 conf.py 檔案中宣告擴充功能來啟用它。 這裡需要兩個步驟

  1. 使用 sys.path.append_ext 目錄新增到 Python 路徑。 這應該放在檔案的頂部。

  2. 更新或建立 extensions 列表,並將擴充功能檔案名稱新增到列表中

此外,我們可能希望設定 todo_include_todos 組態值。 如上所述,這預設為 False,但我們可以明確設定它。

例如

import sys
from pathlib import Path

sys.path.append(str(Path('_ext').resolve()))

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 文件和 Sphinx API

如果您希望在多個專案或與其他人分享您的擴充功能,請查看第三方擴充功能章節。