Sphinxを通して考える、「拡張」の仕方
- author:
- Kazuya Takei / @attakei 
- date:
- 2022/10/14 
- event:
- PyCon JP 2022 
- hashtags:
はじめに
お前誰よ
Kazuya Takei
- attakei (Twitter, GitHub, etc) 
- 株式会社ニジボックス 
- 趣味系Pythonista <= こっち - ライブラリ・拡張系を作りがち 
- Sphinxでプレゼンテーションしたがる人 
 
 
株式会社ニジボックス
 
ニジボックス は「Grow all」をミッションに、企業やサービスの成長に向き合い続けるリクルートグループのデザイン会社。
お客様のビジネスの成長をUI UXのデザインプロセスから開発・運用・改善までワンストップでサポート。
興味が湧いた・問い合わせしたくなったら、 https://www.nijibox.jp へ。
株式会社ニジボックス
POSTD
- エンジニアに向けたキュレーションメディア 
- 海外のテック記事を日本語に翻訳して配信 
 
今日話す予定のこと
- Sphinxの概要 
- Sphinx拡張の概要 
- Sphinx拡張の実装アプローチ 
- And more 
これらを、「発表者の体験を踏まえて」話します。
Sphinx イントロダクション
アンケート
- Sphinx、知ってますか? 
- Sphinx、使ってますか? 
Sphinxとは何か
Python製のドキュメンテーションビルダー
- ソーステキストを束ねて「ドキュメント」として扱う 
- 「ドキュメント」からHTML・PDFなどを生成する 
- メインソースはreStructuredTextし、内部でdocutilsを利用 
Sphinxとは何か
Python製のドキュメンテーションビルダー
Sphinxとは何か
reStructuredText
- 軽量マークアップ言語 
- 「ディレクティブ」の概念(表現力・拡張力の基盤) 
Document title
==============
概要
----
.. toctree::
    overview
    installationSphinxとは何か
アウトプット形式様々
- HTML 
- PDF 
- EPUB 
- man page 
Sphinxで出来ているサイト
Python関連
- Python本体 
- Sphinx 
- Ansible 
- 様々なPythonパッケージ 
Sphinxで出来ているサイト
Python以外
- (このスライド) 
Sphinxで書かれた書籍
(書籍執筆のどこかの工程でSphinxを使っているもの)
おさらい:Sphinx単体で出来ること
- reStructuredTextでドキュメントを管理できる 
- HTMLを生成できる・テーマを切り替えられる 
- PDFを生成できる(要LaTex) 
ちょっと物足りない?
ありがちな「物足りなさ」
- Markdownでドキュメント管理したい 
- 動画やツイートなどを、なるべく楽に埋め込みたい 
- HTMLでの折り返しが気に食わないので、いい感じに改行したい 
Sphinxは「拡張」が出来るようになっている
Sphinx拡張 イントロダクション
Sphinx拡張とは何か
「Sphinxの機能を拡張」するためのPythonライブラリ。
- モジュールでも良いし、パッケージでも良い 
- ローカル管理でも平気(インポートさえできればOK) 
- (ちょっと雑だけど) - conf.py上で実装してもいい
Sphinx拡張とは何か
拡張の使用方法。
# Optional
import sys
sys.path.append(PATH_TO_LOCAL_MODULE)
extensions = [
    "my_extensoin",
]- extensionsにライブラリ名を追加するだけ
- 必要に応じて - sys.pathを編集
- 体裁が整っているなら、よしなに呼ばれる 
Sphinx拡張とは何か
最低限「体裁が整っている」コード
def setup(app):
    print("Hello world")- conf.pyに記述すると、「ビルド時に- Hello Worldとコンソール出力する」
