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()