pythonモジュールをsphinxでhtmlドキュメント化

Qiita内にも他に記事はあるのですが、微妙にバージョンアップによりsphinx-quickstart
で生成されるフォルダ構成ややり方が変わったようなので2024/5月(Sphinx v7.3.7)時点でのメモ

## sphinxによるPythonの自動ドキュメント化とは

他の方の記事がたくさんあるのでそちらをご参照いただければと思います。
https://qiita.com/futakuchi0117/items/4d3997c1ca1323259844
https://qiita.com/kinpira/items/505bccacb2fba89c0ff0

色々な用途がありますが、今回の目的は
Pythonの関数コメント、Classコメント等から自動的にドキュメントを作成したいという目的になります。

## sphinxインストール & 環境作成

インストール
```
pip install sphinx
```

任意のフォルダ下で環境作成(今回はdocsというフォルダを作って配下で実行)
```
sphinx-quickstart
```

プロンプトで色々表示されますが今回は以下

```
> Separate source and build directories (y/n) [n]:y
> Project name: 任意の名前
> Author name(s): 任意の名前
> Project release []: 省略(そのままEnter)
> Project language [en]:  省略(そのままEnter)

```
フォルダ構成はこんな感じになりました。最初の選択肢でyを指定しない場合は各ファイルがproject直下にベタ置きされる形になります。
```
docs/
├── build/
├── source/
│   ├── _static/
│   ├── _templates/
│   ├── conf.py
│   └── index.rst
├───Makefile
└───make.bat
```
自分のファイルを配置します。
どこでも良いのですが、置いた場所は後述のconf.pyでパスを通すことにしてください。
今回は先ほど作ったdocsの外に置くことにしました。(sourceの下に置くのが一般的かもしれないけど、ディレクトリを別にしたかったので)
`__init__.py`も含めて通常のモジュールとして動作できる状態にしてください。
```
my_project/
│
├── docs/
│   ├── source/
│   │   ├── conf.py
│   │   ├── index.rst
│   │   └── ...
│   └── build/
│
├── my_package/
│   ├── __init__.py
│   ├── module1.py
│   ├── module2.py
│   └── ...
│
└── setup.py
```

## 設定ファイル

conf.pyを編集します。
```python
# conf.pyから見た位置で自分のモジュールまでのパスを追加
import os
import sys
sys.path.insert(0, os.path.abspath('../..'))

# extentionsに以下を追加
extensions = [
    'sphinx.ext.autodoc',
    'sphinx.ext.napoleon',
    'sphinx.ext.viewcode',
]
```
今回はGoogleスタイルのコメントで記載したファイル達なのでnapoleonを追加しています。
Googleスタイルとはこんな感じのものです。
https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings
```python
    def even(param1) :
        """関数の説明タイトル

        関数についての細かい説明文

        Args:
            param1 (int): 引数の説明

        Returns:
            bool: 戻り値の説明 (例 : True なら成功, False なら失敗.)  
        """
        return param1 % 2 == 0
```

## rstファイルの作成

docs直下で以下のコマンドを打ちます。
```
sphinx-apidoc -o source/ ../my_package/
```
sourceフォルダ内に自分のパッケージ名のrstが作成されていたら成功です。

作成されたドキュメントの目次ページに作ったドキュメントを表示したいので
index.rstに以下の記載を行います。
(この作業は自動にならないのだろうか・・・一応、本記事内の最後に「おまけ２」として自動スクリプトを記載しています。)
```diff
.. toctree::
   :maxdepth: 2
   :caption: Contents:

   modules
   my_package
```

## htmlドキュメントの生成

正式なコマンドは以下です
```
sphinx-build -b html rstのあるフォルダ 出力
sphinx-build -b html source/ build
```
簡易コマンドで以下も用意されています。
```
make html
```
ちなみに簡易コマンドは出力先がbuild/htmlに出力されるので「あれ？build配下のファイル更新されないな」と思った方は注意してください。(思いっきりプロンプトに出力フォルダ出てるのですが、めっちゃそれでハマりました。)