Sphinx拡張で実現可能なこと
ディレクティブの登録 /既存ディレクティブへの処理を変更 /読み取り可能なフォーマットを追加 /新しい出力形式(ビルダー)の追加 /出力処理時にデータ加工 /その他・Sphinxのビルド処理時の各種処理追加
Sphinx拡張で実現可能なこと
ざっくりとした分類
- 「入力」を拡張する(フォーマットを増やす、文法を増やす)
- 「出力」を拡張する(フォーマットを増やす、自作テーマ)
- 「内部」で何かする 
- (複合系) 
拡張の例
- Sphinx本体にバンドルされているもの
- サードパーティ製
Sphinx拡張 ショーケース
この後に例示用に出てくるSphinx拡張の紹介
sphinxcontrib-oembed
- oEmbedを使ったコンテンツ埋め込みをサポート。 
- URLの指定だけでツイートや動画の埋め込みが可能になる。 
sphinx-revealjs v2.2.0 is released.
— kAZUYA tAKEI (@attakei) September 30, 2022
Thank you for usings, feedbacks and collaborations!
See PyPI: https://t.co/TCCYhLYHWl
See GitHub: https://t.co/F59O49dwnf
sphinxcontrib-oembed
.. raw:: html
    <blockquote class="twitter-tweet">
      <p lang="en" dir="ltr">
        sphinx-revealjs v2.2.0 is released.<br>Thank you for usings, feedbacks and collaborations!<br>See PyPI: <a href="https://t.co/TCCYhLYHWl">https://t.co/TCCYhLYHWl</a><br>See GitHub: <a href="https://t.co/F59O49dwnf">https://t.co/F59O49dwnf</a>
      </p>
      — kAZUYA tAKEI (@attakei)<a href="https://twitter.com/attakei/status/1575887211962290176?ref_src=twsrc%5Etfw">September 30, 2022</a>
    </blockquote>
    <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>↓
.. oembed:: https://twitter.com/attakei/status/1575887211962290176sphinxcontrib-oembed
分類:入力と出力に関する拡張
- ディレクティブ(とノード)の新規作成が必要
- ソース読み込み時にHTTP通信する=> ディレクティブの実装がちょっと複雑
- とはいえ、 ディレクティブとノードで完結する 
sphinxcontrib-budoux
BudouXを使って、日本語文の改行をいい感じに出来るようにする拡張。
 
 
sphinxcontrib-budoux
分類:出力に関する拡張
- Sphinxメイン処理内で行われた「ソースをもとにしたHTML」を再加工する 
- 出力に 割り込んで の加工が必要 
Sphinx拡張の実装アプローチ
基本実装〜編
再掲:最低限「体裁が整っている」コード
def setup(app):
    print("Hello world")Sphinx拡張の実態
大雑把に書くと、以下のような要素たちの集まり
- 追加の入出力を定義するための関数/クラス群 
- イベントハンドラ用の関数群 
- ★Sphinx本体から呼び出される - setup関数
- 補助処理 
setup を制するものはSphinxを制す?
setup() の役割
- 設定項目の宣言=>app.add_config_value
- ディレクティブ/ビルダー等の登録=>app.add_builder他
- イベントハンドラの登録=>app.connect
...これらは、いずれも「Sphinxのメイン処理開始までに完遂しないと困ること」
setup を制するものはSphinxを制す?
setup() の役割
Sphinxから呼ばれるのはsetupのみ
setup を制するものはSphinxを制す?
setup() の役割
メイン処理以降は、登録済みのものを扱うだけ
setup関数/設定項目の宣言
def setup(app):
    app.add_config_value(
        "config_name",  # 名前
        [],             # 初期値
    )- 拡張の「動作」を設定させるための項目を宣言。 
- Sphinx全体で重複しないように注意が必要。 
例)
sphinxcontrib-budoux / budoux_targetsディレクティブ等の登録
入力(出力)に関する拡張をしたいときに必要となるもの。
- ディレクティブ 
- ノード 
- ロール 
- ...他にもあれこれ 
ディレクティブ等の登録
.. oembed:: https://twitter.com/attakei/status/1575887211962290176
.. oembed:: https://www.youtube.com/watch?v=Jn2zvfDhU0wSphinx本体には無いディレクティブなので、自作&登録が必要。
ディレクティブ等の登録
from sphinx.directives import SphinxDirective
class OembedDirective(SphinxDirective):
    ...
    def run(self):
        # 略
        node = oembed()
        ...
        # 略 - nodeの属性に各種データを引き渡す
        ...
        return [node]  #  docutilsのノードを持つリストを返す
def setup(app):
    app.add_directive("oembed", OembedDirective)ディレクティブ等の登録
