debug_sphinx_build.py ダウンロード/コピー

debug_sphinx_build.py をダウンロード

debug_sphinx_build.py
debug_sphinx_build.py
  1#!/usr/bin/env python3
  2# -*- coding: utf-8 -*-
  3
  4"""
  5Sphinx autodocにおけるimportエラーを調査するためのユーティリティスクリプト。
  6
  7各 .py ファイルを subprocess で個別 import することで、
  8Qt/Tk/matplotlib backend などの副作用が後続ファイルに残らないようにする。
  9
 10:doc:`debug_sphinx_build_usage`
 11"""
 12
 13from __future__ import annotations
 14
 15import argparse
 16import importlib.util
 17import re
 18import subprocess
 19import sys
 20import traceback
 21import types
 22from pathlib import Path
 23
 24
 25class DummyObject:
 26    """
 27    モックオブジェクトとして振る舞うダミークラス。
 28
 29    属性アクセスや関数呼び出しがあってもエラーにならず、常に新しい `DummyObject` を返す。
 30    これにより、実際のモジュールが存在しなくても参照エラーを防ぐ。
 31    """
 32
 33    def __init__(self, name: str = "dummy"):
 34        """
 35        DummyObjectインスタンスを初期化する。
 36
 37        :param name: ダミーオブジェクトの名前。
 38        :type name: str
 39        """
 40        self._name = name
 41
 42    def __call__(self, *args, **kwargs):
 43        """
 44        オブジェクトが関数として呼び出されたときの挙動を定義する。
 45
 46        常に新しい `DummyObject` を返し、元の名前に関数呼び出しを示す `()` を追加する。
 47
 48        :param args: 位置引数。
 49        :param kwargs: キーワード引数。
 50        :returns: 新しい `DummyObject` インスタンス。
 51        :rtype: DummyObject
 52        """
 53        return DummyObject(f"{self._name}()")
 54
 55    def __getattr__(self, name: str):
 56        """
 57        オブジェクトの属性にアクセスしようとしたときの挙動を定義する。
 58
 59        存在しない属性が要求された場合、その属性名を持つ `DummyObject` を作成し、返す。
 60        元の名前とアクセスされた属性名を連結する。
 61
 62        :param name: アクセスされた属性の名前。
 63        :type name: str
 64        :returns: 新しい `DummyObject` インスタンス。
 65        :rtype: DummyObject
 66        """
 67        return DummyObject(f"{self._name}.{name}")
 68
 69    def __repr__(self):
 70        """
 71        オブジェクトの公式な文字列表現を返す。
 72
 73        :returns: オブジェクトの文字列表現。
 74        :rtype: str
 75        """
 76        return f"<DummyObject {self._name}>"
 77
 78
 79class DummyModule(types.ModuleType):
 80    """
 81    モックモジュールとして振る舞うダミークラス。
 82
 83    `types.ModuleType` を継承し、未定義の属性にアクセスされた際に `DummyObject` を動的に作成・設定する。
 84    """
 85
 86    def __getattr__(self, name: str):
 87        """
 88        モジュールの属性にアクセスしようとしたときの挙動を定義する。
 89
 90        存在しない属性が要求された場合、その属性名を持つ `DummyObject` を作成し、
 91        モジュールに設定してから返す。
 92
 93        :param name: アクセスされた属性の名前。
 94        :type name: str
 95        :returns: 新しい `DummyObject` インスタンス。
 96        :rtype: DummyObject
 97        """
 98        dummy = DummyObject(f"{self.__name__}.{name}")
 99        setattr(self, name, dummy)
