debug_sphinx_build.py 技術ドキュメント

プログラムの動作

debug_sphinx_build.py は、Sphinx autodoc がPythonモジュールのインポートに失敗する問題を診断するためのユーティリティスクリプトです。通常、Sphinxがドキュメントをビルドする際にモジュールをインポートすると、特定のライブラリ(例: Qt、Tk、matplotlib のバックエンドなど)がグローバルな状態に副作用を残し、後続のモジュールのインポートに影響を与えることがあります。このスクリプトは、この問題を解決するために以下の機能を提供します。

  • 個別プロセスでのモジュールインポート: Sphinxプロジェクトの source ディレクトリ配下にある各Pythonファイル (.py) を、それぞれ独立した子プロセスでインポートします。これにより、インポートによる副作用が他のファイルに波及するのを防ぎ、各ファイルが独立してインポート可能かを確認できます。

  • conf.pyautodoc_mock_imports 設定の尊重: conf.py ファイルで定義されている autodoc_mock_imports の設定を読み込み、該当するモジュールをダミーモジュールとしてモックします。これにより、実際の環境でインストールされていない依存ライブラリがあってもインポートエラーを回避し、Sphinxビルド時の挙動をエミュレートします。

  • 手動モックモジュールの指定: コマンドライン引数 --mock を使用して、conf.py の設定とは別に、追加でモックするモジュールを指定できます。

  • 詳細なエラー報告: 各ファイルのインポートの成否を記録し、失敗した場合にはエラーの種類、メッセージ、完全なトレースバック、および子プロセスの標準出力・標準エラー出力を詳細に報告します。

  • 柔軟な実行制御: エラーが発生しても処理を続行する --keep-going オプションや、インポートに成功した子プロセスの出力も表示する --show-child-output オプションを提供します。

  • 特定のファイルのスキップ: conf.py や特定の命名規則 (_docstring で終わるファイル、_数字 で終わるファイル) に合致するファイルは、診断の対象から自動的にスキップされます。

このプログラムは、Sphinx autodoc ビルドが途中で停止したり、予期しないインポートエラーが発生したりする場合に、どのモジュールがどのような理由でインポートできないのかを特定し、デバッグの効率を大幅に向上させることを目的としています。

原理

本プログラムの主要な原理は、分離された環境での逐次インポートモックオブジェクトによる依存関係の抽象化 に基づいています。

  1. 分離された環境での逐次インポート:

    • プログラムの核となるのは、subprocess モジュールを利用して各Pythonファイルを子プロセスとして実行する点です。

    • 親プロセスは source ディレクトリ内の .py ファイルを走査し、各ファイルに対して sys.executable debug_sphinx_build.py --child-import --single-file <path_to_file> の形式で子プロセスを起動します。

    • 各子プロセスは完全に独立したPythonインタープリタ環境で起動するため、あるファイルが特定のライブラリをインポートしてグローバルな状態を変更しても、その影響は当該子プロセス内に留まります。後続のファイルが別の環境でインポートされるため、Qt/Tk/matplotlib バックエンドの初期化などによる相互作用や副作用を防ぐことができます。

    • 子プロセスは、インポート対象のファイルパスと、親プロセスから継承するsys.path設定、モックモジュール設定を受け取って処理を行います。

  2. モックオブジェクトによる依存関係の抽象化:

    • DummyObject クラスと DummyModule クラスが定義されており、これらは存在しないモジュールやその属性へのアクセスを捕捉し、エラーを発生させずにダミーのオブジェクトを返します。

    • DummyObject は、属性アクセス (__getattr__) や関数呼び出し (__call__) があった際に、常に新しい DummyObject インスタンスを生成して返します。これにより、non_existent_module.some_attr.some_method() のような多階層のアクセスもエラーなく受け流すことができます。

    • DummyModuletypes.ModuleType を継承しており、モジュールの属性アクセス (__getattr__) があった際に、該当する属性名を持つ DummyObject を動的に生成してモジュールの属性として設定し、返します。

    • install_mock_modules 関数は、指定されたモジュール名リストを受け取り、それらのモジュールを DummyModule として sys.modules に挿入します。これにより、Pythonの import 機構が実際にディスク上のファイルを検索する前に、既にダミーモジュールが存在すると認識し、インポート処理を成功させます。

    • このモック機構は、conf.py に記述された autodoc_mock_imports の設定を読み込む (load_conf_mocks 関数) か、コマンドライン引数 --mock で直接指定されたモジュールに対して適用されます。

  3. モジュール名の生成アルゴリズム:

    • make_module_name 関数は、与えられたPythonファイルのパスとソースディレクトリのパスから、一意で有効なPythonモジュール名を生成します。

    • これは、ファイルパスをソースディレクトリからの相対パスに変換し、ディレクトリセパレータを . に、ファイル名の - やスペースを _ に置換することで行われます。さらに、Python識別子の命名規則に準拠しない文字があれば _ に置換し、識別子の先頭が数字にならないよう _ を付与する処理も行われます。最終的に、sphinx_debug. というプレフィックスが付与され、衝突を避けたモジュール名が生成されます。

  4. sys.path の調整:

    • add_sys_paths 関数は、コマンドライン引数 --add-cwd--add-source に応じて、カレントディレクトリやソースディレクトリを sys.path の先頭に追加します。これにより、インポート対象のモジュールが適切なパスから見つけられるようになります。

