check_sphinx_api_rst.py ダウンロード/コピー
check_sphinx_api_rst.py をダウンロード
check_sphinx_api_rst.py
check_sphinx_api_rst.py
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4"""
5Sphinx の *_api.rst を再帰走査し、autodoc で問題になりやすい記述を検出するスクリプト。
6
7詳細説明:
8このスクリプトは、Sphinxプロジェクト内の `*_api.rst` ファイルを走査し、
9`.. automodule::` ディレクティブに指定されたモジュール名に問題がないか、
10また、対応するPythonファイルが `argv` や `sys.argv` を使用しているにもかかわらず
11`if __name__ == "__main__":` ガードがない可能性がないかをチェックします。
12
13`--fix` オプションが指定された場合、モジュール名のハイフンやWindowsパス区切りを自動的に修正し、
14関連するファイル名や参照も更新します。
15
16チェック内容:
17 1. `.. automodule::` に指定されたモジュール名に '-' が含まれていないか。
18 2. `.. automodule::` に指定されたモジュール名に '\\' や '/' が含まれていないか。
19 3. 対応する .py ファイルが `argv` / `sys.argv` を使っているのに
20 `if __name__ == "__main__":` ガードが無い可能性がないか。
21
22`--fix` の自動修正内容:
23 - `automodule` の module-name 中の '\\' と '/' を '.' に置換。
24 - `automodule` の module-name 中の '-' を '_' に置換。
25 - 対応する .py ファイル名の '-' を '_' に置換。
26 - 対応する `*_api.rst` ファイル名の '-' を '_' に置換。
27 - 同じディレクトリ内の関連しそうな `usage`/`examples`/`index` rst/md 参照の '-' を '_' に置換。
28
29例:
30 python check_sphinx_api_rst.py
31 python check_sphinx_api_rst.py --root ./source
32 python check_sphinx_api_rst.py --fix
33 python check_sphinx_api_rst.py --fix --dry-run
34
35:doc:`check_sphinx_api_rst_usage`
36"""
37
38from __future__ import annotations
39
40import argparse
41import re
42import sys
43from pathlib import Path
44
45
46AUTOMODULE_RE = re.compile(
47 r"(^\s*\.\.\s+automodule::\s+)([^\s]+)(\s*$)",
48 flags=re.MULTILINE,
49)
50
51MAIN_GUARD_RE = re.compile(
52 r"if\s+__name__\s*==\s*['\"]__main__['\"]\s*:",
53 flags=re.MULTILINE,
54)
55
56ARGV_RE = re.compile(
57 r"\b(?:sys\s*\.\s*)?argv\b"
58)
59
60
61def read_text(path: Path) -> str:
62 """
63 指定されたパスのテキストファイルを複数のエンコーディングで試行しながら読み込む。
64
65 詳細説明:
66 UTF-8, CP932, Shift_JIS, Latin-1 の順でエンコーディングを試み、
67 いずれのエンコーディングでもデコードできなかった場合は、
68 バイト列として読み込み、UTF-8でエラーを置換しながらデコードします。
69
70 :param path: Path: 読み込むファイルのパス。
71 :returns: str: ファイルの内容を表す文字列。
72 """
73 for enc in ("utf-8", "cp932", "shift_jis", "latin-1"):
74 try:
75 return path.read_text(encoding=enc)
76 except UnicodeDecodeError:
77 continue
78 return path.read_bytes().decode("utf-8", errors="replace")
79
80
81def write_text(path: Path, text: str, dry_run: bool = False) -> None:
82 """
83 指定されたパスにUTF-8エンコーディングでテキストを書き込む。
84
85 詳細説明:
86 dry_runがTrueの場合、ファイルの書き込みは行わず、処理をスキップします。
87
88 :param path: Path: 書き込むファイルのパス。
89 :param text: str: 書き込むテキストの内容。
90 :param dry_run: bool: Trueの場合、ファイルの書き込みを行わない。デフォルトはFalse。
91 :returns: None
92 """
93 if dry_run:
94 return
95 path.write_text(text, encoding="utf-8")
96
97
98def find_files(root: Path, pattern: str) -> list[Path]:
99 """
100 指定されたルートディレクトリ以下から、ワイルドカードパターンに一致するファイルを再帰的に検索する。
101
102 :param root: Path: 検索を開始するルートディレクトリのパス。
103 :param pattern: str: 検索するファイルのワイルドカードパターン(例: "*.rst")。
104 :returns: list[Path]: 見つかったファイルのパスのリスト。パスはソートされている。
105 """
106 return sorted(p for p in root.rglob(pattern) if p.is_file())
107
108
109def extract_automodule_names(rst_text: str) -> list[str]:
110 """
111 RST本文から `.. automodule::` ディレクティブに指定されたモジュール名を抽出する。
112
113 :param rst_text: str: RSTファイルの全テキスト内容。
114 :returns: list[str]: 抽出されたモジュール名のリスト。
115 """
116 return [m.group(2).strip() for m in AUTOMODULE_RE.finditer(rst_text)]
117
118
119def safe_module_name(module_name: str) -> str:
120 """
121 Pythonの `import` 文で安全に使用できるモジュール名に補正する。
122
123 詳細説明:
124 - バックスラッシュ '\\' とスラッシュ '/' をピリオド '.' に置換します。
125 - ハイフン '-' をアンダースコア '_' に置換します。
126
127 :param module_name: str: 元のモジュール名。
128 :returns: str: 補正されたモジュール名。
129 """
130 module_name = module_name.replace("\\", ".")
131 module_name = module_name.replace("/", ".")
132 module_name = module_name.replace("-", "_")
133 return module_name
134
135
136def module_name_to_py_path(root: Path, module_name: str) -> Path:
137 """
138 `automodule` ディレクティブのモジュール名から、対応するPythonファイル (.py) のパス候補を作成する。
139
140 例:
141 `regression.adaptive_gaussian_ridge` は `root/regression/adaptive_gaussian_ridge.py` に変換される。
142
143 :param root: Path: プロジェクトのルートディレクトリ。
144 :param module_name: str: `automodule` で指定されたモジュール名。
145 :returns: Path: 対応するPythonファイルのパス候補。
146 """
147 parts = module_name.split(".")
148 return root.joinpath(*parts).with_suffix(".py")
149
150
151def fallback_py_path_from_rst(rst_path: Path) -> Path:
152 """
153 `*_api.rst` ファイルのパスから、同じディレクトリ内の対応するPythonファイル (.py) のパス候補を推定する。
154
155 詳細説明:
156 ファイル名が `*_api.rst` の場合、`_api` 部分を除去して `.py` 拡張子を付けます。
157 例: `regression/foo_api.rst` は `regression/foo.py` に推定される。
158
159 :param rst_path: Path: `*_api.rst` ファイルのパス。
160 :returns: Path: 推定されたPythonファイルのパス候補。
161 """
162 stem = rst_path.stem
163 if stem.endswith("_api"):
164 stem = stem[:-4]
165 return rst_path.with_name(stem + ".py")
166
167
168def has_unprotected_argv(py_text: str) -> bool:
169 """
170 Pythonコード内で `argv` または `sys.argv` が使用されており、かつ `if __name__ == "__main__":` ガードがないかを判定する。
171
172 詳細説明:
173 この関数は厳密な制御フロー解析ではなく、Sphinx autodocで問題になりやすい典型的なパターンを検出するためのものです。
174 `__main__` ガードが見つからない場合でも、`argv` の使用がない場合は `False` を返します。
175
176 :param py_text: str: Pythonファイルの全テキスト内容。
177 :returns: bool: `argv` が保護なしで使用されている場合はTrue、それ以外はFalse。
178 """
179 result = bool(MAIN_GUARD_RE.search(py_text))
180 if result: return False
181
182 result = bool(ARGV_RE.search(py_text))
183 if result:
184# print("py_text:", py_text) # 既存のコメントアウトを保持
185 return True
186
187 return False
188
189
190def rename_file(src: Path, dst: Path, actions: list[str], dry_run: bool = False) -> Path:
191 """
192 ファイルをリネームする。宛先パスにファイルが既に存在する場合はリネームをスキップする。
193
194 詳細説明:
195 `src` と `dst` が同じ場合は処理をスキップします。
196 `src` が存在しない場合も処理をスキップします。
197 `dry_run` がTrueの場合、実際のリネームは行わず、アクションログのみを記録します。
198
199 :param src: Path: 元ファイルのパス。
200 :param dst: Path: 新しいファイルのパス。
201 :param actions: list[str]: 実行された(または実行予定の)アクションを記録するリスト。
202 :param dry_run: bool: Trueの場合、実際のリネームを行わない。デフォルトはFalse。
203 :returns: Path: 実際にリネームされた後のパス(またはリネームされなかった場合は元のパス)。
204 """
205 if src == dst:
206 return src
207
208 if not src.exists():
209 return src
210
211 if dst.exists():
212 actions.append(f"SKIP rename because destination exists: {src} -> {dst}")
213 return src
214
215 actions.append(f"RENAME {src} -> {dst}")
216 if not dry_run:
217 src.rename(dst)
218 return dst
219
220
221def replace_in_file(path: Path, old: str, new: str, actions: list[str], dry_run: bool = False) -> None:
222 """
223 指定されたファイル内の文字列を置換する。
224
225 詳細説明:
226 ファイルが存在しない場合や、置換対象の文字列 `old` がファイル内に含まれていない場合は処理をスキップします。
227 `dry_run` がTrueの場合、実際のファイル書き込みは行わず、アクションログのみを記録します。
228
229 :param path: Path: 対象ファイルのパス。
230 :param old: str: 置換対象の文字列。
231 :param new: str: 置換後の文字列。
232 :param actions: list[str]: 実行された(または実行予定の)アクションを記録するリスト。
233 :param dry_run: bool: Trueの場合、実際のファイル書き込みを行わない。デフォルトはFalse。
234 :returns: None
235 """
236 if not path.is_file():
237 return
238
239 text = read_text(path)
240 if old not in text:
241 return
242
243 new_text = text.replace(old, new)
244 actions.append(f"REPLACE in {path}: {old} -> {new}")
245 write_text(path, new_text, dry_run=dry_run)
246
247
248def replace_automodule_name(
249 rst_path: Path,
250 old_module_name: str,
251 new_module_name: str,
252 actions: list[str],
253 dry_run: bool = False,
254) -> None:
255 """
256 RSTファイル内の `.. automodule::` ディレクティブに指定されたモジュール名のみを置換する。
257
258 詳細説明:
259 指定されたRSTファイルを開き、正規表現を用いて `.. automodule::` の行を見つけ、
260 その中のモジュール名が `old_module_name` と一致した場合に `new_module_name` に置換します。
261 ファイル内容が変更された場合のみ、アクションログに記録し、ファイルに書き戻します。
262 `dry_run` がTrueの場合、実際のファイル書き込みは行いません。
263
264 :param rst_path: Path: 対象RSTファイルのパス。
265 :param old_module_name: str: 置換対象の古いモジュール名。
266 :param new_module_name: str: 置換後の新しいモジュール名。
267 :param actions: list[str]: 実行された(または実行予定の)アクションを記録するリスト。
268 :param dry_run: bool: Trueの場合、実際のファイル書き込みを行わない。デフォルトはFalse。
269 :returns: None
270 """
271 if not rst_path.is_file():
272 return
273
274 text = read_text(rst_path)
275
276 def repl(m: re.Match) -> str:
277 prefix, name, suffix = m.group(1), m.group(2), m.group(3)
278 if name == old_module_name:
279 return prefix + new_module_name + suffix
280 return m.group(0)
281
282 new_text = AUTOMODULE_RE.sub(repl, text)
283
284 if new_text != text:
285 actions.append(f"REPLACE automodule in {rst_path}: {old_module_name} -> {new_module_name}")
286 write_text(rst_path, new_text, dry_run=dry_run)
287
288
289def fix_module_name(
290 root: Path,
291 rst_path: Path,
292 module_name: str,
293 actions: list[str],
294 dry_run: bool = False,
295) -> tuple[Path, str]:
296 """
297 `automodule` ディレクティブのモジュール名をPythonの import 可能な形に補正し、関連ファイルを修正する。
298
299 詳細説明:
300 - モジュール名内の '\\' と '/' を '.' に置換し、'-' を '_' に置換します。
301 - 対応するPythonファイル名('-' を含む場合)をリネームします。
302 - RSTファイル内の `automodule` ディレクティブのモジュール名を修正します。
303 - RSTファイル名自体('-' を含む場合)をリネームします。
304 - 同じディレクトリ内の関連ドキュメント(`_usage`, `_examples`, `_index` など)の参照も更新します。
305 `dry_run` がTrueの場合、実際のリネームやファイル書き換えは行いません。
306
307 :param root: Path: プロジェクトのルートディレクトリ。
308 :param rst_path: Path: 現在処理中の `*_api.rst` ファイルのパス。
309 :param module_name: str: `automodule` で指定された元のモジュール名。
310 :param actions: list[str]: 実行された(または実行予定の)アクションを記録するリスト。
311 :param dry_run: bool: Trueの場合、実際のリネームやファイル書き込みを行わない。デフォルトはFalse。
312 :returns: tuple[Path, str]: 修正後のRSTファイルのパスと修正後のモジュール名。
313 """
314 fixed_module_name = safe_module_name(module_name)
315 if fixed_module_name == module_name:
316 return rst_path, module_name
317
318 old_py = module_name_to_py_path(root, module_name)
319 new_py = module_name_to_py_path(root, fixed_module_name)
320
321 # path separator が混じる場合、old_py は実在パスとして解決できないことがある。
322 # その場合は rst ファイル名から同名 .py を推定する。
323 if not old_py.is_file():
324 fallback_old_py = fallback_py_path_from_rst(rst_path)
325 fallback_new_py = fallback_old_py.with_name(fallback_old_py.name.replace("-", "_"))
326 if fallback_old_py.is_file():
327 old_py = fallback_old_py
328 new_py = fallback_new_py
329
330 # .py ファイル名の '-' は '_' へリネームする。
331 # '\' -> '.' の修正だけなら、通常 .py のリネームは不要。
332 if "-" in old_py.name:
333 rename_file(old_py, new_py, actions, dry_run=dry_run)
334
335 # rst 内の automodule 名を必ず修正する。
336 replace_automodule_name(
337 rst_path,
338 module_name,
339 fixed_module_name,
340 actions,
341 dry_run=dry_run,
342 )
343
344 # rst ファイル名の '-' は '_' へリネームする。
345 new_rst_path = rst_path.with_name(rst_path.name.replace("-", "_"))
346 actual_rst_path = rst_path
347 if "-" in rst_path.name:
348 actual_rst_path = rename_file(rst_path, new_rst_path, actions, dry_run=dry_run)
349
350 # 同じディレクトリ内の関連ドキュメント参照も軽く直す。
351 old_stem = rst_path.stem.replace("_api", "")
352 new_stem = old_stem.replace("-", "_")
353 if old_stem != new_stem:
354 target_dir = actual_rst_path.parent if not dry_run else rst_path.parent
355
356 for ext in ("*.rst", "*.md"):
357 for p in target_dir.glob(ext):
358 replace_in_file(p, old_stem, new_stem, actions, dry_run=dry_run)
359 replace_in_file(p, old_stem + "_api", new_stem + "_api", actions, dry_run=dry_run)
360 replace_in_file(p, old_stem + "_usage", new_stem + "_usage", actions, dry_run=dry_run)
361 replace_in_file(p, old_stem + "_examples", new_stem + "_examples", actions, dry_run=dry_run)
362 replace_in_file(p, old_stem + "_index", new_stem + "_index", actions, dry_run=dry_run)
363
364 for suffix in ("_usage", "_examples", "_index", "_api"):
365 old_file = target_dir / f"{old_stem}{suffix}.rst"
366 new_file = target_dir / f"{new_stem}{suffix}.rst"
367 rename_file(old_file, new_file, actions, dry_run=dry_run)
368
369 old_file = target_dir / f"{old_stem}{suffix}.md"
370 new_file = target_dir / f"{new_stem}{suffix}.md"
371 rename_file(old_file, new_file, actions, dry_run=dry_run)
372
373 return actual_rst_path, fixed_module_name
374
375
376def check_one_rst(
377 root: Path,
378 rst_path: Path,
379 fix: bool = False,
380 dry_run: bool = False,
381) -> tuple[bool, list[str], list[str]]:
382 """
383 1つの `*_api.rst` ファイルをチェックし、必要に応じて修正を試みる。
384
385 詳細説明:
386 この関数は以下のチェックを行います。
387 - `.. automodule::` ディレクティブが存在するか。
388 - モジュール名に不正な文字('-', '\\', '/')が含まれていないか。
389 - 対応するPythonファイルが見つかるか。
390 - 対応するPythonファイルに `argv`/`sys.argv` が `__main__` ガードなしで使用されていないか。
391 `fix` がTrueの場合、不正なモジュール名を修正し、関連ファイルのリネームや内容置換を行います。
392 `dry_run` がTrueの場合、修正は実行されずにログに記録されるのみです。
393
394 :param root: Path: Sphinxソースのルートディレクトリ。
395 :param rst_path: Path: チェック対象の `*_api.rst` ファイルのパス。
396 :param fix: bool: Trueの場合、検出された問題を自動修正する。デフォルトはFalse。
397 :param dry_run: bool: Trueの場合、修正をシミュレーションし、ファイルは変更しない。`fix` と併用。デフォルトはFalse。
398 :returns: tuple[bool, list[str], list[str]]:
399 - bool: 問題が見つかった場合はTrue、それ以外はFalse。
400 - list[str]: 検出された問題の理由を説明する文字列のリスト。
401 - list[str]: `fix` モードで実行された(または実行予定の)修正内容を説明する文字列のリスト。
402 """
403 rst_text = read_text(rst_path)
404 module_names = extract_automodule_names(rst_text)
405 reasons: list[str] = []
406 actions: list[str] = []
407
408 if not module_names:
409 reasons.append("no automodule directive found")
410 return True, reasons, actions
411
412 current_rst_path = rst_path
413
414 for module_name0 in module_names:
415 module_name = module_name0
416 fixed_module_name = safe_module_name(module_name)
417
418 if fixed_module_name != module_name:
419 if "-" in module_name:
420 reasons.append(f"invalid module name contains '-': {module_name}")
421 if "\\" in module_name or "/" in module_name:
422 reasons.append(f"invalid module name contains path separator: {module_name}")
423
424 if fix:
425 current_rst_path, module_name = fix_module_name(
426 root,
427 current_rst_path,
428 module_name,
429 actions,
430 dry_run=dry_run,
431 )
432 else:
433 module_name = fixed_module_name
434
435 py_path = module_name_to_py_path(root, module_name)
436
437 if not py_path.is_file():
438 fallback = fallback_py_path_from_rst(current_rst_path)
439 if fallback.is_file():
440 py_path = fallback
441 reasons.append(
442 f"module path not found, fallback py file used: {py_path.relative_to(root)}"
443 )
444 else:
445 reasons.append(
446 f"corresponding py file not found for module: {module_name}"
447 )
448 continue
449
450 py_text = read_text(py_path)
451
452 if has_unprotected_argv(py_text):
453 reasons.append(
454 f"argv is used but __main__ guard was not found: {py_path.relative_to(root)}"
455 )
456
457 # fix後に、修正対象だったハイフン/セパレータ理由は再評価して消す。
458 if fix and actions:
459 new_text = read_text(current_rst_path) if current_rst_path.exists() else rst_text
460 remaining_modules = extract_automodule_names(new_text)
461 remaining_bad = [
462 m for m in remaining_modules
463 if "-" in m or "\\" in m or "/" in m
464 ]
465
466 reasons = [
467 r for r in reasons
468 if not r.startswith("invalid module name contains '-'")
469 and not r.startswith("invalid module name contains path separator")
470 ]
471
472 for m in remaining_bad:
473 reasons.append(f"invalid module name still contains invalid character: {m}")
474
475 failed = len(reasons) > 0
476 return failed, reasons, actions
477
478
479def main() -> int:
480 """
481 SphinxのAPIドキュメント (`*_api.rst`) のチェックと修正を行うメイン処理。
482
483 詳細説明:
484 コマンドライン引数を解析し、指定されたルートディレクトリ以下の `*_api.rst` ファイルを検索します。
485 各ファイルに対して `check_one_rst` を呼び出し、問題の検出と必要に応じた修正を行います。
486 結果はコンソールに出力され、`--outfile` で指定されたファイルにも詳細が記録されます。
487 問題が見つかったファイルがある場合、終了コードは1となり、それ以外は0となります。
488
489 :returns: int: 終了コード。問題が見つかった場合は1、それ以外は0。
490 """
491 parser = argparse.ArgumentParser(
492 description=(
493 "Check Sphinx *_api.rst files for invalid automodule names and "
494 "argv usage without __main__ guard in corresponding .py files."
495 )
496 )
497 parser.add_argument(
498 "--root",
499 type=str,
500 default="./source",
501 help='Sphinx source root directory. default: "./source"',
502 )
503 parser.add_argument(
504 "--files",
505 type=str,
506 default="*_api.rst",
507 help='wildcard pattern for target RST files. default: "*_api.rst"',
508 )
509 parser.add_argument(
510 "--outfile",
511 type=str,
512 default="failed_api_rst.txt",
513 help='output file path. default: "failed_api_rst.txt"',
514 )
515 parser.add_argument(
516 "--exclude",
517 action="append",
518 default=[],
519 help="exclude path keyword. Can be used multiple times.",
520 )
521 parser.add_argument(
522 "--show-ok",
523 type=int,
524 default=0,
525 choices=[0, 1],
526 help="show OK files too. default: 0",
527 )
528 parser.add_argument(
529 "--fix",
530 action="store_true",
531 help="automatically fix '-' and path separators in automodule names when safe.",
532 )
533 parser.add_argument(
534 "--dry-run",
535 action="store_true",
536 help="show planned fixes without modifying files. Use with --fix.",
537 )
538
539 args = parser.parse_args()
540
541 root = Path(args.root).resolve()
542 outfile = Path(args.outfile)
543
544 if not root.exists():
545 print(f"ERROR: root directory not found: {root}")
546 return 1
547
548 files = find_files(root, args.files)
549
550 print(f"root : {root}")
551 print(f"pattern : {args.files}")
552 print(f"found : {len(files)} files")
553 print(f"fix : {args.fix}")
554 print(f"dry-run : {args.dry_run}")
555 print()
556
557 failed_files: list[tuple[Path, list[str]]] = []
558 action_log: list[str] = []
559 checked_count = 0
560 skipped_count = 0
561
562 for path in files:
563 if not path.exists():
564 # 直前の --fix でリネーム済みの場合など
565 continue
566
567 rel_str = str(path.relative_to(root)).replace("\\", "/")
568
569 if any(x in rel_str for x in args.exclude):
570 skipped_count += 1
571 if args.show_ok:
572 print(f"[SKIP] {rel_str}")
573 continue
574
575 checked_count += 1
576 failed, reasons, actions = check_one_rst(
577 root,
578 path,
579 fix=args.fix,
580 dry_run=args.dry_run,
581 )
582 action_log.extend(actions)
583
584 if actions:
585 print(f"[FIX] {rel_str}")
586 for a in actions:
587 print(f" - {a}")
588
589 if failed:
590 failed_files.append((path, reasons))
591 print(f"[FAILED] {rel_str}")
592 for r in reasons:
593 print(f" - {r}")
594 elif args.show_ok and not actions:
595 print(f"[OK] {rel_str}")
596
597 print()
598 print(f"checked : {checked_count}")
599 print(f"skipped : {skipped_count}")
600 print(f"failed : {len(failed_files)} / {checked_count}")
601 print(f"actions : {len(action_log)}")
602
603 with outfile.open("w", encoding="utf-8") as f:
604 for path, reasons in failed_files:
605 try:
606 rel = path.relative_to(root)
607 except ValueError:
608 rel = path
609 f.write(str(rel).replace("\\", "/"))
610 f.write("\n")
611 for r in reasons:
612 f.write(f" - {r}\n")
613
614 if action_log:
615 f.write("\n[ACTIONS]\n")
616 for a in action_log:
617 f.write(f"- {a}\n")
618
619 print(f"output : {outfile.resolve()}")
620
621 return 1 if failed_files else 0
622
623
624if __name__ == "__main__":
625 sys.exit(main())