- ディレクティブを用意するなら、まずノードも必要。 
- ノードは出力にも関わるので、出力の実装もセット。 
ディレクティブ等の登録
from docutils import nodes
class oembed(nodes.General):
    # 大抵の場合は、ディレクティブ側で処理をするので
    # 何もしないことが多い
    pass
class visit_oembed_node(self, node):
    if "content" in node and "html" in node["content"]:
        self.body.append(node["content"]["html"])
class depart_oembed_node(self, node):
    passディレクティブ等の登録
def setup(app):
    app.add_node(
        oembed,
        # ビルダー種別ごとに、どんな処理をさせたいか指定する
        html=(visit_oembed_node, depart_oembed_node)
    )ビルダー(概要のみ)
「既存のビルダーの枠組みではどうにもならない出力」をしたいときに、 頑張って用意する存在。
例: sphinx-revealjs 内の revealjs ビルダー
Sphinxコアイベントとハンドラ
- イベントハンドラ関数を登録して、適宜実行させられる 
- 処理直後のデータが引数で渡され、その場での加工などが役割 
- ドキュメントにあるだけで18箇所 
- 自分でイベントを足せる 
Sphinxコアイベントとハンドラ
def some_func(app, config):
    ...
def some_func2(app):
    ...
def setup(app):
    # 本体のイベントに接続
    app.connect("config-inited", some_func)
    # イベントを独自定義した上で、接続
    app.add_event("event-for-my-extension")
    app.connect("event-for-my-extension", some_func2)Sphinx拡張からは、 app.connect() で関数を登録するだけで良い。
Sphinxコアイベントとハンドラ
公開されているイベント(見切れてますし、増やせます)
- builder-inited
- config-inited
- env-get-outdated
- env-purge-doc
- env-before-read-docs
- source-read
- object-description-transform
- doctree-read
- missing-reference
- warn-missing-reference
- doctree-resolved
- env-merge-info
- env-updated
- env-check-consistency
- html-collect-pages
- html-page-context
- linkcheck-process-uri
- build-finished
Sphinxコアイベントとハンドラ
イベントタイミングの目安(参考)
Sphinxコアイベントとハンドラ
使いがちなコアイベント
html-page-context
- ドキュメントごとのHTMLファイルを生成するタイミングのイベント 
- 生成時のテンプレート自体を切り替えたり、テンプレートに渡す値を加工したりと大活躍 
- あくまで「出力直前」であることに注意 
Sphinxコアイベントとハンドラ
使いがちなコアイベント
config-inited
- conf.pyからConfigオブジェクトを生成した直後のイベント
- コアイベントとしては、一番最初のタイミング 
- 「拡張の都合でビルダーを生成するより前にしておきたいこと」のために必要 
イベントハンドラの中身を実装する
「その拡張が何をしたいか」を踏まえた上で、 「どのタイミングで」「どんな処理をすべきか」を整理する。
その上で、必要な実装をする。
イベントハンドラの中身を実装する
sphinxcontrib-budoux の場合。
def apply_budoux(app, page_name, template_name, context, doctree):
    # body ... ドキュメントHTMLの中身
    # update_body内で加工する
    context["body"] = update_body(context["body"])
def setup(app):
    app.ocnnect("html-page-context", apply_budoux)- ページごとの出力HTMLを加工したい=bodyをいじりたい
- html-page-contextイベントで処理する
- 引数を調べて、実装する 
ここまで整理
- setup関数が第一。ここで、もろもろをSphinx本体に登録できる。 
- 文法を増やしたいなら、ディレクティブ・ノードなどの設計・登録する。 
- 本体の処理に割り込みたいなら、イベントハンドラの設計・登録する。 
Sphinx拡張を実装アプローチ+
品質向上〜編
ローカル利用の場合
トライ&エラーで十分。
- 困るのは自分だけで済む。 
- 実装に失敗していれば、Pythonのスタックトレースが出る。 
- 即時対応が難しいけど無視は可能なら、「仕様」と言い張る。 
とはいえ…
(特に公開する場合は)考えておいたほうがいい箇所。
- ロギング 
- エラーハンドリング 
- 関数単位でのテスト 
- ビルド想定のe2eテスト 
ロギング
sphinx.util.logging を利用することで、
Sphinxのビルド時に本体の出力と統一感があるロギングが出来る。
import sys
from sphinx.util import logging
logger = logging.getLogger(__name__)
def setup(app):
    if sys.version_info.minor < 7:
        logger.info("NOTICE: 動きはするけど、今後サポートから外れます")エラーハンドリング