## おまけ1：見た目の変更

見た目はどれがいいかわからないのですが、とりあえず巷で良いと言われているsphinx_rtd_themeに変更。
```
pip install sphinx-rtd-theme
```
conf.pyのhtml_themeのところに
```
import sphinx_rtd_theme

html_theme = "sphinx_rtd_theme"
```

他のテーマはこちらから
https://sphinx-themes.org/

## おまけ2：自動的にindex.rstに追加する
上記の手順だと手動でindex.rstを更新することになるので自動で追加するものを作りました。
_※情報求：本当はsphinx側の機能に欲しいのですがわからなかったので、以下のスクリプトを用意しましたが、rstの考え方が違う、こういうコマンドがある等ありましたらお願いします。_

(今回のようなディレクトリ構成の場合)docsフォルダで実行するとindex.rstに他のrstファイルの記述が自動的に記載されるはずです。
```python
import os

def count_indent(line):
    """インデントの数を数える関数。"""
    return len(line) - len(line.lstrip())

def find_toctree_entry(lines):
    """与えられた行のリストから toctree のエントリの開始と終了インデックスを見つける。"""
    start, end = None, None
    directive_indent = None
    in_directive = False

    for i, line in enumerate(lines):
        if '.. toctree::' in line:
            # toctree ディレクティブ発見 その次の行にディレクティブがあると仮定してインデント数を取得
            directive_indent = count_indent(lines[i + 1])
            in_directive = True
        elif in_directive:
            current_indent = count_indent(line)
            if not line.strip().startswith(':'): #ディレクティブが終わったらstart
                if start is None:
                    start = i  # 最初のエントリ行を記録
            if line.strip() and current_indent < directive_indent:
                # エントリ終了条件
                if start is not None and not line.strip().startswith(':'):
                    end = i
                    break

    if start is not None and end is None:
        end = len(lines)  # 
    
    return start, end

def get_existing_entries(lines, start, end):
    """指定された行の範囲から既存のエントリを抽出する。"""
    existing_entries = set()
    for line in lines[start:end]:
        stripped_line = line.strip()
        if stripped_line and not stripped_line.startswith(':'):
            existing_entries.add(stripped_line)
    return existing_entries

def add_new_entries(lines, start, end, new_files, existing_entries):
    """既存のエントリを更新し、新しいエントリが既存のものにない場合、それを index.rst に追加する。"""
    # 新しいエントリ一覧を作成
    updated_entries = existing_entries.union(new_files)
    
    # 既存のエントリを lines から削除
    del lines[start:end]

    # 新しいエントリを挿入
    new_entries_list = sorted(list(updated_entries))
    lines.insert(start, "\n") #空行を挿入
    
    new_start = start +1
    for i, entry in enumerate(new_entries_list):
        lines.insert(new_start + i, f"   {entry}\n")
    
    # エントリの後に空行を挿入（フォーマット整形）
    lines.insert(new_start + len(new_entries_list), '\n')


def update_index_rst(source_dir, index_file):
    """指定されたディレクトリ内の .rst ファイルを index.rst に適切に追加する。"""
    # .rst ファイル一覧を取得
    new_files = set(f.replace('.rst', '') for f in os.listdir(source_dir) if f.endswith('.rst') and f != 'index.rst')

    # index.rst の内容を読み込む
    with open(index_file, 'r') as file:
        lines = file.readlines()

    # toctree の開始と終了を見つける
    start, end = find_toctree_entry(lines)

    # 既存のエントリを取得
    existing_entries = get_existing_entries(lines, start, end)

    # 新しいエントリが既存のものにない場合のみ追加
    add_new_entries(lines, start, end, new_files, existing_entries)

    # 変更をファイルに書き戻す
    with open(index_file, 'w') as file:
        file.writelines(lines)

# パス設定
source_dir = 'source/'
index_file = 'source/index.rst'
update_index_rst(source_dir, index_file)
```