Sphinxの拡張時に、自分用のイベントを追加する

## Sphinxのコアイベントとイベントハンドラー

Sphinxでのドキュメント作成時に、「特定のタイミングであることをやりたい」というケースがたまに存在します。

例：

- SASS/SCSSファイルを事前にコンパイルしたい
- HTML生成時に出力データの調整をしたい
- 一通りHTMLファイルを生成したあとに、そこからスクリーンショットを生成したい

こういったタイミングで処理を差し込めるように、SphinxにはEvent APIが存在します。

試しに、自身のSphinxドキュメントの`conf.py`を編集して以下のようなコードを追加してみましょう。 [^1]

[^1]: すでに、`setup()`を定義している場合は、`_cowsay`を定義したうえで、`setup()`内の適当な場所に`app.connect(~` を差し込んでください。
```python:conf.py
def _cowsay(app, err):
    print(r"""
          _________________________________
        / Congratuation!!                  \
        \ Please see generated document.   /
          ---------------------------------
                 \   ^__^
                  \  (oo)\_______
                     (__)\       )\/\
                         ||----w |
                         ||     ||
    """)


def setup(app):
    app.connect("build-finished", _cowsay)
```

実際にこのコードが記述された状態でビルドを実施すると、以下のような出力が行われます。

![Screenshot from 2023-12-17 21-01-42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6805/b67b73bd-1b38-2b8a-212d-bdbed6a24df4.png)

### コアイベントたち

