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