Sphinxを通して考える、「拡張」の仕方

author:

Kazuya Takei / @attakei

date:

2022/10/14

event:

PyCon JP 2022

hashtags:

#pyconjp , #pyconjp_1

はじめに

お前誰よ

Kazuya Takei

  • attakei (Twitter, GitHub, etc)

  • 株式会社ニジボックス

  • 趣味系Pythonista <= こっち

    • ライブラリ・拡張系を作りがち

    • Sphinxでプレゼンテーションしたがる人

著者近影

株式会社ニジボックス

../_images/logo-corporate.png

ニジボックス は「Grow all」をミッションに、企業やサービスの成長に向き合い続けるリクルートグループのデザイン会社。

お客様のビジネスの成長をUI UXのデザインプロセスから開発・運用・改善までワンストップでサポート。

興味が湧いた・問い合わせしたくなったら、 https://www.nijibox.jp へ。

株式会社ニジボックス

POSTD

  • https://postd.cc

  • エンジニアに向けたキュレーションメディア

  • 海外のテック記事を日本語に翻訳して配信

../_images/logo-postd1.png

今日話す予定のこと

  • Sphinxの概要

  • Sphinx拡張の概要

  • Sphinx拡張の実装アプローチ

  • And more

これらを、「発表者の体験を踏まえて」話します。

Sphinx イントロダクション

アンケート

  • Sphinx、知ってますか?

  • Sphinx、使ってますか?

Sphinxとは何か

Python製のドキュメンテーションビルダー

  • ソーステキストを束ねて「ドキュメント」として扱う

  • 「ドキュメント」からHTML・PDFなどを生成する

  • メインソースはreStructuredTextし、内部でdocutilsを利用

Sphinxとは何か

Python製のドキュメンテーションビルダー

../_images/about-sphinx.svg

Sphinxとは何か

reStructuredText

  • 軽量マークアップ言語

  • 「ディレクティブ」の概念(表現力・拡張力の基盤)

Document title
==============

概要
----

.. toctree::

    overview
    installation

Sphinxとは何か

アウトプット形式様々

  • 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拡張の紹介

sphinxcontrib-oembed

  • oEmbedを使ったコンテンツ埋め込みをサポート。

  • URLの指定だけでツイートや動画の埋め込みが可能になる。

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>
      &mdash; 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/1575887211962290176

sphinxcontrib-oembed

分類:入力と出力に関する拡張

  • ディレクティブ(とノード)の新規作成が必要
  • ソース読み込み時にHTTP通信する
    => ディレクティブの実装がちょっと複雑
  • とはいえ、 ディレクティブとノードで完結する

sphinxcontrib-budoux

BudouXを使って、日本語文の改行をいい感じに出来るようにする拡張。

../_images/java-before.png
../_images/java-after.png

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() の役割

../_images/setup-flow-1.svg

Sphinxから呼ばれるのはsetupのみ

setup を制するものはSphinxを制す?

setup() の役割

../_images/setup-flow-2.svg

メイン処理以降は、登録済みのものを扱うだけ

setup関数/設定項目の宣言

def setup(app):
    app.add_config_value(
        "config_name",  # 名前
        [],             # 初期値
    )
  • 拡張の「動作」を設定させるための項目を宣言。

  • Sphinx全体で重複しないように注意が必要。

例)

sphinxcontrib-budoux / budoux_targets
=> BudouXに解析して欲しいタグのリスト

ディレクティブ等の登録

入力(出力)に関する拡張をしたいときに必要となるもの。

  • ディレクティブ

  • ノード

  • ロール

  • ...他にもあれこれ

ディレクティブ等の登録

.. oembed:: https://twitter.com/attakei/status/1575887211962290176

.. oembed:: https://www.youtube.com/watch?v=Jn2zvfDhU0w

Sphinx本体には無いディレクティブなので、自作&登録が必要。

ディレクティブ等の登録

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)

ディレクティブ等の登録

  • ディレクティブを用意するなら、まずノードも必要。

  • ノードは出力にも関わるので、出力の実装もセット。

../_images/rst-to-docutils.svg

ディレクティブ等の登録

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コアイベントとハンドラ

イベントタイミングの目安(参考)

../_images/core-events.svg

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

拡張のドキュメントを一読するとよい。

../_images/sphinx-extdev.png

Sphinx拡張を実装するためには その0

  • 「拡張」に依存しないものは別立てすると良い。

    • 例:oEmbedのHTML取得はSphinx拡張である必要はない。

    • 最初は難しくとも、意識しておくだけで分割しやすくなる。

  • 既存のSphinx拡張は、参考にする。

    • 基本的な処理フローは、Sphinx拡張である分には同じ…はず。

    • バンドルされた拡張を読むところから。

Sphinx拡張を実装するためには その-1

  • 「拡張する」ためには「拡張する動機」が必要。

    • 自分が使ったときの不満(推奨)

    • 他者が使ったときの不満

  • 拡張には「拡張の仕方を知る」=「Sphinxを知る」必要がある。

  • Sphinxを使い、ドキュメントを読むことが大事。

  • 「拡張かぶり」を意識しすぎない。

とあるOSSを拡張するためには

  • 「拡張する」ためには「拡張する動機」が必要。

    • 自分が使ったときの不満(推奨)

    • 他者が使ったときの不満

  • 拡張には「拡張の仕方を知る」=「とあるOSSを知る」必要がある。

  • とあるOSSを使い、ドキュメントを読むことが大事。

  • 「拡張かぶり」を意識しすぎない。

「ただ使う」より、ほんの一歩先へ踏み込む。

もし、OSSを拡張可能にするなら

  • (拡張する何かしらの魅力を持たせる)

  • 「データの拡張」をしやすくする。

  • 「イベント」の設計して、割り込みやすくする。

  • 拡張ガイドとなるドキュメントを用意する。

まとめ

Sphinxを「拡張」する

  • Sphinxの拡張は setup() から始まる。

  • まずは拡張ガイドを一読するところから。

    • ディレクティブ、ロール、イベント、ビルダー……

    • あなたは何をさせたいか?

  • 「拡張の動機」は大事。

Thanks