.. Kenneth Lee 版权所有 2021
:Authors: Kenneth Lee :Version: 1.0 :Date: 2021-06-27 :Status: Draft
Sphinx概念空间分析
:index:sphinx
本文剖析一下Python3\ [#n1]_\ 的Sphinx文档系统的概念空间。
Shpinx是一个文档系统,用于生成各种用户手册。当然,你也不见得非要写成用户手册, 用来写什么都可以。这种文档的样式,看看Sphinx自己的手册就知道了:
http://sphinxsearch.com/docs/sphinx3.html
.. note::
本文其实也是用Sphinx写的,只是它只是整个工程中的其中一个子文档。
在写作的时候,原始的Sphinx文档用.rst格式写,这是一种类似Markdown的格式化文本文 件。Sphinx负责把这些原始的Sphinx文件全部组合在一起,变成一本书的样子,最终可以 生成html,pdf等其他格式。
这有点类似Latex的理念:写作内容的聚焦写作内容,排版的聚焦排版。不需要在写内容的 时候老去想“这段文字用红色背景好看还是蓝色背景好看?”这种问题。但Latex过于灵活了 ,也发展得太早了,用Latex还是很让人痛苦的一件事。Sphinx则提供了较为简单的构架, 对于只想写一本漂亮的手册,不想想那么多稀奇古怪问题的人来说,这是一个理想的选择。
Latex对中文的处理一直是一个麻烦的地方,Python3全面支持UTF-8,所以基本上很少中文 问题,你无论直接在Python3代码中直接写中文,还是在源rst代码中写中文,都可以无缝 处理。至于最后显示的时候具体怎么用字体,这是后端生成器的问题。我个人经常是直接 生成html或者epub。这些格式使用浏览器的字体,所以基本上不会有问题,如果需要pdf, 只要把html在浏览器中打印出来就可以了,也不会有字体的问题。
.. note::
rst还有两个常见的中文问题。
其一是默认会把换行替换成空格(英文下这是合理行为),这会让目标文档不好看。这 个问题可以很容易通过后端处理解决掉。这个后面会介绍。
其二是中文搜索功能还不完善,这个可以解决,但本文不是介绍用法,这里不提。
本文是概念空间分析,我们只关心架构,不关心具体的用法,但本文作者建议读者先对 Sphinx的用法作一定理解,这样会比较容易看懂本文。
.. sidebar:: docutils的顶级模块概念
parser 源代码扫描器,当前就只有rst这一个扫描器。
reader 封装parser和读入功能的对象。
transform 封装对doctree进行处理的对象。
writer 把doctree写出去对象。
publisher 组合上述概念最终构成一个应用的对象。
类似源代码,用于生成目标的书的所有文本(.rst文件)组成一个工程,Sphinx负责编译 这个工作,最终生成要求的目标格式。
一个Sphinx工程包括一组rst文件和一个conf.py作为配置文件,conf.py是一个纯python的 源代码文件,sphinx执行的时候先运行这个文件,设置一组参数,然后基于这些参数,编 译其中的master_doc参数指定的rst文件,最终生成指定的目标格式。
master_doc是整本书的入口,它的标题就是全书的标题,它里面通过toctree定义的目录树 就是书中的第一级目录。这个目录树的项目指向其他rst文件,那些rst文件就是书的内容 ,如果那些文件中也定义了doctree,那么那些目录就是书中那个章节的子目录……如此递归 ,就构成了整本书的全部内容。
Sphinx基于rst,而Python基于docutils库来访问rst。docutils用于处理单个的rst文件。 它可以读入一个rst文件,生成一棵docutils.nodes.Node树。Node有很多类型,其中一个 rst文件的根的类型是docutils.nodes.Document,文档中的每个章节,段落,列表,都是 这个对象的子对象,或者子对象的子对象……如此类推。
每个Node都有自己的属性。比如下面这个文档:::
我的文章
=========
第一章
----------
现在我们来介绍一下第一章的内容
section 2
----------
第二章的情况是这样的:
1. 首先,这样这样
* 深入一点说呢,是这样
* 另外补充一点:是那样
2. 然后,那样那样
生成的Document树的结构是这样的:::
document( ids(['id1']) classes names(['我的文章']) dupnames backrefs source(<string>) title(我的文章)): title( ids classes names dupnames backrefs): Text: 我的文章 section( ids(['id2']) classes names(['第一章介绍']) dupnames backrefs): title( ids classes names dupnames backrefs): Text: 第一章介绍 paragraph( ids classes names dupnames backrefs): Text: 现在我们来介绍一下第一章的内容 section( ids(['id3']) classes names(['第二章深入探讨']) dupnames backrefs): title( ids classes names dupnames backrefs): Text: 第二章深入探讨 paragraph( ids classes names dupnames backrefs): Text: 第二章的情况是这样的: enumerated_list( ids classes names dupnames backrefs enumtype(arabic) prefix suffix(.)): list_item( ids classes names dupnames backrefs): paragraph( ids classes names dupnames backrefs): emphasis( ids classes names dupnames backrefs): Text: 首先 Text: ,这样这样 block_quote( ids classes names dupnames backrefs): bullet_list( ids classes names dupnames backrefs bullet(*)): list_item( ids classes names dupnames backrefs): paragraph( ids classes names dupnames backrefs): Text: 深入一点说呢,是这样 list_item( ids classes names dupnames backrefs): paragraph( ids classes names dupnames backrefs): Text: 另外补充一点:是那样 list_item( ids classes names dupnames backrefs): paragraph( ids classes names dupnames backrefs): Text: 然后,那样那样
我们可以用docutils.core.publish_XXXX()函数从一个rst中生成一个document对象,然后 我们就可以根据需要处理这个Node树了。
Node的属性可以通过Node['attrname']来访问,或者直接从Node.attributes获得,Node的 子结点可以通过Node[node_index]访问,或者直接从Node.children获得。有了这两个成员, 可以很容易可以查找,增加,删除树里的Node。比如,你可以找到某个paragraph的Node,用 replace_self()函数,把它的所有子结点换成你加入的其他Node,最后通过 publish_from_doctree()把文档最终生成目标文档。
总结起来说,docutils负责把静态的一个文档解释为一颗动态的文档树,然后在靠不同的 后端,根据这个文档书,把这个文档生成html,pdf这些目标格式。这样整个文档工作就分成 了写作内容和决定输出两个部分了。
sphinx用docutils对工程中的每个rst进行遍历,然后把结果保存在临时的cache中,之后 根据指定的translator对结果进行第二次处理,最后根据你指定的输出格式,用对应的 Writer对象把它们根据需要写成那种格式的目标文件。
所以,一个Writer怎么使用这个document,这完全是那个Writer决定的,它可以根据Node 的名字,title,ids,下面有多少子Node,在目标文件中写不同的内容。这并没有一定的 标准,所以,你只能根据现在的实现,尽量在修改的时候符合现在的样式,这样目标 Writer按默认方式来处理你的Node,你就可以得到预期的结果。
.. note::
从架构的角度来说,sphinx现在比Latex更有竞争力,就是因为它并不依靠定义完美的 标准,而是提供了一个“可以运行”的框架,让人可以不断把结果“试出来”。我个人在架 构设计上很反对“试试能跑就上线”的开发方法,因为这样会导致部分异常流程没有考虑 到,但这种模式特别适合非关键模块(所谓枝叶模块),因为它是快速开发的基础。只 是那种模块没有什么架构设计的需要而已。
换句话说,我们通过组织历经打磨的中间模块,支持大量可以随便犯错的枝叶模块,就 可以让整个代码生态可以快速发展。
所以,sphinx的整个工作原理是对document树进行多次pass,每次调整一部分node的内容 ,等所有的pass都完成了,最终提供给Writer进行最终的输出。
我们用前面提到的中文问题为例子,看看这种处理的逻辑结构是什么样的。
Sphinx支持插件,方法在conf.py的参数extension中加入一个py文件,在该文件中包含一 个setup函数,这样就可以了。setup函数最常见的功能是在sphinx的pass中加回调。比如 这样:::
from docutils.nodes import NodeVisitor, Text, TextElement, literal_block
def setup(app):
app.connect('doctree-resolved', process_chinese_para)
def process_chinese_para(app, doctree, docname):
doctree.walk(ParaVisitor(doctree))
def _is_asiic_end(text): return bytes(text[-1], 'utf-8')[0] < 128
def _this_is_asiic(text): return bytes(text[0], 'utf-8')[0] < 128
def _tran_chinese_text(text):
secs=text.split('\n')
out = ''
last_is_asiic = False
for sec in secs:
if not sec:
continue
if last_is_asiic and _this_is_asiic(sec):
out += ' '
out += sec
last_is_asiic = _is_asiic_end(sec)
return out
class ParaVisitor(NodeVisitor):
def dispatch_visit(self, node):
if isinstance(node, TextElement) and not isinstance(node, literal_block):
for i in range(len(node.children)):
if type(node[i]) == Text:
node[i] = Text(_tran_chinese_text(node[i].astext()))
这个扩展处理前面提到的中文换行变空格的问题。它的setup函数在'doctree-resolved'阶 段加入一个回调,process_chinese_para,这个阶段发生在文档被人引用的时候。上面这个 例子在这个阶段遍历了一次文档树,找到所有TextElement节点,然后把里面的Text节点都 作了一个替换,如果是非ASIIC码发生换行,就直接替换成没有换行,这样在后期Writer进 行处理的时候,就根本不出现这个空格问题了。
更多的阶段,可以在python的docutilsh和sphinx目录中找到,或者直接学习其他 extension是怎么写的,反正理解了这个概念的安排,这个就不是问题了。
按前面的逻辑构架,Sphinx把rst文件转化为docutils的document,然后用内置的或者外加 的扩展对document进行多次pass,最后用writer把Document转化成目标文档。
为了保证可以进行扩展,Sphinx允许增加Node的类型,这样就很容易实现对rst的语法的扩展, 并在扩展后对这些Node进行专门的处理。
最核心的两种Node扩展,反映在rst文件中,是directive和role。它们都是rst文件中特定 格式的文本。其中directive的写法类似这样:
.. code-block:: rst
.. directive-name:: argument1 argument2... :option1: option_value :option2: option_value :option_without_value:
directive-content
directive可以生成一个叫directive-name的节点,如果这个名字是内置的,那么就生成叫 这个名字的节点。如果不是,开发者可以通过扩展在setup的时候增加自己的:::
app.add_directive("directive-name", directive_class)
directive_class是docutils.parser.rst.directive的子类(sphinx也提供了自己的封装 ,sphinx.util.docutils.SphinxDirective),里面提供一个run函数负责在文档扫描的时 候决定生成什么预期的node。这样就实现了对document树的插入。比如,你可以从这里读 入一个外部的数据库,然后用数据库来生成内容。
另一种情况是你需要先扫描完所有的文档(比如你要收集全文的关键字),这时你可以在 这里先放一个自定义的node作为占位符,到最后再更新它。这时可以在run函数里创建自定 义的node实例。这种自定义的node,可以在setup的时候,用这个函数创建:::
app.add_node(cnote_node, ...)
这些自定义的Node可以在后续的pass中替换成其他Writer认识的node,也可以在add_node 的时候制定Writer的处理函数,自己生成对应Writer的输出。
.. note::
老实说,在架构上这(在创建Node的时候指定Writer的回调)是个相当恶心的设计,这 相当于把node的描述逻辑和Writer的逻辑绑定了。如果我来做这个设计,会考虑另外加 一个Writer Plugin来处理不同的Node。但这个其实影响不大,因为这个关联不算强, 可以在后续升级的时候再重建这个逻辑,这不影响其他逻辑。
Role是简单版本的directive,一般directive用在成段的替代上,而Role用在嵌入的文本上, 类似用::
我们要**强调**的是:
把“强调”嵌入到其他句子中。Role的写法如下:::
:role-name:`role content`
它的定义函数是这样的:::
app.add_role('role-name', role_function)
和directive不同的地方是它给定的不是一个类,而是一个函数,但其实本质也没有区别, 用法完全可以和directive一样的。
Event前面介绍过,用于实现回调。它通过app.add_event()添加,app.connect()挂接, app.emit()发起调用。需要具体知道什么事件在什么时机回调的,全文搜emit的位置就可 以了。
如果是写扩展,最常用的几个事件是:
source-read 这是读入rst的时机,这里可以访问源代码
doctree-read 这是把源代码初次转化为document树后的时机,这里可以访问初期的doctree
doctree-resolved 这是其他文档索引本文档的时机,这时你可以根据这个索引,重新更改本文档的 内容或者为其他文档提供本文档的信息等。很多后期处理放在这个阶段
Domain为directive和role提供了一个名称空间,比如在C语言中,你才需要c_function这 个directive,那么我们可以创建一个就叫c:function的directive。默认Domain是可以省 略的,sphinx默认的domain就是rst。所以你平时写的directive,其实都是rst:directive 。
这个概念很容易联想,这里不深入打开了。
配置值就是conf.py中你设置的那些变量,它可以通过:::
app.add_config_value(name, default, rebuild)
增加,然后在后期用app.config[]访问。其中这个rebuild是重新编译条件,也就是如果这 个配置修改了,什么情形下需要重新编译,是个字符串,最常用的是None或者'html',前 者不用rebuild,后者表示如果输出html就rebuild。
.. [#n1] Python2也有一样的东西,但本文专注考虑Python3的情况。