Sphinxのconf.pyを3行にする
Python製ドキュメントビルダーのSphinxは、ビルドにまつわる設定をconf.pyというPythonソースで記述します。
これは、「設定値の組み立てにPythonの動作をフル活用できる」という点では便利ですが、一方で「変に凝れてしまうため複雑になる」というリスクを内包することにもなります。 [1]
というわけで、conf.pyの中身を別場所で管理できるようにしてみました。
やりかた
前提
説明の簡素化のために、次のことを前提として説明します。
- 依存ライブラリを
pyproject.tomlで管理する。 - Sphinxのドキュメントを
sphinx-quickstartで生成している。 - ドキュメントはGitリポジトリのルートから見てdocsフォルダ上にある。
改めて、やりかた
-
atsphinx-toyboxをインストールする。 -
conf.pyの中身をpyproject.toml内の[tool.sphinx-build.docs]セクションに移植する。(制約条件等については後述) -
conf.pyの中身を下記のようにする。 - 普通にビルドする。
変更後のconf.pyがこちら。
from atsphinx.toybox.pyproject import load
load()
代わりに、pyproject.tomlがこんな感じになります。
なお、extensions欄にatsphinx.toybox.pyprojectを入れる必要はありません。 [2]
[tool.sphinx-build.docs]
project = "my-document"
author = "Kazuya Takei"
extensions = [
"sphinx.ext.githubpages",
"atsphinx.footnotes",
]
# ... その他、たくさんの設定項目
pyproject.tomlでの記述時における制約事項
ファイルフォーマットが大きく変わるため、conf.pyでの記述時と比較するとかなりの制約を受けることになります。
(とはいえ、conf.py側に残せばいいだけではあるのですが)
Noneがない
TOMLの仕様 [3] を読むと分かるのですが、設定可能な型にNULLがありません。
そのため、conf.pyの中でNoneを明示的に設定する必要がある際にちょっとした手間を掛ける必要があります。
項目の再利用が出来ない
Sphinxは同じソースから複数のフォーマットを生成する仕様のため、conf.pyの中ではビルダーごとの設定を管理することになります。
とはいえ類似項目も多いため、revealjs_static_path = html_static_pathのように設定済みの項目をそのまま再利用したりするケースがあります。 [4]
一方でTOML上では項目間で相互に参照する仕様が存在しません。 [3:1]
そのため、必然的にすべての項目をベタ書きする必要が出てきます。
他ライブラリの関数を使った値設定が出来ない
いくつかのSphinx拡張では、動作上の理由から「conf.py内で関数などをインポートして、その実行結果を設定に利用する」ことを要求するケースがあります。
TOMLはあくまで設定項目のみを扱い関数の実行は不可能であるため、これらの動作を移植することは不可能と言ってよいでしょう。
setupを定義できない
Sphinxにおけるconf.pyには、setup()という関数を定義することで「Sphinxビルド時の動作にさらなる介入をする」ということが可能になっています。
TOMLには関数という概念も無いため、どうしても必要な場合は何かしらの手段で維持する必要があります。
内部の仕組み
この記事を書いている時点での、load()の内部構造を簡単に解説します。
conf.pyの場所を特定する
Sphinxはビルドのためのsphinx-buildの実行時にソースフォルダを引数として指定します。 [5] このソースフォルダにあるconf.pyを設定ファイルとして認識します。
ここからどうやってconf.pyを読み取るかというと、execを使っています。 [6]
モジュールとしてインポートをしているわけではないため、sys.modulesなどから探索するのも困難です。
そこで今回は、inspect [7] を利用しています。
import inspect
def load():
caller = inspect.stack()[1]
conf_py = Path(caller.filename).resolve()
ispect.stack()を使いload()呼び出し時のスタック情報を取得できるため、一階層手前を取得すればそのまま呼び出し元であるコードとしてのconf.pyにたどり着けます。
なお、スタック情報にはファイルの場所自体も残っているので、.filenameプロパティを使えば簡単に取得可能です。
pyproject.tomlの場所を特定する
取得したconf.pyのファイルパスを下に、設定を管理しているだろうpyproject.tomlを探します。
これ自体はシンプルにpathlib [8] を活用して「順に上の階層に登っていきファイルを見つけたら終了」としています。
.parentsを使えば無理のない形で最後まで遡れはするのですが、少しだけ利用イメージを考えてGitリポジトリのルートまでしか遡らないようにしています。
from pathlib import Path
from typing import Optional
def find_pyoroject(conf_py: Path) -> Optional[Path]: # noqa: D103
for d in conf_py.parents:
pyproject_toml = d / "pyproject.toml"
if pyproject_toml.exists():
return pyproject_toml
if (d / ".git").exists(): ".gitフォルダがある -> Gitリポジトリのルート"
break
return None
pyproject.tomlの値をconf.pyの変数として扱えるようにする
pyproject.tomlからのデータ抽出自体は、標準ライブラリに搭載されたtomllib [9] を使えるので非常に簡単です。JSONと同様にtomllib.loads()するだけ。
問題は、これをどうconf.pyに引き渡すかです。
inspectで扱うスタック情報の中には、frameという要素があります。
frameにはいくつか属性があるのですが、その中にf_localsという「frameから見たローカル変数」を管理しているdictが存在します。
このdictにKey-Valueのペアを追加することで、比較的あっさりと引き渡すことが出来ます~~(よい実装かどうかは別として)~~。
import inspect
import tomllib
def load():
caller = inspect.stack()[1]
conf_py = Path(caller.filename).resolve()
pyproject_toml = find_pyoroject(conf_py)
# 中略
# TOMLファイルをdictにして、対象の階層を取得
pyproject = tomllib.loads(pyproject_toml.read_text())
conf_base = pyproject["tool"]["sphinx-build"][conf_py.parent.stem]
# load()呼び出し元のフレームにアクセスして、ローカル変数に設定項目を移植する
caller.frame.f_locals.update(
{k: v for k, v in conf_base.items() if not k.startswith("_")}
)
この機能の有用性
とりあえず、以前から出ていた話題をふと思い出したときに、「現状なら楽な取り組み方もあるかな?」と試してみました。
Pythonプロジェクトのメタデータがsetup.cfgを参照するようになった頃の懐かしさを感じます。
もしSphinxドキュメントを書きつつ「conf.pyが複雑化しがち」だったり、普段から無意識に技巧を凝らしてしまいがちな人にとっては、シンプルなTOMLのみでしか書けなくなる制約は良いものと言えそうです。
ただ、せっかくpyproject.tomlで管理するのであれば、project.nameのようなPythonプロジェクトで普段から使う項目をそのまま引き継いで利用したいですね。
加えて簡単なテンプレート構文を用意できると、一機能としては使い勝手が良さそうです。
-
ということにしておいてください。 ↩︎
-
Sphinx拡張としての機能を持っていないため、登録してしまうとむしろビルド時にエラーとなります。 ↩︎
-
個人的に一番使っているのが、
html_title = f"{project} v{release}"という、サイトタイトルの調整です。 ↩︎ -
https://www.sphinx-doc.org/en/master/man/sphinx-build.html ↩︎
-
https://github.com/sphinx-doc/sphinx/blob/master/sphinx/config.py#L580 ↩︎
Discussion