これらの原理の組み合わせにより、debug_sphinx_build.py はSphinx autodoc のインポート問題を、副作用を最小限に抑えつつ、かつ実際のビルド環境に近い形で診断することを可能にしています。

必要な非標準ライブラリとインストール方法

このプログラム debug_sphinx_build.py は、Pythonの標準ライブラリのみを使用しており、argparse, importlib.util, re, subprocess, sys, traceback, types, pathlib などのモジュールに依存しています。

したがって、特別な非標準ライブラリのインストールは不要です。 Pythonがインストールされていれば、そのまま実行できます。

必要な入力ファイル

本プログラムを実行するには、Sphinxプロジェクトの構成が必要です。特に以下のファイルとディレクトリが期待されます。

  1. Sphinxのソースディレクトリ:

    • デフォルトでは source という名前のディレクトリが期待されますが、--source 引数で任意のパスを指定できます。

    • このディレクトリ内に、ドキュメント化したいPythonモジュール (.py ファイル) が配置されている必要があります。

    • プログラムは、このディレクトリとそのサブディレクトリを再帰的に走査し、すべての .py ファイルをインポート対象とします。

  2. conf.py ファイル:

    • source ディレクトリの直下に conf.py が存在することが必須です。

    • このファイルは、Sphinx autodoc の設定、特に autodoc_mock_imports を定義していることが期待されます。プログラムはこの設定を読み込み、モックモジュールとして適用します。

    • conf.py 自体も、最初に子プロセスでインポートテストされます。

期待されるファイル構造の例

your_sphinx_project/
├── source/              # デフォルトのソースディレクトリ
│   ├── conf.py          # 必須のSphinx設定ファイル
│   ├── index.rst
│   ├── _static/
│   ├── _templates/
│   ├── my_package/
│   │   ├── __init__.py
│   │   └── module_a.py
│   ├── scripts/
│   │   └── util_script.py
│   └── another_module.py
└── build/

生成される出力ファイル

debug_sphinx_build.py は、いかなるファイルも生成したり、ディスクに保存したりしません。

プログラムの出力はすべて標準出力 (stdout) および標準エラー出力 (stderr) に表示されます。

  • 各Pythonファイルのインポート試行状況 ([IMPORT] <ファイル名>)

  • インポート成功メッセージ ([OK] conf.py import succeeded)

  • インポートスキップメッセージ ([SKIP] <ファイル名> (<理由>)--show-skip 指定時)

  • インポート失敗メッセージ ([ERROR] import に失敗しました: <ファイル名>)

  • エラーが発生した場合の子プロセスの標準出力/標準エラー出力(--show-child-output 指定時、またはエラー時)

  • 最後に、すべてのインポート試行の結果をまとめた概要 ([SUMMARY] import 失敗一覧)

  • エラーが発生し、--show-traceback-summary が指定された場合は、エラーの詳細なトレースバック情報

