Sphinx (Docutils) の拡張を触って得た知識とTIPS¶
はじめに¶
この記事は Sphinx 拡張あるいは Docutils 拡張の開発にこれから挑む人向けに、ざっくり把握しておきたい知識と現状の私の知見を TIPS としてまとめたものです。知見と言っても既存の拡張のバグを修正した程度なので慣れてる人には目新しいことは特にないと思いますが、自分が無知識で挑んでハマったり調査に時間がかかったので記録として残すことにしました。
公式のドキュメントを読んでいない場合はまずそちらを読まれることをオススメします。
知識¶
reStructuredText, Docutils, Sphinx についてまず整理する。
- reStructuredText
軽量マークアップ言語の一つであり、拡張可能な構文を有している。
- Docutils
reStructuredText をパースして html など別形式に変換するテキスト処理システムであり、 reStructuredText の拡張にも対応している。
- Sphinx
Docutils を拡張し、ドキュメントを構成できるようにしたもの
Docutils は基本的に単体の rst ファイルを独立した文書として扱うが、Sphinx は別ファイルへの参照など、ファイル間の繋がりを扱うことができる
Sphinx の機能の多くが Docutils の拡張として定義されており、さらに Sphinx ユーザが拡張を行うための独自のイベントフックや、拡張を容易にするAPI群を提供している。
用語¶
reStructuredText, Docutils, Sphinx それぞれのドメインで拡張の開発に必要な用語をまとめる。
reStructuredText 用語
- Directive
文章構造(ブロック要素)を記述する仕組み。例えば図・テーブル・注釈など。ディレクティブは独自に定義できる。
- Role
テキストをマークアップする仕組み。例えば強調やハイパーリンクなど。ロールも独自に定義できる。
Docutils 用語
- Document Tree (doctree)
1つのrstファイルを解釈し木構造のデータに変換したもの
- Node
doctree に存在する文書の構成要素。単純にxmlのノードと捉えてしまって良い。
- Parser
rst等の文書ファイルをパースして doctree を生成するクラス
- Reader
入力元を読み込んで doctree をメモリ上に展開するクラス。Parserを利用する場合とパース済みのdoctreeファイルを読み込む場合があるので、Parserと別になっている。
- Transform
doctree 内のノードの変換、追加、削除を行うクラス。(詳しくは後述)
- Translator (Visitor)
doctree 内のノードを出力形式ごとのデータに変換するクラス。Writer がそれぞれの Translator を持っている(詳しくは後述)。
- Writer
doctree を入力として、ファイルの出力を行うクラス。 html や tex 、 manpage など、出力フォーマットごとに定義される
Sphinx 用語
- Application (app)
Sphinx自体。ユーザが拡張を追加するインターフェイスを提供するクラス。
- Domain
ディレクティブ・ロールのグループであり、rst のパース時に得られたメタデータを保持するところ(詳しくは後述)。
- Environment
ビルド環境。Domainを保持する(詳しくは後述)。
- Builder
パースされたドキュメントを入力として、ファイルの出力やなんらかのデータ処理を行うクラス。docutils の Writer, Translator を利用したり拡張したりしている。
Transform¶
Transfrom は doctree 生成後に doctree 内のノードの変換、追加、削除を行う。
Transform の内容は多岐にわたる。デフォルトで適用される Transform が多数あり、役割がよくわからんものも割とあるが、例えば docutils.transforms.universal.StripComments
は comment
ノードを doctree から削除するのに使われる。
また、Sphinx レイヤでの例としては、 sphinx.transforms.post_transforms.ReferencesResolver
がある。前述したように Sphinx は異なる rstファイルへの参照(クロスリファレンス)が可能だが、参照先が具体的にどこになるか(あるいは実在しているか)は一度全ての rstファイルをパースして見ないことにはわからない。そこで、Sphinx では次の手順をとっている。
rstをパースする際に以下の処理を行う
:ref:
が出現した場合<pending_xref>
という一時的なノードを生成するラベル(例:
.. _my-ref-label:
) が出現した場合、参照先としてstd domain
にラベル情報を追加する(ドメインについては後述)
全てのrstをパースしたのち、
ReferenceResolver
で<pending_xref>
の解決(変換)を行う。解決には 1. で取集したstd domain
のラベル情報を用いる。
post_transforms について
Transform は docutils の機能だが、Sphinx は
add_post_transform
というAPIを追加で提供している。transform が1つ1つのrstファイルをパースした後に実行されるのに対して、 post_transform は全ての rst をパースし終わった後に実行される、ということのようだ。
Translator (Visitor)¶
出力形式に合わせてノード単位のデータ処理を行う。例えば、html 出力を行う HTMLTranslator
は title ノードを h1 ~ h6 に変換している。
docutils で拡張されたノードを扱うには Translator に
visit_{node_name}
とdepart_{node_name}
関数をねじ込むこの方法は Sphinx の内部実装を参考にしたが、場合によっては NodeVisitor(Translator), Writer をそれぞれ継承した新しいクラスをつくった方が良いかもしれない。
from docutils import nodes from docutils.parsers.rst import ( Directive, directives, ) from docutils.writers.html5_polyglot import HTMLTranslator class mynode(nodes.General, nodes.Inline, nodes.Element): pass class MyDirecitive(Directive): required_arguments = 0 optional_arguments = 0 final_argument_whitespace = True has_content = True def run(self): node = mynode() node['content'] = self.content return [node] def visit_mynode(self, node): content = node['content'] html = f'<div class="mydirective">{content}</div>' self.body.append(html) def depart_mynode(self, node): pass directives.register_directive("mydirective", MyDirective) setattr(HTMLTranslator, "visit_mynode", visit_mynode) setattr(HTMLTranslator, "depart_mynode", depart_mynode)
Sphinx の場合は簡略化されたAPIが提供されており、
app.add_node()
の引数に(visit_.., depart_..)
のタプルを渡せば良いdef setup(app): app.add_directive('mydirective', MyDirective) app.add_node(mynode, html=(visit_mynode, depart_mynode))
Translator は Visitor パターンで実装されており、変数名が visitor になっていることもある
独自のノードを追加する場合、上記例のような html だけの対応では tex など他の出力でエラーになってしまい、汎用性が失われてしまう。とはいえ全ての形式に対応するのも結構な手間と知識が要求されるため、可能なら既存のノードを応用する方が望ましい。
Environment と Domain¶
Environment は rst パース時に得たメタデータなどを保持する。
Environment (および doctree )はパース時に .doctrees
ディレクトリ以下にファイルキャッシュされる。キャッシュは単純な Python の pickle データなので、以下のようなコードで内容を確認できる。
import pickle
from pprint import pprint
with open('.doctrees/environment.pickle', 'rb') as f:
env = pickle.load(f)
print("docname:", env.docname)
for domain, data in env.domaindata.items():
print("domain:", domain)
pprint(data)
Domain は Sphinx で Python 以外の言語のドキュメントを書けるようにするための機能であり、次のようなものがある
std (言語依存のない汎用ドメイン)
python
cpp
javascript
ビルド環境が複数のドメインを持ち、ドメインごとに固有の Directive / Role とメタ情報がある、という感じっぽいがこの辺は結構複雑でよくわかっていない。
とりあえず
メタ情報は
domain.data
の辞書で管理されており、例えば前述のラベルの情報はenv.domains["std"].data["labels"]
に入っている。拡張で独自にメタ情報を保持する必要がある場合は data 内に格納する。
というあたりを押さえておけば良いかと思う。
Sphinx のイベントフック¶
Sphinx はビルド中に発生したイベントに対して拡張でコールバック関数を登録できるようになっている。
Sphinx のビルド流れと、ビルドのフェーズごとに発生するイベントについては以下を参照
TIPS¶
キャッシュが有効な場合、独自に定義したディレクティブの
Directive.run
が呼び出されない上にも少し書いたが、Sphinx は rstファイルをパースした結果を
.doctrees
ディレクトリ以下にキャッシュする。キャッシュが有効な場合、rstのパース処理をスキップするので、Directive.run
が呼び出されなくなる対応としては
sphinx-build
コマンドに-E
オプションを渡すか、単純に.doctrees
ディレクトリを消す。余談だが environment は拡張の実行時のバージョンも保持しており、その情報は再ビルド時のキャッシュの有効判定に利用される。このため
setup
関数で返すenv_version
など、拡張のバージョン情報は適切に更新した方がよいだろう。
printデバッグする時は、
sphinx-build
コマンドに-v
オプションをつけるデフォルトの verbosity=0 (
-v
無し) の状態では、進捗表示をしている一部のログ出力でキャリッジリターンを使っているため、print
出力が正しく表示されない・上書きされて見れないと言った問題が起きることがある
ログ出力は、
sphinx.util.logging
を使う使用例(docstringから引用)
>>> from sphinx.util import logging >>> logger = logging.getLogger(__name__) >>> logger.info('Hello, this is an extension!')
sphinx.util.logging.getLogger の docstring に拡張でも使って良いように書いてある
Sphinx は root ロガーには何も手をつけず、
sphinx
ロガーを定義している。わざわざ独自のロガーを設定するよりは乗っからせてもらうのが良いだろうdebug ログは -vv 以降で出力される(かなりの量がログ出力される)
Sphinx のイベントハンドラについて、コールバックは他の拡張や Sphinx 本体でも登録されている可能性があるので、その前提で実装を行う。可能なら「そのコールバックで扱うべきイベントかを判定する」ような実装をすると良さそう。
例えば、
missing-reference
イベントはクロスリファレンスが解決できなかった際に発生するが、missing-reference
をイベントを拾ってコールバックで解決を試みるような実装はsphinx.ext.intersphinx
でされている。このように別の拡張のコールバックで解決される可能性があるので、自前のコールバックで参照が解決できなかったとしても例外を投げたりエラーログを残す必要はない。ただし、自前のコールバックで解決すべき未解決の参照なのかどうかを判定できるのであれば別。
doctree の構造を確認したい場合
Sphinx で変換 => .doctrees 以下のファイルを見てみる
docutils で変換 => docutils に同梱の
rst2pseudoxml.py
を使う例)
foo ======= * bar
$ rst2pseudoxml.py sample.rst <document ids="foo" names="foo" source="sample.rst" title="foo"> <title> foo <bullet_list bullet="*"> <list_item> <paragraph> bar
さいごに¶
とりあえずTIPSを書いておこうと思って、ついでなので基本知識も整理しておこうと思ったら際限なく広がっていって困った。あとリファレンス周りを自分が触っていたのでそっちに内容が偏った印象があるが、まぁクロスリファレンスを持てるのが Sphinx の特徴なのでちょうど良いでしょう(きっと)。
TIPSの方が少ないので何かしら知見を得たら追記していきたい。
唐突なダイマ¶
Sphinx のメンテナでいらっしゃる @tk0miya さんが Inside Sphinx という書籍を出版されているのでそちらも参考になると思います 1 。購入して凄まじい勢いでコントリビュートされている tk0miya さんを応援しよう!
参考¶
reStructuredText マークアップ仕様 — Docutils documentation in Japanese 0.12 ドキュメント
ファイルを超えてリンクを貼る (domain#resolve_xref() のすゝめ) - Hack like a rolling stone
Sphinx ではどのようにラベルとキャプションを結びつけているのか - Hack like a rolling stone
Footnotes
- 1
この記事を書いてから存在を知った..
Sphinx (Docutils) の拡張を触って得た知識とTIPS — ykrods note
https://www.ykrods.net/posts/2020/10/15/sphinx-docutils-extension/