上記コードの`build-finished`は文字通り「ビルド処理が一通り完了した最後のタイミング」を指しています。
このようなSphinxにおいて処理全体におけるチェックポイントと呼べるような箇所をいくつか定めており、「[コアイベント](https://www.sphinx-doc.org/ja/master/extdev/appapi.html#sphinx-core-events)」と名付けられています。

いくつかメジャー（？）なコアイベントを紹介します。

| イベント名            | タイミング |
| :------------------ | ---------| 
| `config-inited`     | `conf.py`の読み込みを行い、設定オブジェクトが生成された |
| `builder-inited`    | 設定オブジェクトと引数をもとに実行するビルダーを生成した |
| `source-read`       | ドキュメントのソースを読み込んだ（ファイルごとに発生） |
| `html-page-context` | HTML系ビルダーが、各HTMLファイルを生成する直前（出力ファイルごとに発生） |
| `build-finished`    | ビルド処理が完了した |

### イベントハンドラー

各種イベント発生時に何をさせたいかはイベントハンドラーとして関数を実装することで実現できます。
前述のサンプルでは`_cowsay`関数がそれに該当します。

もちろん関数である必要はなくCallableであれば問題なく動作します。
ただし、各イベントごとにイベントハンドラーを呼び出す際の引数の形式は決まっているので、まずはSphinxドキュメントの「[コアイベント](https://www.sphinx-doc.org/ja/master/extdev/appapi.html#sphinx-core-events)」を読んで、どのような引数を受け付けるかを確認しましょう。

イベントで想定されている引数を受付さえすれば処理の中身は何でも良いです。
「`html-page-context`を利用して、生成するHTML向けにページ単位で特殊な値を差し込む」といった実用的な処理だったり、今回のcowsayの発展形として「ドネーションの募集」といったことも可能です。

## 自分用にイベントを追加する

さて、まれにですがSphinx拡張を開発する際に「やりたい処理を差し込みたいタイミングがコアイベントにない！」というケースがあります。

<details><summary>とあるSphinx拡張での話</summary>

自作しているSphinx拡張の[sphinx-revealjs](https://pypi.org/project/sphinx-revealjs/)には、reStructuredTextの脚注表示をReveal.jsプレゼンテーション向けの配置にするためのサブ拡張が存在します。
その際に、脚注の位置を調整するためのCSSを生成するのですが、ドキュメントの設定で指定したフォントサイズをCSSに反映するという仕様にしました。

「ドキュメントの設定で指定したフォントサイズをCSSに反映する」という動作を実現するためには、[CSSのテンプレート](https://github.com/attakei/sphinx-revealjs/blob/master/sphinx_revealjs/ext/footnotes/static/sphinx-revealjs/css/footnotes.css_t) [^2]から、設定値を注入してCSSファイルを生成することになります。

[^2]: Sphinxでは、通常の静的ファイル置き場に拡張子が`_t`で終わるファイルを配置した場合、「静的ファイルのテンプレート」として扱い、Jinja2による生成をすることができます。

当然ですが、CSSファイルはHTMLファイルに都度生成する必要するものではないため、`html-page-context`は不適切です。
とはいえ、Jinja2へ引き渡す情報はcontextオブジェクトに属するため、それよりの前のイベントでは準備ができません。

そのため、「ファイル生成処理より前」で「全体共有のcontextオブジェクトが用意されている」タイミングにイベントが必要となりました。
</details>

Sphinxのコアアプリケーションオブジェクトには、「イベントを追加」「イベントの呼び出し」を行うメソッドが用意されています。
これを利用することで、本体以外のプロセスでもイベントの自作をすることができます。

### イベントの追加

`Sphinx.add_event()`メソッドが提供されています。
第1引数にイベント名を指定することで、「Sphinxのビルドプロセスのどこかでこのイベントが発生することがある」ということを本体に登録することができます。

```python:sphinx-extension.py
def setup(ap):
    ...
    # 文字通り、"revealjs:ready-for-writing" というイベントの存在を登録する。
    app.add_event("revealjs:ready-for-writing")
    ...
```

ここで登録したイベント名は、以降の処理でコアイベントと同様に、`Sphinx.connect()`の第1引数として指定することが可能になります。

### イベントの呼び出し

カスタムイベントの宣言だけしても、実際のビルド時には何も起きません。
ビルド処理の途中に`EventManager.emit()`を用いることで、あらかじめ`connect()`で登録していたイベントハンドラーを実行することができます。

```python:builders.py
class RevealjsHTMLBuilder:
    ...
    def prepare_writing(self, docnames: Set[str]):
        super().prepare_writing((docnames))
        self.events.emit("revealjs:ready-for-writing", self.globalcontext)
    ...
```

重要なポイントとして、コアイベントと同様に「あるイベント用のイベントハンドラーは同じ形式の引数を受け付けるように定義しなければならない」という点です。
上記の例では、`emit()`実行時に`self.defaultcontext`というContextオブジェクトを渡しています。
必ず第1引数にはSphinxのコアアプリケーションを渡すため、このイベントは引数を2個受け付ける関数でないといけません。

まとめると、

1. 初期化のタイミングで、`Sphinx.add_event()`を実行してカスタムイベントの登録
1. カスタムイベントのハンドラー関数を`Sphinx.connect()`で追加
1. ビルド処理の途中で、`EventManager.emit()`を実行してイベントハンドラーの呼び出し

これらを実施することで、コアイベントとは別に必要なイベントを適宜追加することができます。

## 気をつけたほうがよい（かもしれない）ポイント

この仕組みは「イベントそのものに関する処理を一つに集約すること」「コアイベントも同様の仕組みを取る」ことで、プラガブルな挙動を実現しています。[^3]

そのため、「他者によって登録したイベント名と重複する」ことやもっと言えば「想定外のところで重複したいイベントに対する`emit`が発生する」という危険性があります。

自分の場合は、ある程度明示的になるように`:`を間に入れることで「拡張由来のイベント」であることを伝えるように定義しています。

元々の利用者はそんなに多くはないと思いますが、無理なく協調出来るような実装をしましょう。


[^3]: ビルダーに一切依存しないコアイベントは[ここ](https://github.com/sphinx-doc/sphinx/blob/master/sphinx/events.py#L32)で`dict`として定義されており、[組み込みビルダー](https://github.com/sphinx-doc/sphinx/blob/master/sphinx/builders/html/__init__.py#L1357)であっても`add_event`で登録しています。