コマンドラインでの使用例 (Usage)

debug_sphinx_build.py は、以下のコマンドライン引数を受け付けます。

python debug_sphinx_build.py [OPTIONS]

OPTIONS:

  • --source SOURCE:

    • Sphinxソースディレクトリのパスを指定します(デフォルト: source)。

  • --add-cwd:

    • カレントディレクトリを sys.path の先頭に追加します。

  • --add-source:

    • --source で指定したディレクトリを sys.path の先頭に追加します。

  • --mock MOCK:

    • conf.py のインポートよりも前に手動でモックするモジュールをセミコロン ; 区切りで指定します。

    • 例: --mock "whisper;torch;numba"

  • --show-skip:

    • スキップされたファイルもコンソールに表示します。

  • --keep-going:

    • エラーが出ても処理を終了せず、最後まで続行して失敗したファイルの一覧を表示します。

  • --show-traceback-summary:

    • --keep-going オプションが有効な場合、最後の失敗一覧で各エラーの詳細なトレースバックも表示します。

  • --show-child-output:

    • 子プロセスが生成した標準出力と標準エラー出力を、インポート成功時にも表示します。

コマンドラインでの具体的な使用例

まず、以下の構造を持つSphinxプロジェクトのサンプルを作成します。

my_sphinx_project/
├── source/
│   ├── conf.py
│   ├── module_ok.py
│   └── module_error.py
└── debug_sphinx_build.py

my_sphinx_project/source/conf.py の内容:

# conf.py
import sys
from pathlib import Path

# Sphinx autodoc が利用するモジュールの検索パスに source ディレクトリを追加
sys.path.insert(0, str(Path(__file__).parent))

# モックする外部ライブラリ
autodoc_mock_imports = ["external_lib_a", "another_lib"]

project = 'My Sphinx Project'
copyright = '2023, Your Name'
extensions = ['sphinx.ext.autodoc']

my_sphinx_project/source/module_ok.py の内容:

# module_ok.py
import os
import sys
import external_lib_a # conf.py でモックされる
from . import module_non_existent # 親プロセスではエラーになるが子プロセスではパス解決が異なるため問題ない場合がある

def func_ok():
    """正常に動作する関数."""
    print("module_ok.py の func_ok が実行されました。")
    return "OK"

class MyClass:
    """正常なクラス."""
    def __init__(self, value):
        self.value = value

    def get_value(self):
        return self.value

my_sphinx_project/source/module_error.py の内容:

# module_error.py
import non_existent_lib # 存在しないライブラリ、モックされていない
import sys

def func_error():
    """エラーを引き起こす関数."""
    raise ValueError("意図的なエラー発生")

class AnotherClass:
    """問題のあるクラス."""
    def __init__(self):
        sys.exit(1) # インポート時にプロセスを終了させる

my_sphinx_project ディレクトリに移動し、debug_sphinx_build.py をコピーして配置します。


1. 基本的な実行例 (エラーが発生し次第停止)

cd my_sphinx_project
python debug_sphinx_build.py --add-source

実行結果の説明: conf.py が最初にインポートされ、external_lib_aanother_lib がモックされます。次に module_ok.py が正常にインポートされます。しかし、module_error.py のインポート時に non_existent_lib がモックされていないため ModuleNotFoundError が発生するか、または sys.exit(1) が呼び出されて子プロセスが終了し、エラーでスクリプトが停止します。

[INFO] source_dir = /path/to/my_sphinx_project/source
[INFO] conf.py    = /path/to/my_sphinx_project/source/conf.py
[INFO] import mode = subprocess per file
[IMPORT] /path/to/my_sphinx_project/source/conf.py
[OK] conf.py import succeeded
[IMPORT] /path/to/my_sphinx_project/source/module_error.py

[ERROR] import に失敗しました: /path/to/my_sphinx_project/source/module_error.py

