#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Sphinx の *_api.rst を再帰走査し、autodoc で問題になりやすい記述を検出するスクリプト。
詳細説明:
このスクリプトは、Sphinxプロジェクト内の `*_api.rst` ファイルを走査し、
`.. automodule::` ディレクティブに指定されたモジュール名に問題がないか、
また、対応するPythonファイルが `argv` や `sys.argv` を使用しているにもかかわらず
`if __name__ == "__main__":` ガードがない可能性がないかをチェックします。
`--fix` オプションが指定された場合、モジュール名のハイフンやWindowsパス区切りを自動的に修正し、
関連するファイル名や参照も更新します。
チェック内容:
1. `.. automodule::` に指定されたモジュール名に '-' が含まれていないか。
2. `.. automodule::` に指定されたモジュール名に '\\' や '/' が含まれていないか。
3. 対応する .py ファイルが `argv` / `sys.argv` を使っているのに
`if __name__ == "__main__":` ガードが無い可能性がないか。
`--fix` の自動修正内容:
- `automodule` の module-name 中の '\\' と '/' を '.' に置換。
- `automodule` の module-name 中の '-' を '_' に置換。
- 対応する .py ファイル名の '-' を '_' に置換。
- 対応する `*_api.rst` ファイル名の '-' を '_' に置換。
- 同じディレクトリ内の関連しそうな `usage`/`examples`/`index` rst/md 参照の '-' を '_' に置換。
例:
python check_sphinx_api_rst.py
python check_sphinx_api_rst.py --root ./source
python check_sphinx_api_rst.py --fix
python check_sphinx_api_rst.py --fix --dry-run
:doc:`check_sphinx_api_rst_usage`
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
AUTOMODULE_RE = re.compile(
r"(^\s*\.\.\s+automodule::\s+)([^\s]+)(\s*$)",
flags=re.MULTILINE,
)
MAIN_GUARD_RE = re.compile(
r"if\s+__name__\s*==\s*['\"]__main__['\"]\s*:",
flags=re.MULTILINE,
)
ARGV_RE = re.compile(
r"\b(?:sys\s*\.\s*)?argv\b"
)
[ドキュメント]
def read_text(path: Path) -> str:
"""
指定されたパスのテキストファイルを複数のエンコーディングで試行しながら読み込む。
詳細説明:
UTF-8, CP932, Shift_JIS, Latin-1 の順でエンコーディングを試み、
いずれのエンコーディングでもデコードできなかった場合は、
バイト列として読み込み、UTF-8でエラーを置換しながらデコードします。
:param path: Path: 読み込むファイルのパス。
:returns: str: ファイルの内容を表す文字列。
"""
for enc in ("utf-8", "cp932", "shift_jis", "latin-1"):
try:
return path.read_text(encoding=enc)
except UnicodeDecodeError:
continue
return path.read_bytes().decode("utf-8", errors="replace")
[ドキュメント]
def write_text(path: Path, text: str, dry_run: bool = False) -> None:
"""
指定されたパスにUTF-8エンコーディングでテキストを書き込む。
詳細説明:
dry_runがTrueの場合、ファイルの書き込みは行わず、処理をスキップします。
:param path: Path: 書き込むファイルのパス。
:param text: str: 書き込むテキストの内容。
:param dry_run: bool: Trueの場合、ファイルの書き込みを行わない。デフォルトはFalse。
:returns: None
"""
if dry_run:
return
path.write_text(text, encoding="utf-8")
[ドキュメント]
def find_files(root: Path, pattern: str) -> list[Path]:
"""
指定されたルートディレクトリ以下から、ワイルドカードパターンに一致するファイルを再帰的に検索する。
:param root: Path: 検索を開始するルートディレクトリのパス。
:param pattern: str: 検索するファイルのワイルドカードパターン(例: "*.rst")。
:returns: list[Path]: 見つかったファイルのパスのリスト。パスはソートされている。
"""
return sorted(p for p in root.rglob(pattern) if p.is_file())
[ドキュメント]
def safe_module_name(module_name: str) -> str:
"""
Pythonの `import` 文で安全に使用できるモジュール名に補正する。
詳細説明:
- バックスラッシュ '\\' とスラッシュ '/' をピリオド '.' に置換します。
- ハイフン '-' をアンダースコア '_' に置換します。
:param module_name: str: 元のモジュール名。
:returns: str: 補正されたモジュール名。
"""
module_name = module_name.replace("\\", ".")
module_name = module_name.replace("/", ".")
module_name = module_name.replace("-", "_")
return module_name
[ドキュメント]
def module_name_to_py_path(root: Path, module_name: str) -> Path:
"""
`automodule` ディレクティブのモジュール名から、対応するPythonファイル (.py) のパス候補を作成する。
例:
`regression.adaptive_gaussian_ridge` は `root/regression/adaptive_gaussian_ridge.py` に変換される。
:param root: Path: プロジェクトのルートディレクトリ。
:param module_name: str: `automodule` で指定されたモジュール名。
:returns: Path: 対応するPythonファイルのパス候補。
"""
parts = module_name.split(".")
return root.joinpath(*parts).with_suffix(".py")
[ドキュメント]
def fallback_py_path_from_rst(rst_path: Path) -> Path:
"""
`*_api.rst` ファイルのパスから、同じディレクトリ内の対応するPythonファイル (.py) のパス候補を推定する。
詳細説明:
ファイル名が `*_api.rst` の場合、`_api` 部分を除去して `.py` 拡張子を付けます。
例: `regression/foo_api.rst` は `regression/foo.py` に推定される。
:param rst_path: Path: `*_api.rst` ファイルのパス。
:returns: Path: 推定されたPythonファイルのパス候補。
"""
stem = rst_path.stem
if stem.endswith("_api"):
stem = stem[:-4]
return rst_path.with_name(stem + ".py")
[ドキュメント]
def has_unprotected_argv(py_text: str) -> bool:
"""
Pythonコード内で `argv` または `sys.argv` が使用されており、かつ `if __name__ == "__main__":` ガードがないかを判定する。
詳細説明:
この関数は厳密な制御フロー解析ではなく、Sphinx autodocで問題になりやすい典型的なパターンを検出するためのものです。
`__main__` ガードが見つからない場合でも、`argv` の使用がない場合は `False` を返します。
:param py_text: str: Pythonファイルの全テキスト内容。
:returns: bool: `argv` が保護なしで使用されている場合はTrue、それ以外はFalse。
"""
result = bool(MAIN_GUARD_RE.search(py_text))
if result: return False
result = bool(ARGV_RE.search(py_text))
if result:
# print("py_text:", py_text) # 既存のコメントアウトを保持
return True
return False
[ドキュメント]
def rename_file(src: Path, dst: Path, actions: list[str], dry_run: bool = False) -> Path:
"""
ファイルをリネームする。宛先パスにファイルが既に存在する場合はリネームをスキップする。
詳細説明:
`src` と `dst` が同じ場合は処理をスキップします。
`src` が存在しない場合も処理をスキップします。
`dry_run` がTrueの場合、実際のリネームは行わず、アクションログのみを記録します。
:param src: Path: 元ファイルのパス。
:param dst: Path: 新しいファイルのパス。
:param actions: list[str]: 実行された(または実行予定の)アクションを記録するリスト。
:param dry_run: bool: Trueの場合、実際のリネームを行わない。デフォルトはFalse。
:returns: Path: 実際にリネームされた後のパス(またはリネームされなかった場合は元のパス)。
"""
if src == dst:
return src
if not src.exists():
return src
if dst.exists():
actions.append(f"SKIP rename because destination exists: {src} -> {dst}")
return src
actions.append(f"RENAME {src} -> {dst}")
if not dry_run:
src.rename(dst)
return dst
[ドキュメント]
def replace_in_file(path: Path, old: str, new: str, actions: list[str], dry_run: bool = False) -> None:
"""
指定されたファイル内の文字列を置換する。
詳細説明:
ファイルが存在しない場合や、置換対象の文字列 `old` がファイル内に含まれていない場合は処理をスキップします。
`dry_run` がTrueの場合、実際のファイル書き込みは行わず、アクションログのみを記録します。
:param path: Path: 対象ファイルのパス。
:param old: str: 置換対象の文字列。
:param new: str: 置換後の文字列。
:param actions: list[str]: 実行された(または実行予定の)アクションを記録するリスト。
:param dry_run: bool: Trueの場合、実際のファイル書き込みを行わない。デフォルトはFalse。
:returns: None
"""
if not path.is_file():
return
text = read_text(path)
if old not in text:
return
new_text = text.replace(old, new)
actions.append(f"REPLACE in {path}: {old} -> {new}")
write_text(path, new_text, dry_run=dry_run)
[ドキュメント]
def replace_automodule_name(
rst_path: Path,
old_module_name: str,
new_module_name: str,
actions: list[str],
dry_run: bool = False,
) -> None:
"""
RSTファイル内の `.. automodule::` ディレクティブに指定されたモジュール名のみを置換する。
詳細説明:
指定されたRSTファイルを開き、正規表現を用いて `.. automodule::` の行を見つけ、
その中のモジュール名が `old_module_name` と一致した場合に `new_module_name` に置換します。
ファイル内容が変更された場合のみ、アクションログに記録し、ファイルに書き戻します。
`dry_run` がTrueの場合、実際のファイル書き込みは行いません。
:param rst_path: Path: 対象RSTファイルのパス。
:param old_module_name: str: 置換対象の古いモジュール名。
:param new_module_name: str: 置換後の新しいモジュール名。
:param actions: list[str]: 実行された(または実行予定の)アクションを記録するリスト。
:param dry_run: bool: Trueの場合、実際のファイル書き込みを行わない。デフォルトはFalse。
:returns: None
"""
if not rst_path.is_file():
return
text = read_text(rst_path)
def repl(m: re.Match) -> str:
prefix, name, suffix = m.group(1), m.group(2), m.group(3)
if name == old_module_name:
return prefix + new_module_name + suffix
return m.group(0)
new_text = AUTOMODULE_RE.sub(repl, text)
if new_text != text:
actions.append(f"REPLACE automodule in {rst_path}: {old_module_name} -> {new_module_name}")
write_text(rst_path, new_text, dry_run=dry_run)
[ドキュメント]
def fix_module_name(
root: Path,
rst_path: Path,
module_name: str,
actions: list[str],
dry_run: bool = False,
) -> tuple[Path, str]:
"""
`automodule` ディレクティブのモジュール名をPythonの import 可能な形に補正し、関連ファイルを修正する。
詳細説明:
- モジュール名内の '\\' と '/' を '.' に置換し、'-' を '_' に置換します。
- 対応するPythonファイル名('-' を含む場合)をリネームします。
- RSTファイル内の `automodule` ディレクティブのモジュール名を修正します。
- RSTファイル名自体('-' を含む場合)をリネームします。
- 同じディレクトリ内の関連ドキュメント(`_usage`, `_examples`, `_index` など)の参照も更新します。
`dry_run` がTrueの場合、実際のリネームやファイル書き換えは行いません。
:param root: Path: プロジェクトのルートディレクトリ。
:param rst_path: Path: 現在処理中の `*_api.rst` ファイルのパス。
:param module_name: str: `automodule` で指定された元のモジュール名。
:param actions: list[str]: 実行された(または実行予定の)アクションを記録するリスト。
:param dry_run: bool: Trueの場合、実際のリネームやファイル書き込みを行わない。デフォルトはFalse。
:returns: tuple[Path, str]: 修正後のRSTファイルのパスと修正後のモジュール名。
"""
fixed_module_name = safe_module_name(module_name)
if fixed_module_name == module_name:
return rst_path, module_name
old_py = module_name_to_py_path(root, module_name)
new_py = module_name_to_py_path(root, fixed_module_name)
# path separator が混じる場合、old_py は実在パスとして解決できないことがある。
# その場合は rst ファイル名から同名 .py を推定する。
if not old_py.is_file():
fallback_old_py = fallback_py_path_from_rst(rst_path)
fallback_new_py = fallback_old_py.with_name(fallback_old_py.name.replace("-", "_"))
if fallback_old_py.is_file():
old_py = fallback_old_py
new_py = fallback_new_py
# .py ファイル名の '-' は '_' へリネームする。
# '\' -> '.' の修正だけなら、通常 .py のリネームは不要。
if "-" in old_py.name:
rename_file(old_py, new_py, actions, dry_run=dry_run)
# rst 内の automodule 名を必ず修正する。
replace_automodule_name(
rst_path,
module_name,
fixed_module_name,
actions,
dry_run=dry_run,
)
# rst ファイル名の '-' は '_' へリネームする。
new_rst_path = rst_path.with_name(rst_path.name.replace("-", "_"))
actual_rst_path = rst_path
if "-" in rst_path.name:
actual_rst_path = rename_file(rst_path, new_rst_path, actions, dry_run=dry_run)
# 同じディレクトリ内の関連ドキュメント参照も軽く直す。
old_stem = rst_path.stem.replace("_api", "")
new_stem = old_stem.replace("-", "_")
if old_stem != new_stem:
target_dir = actual_rst_path.parent if not dry_run else rst_path.parent
for ext in ("*.rst", "*.md"):
for p in target_dir.glob(ext):
replace_in_file(p, old_stem, new_stem, actions, dry_run=dry_run)
replace_in_file(p, old_stem + "_api", new_stem + "_api", actions, dry_run=dry_run)
replace_in_file(p, old_stem + "_usage", new_stem + "_usage", actions, dry_run=dry_run)
replace_in_file(p, old_stem + "_examples", new_stem + "_examples", actions, dry_run=dry_run)
replace_in_file(p, old_stem + "_index", new_stem + "_index", actions, dry_run=dry_run)
for suffix in ("_usage", "_examples", "_index", "_api"):
old_file = target_dir / f"{old_stem}{suffix}.rst"
new_file = target_dir / f"{new_stem}{suffix}.rst"
rename_file(old_file, new_file, actions, dry_run=dry_run)
old_file = target_dir / f"{old_stem}{suffix}.md"
new_file = target_dir / f"{new_stem}{suffix}.md"
rename_file(old_file, new_file, actions, dry_run=dry_run)
return actual_rst_path, fixed_module_name
[ドキュメント]
def check_one_rst(
root: Path,
rst_path: Path,
fix: bool = False,
dry_run: bool = False,
) -> tuple[bool, list[str], list[str]]:
"""
1つの `*_api.rst` ファイルをチェックし、必要に応じて修正を試みる。
詳細説明:
この関数は以下のチェックを行います。
- `.. automodule::` ディレクティブが存在するか。
- モジュール名に不正な文字('-', '\\', '/')が含まれていないか。
- 対応するPythonファイルが見つかるか。
- 対応するPythonファイルに `argv`/`sys.argv` が `__main__` ガードなしで使用されていないか。
`fix` がTrueの場合、不正なモジュール名を修正し、関連ファイルのリネームや内容置換を行います。
`dry_run` がTrueの場合、修正は実行されずにログに記録されるのみです。
:param root: Path: Sphinxソースのルートディレクトリ。
:param rst_path: Path: チェック対象の `*_api.rst` ファイルのパス。
:param fix: bool: Trueの場合、検出された問題を自動修正する。デフォルトはFalse。
:param dry_run: bool: Trueの場合、修正をシミュレーションし、ファイルは変更しない。`fix` と併用。デフォルトはFalse。
:returns: tuple[bool, list[str], list[str]]:
- bool: 問題が見つかった場合はTrue、それ以外はFalse。
- list[str]: 検出された問題の理由を説明する文字列のリスト。
- list[str]: `fix` モードで実行された(または実行予定の)修正内容を説明する文字列のリスト。
"""
rst_text = read_text(rst_path)
module_names = extract_automodule_names(rst_text)
reasons: list[str] = []
actions: list[str] = []
if not module_names:
reasons.append("no automodule directive found")
return True, reasons, actions
current_rst_path = rst_path
for module_name0 in module_names:
module_name = module_name0
fixed_module_name = safe_module_name(module_name)
if fixed_module_name != module_name:
if "-" in module_name:
reasons.append(f"invalid module name contains '-': {module_name}")
if "\\" in module_name or "/" in module_name:
reasons.append(f"invalid module name contains path separator: {module_name}")
if fix:
current_rst_path, module_name = fix_module_name(
root,
current_rst_path,
module_name,
actions,
dry_run=dry_run,
)
else:
module_name = fixed_module_name
py_path = module_name_to_py_path(root, module_name)
if not py_path.is_file():
fallback = fallback_py_path_from_rst(current_rst_path)
if fallback.is_file():
py_path = fallback
reasons.append(
f"module path not found, fallback py file used: {py_path.relative_to(root)}"
)
else:
reasons.append(
f"corresponding py file not found for module: {module_name}"
)
continue
py_text = read_text(py_path)
if has_unprotected_argv(py_text):
reasons.append(
f"argv is used but __main__ guard was not found: {py_path.relative_to(root)}"
)
# fix後に、修正対象だったハイフン/セパレータ理由は再評価して消す。
if fix and actions:
new_text = read_text(current_rst_path) if current_rst_path.exists() else rst_text
remaining_modules = extract_automodule_names(new_text)
remaining_bad = [
m for m in remaining_modules
if "-" in m or "\\" in m or "/" in m
]
reasons = [
r for r in reasons
if not r.startswith("invalid module name contains '-'")
and not r.startswith("invalid module name contains path separator")
]
for m in remaining_bad:
reasons.append(f"invalid module name still contains invalid character: {m}")
failed = len(reasons) > 0
return failed, reasons, actions
[ドキュメント]
def main() -> int:
"""
SphinxのAPIドキュメント (`*_api.rst`) のチェックと修正を行うメイン処理。
詳細説明:
コマンドライン引数を解析し、指定されたルートディレクトリ以下の `*_api.rst` ファイルを検索します。
各ファイルに対して `check_one_rst` を呼び出し、問題の検出と必要に応じた修正を行います。
結果はコンソールに出力され、`--outfile` で指定されたファイルにも詳細が記録されます。
問題が見つかったファイルがある場合、終了コードは1となり、それ以外は0となります。
:returns: int: 終了コード。問題が見つかった場合は1、それ以外は0。
"""
parser = argparse.ArgumentParser(
description=(
"Check Sphinx *_api.rst files for invalid automodule names and "
"argv usage without __main__ guard in corresponding .py files."
)
)
parser.add_argument(
"--root",
type=str,
default="./source",
help='Sphinx source root directory. default: "./source"',
)
parser.add_argument(
"--files",
type=str,
default="*_api.rst",
help='wildcard pattern for target RST files. default: "*_api.rst"',
)
parser.add_argument(
"--outfile",
type=str,
default="failed_api_rst.txt",
help='output file path. default: "failed_api_rst.txt"',
)
parser.add_argument(
"--exclude",
action="append",
default=[],
help="exclude path keyword. Can be used multiple times.",
)
parser.add_argument(
"--show-ok",
type=int,
default=0,
choices=[0, 1],
help="show OK files too. default: 0",
)
parser.add_argument(
"--fix",
action="store_true",
help="automatically fix '-' and path separators in automodule names when safe.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="show planned fixes without modifying files. Use with --fix.",
)
args = parser.parse_args()
root = Path(args.root).resolve()
outfile = Path(args.outfile)
if not root.exists():
print(f"ERROR: root directory not found: {root}")
return 1
files = find_files(root, args.files)
print(f"root : {root}")
print(f"pattern : {args.files}")
print(f"found : {len(files)} files")
print(f"fix : {args.fix}")
print(f"dry-run : {args.dry_run}")
print()
failed_files: list[tuple[Path, list[str]]] = []
action_log: list[str] = []
checked_count = 0
skipped_count = 0
for path in files:
if not path.exists():
# 直前の --fix でリネーム済みの場合など
continue
rel_str = str(path.relative_to(root)).replace("\\", "/")
if any(x in rel_str for x in args.exclude):
skipped_count += 1
if args.show_ok:
print(f"[SKIP] {rel_str}")
continue
checked_count += 1
failed, reasons, actions = check_one_rst(
root,
path,
fix=args.fix,
dry_run=args.dry_run,
)
action_log.extend(actions)
if actions:
print(f"[FIX] {rel_str}")
for a in actions:
print(f" - {a}")
if failed:
failed_files.append((path, reasons))
print(f"[FAILED] {rel_str}")
for r in reasons:
print(f" - {r}")
elif args.show_ok and not actions:
print(f"[OK] {rel_str}")
print()
print(f"checked : {checked_count}")
print(f"skipped : {skipped_count}")
print(f"failed : {len(failed_files)} / {checked_count}")
print(f"actions : {len(action_log)}")
with outfile.open("w", encoding="utf-8") as f:
for path, reasons in failed_files:
try:
rel = path.relative_to(root)
except ValueError:
rel = path
f.write(str(rel).replace("\\", "/"))
f.write("\n")
for r in reasons:
f.write(f" - {r}\n")
if action_log:
f.write("\n[ACTIONS]\n")
for a in action_log:
f.write(f"- {a}\n")
print(f"output : {outfile.resolve()}")
return 1 if failed_files else 0
if __name__ == "__main__":
sys.exit(main())