#!/usr/bin/env python3
"""Sphinx toctree globパターン展開スクリプト。
概要:
Sphinxのtoctreeにおけるglobパターンを展開し、指定されたファイル群を自動的に挿入します。
詳細説明:
親のreStructuredTextファイル内の `.. toctree::` ディレクティブにおいて、
ファイル名パターン(glob形式)が指定された場合、そのパターンに合致するファイルを
自動的に見つけ出し、toctreeのエントリとして挿入します。
Markdown (`.md`) と reStructuredText (`.rst`) ファイルの両方に対応します。
重複エントリの除外、エントリのソート、および子ファイルのセクションタイトルを
ラベルとして使用するオプションをサポートします。
関連リンク:
:doc:`burst_sphinx_index_usage`
"""
import argparse
import re
from pathlib import Path
import shutil
import sys
from typing import List, Optional
RST_UNDERLINE_RE = re.compile(r'^[=\-~`^"\'#*+]+$')
MD_H1_RE = re.compile(r'^\s*#\s+(.+?)\s*$')
[ドキュメント]
def log(verbose: bool, message: str):
"""verboseモードが有効な場合に標準エラー出力に情報メッセージを出力する。
:param verbose: bool - メッセージを出力するかどうかを決定するフラグ。
:param message: str - 出力する情報メッセージ。
:returns: None
"""
if verbose:
print(f"[INFO] {message}", file=sys.stderr)
[ドキュメント]
def warn(message: str):
"""標準エラー出力に警告メッセージを出力する。
:param message: str - 出力する警告メッセージ。
:returns: None
"""
print(f"[WARN] {message}", file=sys.stderr)
[ドキュメント]
def indent_width(line: str) -> int:
"""行頭のインデント幅を計算して返す。
詳細説明:
タブ文字 (`\t`) は4文字分のスペースとして扱って計算します。
:param line: str - インデント幅を計算する対象の行文字列。
:returns: int - 行頭のインデント幅。
"""
expanded = line.expandtabs(4)
return len(expanded) - len(expanded.lstrip(" "))
[ドキュメント]
def glob_with_extensions(parent_dir: Path, pattern: str, verbose: bool = False) -> List[Path]:
"""Sphinxのtoctreeのglobルールに沿ってファイルを検索する。
詳細説明:
パターンに拡張子がない場合、`.rst` と `.md` の両方を補完してファイルを検索します。
拡張子が明示されている場合は、その拡張子のみで検索します。
:param parent_dir: Path - 検索の基準となる親ディレクトリのパス。
:param pattern: str - 検索するファイル名のglobパターン。
:param verbose: bool - 詳細ログを出力するかどうか。デフォルトは `False`。
:returns: List[Path] - パターンにマッチしたファイルのパスのリスト。
"""
p = Path(pattern)
if p.suffix:
paths = list(parent_dir.glob(pattern))
log(verbose, f"glob pattern={pattern!r} matched {len(paths)} path(s) with explicit suffix")
else:
paths = []
for ext in (".rst", ".md"):
matched = list(parent_dir.glob(pattern + ext))
log(verbose, f"glob pattern={pattern + ext!r} matched {len(matched)} path(s)")
paths.extend(matched)
return paths
[ドキュメント]
def expand_glob_in_toctree(parent_rst: Path, sort_flag: bool, label_flag: bool, verbose: bool = False) -> str:
"""reStructuredTextファイル内のtoctreeにおけるglobパターンを展開して置換する。
詳細説明:
複数の `.. toctree::` ディレクティブに対応し、globパターンに合致する子ファイルを
toctreeエントリとして挿入した新しい内容を返します。
展開されたエントリのソートやラベル付けもサポートします。
:param parent_rst: Path - globパターンを展開する対象の親reStructuredTextファイルのパス。
:param sort_flag: bool - 展開されたエントリをファイル名でソートするかどうか。`True` の場合ソートされる。
:param label_flag: bool - 展開されたエントリに子ファイルの最初のセクションタイトルをラベルとして使用するかどうか。`True` の場合ラベルが使用される。
:param verbose: bool - 詳細ログを出力するかどうか。デフォルトは `False`。
:returns: str - globパターンが展開された後のreStructuredTextファイルの内容。
"""
lines = parent_rst.read_text(encoding="utf-8").splitlines()
parent_dir = parent_rst.parent
out: List[str] = []
in_toctree = False
toctree_indent = 0
seen_paths_in_block = set()
for lineno, line in enumerate(lines, start=1):
stripped = line.strip()
current_indent = indent_width(line)
# toctree 開始
if re.match(r"\s*\.\.\s+toctree::", line):
in_toctree = True
toctree_indent = current_indent
seen_paths_in_block = set()
log(verbose, f"entered toctree at line {lineno}, indent={toctree_indent}")
out.append(line)
continue
if in_toctree:
# toctree の本文ブロックを抜けたか判定
# 空行はブロックの一部として許可する。
if stripped != "" and current_indent <= toctree_indent:
in_toctree = False
log(verbose, f"left toctree before line {lineno}")
# fall through to normal line handling
else:
# toctree 内のオプション行
if stripped.startswith(":"):
out.append(line)
continue
# toctree 内の空行
if stripped == "":
out.append(line)
continue
# toctree 内のエントリ行
pattern = stripped
paths = glob_with_extensions(parent_dir, pattern, verbose=verbose)
paths = [p for p in paths if p.suffix in [".rst", ".md"]]
# 重複除去(出現順保持)
unique_paths: List[Path] = []
local_seen = set()
for p in paths:
resolved = p.resolve()
if resolved not in local_seen:
local_seen.add(resolved)
unique_paths.append(p)
paths = unique_paths
if sort_flag:
paths = sorted(paths, key=lambda p: p.name)
if not paths:
warn(f"No matches for toctree entry {pattern!r} in {parent_rst} (line {lineno})")
continue
for p in paths:
rel = p.relative_to(parent_dir).as_posix()
if rel in seen_paths_in_block:
log(verbose, f"skip duplicated entry: {rel}")
continue
seen_paths_in_block.add(rel)
if label_flag:
title = extract_first_section_title(p, verbose=verbose)
if title:
out.append(f" {title} <{rel}>")
log(verbose, f"added labeled entry: {title} <{rel}>")
continue
log(verbose, f"title not found for {rel}, fallback to plain path")
out.append(f" {rel}")
log(verbose, f"added entry: {rel}")
# 元の glob/path 行は出力しない
continue
# 通常行
out.append(line)
if in_toctree:
log(verbose, "reached EOF while still inside toctree block")
return "\n".join(out) + "\n"
[ドキュメント]
def main():
"""コマンドライン引数に基づいてSphinx toctreeのglobパターンを展開するメイン処理。
詳細説明:
指定された親reStructuredTextファイルを読み込み、`.. toctree::` ディレクティブ内の
globパターンを展開します。展開された内容は、元のファイルに書き戻すか、
標準出力に出力されます。バックアップ作成、ソート、ラベル付け、詳細ログの
オプションをサポートします。
:returns: None
"""
parser = argparse.ArgumentParser(description="Expand Sphinx toctree glob patterns.")
parser.add_argument("parent", help="Parent .rst file")
parser.add_argument("--sort", type=int, default=1, help="1=sort entries, 0=keep original glob order")
parser.add_argument("--label", type=int, default=0, help="1=use section title labels, 0=paths only")
parser.add_argument("--backup", type=int, default=1, help="1=create backup, 0=do not create backup")
parser.add_argument("--verbose", type=int, default=0, help="1=verbose log to stderr, 0=quiet")
args = parser.parse_args()
parent_rst = Path(args.parent).resolve()
if not parent_rst.exists():
raise FileNotFoundError(f"Parent file not found: {parent_rst}")
if args.backup == 1:
backup_path = parent_rst.with_suffix(parent_rst.suffix + ".backup")
shutil.copy2(parent_rst, backup_path)
log(args.verbose == 1, f"backup created: {backup_path}")
original = parent_rst.read_text(encoding="utf-8")
result = expand_glob_in_toctree(
parent_rst,
sort_flag=(args.sort == 1),
label_flag=(args.label == 1),
verbose=(args.verbose == 1),
)
if result != original:
parent_rst.write_text(result, encoding="utf-8")
log(args.verbose == 1, f"updated file: {parent_rst}")
else:
log(args.verbose == 1, f"no changes detected: {parent_rst}")
print(result)
if __name__ == "__main__":
main()