================================================================================
[SUMMARY] import 失敗一覧
================================================================================
1. [module] /path/to/my_sphinx_project/source/module_error.py
   ModuleNotFoundError: No module named 'non_existent_lib'
--------------------------------------------------------------------------------
合計 1 件の import エラーがありました

2. エラーが発生しても処理を続行し、詳細なトレースバックを表示する例

cd my_sphinx_project
python debug_sphinx_build.py --add-source --keep-going --show-traceback-summary --mock "non_existent_lib"

実行結果の説明: --keep-going が指定されているため、module_error.py でエラーが発生しても処理が続行されます。さらに、--mock "non_existent_lib"non_existent_lib もモックされるため、module_error.py のインポート自体は成功するかもしれません。しかし、module_error.py 内の sys.exit(1) が呼び出されれば、子プロセスは異常終了し、RuntimeError として記録されます。 最終的に、すべてのファイルの処理が完了した後、--show-traceback-summary により、発生したエラーの完全なトレースバックを含む詳細なレポートが表示されます。

[INFO] source_dir = /path/to/my_sphinx_project/source
[INFO] conf.py    = /path/to/my_sphinx_project/source/conf.py
[INFO] import mode = subprocess per file
[INFO] manual mock modules = ['non_existent_lib']
[IMPORT] /path/to/my_sphinx_project/source/conf.py
[OK] conf.py import succeeded
[IMPORT] /path/to/my_sphinx_project/source/module_error.py

[ERROR] import に失敗しました: /path/to/my_sphinx_project/source/module_error.py

[IMPORT] /path/to/my_sphinx_project/source/module_ok.py
================================================================================
[SUMMARY] import 失敗一覧
================================================================================
1. [module] /path/to/my_sphinx_project/source/module_error.py
   RuntimeError: subprocess import failed: returncode=1, file=/path/to/my_sphinx_project/source/module_error.py
--------------------------------------------------------------------------------
合計 1 件の import エラーがありました

================================================================================
[SUMMARY] traceback 詳細
================================================================================

--- 1. [module] /path/to/my_sphinx_project/source/module_error.py ---
[child stderr]
module_error.py:8: UserWarning: Some warning from a mocked lib
[parent traceback]
Traceback (most recent call last):
  File "debug_sphinx_build.py", line 405, in main
    raise_if_subprocess_failed(cp, py_file)
  File "debug_sphinx_build.py", line 324, in raise_if_subprocess_failed
    raise RuntimeError(
RuntimeError: subprocess import failed: returncode=1, file=/path/to/my_sphinx_project/source/module_error.py

3. スキップされたファイルも表示し、子プロセスの出力を確認する例

もし source ディレクトリに _docstring.py のようなファイルが存在すると仮定します。

cd my_sphinx_project
python debug_sphinx_build.py --add-source --show-skip --show-child-output

実行結果の説明: --show-skip が指定されているため、_docstring.py のようなスキップされるファイルについてもその理由とともに表示されます。また、--show-child-output が指定されているため、各子プロセスが標準出力に出力した内容(例: module_ok.py 内の print 文)もコンソールに表示されます。

[INFO] source_dir = /path/to/my_sphinx_project/source
[INFO] conf.py    = /path/to/my_sphinx_project/source/conf.py
[INFO] import mode = subprocess per file
[IMPORT] /path/to/my_sphinx_project/source/conf.py
[OK] conf.py import succeeded
[IMPORT] /path/to/my_sphinx_project/source/module_error.py
<child output if any, e.g., warnings>

[ERROR] import に失敗しました: /path/to/my_sphinx_project/source/module_error.py

[IMPORT] /path/to/my_sphinx_project/source/module_ok.py
module_ok.py の func_ok が実行されました。
[SKIP]   /path/to/my_sphinx_project/source/my_module_docstring.py (stem が _docstring で終わる)
================================================================================
[SUMMARY] import 失敗一覧
================================================================================
1. [module] /path/to/my_sphinx_project/source/module_error.py
   RuntimeError: subprocess import failed: returncode=1, file=/path/to/my_sphinx_project/source/module_error.py
--------------------------------------------------------------------------------
合計 1 件の import エラーがありました