100        return dummy
101
102
103def install_mock_modules(module_names: list[str]) -> None:
104    """
105    指定されたモジュール名を `sys.modules` にダミーモジュールとしてインストールする。
106
107    モジュール名が階層的(例: `numpy.linalg`)である場合、親モジュール (`numpy`) も
108    ダミーモジュールとして追加される。
109
110    :param module_names: モックとしてインストールするモジュール名のリスト。
111    :type module_names: list[str]
112    :returns: None
113    :rtype: None
114    """
115    for name in module_names:
116        name = name.strip()
117        if not name:
118            continue
119
120        parts = name.split(".")
121        for i in range(1, len(parts) + 1):
122            subname = ".".join(parts[:i])
123            if subname not in sys.modules:
124                sys.modules[subname] = DummyModule(subname)
125
126
127def load_module_from_file(module_name: str, file_path: Path):
128    """
129    指定されたファイルパスからモジュールをロードし、`sys.modules` に追加する。
130
131    `importlib.util` を使用してモジュール仕様を作成し、ロードして実行する。
132
133    :param module_name: ロードするモジュールの名前。
134    :type module_name: str
135    :param file_path: ロードするモジュールのファイルパス。
136    :type file_path: Path
137    :returns: ロードされたモジュールオブジェクト。
138    :rtype: types.ModuleType
139    :raises ImportError: モジュール仕様の作成に失敗した場合。
140    """
141    spec = importlib.util.spec_from_file_location(module_name, str(file_path))
142    if spec is None or spec.loader is None:
143        raise ImportError(f"spec を作成できませんでした: {file_path}")
144
145    module = importlib.util.module_from_spec(spec)
146    sys.modules[module_name] = module
147    spec.loader.exec_module(module)
148    return module
149
150
151def make_module_name(source_dir: Path, py_file: Path) -> str:
152    """
153    ソースディレクトリとPythonファイルのパスから、一意なモジュール名を生成する。
154
155    ファイルパスをソースディレクトリからの相対パスに変換し、ファイル名をハイフンやスペースを
156    アンダースコアに置換して、有効なPython識別子になるように調整する。
157    `sphinx_debug.` プレフィックスを付与する。
158
159    :param source_dir: Sphinxのソースディレクトリのパス。
160    :type source_dir: Path
161    :param py_file: 対象のPythonファイルのパス。
162    :type py_file: Path
163    :returns: 生成されたモジュール名。
164    :rtype: str
165    """
166    rel = py_file.relative_to(source_dir).with_suffix("")
167    parts = []
168
169    for p in rel.parts:
170        p2 = p.replace("-", "_").replace(" ", "_")
171        if not p2.isidentifier():
172            p2 = "_" + "".join(
173                ch if (ch.isalnum() or ch == "_") else "_" for ch in p2
174            )
175        parts.append(p2)
176
177    return "sphinx_debug." + ".".join(parts)
178
179
180def should_skip(py_file: Path) -> tuple[bool, str]:
181    """
182    特定の条件に基づいてPythonファイルを処理対象からスキップするかどうかを判定する。
183
184    `conf.py`、ファイル名が `_docstring` で終わるファイル、
185    ファイル名が `_数字` で終わるファイルはスキップされる。
186
187    :param py_file: 検査対象のPythonファイルのパス。
188    :type py_file: Path
189    :returns: スキップするかどうかを示す真偽値と、スキップ理由の文字列のタプル。
190    :rtype: tuple[bool, str]
191    """
192    if py_file.name == "conf.py":
193        return True, "conf.py は先に個別 import 済み"
194
195    if py_file.stem.endswith("_docstring"):
196        return True, "stem が _docstring で終わる"
197
198    if re.search(r"_\d+$", py_file.stem):
199        return True, "stem が _日付 で終わる"
200
201    return False, ""
202
203
204def parse_mock_arg(mock_text: str) -> list[str]:
205    """
206    セミコロン区切りの文字列からモックモジュール名のリストを解析する。
207
208    空白文字列や空の要素は無視される。
209
210    :param mock_text: セミコロン区切りのモックモジュール名文字列。
211    :type mock_text: str
212    :returns: 解析されたモックモジュール名のリスト。
213    :rtype: list[str]
214    """
215    if not mock_text:
216        return []
217    return [x.strip() for x in mock_text.split(";") if x.strip()]
218
219
220def record_error(
221    errors: list[dict],
222    phase: str,
223    file_path: Path,
224    exc: BaseException,
225    stdout: str = "",
226    stderr: str = "",
227) -> None:
228    """
229    発生したエラー情報を記録リストに追加する。
230
231    エラーのフェーズ、ファイルパス、例外情報、標準出力、標準エラー出力などを
232    辞書形式で記録する。
233
234    :param errors: エラー情報を格納するリスト。
235    :type errors: list[dict]
236    :param phase: エラーが発生したフェーズ(例: "conf.py", "module")。
237    :type phase: str
238    :param file_path: エラーが発生したファイルのパス。
239    :type file_path: Path
240    :param exc: 発生した例外オブジェクト。
241    :type exc: BaseException
242    :param stdout: サブプロセスの標準出力の内容。
243    :type stdout: str
244    :param stderr: サブプロセスの標準エラー出力の内容。
245    :type stderr: str
246    :returns: None
247    :rtype: None
248    """
249    tb_text = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
250    errors.append(
251        {
252            "phase": phase,
253            "file": str(file_path),
254            "error_type": type(exc).__name__,
255            "message": str(exc),
256            "traceback": tb_text,
257            "stdout": stdout,
258            "stderr": stderr,
259        }
260    )
261
262
263def print_error_summary(errors: list[dict]) -> None:
264    """
265    記録されたエラーの概要をコンソールに出力する。
266
267    エラーがない場合はその旨を表示する。
268
269    :param errors: 記録されたエラー情報のリスト。
270    :type errors: list[dict]
271    :returns: None
272    :rtype: None
273    """
274    print("\n" + "=" * 80)
275    print("[SUMMARY] import 失敗一覧")
276    print("=" * 80)
277
278    if not errors:
279        print("[SUMMARY] エラーはありません")
280        return
281
282    for i, err in enumerate(errors, 1):
283        print(f"{i}. [{err['phase']}] {err['file']}")
284        print(f"   {err['error_type']}: {err['message']}")
285
286    print("-" * 80)
287    print(f"合計 {len(errors)} 件の import エラーがありました")
288
289
290def add_sys_paths(args, source_dir: Path) -> None:
291    """
292    コマンドライン引数に基づいて `sys.path` にパスを追加する。
293
294    `args.add_cwd` が真ならカレントディレクトリを、`args.add_source` が真なら
295    ソースディレクトリを `sys.path` の先頭に追加する。
296    すでにパスが存在する場合は追加しない。
297
298    :param args: コマンドライン引数を格納する名前空間オブジェクト。
299    :type args: argparse.Namespace
300    :param source_dir: Sphinxのソースディレクトリのパス。
301    :type source_dir: Path
302    :returns: None
303    :rtype: None
304    """
305    if args.add_cwd:
306        cwd = str(Path.cwd().resolve())
307        if cwd not in sys.path:
308            sys.path.insert(0, cwd)
309
310    if args.add_source:
311        sdir = str(source_dir)
312        if sdir not in sys.path:
313            sys.path.insert(0, sdir)
314
315
316def load_conf_mocks(source_dir: Path) -> list[str]:
317    """
318    `conf.py` から `autodoc_mock_imports` の設定を読み込む。
319
320    `conf.py` が存在しない場合は空のリストを返す。
321    `autodoc_mock_imports` がリストまたはタプルでない場合は警告を表示し、空リストを返す。
322
323    :param source_dir: Sphinxのソースディレクトリのパス。
324    :type source_dir: Path
325    :returns: `autodoc_mock_imports` に設定されたモックモジュール名のリスト。
326    :rtype: list[str]
327    """
328    conf_py = source_dir / "conf.py"
329    if not conf_py.is_file():
330        return []
331
332    conf_mod = load_module_from_file("sphinx_debug.conf", conf_py)
333    conf_mocks = getattr(conf_mod, "autodoc_mock_imports", [])
334
335    if not isinstance(conf_mocks, (list, tuple)):
336        print(
337            f"[WARN] autodoc_mock_imports が list/tuple ではありません: "
338            f"{type(conf_mocks).__name__}",
339            file=sys.stderr,
340        )
341        return []
342
343    return [str(x).strip() for x in conf_mocks if str(x).strip()]
344
345
346def child_import_one_file(args) -> None:
347    """
348    子プロセスとして単一のPythonファイルをインポートする。
349
350    `sys.path` の設定、手動モック、`conf.py` 由来のモックを適用した後、
351    指定されたファイルをモジュールとしてロードする。
352    この関数は、`--child-import` 引数が指定された場合にメイン関数から呼び出される。
353
354    :param args: コマンドライン引数を格納する名前空間オブジェクト。
355    :type args: argparse.Namespace
356    :returns: None
357    :rtype: None
358    """
359    source_dir = Path(args.source).resolve()
360    py_file = Path(args.single_file).resolve()
361
362    add_sys_paths(args, source_dir)
363
364    manual_mocks = parse_mock_arg(args.mock)
365    if manual_mocks:
366        install_mock_modules(manual_mocks)
367
368    if py_file.name == "conf.py":
369        module_name = "sphinx_debug.conf"
370        load_module_from_file(module_name, py_file)
371        return
372
373    conf_mocks = load_conf_mocks(source_dir)
374    if conf_mocks:
375        install_mock_modules(conf_mocks)
376
377    module_name = make_module_name(source_dir, py_file)
378    load_module_from_file(module_name, py_file)
379
380
381def run_import_in_subprocess(
382    args,
383    source_dir: Path,
384    py_file: Path,
385) -> subprocess.CompletedProcess:
386    """
387    指定されたPythonファイルを子プロセスでインポート実行する。
388
389    現在のスクリプト自体を `python -m` のように `--child-import` オプションを付けて実行し、
390    指定されたファイルのみをインポートさせる。
391    子プロセスの標準出力と標準エラー出力をキャプチャする。
392
393    :param args: コマンドライン引数を格納する名前空間オブジェクト。
394    :type args: argparse.Namespace
395    :param source_dir: Sphinxのソースディレクトリのパス。
396    :type source_dir: Path
397    :param py_file: インポートするPythonファイルのパス。
398    :type py_file: Path
399    :returns: サブプロセスの実行結果を含む `subprocess.CompletedProcess` オブジェクト。
400    :rtype: subprocess.CompletedProcess
401    """
402    cmd = [
403        sys.executable,
404        str(Path(__file__).resolve()),
405        "--source",
406        str(source_dir),
407        "--single-file",
408        str(py_file),
409        "--child-import",
410    ]
411
412    if args.add_cwd:
413        cmd.append("--add-cwd")
414
415    if args.add_source:
416        cmd.append("--add-source")
417
418    if args.mock:
419        cmd.extend(["--mock", args.mock])
420
421    return subprocess.run(
422        cmd,
423        text=True,
424        capture_output=True,
425    )
426
427
428def raise_if_subprocess_failed(cp: subprocess.CompletedProcess, py_file: Path) -> None:
429    """
430    サブプロセスが非ゼロの終了コードを返した場合に `RuntimeError` を発生させる。
431
432    :param cp: サブプロセスの実行結果を含む `subprocess.CompletedProcess` オブジェクト。
433    :type cp: subprocess.CompletedProcess
434    :param py_file: サブプロセスで処理されたファイルのパス。
435    :type py_file: Path
436    :returns: None
437    :rtype: None
438    :raises RuntimeError: サブプロセスが失敗した場合。
439    """
440    if cp.returncode != 0:
441        raise RuntimeError(
442            f"subprocess import failed: returncode={cp.returncode}, file={py_file}"
443        )
444
445
446def main():
447    """
448    スクリプトのメインエントリポイント。
449
450    コマンドライン引数を解析し、親プロセスとして動作する場合は `source` ディレクトリ内の
451    すべてのPythonファイルを子プロセスで順次インポートを試みる。
452    子プロセスとして動作する場合は、単一のファイルをインポートする (`--child-import` 指定時)。
453    エラーが発生した場合は記録し、最終的に概要を出力する。
454    """
455    parser = argparse.ArgumentParser(
456        description="Sphinx import エラー調査用: source配下の *.py を subprocess で個別 import する"
457    )
458    parser.add_argument(
459        "--source",
460        default="source",
461        help="Sphinx source ディレクトリ (default: source)",
462    )
463    parser.add_argument(
464        "--add-cwd",
465        action="store_true",
466        help="カレントディレクトリを sys.path 先頭に追加する",
467    )
468    parser.add_argument(
469        "--add-source",
470        action="store_true",
471        help="source ディレクトリを sys.path 先頭に追加する",
472    )
473    parser.add_argument(
474        "--mock",
475        default="",
476        help='conf.py import 前に手動でモックするモジュールを ; 区切りで指定 '
477        '(例: "whisper;torch;numba")',
478    )
479    parser.add_argument(
480        "--show-skip",
481        action="store_true",
482        help="スキップしたファイルも表示する",
483    )
484    parser.add_argument(
485        "--keep-going",
486        action="store_true",
487        help="エラーが出ても終了せず、最後まで続行して失敗一覧を表示する",
488    )
489    parser.add_argument(
490        "--show-traceback-summary",
491        action="store_true",
492        help="最後の失敗一覧で traceback も全文表示する (--keep-going 向け)",
493    )
494    parser.add_argument(
495        "--show-child-output",
496        action="store_true",
497        help="子プロセスの stdout/stderr を成功時にも表示する",
498    )
499
500    # subprocess 内部用
501    parser.add_argument(
502        "--single-file",
503        default="",
504        help=argparse.SUPPRESS,
505    )
506    parser.add_argument(
507        "--child-import",
508        action="store_true",
509        help=argparse.SUPPRESS,
510    )
511
512    args = parser.parse_args()
513
514    if args.child_import:
515        child_import_one_file(args)
516        return
517
518    source_dir = Path(args.source).resolve()
519    conf_py = source_dir / "conf.py"
520    errors: list[dict] = []
521
522    if not source_dir.is_dir():
523        print(f"ERROR: source ディレクトリが存在しません: {source_dir}", file=sys.stderr)
524        sys.exit(1)
525
526    if not conf_py.is_file():
527        print(f"ERROR: conf.py が存在しません: {conf_py}", file=sys.stderr)
528        sys.exit(1)
529
530    print(f"[INFO] source_dir = {source_dir}")
531    print(f"[INFO] conf.py    = {conf_py}")
532    print("[INFO] import mode = subprocess per file")
533
534    manual_mocks = parse_mock_arg(args.mock)
535    if manual_mocks:
536        print(f"[INFO] manual mock modules = {manual_mocks}")
537
538    print(f"[IMPORT] {conf_py}")
539    try:
540        cp = run_import_in_subprocess(args, source_dir, conf_py)
541
542        if args.show_child_output:
543            if cp.stdout:
544                print(cp.stdout, end="")
545            if cp.stderr:
546                print(cp.stderr, end="", file=sys.stderr)
547
548        raise_if_subprocess_failed(cp, conf_py)
549
550        # 親プロセス側でも autodoc_mock_imports の表示だけ行う。
551        # ここで conf.py を親に import すると副作用が残るため、
552        # 直接 import はせず、簡易的に子プロセスで成功確認だけにする。
553        print("[OK] conf.py import succeeded")
554
555    except Exception as e:
556        print("\n[ERROR] conf.py の import に失敗しました\n", file=sys.stderr)
557
558        if "cp" in locals():
559            if cp.stdout:
560                print(cp.stdout, end="")
561            if cp.stderr:
562                print(cp.stderr, end="", file=sys.stderr)
563
564        record_error(
565            errors,
566            "conf.py",
567            conf_py,
568            e,
569            stdout=cp.stdout if "cp" in locals() else "",
570            stderr=cp.stderr if "cp" in locals() else "",
571        )
572
573        if not args.keep_going:
574            print_error_summary(errors)
575            sys.exit(1)
576
577    for py_file in sorted(source_dir.rglob("*.py")):
578        skip, reason = should_skip(py_file)
579        if skip:
580            if args.show_skip:
581                print(f"[SKIP]   {py_file} ({reason})")
582            continue
583
584        print(f"[IMPORT] {py_file}")
585        try:
586            cp = run_import_in_subprocess(args, source_dir, py_file)
587
588            if args.show_child_output:
589                if cp.stdout:
590                    print(cp.stdout, end="")
591                if cp.stderr:
592                    print(cp.stderr, end="", file=sys.stderr)
593
594            raise_if_subprocess_failed(cp, py_file)
595
596        except Exception as e:
597            print(f"\n[ERROR] import に失敗しました: {py_file}\n", file=sys.stderr)
598
599            if "cp" in locals():
600                if cp.stdout:
601                    print(cp.stdout, end="")
602                if cp.stderr:
603                    print(cp.stderr, end="", file=sys.stderr)
604
605            record_error(
606                errors,
607                "module",
608                py_file,
609                e,
610                stdout=cp.stdout if "cp" in locals() else "",
611                stderr=cp.stderr if "cp" in locals() else "",
612            )
613
614            if not args.keep_going:
615                print_error_summary(errors)
616                sys.exit(1)
617
618    print_error_summary(errors)
619
620    if args.keep_going and args.show_traceback_summary and errors:
621        print("\n" + "=" * 80)
622        print("[SUMMARY] traceback 詳細")
623        print("=" * 80)
624        for i, err in enumerate(errors, 1):
625            print(f"\n--- {i}. [{err['phase']}] {err['file']} ---")
626
627            if err.get("stdout"):
628                print("[child stdout]")
629                print(err["stdout"])
630
631            if err.get("stderr"):
632                print("[child stderr]")
633                print(err["stderr"])
634
635            print("[parent traceback]")
636            print(err["traceback"])
637
638    if errors:
639        sys.exit(1)
640
641    print("\n[OK] すべての対象 .py ファイルを import できました")
642
643
644if __name__ == "__main__":
645    main()