🤔
エラーハンドリング
- 実はあまり気にしていない。 - 基本的に「拡張利用時の不足のエラー時には速やかにビルド失敗」させるスタンス。 
- 素のエラーだと分かりづらそうなら、適宜解説を差し込む。 
 
- どちらかというと、エラーを必要に応じて握りつぶす傾向。 
関数単位でのテスト
- こちらも「やるに越したことはない」が、複雑そうなときのみ実施。 - 基本的には単なるPythonモジュールでしかない。 
- 適切な機能分離をして、必要に応じたテストを用意しておけばよい。 
 
- なお、後述の理由からpytestの利用を推奨。 
ビルド想定のe2eテスト
sphinx.testing を利用できる。
import pytest
from bs4 import BeautifulSoup
from bs4.element import NavigableString, Tag
from sphinx.testing.util import SphinxTestApp
@pytest.mark.sphinx("html")
def test_default(app: SphinxTestApp, status: StringIO, warning: StringIO):
    app.build()
    out_html = app.outdir / "index.html"
    soup = BeautifulSoup(out_html.read_text(), "html.parser")
    contents = list(soup.h1.children)
    assert len(contents) > 1
    assert isinstance(contents[0], NavigableString)
    assert isinstance(contents[1], Tag)
    assert contents[1].name == "wbr"公開する?
- 「自分以外にも使いそうじゃない?」と思ったらPyPIに公開してみる。 
- 今回は公開手法については省略 - 単なるPythonパッケージでしか無いので、情報は出回ってる。 
- Search: - PyPI デビュー
 
Not実装の話
Sphinx拡張を実装するためには その0
- その拡張に「何をさせたいか」をイメージする。 
- 「させたいこと」の 5W1H を整理し、分割する。 
- 5W1H にある拡張ポイントを抑える。 
- 実装する。(前述) 
Sphinx拡張を実装するためには その0
 
Sphinx拡張を実装するためには その0
- 「拡張」に依存しないものは別立てすると良い。 - 例:oEmbedのHTML取得はSphinx拡張である必要はない。 
- 最初は難しくとも、意識しておくだけで分割しやすくなる。 
 
- 既存のSphinx拡張は、参考にする。 - 基本的な処理フローは、Sphinx拡張である分には同じ…はず。 
- バンドルされた拡張を読むところから。 
 
Sphinx拡張を実装するためには その-1
- 「拡張する」ためには「拡張する動機」が必要。 - 自分が使ったときの不満(推奨) 
- 他者が使ったときの不満 
 
- 拡張には「拡張の仕方を知る」=「Sphinxを知る」必要がある。 
- Sphinxを使い、ドキュメントを読むことが大事。 
- 「拡張かぶり」を意識しすぎない。 
とあるOSSを拡張するためには
- 「拡張する」ためには「拡張する動機」が必要。 - 自分が使ったときの不満(推奨) 
- 他者が使ったときの不満 
 
- 拡張には「拡張の仕方を知る」=「とあるOSSを知る」必要がある。 
- とあるOSSを使い、ドキュメントを読むことが大事。 
- 「拡張かぶり」を意識しすぎない。 
「ただ使う」より、ほんの一歩先へ踏み込む。
もし、OSSを拡張可能にするなら
- (拡張する何かしらの魅力を持たせる) 
- 「データの拡張」をしやすくする。 
- 「イベント」の設計して、割り込みやすくする。 
- 拡張ガイドとなるドキュメントを用意する。 
まとめ
Sphinxを「拡張」する
- Sphinxの拡張は - setup()から始まる。
- まずは拡張ガイドを一読するところから。 - ディレクティブ、ロール、イベント、ビルダー…… 
- あなたは何をさせたいか? 
 
- 「拡張の動機」は大事。