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

burst_sphinx_indexes.py をダウンロード

burst_sphinx_indexes.py
burst_sphinx_indexes.py
  1#!/usr/bin/env python3
  2"""Sphinx toctree globパターン展開スクリプト。
  3
  4概要:
  5    Sphinxのtoctreeにおけるglobパターンを展開し、指定されたファイル群を自動的に挿入します。
  6
  7詳細説明:
  8    親のreStructuredTextファイル内の .. toctree:: ディレクティブにおいて、
  9    ファイル名パターン(glob形式)が指定された場合、そのパターンに合致するファイルを
 10    自動的に見つけ出し、toctreeのエントリとして挿入します。
 11    Markdown (.md) と reStructuredText (.rst) ファイルの両方に対応します。
 12    重複エントリの除外、エントリのソート、および子ファイルのセクションタイトルを
 13    ラベルとして使用するオプションをサポートします。
 14
 15"""
 16import argparse
 17import re
 18from pathlib import Path
 19import shutil
 20import sys
 21from typing import List, Optional
 22
 23
 24RST_UNDERLINE_RE = re.compile(r'^[=\-~`^"\'#*+]+$')
 25MD_H1_RE = re.compile(r'^\s*#\s+(.+?)\s*$')
 26
 27
 28def log(verbose: bool, message: str):
 29    """
 30    概要:
 31        verboseモードが有効な場合に標準エラー出力に情報メッセージを出力する。
 32    引数:
 33        :param verbose: メッセージを出力するかどうかを決定するフラグ。
 34        :type verbose: bool
 35        :param message: 出力する情報メッセージ。
 36        :type message: str
 37    戻り値:
 38        :returns: なし。
 39        :rtype: None
 40    """
 41    if verbose:
 42        print(f"[INFO] {message}", file=sys.stderr)
 43
 44
 45def warn(message: str):
 46    """
 47    概要:
 48        標準エラー出力に警告メッセージを出力する。
 49    引数:
 50        :param message: 出力する警告メッセージ。
 51        :type message: str
 52    戻り値:
 53        :returns: なし。
 54        :rtype: None
 55    """
 56    print(f"[WARN] {message}", file=sys.stderr)
 57
 58
 59def indent_width(line: str) -> int:
 60    """
 61    概要:
 62        行頭のインデント幅を計算して返す。
 63    詳細説明:
 64        タブ文字 (\\t) は4文字分のスペースとして扱って計算します。
 65    引数:
 66        :param line: インデント幅を計算する対象の行文字列。
 67        :type line: str
 68    戻り値:
 69        :returns: 行頭のインデント幅。
 70        :rtype: int
 71    """
 72    expanded = line.expandtabs(4)
 73    return len(expanded) - len(expanded.lstrip(" "))
 74
 75
 76def extract_first_section_title(path: Path, verbose: bool = False) -> Optional[str]:
 77    """
 78    概要:
 79        指定されたファイルから最初のセクションタイトルを抽出する。
 80    詳細説明:
 81        reStructuredText形式の下線見出し (----, ==== など) または
 82        Markdown形式のH1見出し (# Title) のいずれかをサポートします。
 83    引数:
 84        :param path: タイトルを抽出する対象ファイルのパス。
 85        :type path: Path
 86        :param verbose: 詳細ログを出力するかどうか。デフォルトは False。
 87        :type verbose: bool
 88    戻り値:
 89        :returns: 抽出されたタイトル文字列。タイトルが見つからない場合やエラーが発生した場合は None。
 90        :rtype: Optional[str]
 91    """
 92    try:
 93        with path.open(encoding="utf-8") as f:
 94            lines = f.readlines()
 95
 96        # Markdown H1
 97        for line in lines:
 98            m = MD_H1_RE.match(line.rstrip("\n"))
 99            if m:
100                title = m.group(1).strip()
101                if title:
102                    return title
103
104        # reStructuredText underline style
105        for i in range(len(lines) - 1):
106            title = lines[i].rstrip()
107            underline = lines[i + 1].rstrip()
108            if title and RST_UNDERLINE_RE.match(underline):
109                return title
110
111    except Exception as e:
112        log(verbose, f"title extraction failed: {path} ({e})")
113
114    return None
115
116
117def glob_with_extensions(parent_dir: Path, pattern: str, verbose: bool = False) -> List[Path]:
118    """
119    概要:
120        Sphinxのtoctreeのglobルールに沿ってファイルを検索する。
121    詳細説明:
122        パターンに拡張子がない場合、.rst と .md の両方を補完してファイルを検索します。
123        拡張子が明示されている場合は、その拡張子のみで検索します。
124    引数:
125        :param parent_dir: 検索の基準となる親ディレクトリのパス。
126        :type parent_dir: Path
127        :param pattern: 検索するファイル名のglobパターン。
128        :type pattern: str
129        :param verbose: 詳細ログを出力するかどうか。デフォルトは False。
130        :type verbose: bool
131    戻り値:
132        :returns: パターンにマッチしたファイルのパスのリスト。
133        :rtype: List[Path]
134    """
135    p = Path(pattern)
136    if p.suffix:
137        paths = list(parent_dir.glob(pattern))
138        log(verbose, f"glob pattern={pattern!r} matched {len(paths)} path(s) with explicit suffix")
139    else:
140        paths = []
141        for ext in (".rst", ".md"):
142            matched = list(parent_dir.glob(pattern + ext))
143            log(verbose, f"glob pattern={pattern + ext!r} matched {len(matched)} path(s)")
144            paths.extend(matched)
145    return paths
146
147
148def expand_glob_in_toctree(parent_rst: Path, sort_flag: bool, label_flag: bool, verbose: bool = False) -> str:
149    """
150    概要:
151        reStructuredTextファイル内のtoctreeにおけるglobパターンを展開して置換する。
152    詳細説明:
153        複数の .. toctree:: ディレクティブに対応し、globパターンに合致する子ファイルを
154        toctreeエントリとして挿入した新しい内容を返します。
155        展開されたエントリのソートやラベル付けもサポートします。
156    引数:
157        :param parent_rst: globパターンを展開する対象の親reStructuredTextファイルのパス。
158        :type parent_rst: Path
159        :param sort_flag: 展開されたエントリをファイル名でソートするかどうか。True の場合ソートされる。
160        :type sort_flag: bool
161        :param label_flag: 展開されたエントリに子ファイルの最初のセクションタイトルをラベルとして使用するかどうか。True の場合ラベルが使用される。
162        :type label_flag: bool
163        :param verbose: 詳細ログを出力するかどうか。デフォルトは False。
164        :type verbose: bool
165    戻り値:
166        :returns: globパターンが展開された後のreStructuredTextファイルの内容。
167        :rtype: str
168    """
169    lines = parent_rst.read_text(encoding="utf-8").splitlines()
170    parent_dir = parent_rst.parent
171
172    out: List[str] = []
173    in_toctree = False
174    toctree_indent = 0
175    seen_paths_in_block = set()
176
177    for lineno, line in enumerate(lines, start=1):
178        stripped = line.strip()
179        current_indent = indent_width(line)
180
181        # toctree 開始
182        if re.match(r"\s*\.\.\s+toctree::", line):
183            in_toctree = True
184            toctree_indent = current_indent
185            seen_paths_in_block = set()
186            log(verbose, f"entered toctree at line {lineno}, indent={toctree_indent}")
187            out.append(line)
188            continue
189
190        if in_toctree:
191            # toctree の本文ブロックを抜けたか判定
192            # 空行はブロックの一部として許可する。
193            if stripped != "" and current_indent <= toctree_indent:
194                in_toctree = False
195                log(verbose, f"left toctree before line {lineno}")
196                # fall through to normal line handling
197            else:
198                # toctree 内のオプション行
199                if stripped.startswith(":"):
200                    out.append(line)
201                    continue
202
203                # toctree 内の空行
204                if stripped == "":
205                    out.append(line)
206                    continue
207
208                # toctree 内のエントリ行
209                pattern = stripped
210                paths = glob_with_extensions(parent_dir, pattern, verbose=verbose)
211                paths = [p for p in paths if p.suffix in [".rst", ".md"]]
212
213                # 重複除去(出現順保持)
214                unique_paths: List[Path] = []
215                local_seen = set()
216                for p in paths:
217                    resolved = p.resolve()
218                    if resolved not in local_seen:
219                        local_seen.add(resolved)
220                        unique_paths.append(p)
221                paths = unique_paths
222
223                if sort_flag:
224                    paths = sorted(paths, key=lambda p: p.name)
225
226                if not paths:
227                    warn(f"No matches for toctree entry {pattern!r} in {parent_rst} (line {lineno})")
228                    continue
229
230                for p in paths:
231                    rel = p.relative_to(parent_dir).as_posix()
232                    if rel in seen_paths_in_block:
233                        log(verbose, f"skip duplicated entry: {rel}")
234                        continue
235                    seen_paths_in_block.add(rel)
236
237                    if label_flag:
238                        title = extract_first_section_title(p, verbose=verbose)
239                        if title:
240                            out.append(f"   {title} <{rel}>")
241                            log(verbose, f"added labeled entry: {title} <{rel}>")
242                            continue
243                        log(verbose, f"title not found for {rel}, fallback to plain path")
244
245                    out.append(f"   {rel}")
246                    log(verbose, f"added entry: {rel}")
247
248                # 元の glob/path 行は出力しない
249                continue
250
251        # 通常行
252        out.append(line)
253
254    if in_toctree:
255        log(verbose, "reached EOF while still inside toctree block")
256
257    return "\n".join(out) + "\n"
258
259
260def main():
261    """
262    概要:
263        コマンドライン引数に基づいてSphinx toctreeのglobパターンを展開するメイン処理。
264    詳細説明:
265        指定された親reStructuredTextファイルを読み込み、.. toctree:: ディレクティブ内の
266        globパターンを展開します。展開された内容は、元のファイルに書き戻すか、
267        標準出力に出力されます。バックアップ作成、ソート、ラベル付け、詳細ログの
268        オプションをサポートします。
269    戻り値:
270        :returns: なし。
271        :rtype: None
272    例外:
273        :raises FileNotFoundError: 指定された親reStructuredTextファイルが存在しない場合に発生します。
274    """
275    parser = argparse.ArgumentParser(description="Expand Sphinx toctree glob patterns.")
276    parser.add_argument("parent", help="Parent .rst file")
277    parser.add_argument("--sort", type=int, default=1, help="1=sort entries, 0=keep original glob order")
278    parser.add_argument("--label", type=int, default=0, help="1=use section title labels, 0=paths only")
279    parser.add_argument("--backup", type=int, default=1, help="1=create backup, 0=do not create backup")
280    parser.add_argument("--verbose", type=int, default=0, help="1=verbose log to stderr, 0=quiet")
281    args = parser.parse_args()
282
283    parent_rst = Path(args.parent).resolve()
284
285    if not parent_rst.exists():
286        raise FileNotFoundError(f"Parent file not found: {parent_rst}")
287
288    if args.backup == 1:
289        backup_path = parent_rst.with_suffix(parent_rst.suffix + ".backup")
290        shutil.copy2(parent_rst, backup_path)
291        log(args.verbose == 1, f"backup created: {backup_path}")
292
293    original = parent_rst.read_text(encoding="utf-8")
294    result = expand_glob_in_toctree(
295        parent_rst,
296        sort_flag=(args.sort == 1),
297        label_flag=(args.label == 1),
298        verbose=(args.verbose == 1),
299    )
300
301    if result != original:
302        parent_rst.write_text(result, encoding="utf-8")
303        log(args.verbose == 1, f"updated file: {parent_rst}")
304    else:
305        log(args.verbose == 1, f"no changes detected: {parent_rst}")
306
307    print(result)
308
309
310if __name__ == "__main__":
311    main()