Sphinx作成支援プログラム

Sphinxマニュアル作成を支援するプログラムの使い方をまとめています。

  1. 生成AIを使うプログラムでは、API KEYを 環境変数 OPENAI_API_KEY あるいは GOOGLE_API_KEYに設定してください。また、AI modelのデフォルトは explain_program5.ini で設定されていますが、このファイルは無くても問題ありません。

  2. explain_program5.py: 引数で与えたpythonプログラムのマニュアル(usage) Markdownを作成します。プロンプトは explain_program5.ini で設定します。

  3. add_docstring.py: 引数で与えたpythonプログラムにsphinx互換のdocstringを追加します。プロンプトは add_docstring.ini で設定します。

  4. make_sphinx_files.py: 引数で与えたpythonプログラムに対して、上記プログラムを呼び出すとともに、関連するSphinxファイルを作成します

  5. debug_sphinx-build.py: sphinx-buildでimportエラーになるファイルを発見する

  6. files2md_tags.py: 指定したファイルMarkdown書式のリンクを出力する。画像ファイルの場合はサムネイルを作成する。

  7. burst_sphinx_indexes.py: Sphinx toctree globパターン展開スクリプト

  8. save_clipboard.py: (Windowsのみ) クリップボードのテキストや画像をファイルに保存する

  9. compare_dir_files.py: sphinxのsourceツリーと、元のプログラムファイルツリーを比較し、存在しないファイルを抽出する

AI共通設定ファイル ai.env

ai.env をダウンロード

ai.env
 1account_inf_path=d:/MyWebs/Database/accounts.env
 2
 3min_translate_length = 0
 4allowed_translation_length_ratio = 5.0
 5html_template_path = 'template_translate.html'
 6max_tokens = 5000
 7
 8#openai_model = "gpt-3.5-turbo"
 9openai_model = "gpt-4o"
10#openai_model = "gpt-4.5"
11#openai_model5=gpt-5
12#openai_model5=gpt-5-mini
13openai_model5=gpt-5-nano
14#openai_model5=gpt-5-chat-latest
15
16temperature = 0.3
17#reasoning_effort=minimal
18reasoning_effort=low
19#reasoning_effort=medium
20#reasoning_effort=high
21
22
23#gemini_model = "gemini-1.5-pro-latest"
24gemini_model = "gemini-2.5-flash"
25tsleep_rpm = 4
explain_program5.py (プログラム)

explain_program5.py をダウンロード

explain_program5.py
  1"""
  2プログラムコードをAIでドキュメント化するツール。
  3
  4指定されたワイルドカードパターンに一致するソースコードファイルを読み込み、
  5設定ファイルに基づいたプロンプトを使用してAI(OpenAIまたはGoogle Gemini)に
  6ドキュメントを生成させ、指定された出力ファイルに書き出します。
  7
  8:doc:`explain_program5_usage`
  9"""
 10
 11#!/usr/bin/env python3
 12# -*- coding: utf-8 -*-
 13
 14import os
 15import sys
 16import argparse
 17import glob
 18import time
 19import re
 20from pathlib import Path
 21
 22# 既存のライブラリ(環境に合わせてパスを通してください)
 23try:
 24    from tkai_lib import read_ai_config
 25    from tkai_lib import query_openai4, query_openai5, query_google
 26    from tkai_lib import extract_openai5_text
 27except ImportError:
 28    print("Error: tkai_lib が見つかりません。パスを確認してください。", file=sys.stderr)
 29    sys.exit(1)
 30
 31#=========================================================
 32# INI Reading Logic (make_textbook5.py より継承)
 33#=========================================================
 34def search_file(infile=None):
 35    """
 36    指定されたファイルパスまたはデフォルトのINIファイルを探索します。
 37
 38    infileが指定されない場合、スクリプトの実行ディレクトリとカレントディレクトリから
 39    デフォルトのINIファイル(スクリプト名.ini)を探します。
 40    infileが指定された場合、そのパスが存在するか、またはスクリプトの実行ディレクトリからの
 41    相対パスで存在するかを確認します。
 42
 43    :param infile: str, 探索するファイルパス。デフォルトはNoneで、その場合はデフォルトINIファイル名を使用。
 44    :returns: str または None, 見つかったファイルの絶対パス。見つからない場合はNone。
 45    """
 46    script_path = os.path.abspath(sys.argv[0])
 47    script_dir = os.path.dirname(script_path)
 48    script_name = os.path.splitext(os.path.basename(script_path))[0]
 49    default_ini = f"{script_name}.ini"
 50
 51    if infile is None:
 52        for path in [os.getcwd(), script_dir]:
 53            candidate = os.path.join(path, default_ini)
 54            if os.path.isfile(candidate): return candidate
 55        return None
 56    
 57    if not os.path.isfile(infile):
 58        candidate = os.path.join(script_dir, infile)
 59        if os.path.isfile(candidate): return candidate
 60        return None
 61    return infile
 62
 63def read_ini(inifile=None):
 64    """
 65    指定されたINIファイルから設定を読み込み、変数を展開します。
 66
 67    key=value形式の行を解析し、3重引用符で囲まれた複数行の値をサポートします。
 68    また、`$VARIABLE_NAME` 形式の変数を、既に読み込まれた設定値で展開します。
 69    コメント行(`#` または `;` で始まる)と空行は無視されます。
 70
 71    :param inifile: str または None, 読み込むINIファイルのパス。Noneの場合はsearch_fileで探索される。
 72    :returns: dict, 読み込まれた設定をキーと値のペアで格納した辞書。
 73    :raises FileNotFoundError: INIファイルが見つからない場合。
 74    """
 75    path = search_file(inifile)
 76    if path is None:
 77        raise FileNotFoundError("INIファイルが見つかりませんでした")
 78
 79    result = {}
 80    variables = {}
 81    current_key = None
 82    multiline_val = []
 83    multiline_delim = None
 84
 85    with open(path, 'r', encoding='utf-8') as f:
 86        for line in f:
 87            line = line.rstrip()
 88
 89            if not line or line.startswith('#') or line.startswith(';'):
 90                continue
 91
 92            # 複数行値の終了判定(stripで判定)
 93            if multiline_delim:
 94                if line.strip() == multiline_delim:
 95                    val = '\n'.join(multiline_val)
 96                    result[current_key] = val
 97                    variables[current_key] = val
 98                    current_key = None
 99                    multiline_val = []
100                    multiline_delim = None
101                else:
102                    multiline_val.append(line)
103                continue
104
105            # key=val の解析
106            if '=' in line:
107                key, val = map(str.strip, line.split('=', 1))
108                val = val.strip()
109
110                # 複数行値の開始判定(空文字でも対応)
111                if (val == '"""' or val == "'''" or
112                   (val.startswith('"""') and not val.endswith('"""')) or \
113                   (val.startswith("'''") and not val.endswith("'''")) ):
114                    multiline_delim = val[:3]
115                    content = val[3:]
116                    multiline_val = [content] if content else []
117                    current_key = key
118                    continue
119
120                # 単一行の複数行値
121                if (val.startswith('"""') and val.endswith('"""')) or \
122                   (val.startswith("'''") and val.endswith("'''")):
123                    val = val[3:-3]
124
125                result[key] = val
126                variables[key] = val
127
128    # 変数展開(あとから一括処理)
129    for key, val in result.items():
130        def expand_var(match):
131            var_name = match.group(1)
132            return variables.get(var_name, match.group(0))
133        result[key] = re.sub(r"\$(\w+)\b", expand_var, val)
134
135    return result
136
137#=========================================================
138# Language Dictionary
139#=========================================================
140language_dict = {
141    ".py": "python", ".pl": "perl", ".pm": "perl", ".c" : "C",
142    ".cpp": "C++", ".pas": "pascal", ".f"  : "fortran", ".js" : "Javascript",
143    ".java": "Java", ".go": "Go", ".sh": "bash script", ".html": "HTML"
144}
145
146def get_program_type(path):
147    """
148    プログラムファイルのパスからプログラムのタイプ(`main` または `lib`)を判定します。
149
150    ファイル拡張子が`.pm`の場合、またはファイル名が`tk`で始まる場合、ライブラリ(`lib`)と判定します。
151    それ以外の場合はメインプログラム(`main`)と判定します。
152
153    :param path: str, プログラムファイルのパス。
154    :returns: str, プログラムのタイプ (`'main'` または `'lib'`)。
155    """
156    base = os.path.basename(path)
157    name, ext = os.path.splitext(base)
158    if ext == ".pm" or base.startswith("tk"): return 'lib'
159    return 'main'
160
161def initialize():
162    """
163    コマンドライン引数パーサーを初期化し、設定します。
164
165    `argparse.ArgumentParser` を設定し、必要な引数とオプションを定義します。
166    `pattern` (必須, ワイルドカード), `output` (任意), `--inifile`, `--api`,
167    `-t` (`--program_type`), `-u` (`--update`), `-w` (`--overwrite`) オプションが含まれます。
168    デフォルトのINIファイルパスはスクリプト名から自動生成されます。
169
170    :returns: argparse.ArgumentParser, 初期化されたコマンドライン引数パーサーオブジェクト。
171    """
172    ini_path = os.path.splitext(sys.argv[0])[0] + ".ini"
173
174    parser = argparse.ArgumentParser(description="プログラムコードをAIでドキュメント化するツール")
175    parser.add_argument("pattern", help="対象ファイルのワイルドカード(例: '*.py')")
176    parser.add_argument("output", nargs="?", help="出力名(単一ファイル時のみ)")
177    parser.add_argument("--inifile", default=ini_path, help="プロンプト設定ファイル")
178    parser.add_argument("--api", choices=["openai", "openai5", "google", "gemini"], default='google')
179    parser.add_argument("-t", "--program_type", choices=["", "main", "lib"], default="")
180    parser.add_argument("-u", "--update", type=int, default=0)
181    parser.add_argument("-w", "--overwrite", type=int, default=0)
182    return parser
183
184def main():
185    """
186    プログラムの主要な処理を実行します。
187
188    `initialize` 関数で初期化された引数を解析し、AI設定ファイル (`ai.env`) と
189    プロンプトINIファイルを読み込みます。
190    指定されたパターンに一致するファイルを検索し、一つずつ処理します。
191    出力ファイルが既に存在し、更新フラグや上書きフラグが設定されていない場合はスキップします。
192    ソースコードを読み込み、言語タイプとプログラムタイプを判定し、
193    INIファイルの設定に基づいてプロンプトを構築します。
194    AIサービス(OpenAIまたはGoogle Gemini)に問い合わせてドキュメントを生成し、
195    生成されたドキュメントを出力ファイルに書き込みます。
196    各ファイル処理後に1秒間待機し、最後にユーザーからの終了入力を待ちます。
197
198    :returns: None
199    """
200    parser = initialize()
201    args = parser.parse_args()
202
203    # AI設定の読み込み
204    read_ai_config("ai.env")
205    
206    # プロンプトINIの読み込み
207    try:
208        ini_data = read_ini(args.inifile)
209        print(f"Loaded INI: {search_file(args.inifile)}")
210    except Exception as e:
211        print(f"Error loading INI: {e}")
212        sys.exit(1)
213
214    files = glob.glob(args.pattern)
215    if not files: sys.exit(1)
216
217    outputs = [args.output] if args.output else [os.path.splitext(f)[0] + ".md" for f in files]
218
219    for inp, out in zip(files, outputs):
220        if os.path.exists(out) and not args.overwrite and (not args.update or os.path.getmtime(out) >= os.path.getmtime(inp)):
221            print(f"Skip: {out}")
222            continue
223
224        print(f"Processing: {inp} -> {out}")
225        try:
226            code = Path(inp).read_text(encoding="utf-8")
227        except:
228            code = Path(inp).read_text(encoding="shift-jis")
229
230        ext = os.path.splitext(inp)[1]
231        lang = language_dict.get(ext, "text")
232        p_type = args.program_type or get_program_type(inp)
233
234        # プロンプト構築
235        tpl_key = f"PROMPT_{p_type.upper()}"
236        template = ini_data.get(tpl_key, ini_data.get("PROMPT_MAIN"))
237        role = ini_data.get("SYSTEM_ROLE", "Assistant")
238        
239        prompt = template.replace("{{script_name}}", inp).replace("{{code}}", code).replace("{{language}}", lang)
240
241        # AI呼び出し
242        if args.api == "openai5":
243            res = query_openai5(prompt, os.getenv("openai_model5"), instructions=role)
244            doc = extract_openai5_text(res)
245        elif args.api == "openai":
246            res = query_openai4(prompt, os.getenv("openai_model"), role=role)
247            doc = res.choices[0].message.content
248        else:
249            res = query_google(prompt, os.getenv("gemini_model"), role=role)
250            doc = res.text if res else None
251
252        if doc:
253            Path(out).write_text(doc, encoding="utf-8")
254            print(f"Done: {out}")
255        time.sleep(1)
256
257    input("\nPress ENTER to terminate>>\n")
258
259if __name__ == "__main__":
260    main()
explain_program5.ini (設定ファイル)

explain_program5.ini をダウンロード

explain_program5.ini
 1SYSTEM_ROLE = """
 2You are a helpful assistant that generates clear, well-structured documentation for the given program.
 3"""
 4
 5PROMPT_MAIN = """
 6以下は {{language}}言語 で記述されたプログラム `{{script_name}}` のソースコードです。
 7このコードを解析し、以下の「構成」と「出力形式の絶対条件」に従って、技術ドキュメント(Markdown)を作成してください。
 8
 9### 1. 全体要件(厳守)
101. **適切な改行**: 各セクションの間や、見出しの前後には必ず空行(連続した改行)を1行以上挿入してください。
112. **全体をコードブロックで囲わない**
123. **冒頭のタイトル**: 出力の1行目はMarkdownの大見出し # を使った以下の形式とし、直後に必ず1行の空行を入れてください。
13# {{script_name}} ドキュメント
14(ここに空行を入れる)
15
16### 1. 構成案
171. **見出し**: 以下の各章は、Markdownの第二段階見出し ## で始める
182. **プログラムの動作**: プログラムの目的、主な機能、および解決する課題。
193. **原理**: 使用されている数式・物理式やアルゴリズムの解説。
204. **必要な非標準ライブラリとインストール方法**: `pip` コマンド等を含めること。
215. **必要な入力ファイル**: 期待されるファイル形式やデータ構造。
226. **生成される出力ファイル**: 保存されるファイル名とその内容。
237. **コマンドラインでの使用例 (Usage)**: 基本的な実行コマンドと引数の説明。
248. **コマンドラインでの具体的な使用例**: 具体的な引数を与えた実行例とその実行結果の説明。
25
26
27### 2. 出力形式に関する絶対条件(厳守)
28- **禁止記号と代替表記(重要)**:
29    - **三重引用符( `"""` および `'''` )の出力は、いかなる理由があっても厳禁とします。**
30    - プログラミング上の記号として言及が必要な場合は、必ず **「3重引用符」** というテキストに置き換えて記述してください。
31    - 例:「docstringを3重引用符で囲む」とし、「docstringを """ で囲む」とは書かないこと。
32
33- **数式の共通仕様**:
34    - **インライン数式**: $a^2 + b^2 = c^2$ のように、ドル記号1つで囲む。
35    - **ブロック数式**: 独立した行に記述し、**その前後は必ず空行で挟んでください。**
36        例:
37        
38        $$\rho = \frac{\pi d}{\ln 2} \cdot \frac{R_A + R_B}{2} \cdot f$$
39
40- **プログラム名**: マニュアル内では `{{script_name}}` として扱ってください。
41
42---
43### 対象プログラムコード
44```{{language}}
45{{code}}
46```
47"""
48
49
50PROMPT_LIB = """
51以下は {{language}}言語 で記述されたライブラリ `{{script_name}}` のソースコードです。
52このコードを解析し、以下の「構成」と「出力形式の絶対条件」に従って、技術ドキュメント(Markdown)を作成してください。
53
54### 1. 全体要件(厳守)
551. **適切な改行**: 各セクションの間や、見出しの前後には必ず空行(連続した改行)を1行以上挿入してください。
562. **全体をコードブロックで囲わない**
573. **冒頭のタイトル**: 出力の1行目はMarkdownの大見出し # を使った以下の形式とし、直後に必ず1行の空行を入れてください。
58# {{script_name}} ドキュメント
59(ここに空行を入れる)
60
61### 2. 構成案
621. **見出し**: 以下の各章は、Markdownの第二段階見出し ## で始める
632. **ライブラリの機能や目的**: ライブラリの目的、主な機能、および解決する課題。
643. **importする方法**: このライブラリを他のプログラムからimportする方法。
654. **必要な非標準ライブラリとインストール方法**: `pip` コマンド等を含めること。
665. **importできる変数と関数**: 変数には説明するコメントを、関数には関数の動作と引数・戻り値の説明を入れること。
676. **main scriptとして実行したときの動作**
68
69### 3. 出力形式に関する絶対条件(厳守)
70- **禁止記号と代替表記(重要)**:
71    - **三重引用符( `"""` および `'''` )の出力は、いかなる理由があっても厳禁とします。**
72    - プログラミング上の記号として言及が必要な場合は、必ず **「3重引用符」** というテキストに置き換えて記述してください。
73    - 例:「docstringを3重引用符で囲む」とし、「docstringを """ で囲む」とは書かないこと。
74
75- **数式の共通仕様**:
76    - **インライン数式**: $a^2 + b^2 = c^2$ のように、ドル記号1つで囲む。
77    - **ブロック数式**: 独立した行に記述し、**その前後は必ず空行で挟んでください。**
78        例:
79        
80        $$\rho = \frac{\pi d}{\ln 2} \cdot \frac{R_A + R_B}{2} \cdot f$$
81
82- **ライブラリ名**: マニュアル内では `{{script_name}}` として扱ってください。
83
84---
85### 対象プログラムコード
86```{{language}}
87{{code}}
88```
89"""
add_docstring.py (プログラム)

add_docstring.py をダウンロード

add_docstring.py
  1#!/usr/bin/env python3
  2# -*- coding: utf-8 -*-
  3"""
  4PythonコードにSphinx形式のDocstringを自動追加するツール。
  5
  6このスクリプトは、指定されたPythonソースファイルの内容をAIモデルに送信し、
  7Sphinx形式のDocstringが挿入された完成版のコードを生成します。
  8コマンドライン引数で入力ファイルパターン、出力ファイル名、
  9INIファイルによるプロンプト設定、使用するAIモデルなどを指定できます。
 10生成されたDocstringは、関数やクラスの概要、詳細説明、引数、戻り値などを
 11日本語で記述します。既存のファイルを上書きするか、更新日時でスキップするかなど、
 12柔軟なファイル処理オプションを提供します。
 13
 14:doc:`add_docstring_usage`
 15"""
 16
 17import os
 18import sys
 19import argparse
 20import glob
 21import time
 22import re
 23import traceback
 24from pathlib import Path
 25
 26# AI連携用ライブラリのインポート
 27try:
 28    from tkai_lib import read_ai_config
 29    from tkai_lib import query_openai4, query_openai5, query_google
 30    from tkai_lib import extract_openai5_text
 31except ImportError:
 32    print("Error: tkai_lib.py が見つかりません。ライブラリのパスを確認してください。", file=sys.stderr)
 33    sys.exit(1)
 34
 35# =========================================================
 36# INI検索・読み込みロジック (explain_program5.py の設計を継承)
 37# =========================================================
 38def search_file(infile=None):
 39    """
 40    指定されたファイルを作業ディレクトリとスクリプトディレクトリから検索する。
 41
 42    `infile` が指定されない場合、実行スクリプト名からデフォルトのINIファイル名を生成します。
 43    まずカレントディレクトリでファイルを検索し、見つからない場合はスクリプトが
 44    配置されているディレクトリで検索します。
 45
 46    :param infile: str, optional: 検索対象のファイル名。デフォルトはNone。
 47    :returns: str or None: 見つかったファイルの絶対パス、またはNone。
 48    """
 49    script_path = os.path.abspath(sys.argv[0])
 50    script_dir = os.path.dirname(script_path)
 51    script_name = os.path.splitext(os.path.basename(script_path))[0]
 52    default_ini = f"{script_name}.ini"
 53
 54    search_target = infile if infile else default_ini
 55
 56    candidate1 = os.path.join(os.getcwd(), search_target)
 57    if os.path.isfile(candidate1):
 58        return candidate1
 59    
 60    candidate2 = os.path.join(script_dir, search_target)
 61    if os.path.isfile(candidate2):
 62        return candidate2
 63        
 64    return None
 65
 66def read_ini(inifile=None):
 67    """
 68    INIファイルの内容を読み込み、辞書として返す。
 69
 70    `search_file` を使用してINIファイルのパスを特定し、ファイルを読み込みます。
 71    `#` または `;` で始まる行はコメントとしてスキップされます。
 72    `key = value` 形式の行を解析し、トリプルクォート (`\"\"\"` または `\'\'\'`) を
 73    使用した複数行の値をサポートします。また、読み込み後には `$VAR` 形式の
 74    変数を展開します。
 75
 76    :param inifile: str, optional: 読み込むINIファイルのパス。デフォルトはNone。
 77    :returns: dict: INIファイルから読み込んだ設定を格納する辞書。
 78    :raises FileNotFoundError: 指定されたINIファイルが見つからない場合。
 79    """
 80    path = search_file(inifile)
 81    if path is None:
 82        raise FileNotFoundError(f"INIファイルが見つかりませんでした: {inifile}")
 83
 84    result = {}
 85    variables = {}
 86    current_key = None
 87    multiline_val = []
 88    multiline_delim = None
 89
 90    with open(path, 'r', encoding='utf-8') as f:
 91        for line in f:
 92            line = line.rstrip()
 93            if not line or line.startswith('#') or line.startswith(';'):
 94                continue
 95
 96            if multiline_delim:
 97                if line.strip() == multiline_delim:
 98                    val = '\n'.join(multiline_val)
 99                    result[current_key] = val
100                    variables[current_key] = val
101                    current_key = None
102                    multiline_val = []
103                    multiline_delim = None
104                else:
105                    multiline_val.append(line)
106                continue
107
108            if '=' in line:
109                key, val = map(str.strip, line.split('=', 1))
110                if (val == '\"\"\"' or val == "\'\'\'" or
111                   (val.startswith('\"\"\"') and not val.endswith('\"\"\"')) or
112                   (val.startswith("\'\'\'") and not val.endswith("\'\'\'"))):
113                    multiline_delim = val[:3]
114                    content = val[3:]
115                    multiline_val = [content] if content else []
116                    current_key = key
117                    continue
118                
119                if (val.startswith('\"\"\"') and val.endswith('\"\"\"')) or \
120                   (val.startswith("\'\'\'") and val.endswith("\'\'\'")):
121                    val = val[3:-3]
122
123                result[key] = val
124                variables[key] = val
125
126    for key, val in result.items():
127        def expand_var(match):
128            return variables.get(match.group(1), match.group(0))
129        result[key] = re.sub(r"\$(\w+)\b", expand_var, val)
130
131    return result
132
133# =========================================================
134# メイン処理
135# =========================================================
136def initialize():
137    """
138    コマンドライン引数パーサーを初期化し、設定する。
139
140    `argparse.ArgumentParser` を初期化し、ツールの説明を設定します。
141    必要な引数(`pattern`, `output`)とオプション引数(`--inifile`, `--api`,
142    `--update`, `--overwrite`, `--pause`)を追加します。
143    `default_ini_name` は実行スクリプト名に基づいて生成されます。
144
145    :returns: argparse.ArgumentParser: 設定済みのパーサーオブジェクト。
146    """
147    default_ini_name = os.path.splitext(os.path.basename(sys.argv[0]))[0] + ".ini"
148
149    parser = argparse.ArgumentParser(description="PythonコードにSphinx形式のDocstringを自動追加するツール")
150    # explain_program5.py に合わせた引数構成
151    parser.add_argument("pattern", help="対象ファイルのワイルドカード(例: '*.py')")
152    parser.add_argument("output", nargs="?", help="出力名(単一ファイル時のみ)")
153    parser.add_argument("--inifile", default=default_ini_name, help="プロンプト設定ファイルパス")
154    parser.add_argument("--api", choices=["openai", "openai5", "google", "gemini"], default='google')
155    parser.add_argument("-u", "--update", type=int, default=0, help="1の場合、ソースが新しい場合のみ更新")
156    parser.add_argument("-w", "--overwrite", type=int, default=0, help="1の場合、既存ファイルを上書き")
157    parser.add_argument("-p", "--pause", type=int, default=1)
158    return parser
159
160def read_args(parser):
161    """
162    ArgumentParserから引数を解析し、その値を表示する。
163
164    `parser.parse_args()` を呼び出して引数を解析し、
165    解析された引数の値を標準出力に表示します。
166
167    :param parser: argparse.ArgumentParser: 初期化済みのArgumentParserオブジェクト。
168    :returns: argparse.Namespace: 解析された引数を格納するオブジェクト。
169    """
170    args = parser.parse_args()
171    print("Args:")
172    print(f"  {args.pattern=}")
173    print(f"  {args.output=}")
174    print(f"  {args.inifile=}")
175    print(f"  {args.api=}")
176    print(f"  {args.update=}")
177    print(f"  {args.overwrite=}")
178    print(f"  {args.pause=}")
179    return args
180
181def main():
182    """
183    スクリプトのメイン処理を実行する。
184
185    `initialize` と `read_args` を呼び出してコマンドライン引数を処理します。
186    AI設定ファイル (`ai.env`) を読み込み、指定されたINIファイルからプロンプト設定を取得します。
187    ワイルドカードパターンに合致するファイル群を処理し、各ファイルについて
188    更新・上書きルールに従って処理をスキップするか判断します。
189    ファイル内容を読み込み、プロンプトを構築してAIモデルを呼び出します。
190    AIからの応答からDocstringを抽出し、指定された出力ファイルに書き込みます。
191    エラー発生時はメッセージを表示し、スタックトレースを出力します。
192    API呼び出しの負荷軽減のため、ファイル処理ごとに1秒待機し、
193    `pause` 引数が設定されている場合、終了前にユーザー入力を待ちます。
194
195    :returns: None
196    :raises SystemExit: INIファイルの読み込み失敗時や、マッチするファイルがない場合に発生。
197    """
198    print()
199    print(f"=== {sys.argv[0]} ===")
200
201    parser = initialize()
202    args = read_args(parser)
203
204    read_ai_config("ai.env")
205    
206    try:
207        ini_data = read_ini(args.inifile)
208        print(f"Loaded INI: {search_file(args.inifile)}")
209    except Exception as e:
210        print(f"Error loading INI: {e}")
211        sys.exit(1)
212
213    # ワイルドカード展開
214    files = glob.glob(args.pattern)
215    if not files:
216        print(f"No files matched pattern: {args.pattern}")
217        sys.exit(1)
218
219    # 出力ファイル名のリスト作成
220    if args.output and len(files) == 1:
221        outputs = [args.output]
222    else:
223        outputs = [os.path.splitext(f)[0] + "_docstring.py" for f in files]
224
225    for inp, out in zip(files, outputs):
226        # 更新・上書きチェックロジック
227        if os.path.exists(out) and not args.overwrite:
228            # updateモードかつ、出力ファイルの方が新しい場合はスキップ
229            if not args.update or os.path.getmtime(out) >= os.path.getmtime(inp):
230                print(f"Skip: {out}")
231                continue
232
233        print(f"Processing: {inp} -> {out}")
234        
235        try:
236            code = Path(inp).read_text(encoding="utf-8")
237        except:
238            try:
239                code = Path(inp).read_text(encoding="shift-jis")
240            except Exception as e:
241                print(f"Error reading {inp}: {e}")
242                continue
243
244        base_name = os.path.splitext(os.path.basename(inp))[0]
245        template = ini_data.get("PROMPT_MAIN", "")
246        role = ini_data.get("SYSTEM_ROLE", "Assistant")
247        
248        prompt = template.replace("{{script_name}}", inp)\
249                         .replace("{{code}}", code)\
250                         .replace("add_docstring", base_name)
251
252        try:
253            if args.api == "openai5":
254                res = query_openai5(prompt, os.getenv("openai_model5"), instructions=role)
255                doc = extract_openai5_text(res)
256            elif args.api == "openai":
257                res = query_openai4(prompt, os.getenv("openai_model"), role=role)
258                doc = res.choices[0].message.content
259            else:
260                res = query_google(prompt, os.getenv("gemini_model"), role=role)
261                doc = res.text if res else None
262
263            if doc:
264                # Markdownブロックの除去
265                doc = doc.strip()
266                if doc.startswith("```"):
267                    doc = re.sub(r'^```[a-zA-Z]*\n', '', doc)
268                    doc = re.sub(r'\n```$', '', doc)
269
270                Path(out).write_text(doc, encoding="utf-8")
271                print(f"Done: {out}")
272            else:
273                print(f"Error: No response from AI for {inp}")
274
275        except Exception:
276            print(f"\n!!! Error during AI processing for {inp} !!!")
277            traceback.print_exc()
278        
279        time.sleep(1) # API連続呼び出しの負荷軽減
280
281    if args.pause:
282        input("\nPress ENTER to terminate>>\n")
283    
284if __name__ == "__main__":
285    main()
add_docstring.ini (設定ファイル)

add_docstring.ini をダウンロード

add_docstring.ini
 1# システムの役割
 2SYSTEM_ROLE = """
 3あなたはPythonのドキュメンテーションエンジニアです。提供するソースコードの内容を解析し、
 4Sphinx (reStructuredText) 形式に最適化された高品質なDocstringのみを生成してください。
 5"""
 6
 7# メインのプロンプトテンプレート
 8PROMPT_MAIN = """
 9【絶対遵守ルール】
10 1. 既存のロジック(実行コード)は一切変更しないこと。
11 2. Docstringは日本語で出力すること。
12 3. 出力は、既存のコードにDocstringを挿入した「完成版のコード全体」として出力すること。
13 4. 各関数・クラスの冒頭に、適切な \"\"\"Docstring\"\"\" を追加すること。
14
15【Docstringの構成要素】
16 - 概要: 1行で何をする関数か記述。
17 - 詳細説明: 必要に応じて動作の詳細を記述。
18 - 引数 (Parameters): :param name: 形式で型と説明を記述。
19 - 戻り値 (Returns): :returns: 形式で型と説明を記述。
20 - 関連リンク: モジュールの冒頭に、別のドキュメントへのリンク(例: :doc:`{{base_name}}_usage`)を含めること。
21
22【対象ソースコード: {{script_name}}】
23{{code}}
24"""
make_sphinx_files.py (プログラム)

make_sphinx_files.py をダウンロード

make_sphinx_files.py
  1#!/usr/bin/env python3
  2# -*- coding: utf-8 -*-
  3"""
  4Sphinxドキュメント自動生成スクリプト。
  5
  6指定されたPythonスクリプトファイルに対して、Docstringの追加、プログラム解説の生成、
  7Sphinx用の各種RST/MDファイルの作成、および関連するファイルのバックアップと置換を自動化します。
  8`add_docstring.py` と `explain_program5.py` を利用して、AIによるドキュメント生成を行います。
  9
 10:doc:`make_sphinx_files_usage`
 11"""
 12
 13import os
 14import sys
 15import argparse
 16import shutil
 17from glob import glob
 18import subprocess
 19import traceback
 20from pathlib import Path
 21from datetime import datetime
 22
 23
 24SCRIPT_FULLPATH = os.path.abspath(sys.argv[0])
 25SCRIPT_DIR = os.path.dirname(SCRIPT_FULLPATH)
 26SCRIPT_BASENAME = os.path.splitext(os.path.basename(SCRIPT_FULLPATH))[0]
 27
 28# 起動スクリプトのディレクトリに基づいたパス設定
 29ADD_DOCSTRING_PATH = os.path.join(SCRIPT_DIR, "add_docstring.py")
 30EXPLAIN_PROGRAM_PATH = os.path.join(SCRIPT_DIR, "explain_program5.py")
 31DEFAULT_INI_PATH = os.path.join(SCRIPT_DIR, f"{SCRIPT_BASENAME}.ini")
 32
 33
 34def relative_from_source(argv0: str) -> str:
 35    """
 36    ファイルパスから'source'ディレクトリ以下の相対パスを取得する。
 37
 38    指定されたファイルパスを解決し、そのパス中に 'source' ディレクトリがあれば、
 39    その直下からの相対パス(ファイル名を除いたディレクトリ部分)を返す。
 40    'source' が見つからない場合は空文字列を返す。
 41
 42    :param argv0: プログラムのパス。通常 `sys.argv[0]` が渡される。
 43    :returns: 'source' ディレクトリ以下の相対パスの文字列。
 44    """
 45    p = Path(argv0).resolve()
 46    parts = p.parts
 47
 48    # 最初に出現する "source" を探す
 49    try:
 50        idx = parts.index("source")
 51    except ValueError:
 52#        raise RuntimeError("'source' ディレクトリがパスに含まれていません")
 53        return ""
 54
 55    # source 以下のパス(ファイル名付き)
 56    rel_path = Path(*parts[idx+1:])
 57
 58    # ファイル名を削除してディレクトリだけにする
 59    return str(rel_path.parent)
 60
 61
 62def run_step(message, cmd_list):
 63    """
 64    作業ステップを表示し、外部コマンドを実行します。
 65
 66    指定されたメッセージを表示した後、`subprocess.run` を使用してコマンドリストを実行します。
 67    コマンドの実行結果が成功(終了コード0)であれば `True` を返し、
 68    エラーが発生した場合はエラーメッセージを表示して `False` を返します。
 69
 70    :param message: 実行するステップのメッセージ。
 71    :param cmd_list: 実行するコマンドとその引数を要素とするリスト。
 72    :returns: コマンドが正常に実行された場合は `True`、それ以外は `False`。
 73    """
 74    print(f"\n>>> {message}")
 75    print(f"    コマンド: {' '.join(cmd_list)}")
 76    try:
 77        result = subprocess.run(cmd_list, text=True, errors='ignore')
 78#        result = subprocess.run(cmd_list, capture_output=True, text=True, encoding='utf-8', errors='ignore')
 79        if result.returncode != 0:
 80            print(f"!!! エラーが発生しました:\n{result.stderr}")
 81            return False
 82        return True
 83    except Exception as e:
 84        print(f"!!! 実行エラー: {e}")
 85        return False
 86
 87def make_init_py(path):
 88    """
 89    指定されたパスに `__init__.py` ファイルを作成します。
 90
 91    既にファイルが存在する場合はその旨を表示し、存在しない場合は空の `__init__.py` ファイルを作成します。
 92    これはPythonパッケージとして認識させるために必要です。
 93
 94    :param path: `__init__.py` を作成するパス。
 95    """
 96    if os.path.exists(path):
 97        print(f">>> Step: {path} が見つかりました。")
 98    else:
 99        print(f">>> Step: {path} を作成中...")
100        with open(path, "w", encoding="utf-8") as f:
101            f.write("")
102        print(f"    Done: {path}")
103
104
105def make_index_template(path, module_path):
106    """
107    `index.template` ファイルを作成または更新します。
108
109    プロジェクト全体のSphinxインデックスファイルとなる `index.template` を作成または追記します。
110    ファイルが存在しない場合は新規作成し、基本的なtoctree構造を含みます。
111    存在する場合は、指定された `module_path` をtoctreeに追加します。
112
113    :param path: `index.template` ファイルのパス。
114    :param module_path: Sphinxドキュメントのインデックスに追加するモジュールパス。
115    """
116    if os.path.exists(path):
117        print(f">>> Step: {path} に追加中...")
118        with open(path, "a", encoding="utf-8") as f:
119            f.write(f"   {module_path}_index\n")
120        print(f"    Done: {path}")
121    else:
122        print(f">>> Step: {path} を作成中...")
123        content = f"""\
124プロジェクト全体ドキュメント
125============================
126
127.. toctree::
128   :maxdepth: 2
129   :caption: メインメニュー:
130   :hidden:
131   :glob:
132
133   {module_path}_index
134"""
135        with open(path, "w", encoding="utf-8") as f:
136            f.write(content)
137        print(f"    Done: {path}")
138
139def make_index_rst(index_rst, base_name, package_path):
140    """
141    Sphinxのモジュール別インデックス (`_index.rst`) ファイルを作成します。
142
143    指定された `base_name` と `package_path` を使用して、
144    モジュールごとのドキュメントのトップページとなる `.rst` ファイルを作成します。
145    このファイルには、usage, examples, apiへのリンクを含むtoctreeが定義されます。
146
147    :param index_rst: 作成する `.rst` ファイルのパス。
148    :param base_name: ベースとなるファイル名(拡張子なし)。
149    :param package_path: Pythonパッケージのフルパス。
150    """
151    print(f">>> Step: {index_rst} を作成中...")
152    index_content = f"""{base_name} ドキュメント
153============================================================================
154
155.. toctree::
156   :maxdepth: 1
157
158   {base_name}_usage
159   {base_name}_examples
160   {base_name}_api
161"""
162    with open(index_rst, "w", encoding="utf-8") as f:
163        f.write(index_content)
164    print(f"    Done: {index_rst}")
165
166def make_api_rst(api_rst, base_name, package_path):
167    """
168    SphinxのAPIドキュメント (`_api.rst`) ファイルを作成します。
169
170    指定された `base_name` と `package_path` を使用して、
171    PythonモジュールのAPIリファレンスとなる `.rst` ファイルを作成します。
172    `automodule` ディレクティブを用いて、自動的にメンバー、非公開メンバー、継承情報を抽出する設定を行います。
173
174    :param api_rst: 作成するAPI `.rst` ファイルのパス。
175    :param base_name: ベースとなるファイル名(拡張子なし)。
176    :param package_path: Pythonパッケージのフルパス。
177    """
178    print(f">>> Step: {api_rst} を作成中...")
179    api_content = f"""{base_name} プログラム仕様
180============================================================================
181
182.. currentmodule:: {package_path}
183
184.. automodule:: {package_path}
185   :members:
186   :undoc-members:
187   :show-inheritance:
188"""
189    with open(api_rst, "w", encoding="utf-8") as f:
190        f.write(api_content)
191    print(f"    Done: {api_rst}")
192
193def make_examples_md(examples_md, infile, base_name):
194    """
195    実行例を示すMarkdownファイル (`_examples.md`) を作成します。
196
197    指定された `infile` に対して `--help` オプションを実行してヘルプ出力を取得し、
198    現在のディレクトリ内の関連する画像ファイルやデータファイルを検出し、
199    それらを組み込んだMarkdown形式の実行例ファイルを作成します。
200
201    :param examples_md: 作成する実行例Markdownファイルのパス。
202    :param infile: ヘルプ出力を取得する対象のPythonスクリプトファイル。
203    :param base_name: ベースとなるファイル名(拡張子なし)。
204    """
205    print(f">>> Step: {examples_md} テンプレートを作成中...")
206
207# 画像ファイルの自動検出
208    image_files = sorted(
209        f for f in os.listdir(".")
210        if f.startswith(base_name) and f.lower().endswith((".png", ".jpg", ".jpeg"))
211        )
212
213# データファイルの自動検出(CSV / Excel / TXT)
214    data_files = sorted(
215        f for f in os.listdir(".")
216        if f.startswith(base_name) and f.lower().endswith((".csv", ".xlsx", ".xls", ".txt"))
217        )
218    
219    print("Image files:", image_files)
220    print("Data files:", data_files)
221
222    
223    # ヘルプ出力を取得
224    print("  help logを取得します")
225    result = subprocess.run(["python", infile, "--help"], 
226                   text=True, capture_output = True)
227    print("    return code:", result.returncode)
228    if result.returncode == 0:
229        help_log = result.stdout + "\n" + result.stderr
230#        print("    help log:", help_log)                        
231    else:
232        help_log = "(ヘルプの自動取得に失敗しました。ここに実行ログを貼り付けてください)"
233
234    if data_files:
235        data_section = "## データファイル\n"
236        for df in data_files:
237            data_section += f"- [{df}](./{df})\n"
238        data_section += "\n"
239    else:
240        data_section = "## 生成されたデータファイル\n(データファイルが見つかりませんでした)\n\n"
241
242    if image_files:
243        image_section = "## 画像ファイル\n\n"
244        for img in image_files:
245            image_section += f"- [{img}](./{img})\n"
246            image_section += f"![{img}](./{img})\n\n"
247    else:
248        image_section = "## 生成された画像一覧\n(画像ファイルが見つかりませんでした)\n\n"
249
250
251    examples_content = f"""# {base_name} 実行例
252
253## help出力 `{base_name}.py --help`
254
255<pre style="background-color: #f4f4f4; border: 1px solid #ccc; padding: 10px; border-radius: 5px; font-family: 'Courier New', Courier, monospace; overflow-x: auto;">
256{help_log}
257</pre>
258
259{data_section}
260
261{image_section}
262
263"""
264
265    with open(examples_md, "w", encoding="utf-8") as f:
266        f.write(examples_content)
267    print(f"    Done: {examples_md}")
268
269
270def main(args):
271    """
272    Sphinxドキュメント生成の主要な処理を実行します。
273
274    入力ファイルから基本情報を抽出し、`__init__.py`, `index.template`,
275    `_index.rst`, `_api.rst`, `_examples.md` といったSphinx関連ファイルを生成します。
276    その後、`explain_program5.py` と `add_docstring.py` を実行して、
277    プログラム解説とDocstringを生成・追加し、最後に元のファイルをバックアップし、
278    Docstringが追加されたファイルで置き換えます。
279
280    :param args: コマンドライン引数を格納した `argparse.Namespace` オブジェクト。
281    """
282    infile = args.infile
283    if not os.path.exists(infile):
284        print(f"エラー: 入力ファイル '{infile}' が見つかりません。")
285        return
286
287    # 基本情報の整理
288    base_name = os.path.splitext(os.path.basename(infile))[0]
289    date_str = datetime.now().strftime("%Y%m%d")
290    
291    # ファイル名定義
292    init_py = "__init__.py"
293    index_template = "index.template"
294    docstring_out = f"{base_name}_docstring.py"
295    backup_file = f"{base_name}_{date_str}.py"
296    usage_md = f"{base_name}_usage.md"
297    examples_md = f"{base_name}_examples.md"
298    index_rst = f"{base_name}_index.rst"
299    api_rst = f"{base_name}_api.rst"
300
301    # カレントディレクトリ名からパッケージパスを判定 (010...等の数値ディレクトリを想定)
302    current_dir_name = os.path.basename(os.getcwd())
303    # フォルダ名が 010xx_page のような形式ならモジュールパスに含める
304#    module_path = f"{current_dir_name}.{base_name}" if "page" in current_dir_name else base_name
305    if args.subdir is not None and args.subdir != "":
306        module_path = os.path.join(args.subdir, base_name)
307        package_path = f"{args.subdir}.{base_name}"
308    else:
309        package_path = module_path
310        module_path = base_name
311
312    print("="*60)
313    print(f" プロジェクト: {base_name} のSphinxファイル自動生成を開始します")
314    print(f"    モジュールパス: {module_path}")
315    print(f"    パッケージパス: {package_path}")
316    print("="*60)
317
318    make_init_py(init_py)
319    make_index_template(index_template, module_path)
320    make_index_rst(index_rst, base_name, package_path)
321    make_api_rst(api_rst, base_name, package_path)
322    make_examples_md(examples_md, infile, base_name)
323
324    args_list = ["--api", args.api, "--update", str(args.update), "--overwrite", str(args.overwrite),
325                 "--pause", str(args.pause)]
326
327    # explain_program.py の実行
328    if not run_step("Step: EXPLAIN_PROGRAM_PATH を実行してプログラム解説を生成中...", 
329                    ["python", EXPLAIN_PROGRAM_PATH, infile, *args_list]):
330        return
331
332    # add_docstring.py の実行
333    if not run_step("Step: ADD_DOCSTRING_PATH を実行してDocstringを追加中...", 
334                    ["python", ADD_DOCSTRING_PATH, infile, *args_list]):
335        return
336
337#======================================================================
338    # バックアップの作成
339    print(f">>> Step: オリジナルファイルのバックアップを作成中...")
340    if os.path.exists(infile):
341        shutil.copy2(infile, backup_file)
342        print(f"    Done: {infile} -> {backup_file}")
343    else:
344        print("!!! バックアップ対象のファイルが見つかりません。")
345        return
346
347#======================================================================
348    # ファイルの置き換え
349    print(f">>> Step: 生成されたDocstring版ファイルを {infile} にリネーム中...")
350    if os.path.exists(docstring_out):
351        os.replace(docstring_out, infile)
352        print(f"    Done: {docstring_out} -> {infile}")
353        with open(docstring_out, "w") as fp:
354            fp.write("")
355        print(f"    ダミーの空ファイル {docstring_out} を作りました")
356    else:
357        print("!!! Docstring版ファイルが生成されていなかったため、リネームをスキップします。")
358        return
359
360
361    print("\n" + "="*60)
362    print(" 全ての自動生成プロセスが正常に終了しました。")
363    print("="*60)
364
365
366def initialize():
367    """
368    コマンドライン引数パーサーを初期化し、設定します。
369
370    `argparse.ArgumentParser` オブジェクトを生成し、スクリプトの説明、
371    必要な引数 (`infile`)、およびオプション引数 (`subdir`, `api`, `update`, `overwrite`, `pause`) を定義します。
372
373    :returns: 設定済みの `ArgumentParser` オブジェクト。
374    """
375    parser = argparse.ArgumentParser(
376        description="Sphinxドキュメント生成の一連のルーチン(Docstring追加、バックアップ、解説生成、RST作成)を自動化します。"
377    )
378    parser.add_argument("infile", help="対象となるPythonスクリプトファイル名 (例: mu_fit.py)")
379    parser.add_argument("--subdir", default=None, help="sourceディレクトリからの相対パス")
380
381    parser.add_argument("--api", choices=["openai", "openai5", "google", "gemini"], default='google')
382    parser.add_argument("-u", "--update", type=int, default=1)
383    parser.add_argument("-w", "--overwrite", type=int, default=0)
384    parser.add_argument("-p", "--pause", type=int, default=0)
385    return parser
386
387
388def read_args(parser):
389    """
390    コマンドライン引数を解析し、整形して返します。
391
392    与えられた `ArgumentParser` を使用してコマンドライン引数を解析します。
393    `subdir` が指定されていない場合は `relative_from_source` 関数を使って自動的に設定します。
394    解析された引数を表示し、`argparse.Namespace` オブジェクトとして返します。
395
396    :param parser: `argparse.ArgumentParser` オブジェクト。
397    :returns: 解析された引数を格納する `argparse.Namespace` オブジェクト。
398    """
399    args = parser.parse_args()
400    if args.subdir is None:
401        args.subdir = relative_from_source(args.infile)
402
403    print()
404    print("Args:")
405    print(f"  {args.infile=}")
406    print(f"  {args.subdir=}")
407    print(f"  {args.api=}")
408    print(f"  {args.update=}")
409    print(f"  {args.overwrite=}")
410    print(f"  {args.pause=}")
411
412    return args
413
414
415if __name__ == "__main__":
416    print()
417    print(f"=== {sys.argv[0]} ===")
418    print(f"{EXPLAIN_PROGRAM_PATH=}")
419    print(f"{ADD_DOCSTRING_PATH=}")
420
421    parser = initialize()
422    args = read_args(parser)
423
424    try:
425        main(args)
426    except Exception:
427        print("\n" + "!"*60)
428        print(" 予期せぬ致命的なエラーが発生しました。")
429        traceback.print_exc()
430        print("!"*60)
431        sys.exit(1)
debug_sphinx-build.py (プログラム)

debug_sphinx-build.py をダウンロード

debug_sphinx-build.py
  1#!/usr/bin/env python3
  2# -*- coding: utf-8 -*-
  3
  4"""
  5Sphinx autodocにおけるimportエラーを調査するためのユーティリティスクリプト。
  6
  7このスクリプトは、Sphinxのビルドプロセスで発生する可能性のあるPythonモジュールのimportエラーをデバッグするために設計されています。
  8特に、`autodoc_mock_imports`の設定が正しく機能しているか、または他の原因でimportエラーが発生しているかを特定するのに役立ちます。
  9
 10目的:
 11- `./source/conf.py` をインポートし、`autodoc_mock_imports` の設定を読み込む。
 12- 読み取った `autodoc_mock_imports` に基づいて、ダミーモジュールを `sys.modules` に登録する。
 13- 指定された `source` ディレクトリ配下のすべての `.py` ファイルを再帰的にインポートする。
 14- インポート中に発生したエラーを記録し、必要に応じて処理を継続する。
 15
 16仕様:
 17- スクリプト実行時、`--source` 引数で指定されたディレクトリ (デフォルトは `source`) をSphinxのソースディレクトリとして扱います。
 18- `conf.py` は他のファイルよりも先に個別にインポートされ、その設定が適用されます。
 19- `conf.py` 以外の `.py` ファイルは、`source` ディレクトリからの相対パスに基づいて一意な疑似モジュール名でインポートされます。
 20- ファイル名が `_数字列` で終わるファイル (例: `sample_1.py`, `test_2024.py`) はスキップされます。
 21- デフォルトでは、インポート中に例外が発生した場合、トレースバックを表示してスクリプトは終了します。
 22- `--keep-going` オプションを指定すると、エラーが発生しても処理を最後まで継続し、最後にすべての失敗一覧を表示します。
 23- `--mock` オプションを使用すると、`conf.py` をインポートする前に手動でモジュールをモックできます。
 24- `sys.path` にカレントディレクトリやソースディレクトリを追加するオプションも提供されます。
 25
 26:doc:`debug_sphinx-build_usage`
 27"""
 28
 29from __future__ import annotations
 30
 31import argparse
 32import importlib.util
 33import re
 34import sys
 35import traceback
 36import types
 37from pathlib import Path
 38
 39
 40class DummyObject:
 41    """
 42    どんな属性アクセスや関数呼び出しでも受け流すダミーオブジェクト。
 43
 44    このクラスは、存在しないモジュールやオブジェクトに対する参照、
 45    メソッド呼び出しなどが発生した場合にエラーを回避するために使用されます。
 46    どのような操作に対しても自身を返すか、新しい `DummyObject` を生成することで、
 47    実行を中断させずに処理を継続させます。
 48    """
 49
 50    def __init__(self, name: str = "dummy"):
 51        """
 52        DummyObject を初期化します。
 53
 54        :param name: ダミーオブジェクトの内部的な名前。デバッグ表示に使用されます。
 55        :type name: str
 56        """
 57        self._name = name
 58
 59    def __call__(self, *args, **kwargs):
 60        """
 61        オブジェクトが関数として呼び出されたときに実行されます。
 62
 63        呼び出されたことを示す新しい `DummyObject` を返します。
 64
 65        :param args: 呼び出し時の位置引数。
 66        :param kwargs: 呼び出し時のキーワード引数。
 67        :returns: 新しい `DummyObject` インスタンス。
 68        :rtype: DummyObject
 69        """
 70        return DummyObject(f"{self._name}()")
 71
 72    def __getattr__(self, name: str):
 73        """
 74        オブジェクトの属性がアクセスされたときに実行されます。
 75
 76        アクセスされた属性名を持つ新しい `DummyObject` を返します。
 77
 78        :param name: アクセスされた属性の名前。
 79        :type name: str
 80        :returns: 新しい `DummyObject` インスタンス。
 81        :rtype: DummyObject
 82        """
 83        return DummyObject(f"{self._name}.{name}")
 84
 85    def __repr__(self):
 86        """
 87        オブジェクトの文字列表現を返します。
 88
 89        :returns: `DummyObject` の文字列表現。
 90        :rtype: str
 91        """
 92        return f"<DummyObject {self._name}>"
 93
 94
 95class DummyModule(types.ModuleType):
 96    """
 97    属性アクセス時に `DummyObject` を返すダミーモジュール。
 98
 99    `sys.modules` に登録することで、存在しないモジュールがimportされようとした際に、
100    実際には何もしないダミーのモジュールとして機能させます。
101    これにより、モジュール内の属性アクセスや関数呼び出しがすべて `DummyObject` に
102    よって処理され、`AttributeError` や `ImportError` を回避できます。
103    """
104
105    def __getattr__(self, name: str):
106        """
107        モジュールの属性がアクセスされたときに実行されます。
108
109        アクセスされた属性名を持つ `DummyObject` を生成し、その属性として設定して返します。
110
111        :param name: アクセスされた属性の名前。
112        :type name: str
113        :returns: 新しい `DummyObject` インスタンス。
114        :rtype: DummyObject
115        """
116        dummy = DummyObject(f"{self.__name__}.{name}")
117        setattr(self, name, dummy)
118        return dummy
119
120
121def install_mock_modules(module_names: list[str]) -> None:
122    """
123    指定したモジュール名を `sys.modules` にダミーモジュールとして登録する。
124
125    モジュール名が `pkg.subpkg` のように階層的である場合、`pkg` と `pkg.subpkg` の両方を
126    ダミーモジュールとして登録し、ネストされたimportをサポートします。
127    既に登録されているモジュールは上書きされません。
128
129    :param module_names: モックするモジュール名のリスト。
130    :type module_names: list[str]
131    :returns: None
132    """
133    for name in module_names:
134        name = name.strip()
135        if not name:
136            continue
137
138        parts = name.split(".")
139        for i in range(1, len(parts) + 1):
140            subname = ".".join(parts[:i])
141            if subname not in sys.modules:
142                sys.modules[subname] = DummyModule(subname)
143
144
145def load_module_from_file(module_name: str, file_path: Path):
146    """
147    任意の .py ファイルを `module_name` という名前でインポートする。
148
149    `importlib.util` を使用してファイルからモジュールをロードし、
150    `sys.modules` に登録します。これにより、通常の `import` 文と同様に
151    モジュールをロードし、そのオブジェクトにアクセスできるようになります。
152
153    :param module_name: インポートするモジュールの名前。`sys.modules` に登録される名前となります。
154    :type module_name: str
155    :param file_path: インポート対象の .py ファイルのパス。
156    :type file_path: Path
157    :returns: ロードされたモジュールオブジェクト。
158    :raises ImportError: モジュールのスペックを生成できなかった場合。
159    """
160    spec = importlib.util.spec_from_file_location(module_name, str(file_path))
161    if spec is None or spec.loader is None:
162        raise ImportError(f"spec を作成できませんでした: {file_path}")
163
164    module = importlib.util.module_from_spec(spec)
165    sys.modules[module_name] = module
166    spec.loader.exec_module(module)
167    return module
168
169
170def make_module_name(source_dir: Path, py_file: Path) -> str:
171    """
172    `source_dir` 配下の相対パスから、一意な疑似モジュール名を生成する。
173
174    ファイルパスをPythonの識別子として有効な形式に変換し、モジュール名の衝突を避けるために
175    `sphinx_debug.` というプレフィックスを付与します。
176    ファイル名やディレクトリ名に含まれるハイフン (`-`) やスペース (` `) はアンダースコア (`_`) に変換され、
177    無効な文字を含む識別子にはさらにプレフィックスが追加されます。
178
179    :param source_dir: Sphinxのソースディレクトリのパス。このパスからの相対パスがモジュール名の基になります。
180    :type source_dir: Path
181    :param py_file: 疑似モジュール名を生成する対象の .py ファイルのパス。
182    :type py_file: Path
183    :returns: 生成された一意な疑似モジュール名。
184    :rtype: str
185    """
186    rel = py_file.relative_to(source_dir).with_suffix("")
187    parts = []
188
189    for p in rel.parts:
190        p2 = p.replace("-", "_").replace(" ", "_")
191        if not p2.isidentifier():
192            p2 = "_" + "".join(ch if (ch.isalnum() or ch == "_") else "_" for ch in p2)
193        parts.append(p2)
194
195    return "sphinx_debug." + ".".join(parts)
196
197
198def should_skip(py_file: Path) -> tuple[bool, str]:
199    """
200    特定の条件に基づいてファイルをスキップするかどうかを判定する。
201
202    以下の条件に合致する場合、ファイルはスキップされます。
203    - ファイル名が `conf.py` である場合 (このファイルは個別に先に処理されるため)。
204    - ファイルのステム (拡張子を除いた部分) が `_数字列` で終わる場合。
205      例: `sample_1.py`, `test_2024.py`
206
207    :param py_file: 判定対象のファイルパス。
208    :type py_file: Path
209    :returns: スキップするかどうかの真偽値と、スキップ理由の文字列のタプル。
210    :rtype: tuple[bool, str]
211    """
212    if py_file.name == "conf.py":
213        return True, "conf.py は先に個別 import 済み"
214
215    if py_file.stem.endswith("_docstring"):
216        return True, "stem が _docstring で終わる"
217
218    if re.search(r"_\d+$", py_file.stem):
219        return True, "stem が _日付 で終わる"
220
221    return False, ""
222
223
224def parse_mock_arg(mock_text: str) -> list[str]:
225    """
226    コマンドライン引数 `--mock` で渡された文字列をモジュール名のリストに変換する。
227
228    入力文字列はセミコロン (`;`) で区切られたモジュール名を含むと想定されます。
229    各モジュール名から前後の空白が除去され、空の文字列は無視されます。
230    例: `"whisper;torch;numba"` は `["whisper", "torch", "numba"]` に変換されます。
231
232    :param mock_text: モックするモジュール名をセミコロン区切りで含む文字列。
233    :type mock_text: str
234    :returns: モックするモジュール名のリスト。
235    :rtype: list[str]
236    """
237    if not mock_text:
238        return []
239    return [x.strip() for x in mock_text.split(";") if x.strip()]
240
241
242def record_error(errors: list[dict], phase: str, file_path: Path, exc: BaseException) -> None:
243    """
244    発生したエラーの詳細をエラーリストに記録する。
245
246    エラー情報には、発生フェーズ、ファイルパス、エラータイプ、メッセージ、完全なトレースバックが含まれます。
247    これは `--keep-going` オプションが指定された場合に、複数のエラーをまとめて報告するために使用されます。
248
249    :param errors: エラー情報を格納するリスト。各エラーは辞書としてこのリストに追加されます。
250    :type errors: list[dict]
251    :param phase: エラーが発生した処理フェーズを示す文字列(例: "conf.py", "module")。
252    :type phase: str
253    :param file_path: エラーが発生したファイルのパス。
254    :type file_path: Path
255    :param exc: 発生した例外オブジェクト。
256    :type exc: BaseException
257    :returns: None
258    """
259    tb_text = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
260    errors.append(
261        {
262            "phase": phase,
263            "file": str(file_path),
264            "error_type": type(exc).__name__,
265            "message": str(exc),
266            "traceback": tb_text,
267        }
268    )
269
270
271def print_error_summary(errors: list[dict]) -> None:
272    """
273    記録されたエラーの要約を表示する。
274
275    エラーがない場合はその旨を表示し、ある場合は各エラーのファイルパス、エラータイプ、
276    エラーメッセージを一覧形式でコンソールに出力します。
277
278    :param errors: 記録されたエラー情報のリスト。
279    :type errors: list[dict]
280    :returns: None
281    """
282    print("\n" + "=" * 80)
283    print("[SUMMARY] import 失敗一覧")
284    print("=" * 80)
285
286    if not errors:
287        print("[SUMMARY] エラーはありません")
288        return
289
290    for i, err in enumerate(errors, 1):
291        print(f"{i}. [{err['phase']}] {err['file']}")
292        print(f"   {err['error_type']}: {err['message']}")
293
294    print("-" * 80)
295    print(f"合計 {len(errors)} 件の import エラーがありました")
296
297
298def main():
299    """
300    スクリプトのメイン処理を実行する。
301
302    コマンドライン引数を解析し、Sphinxのソースディレクトリ内のPythonファイルを順次インポートします。
303    `conf.py` をロードして `autodoc_mock_imports` を取得し、それらのモジュールをモックします。
304    その後、ソースディレクトリ内の残りの `.py` ファイルをインポートし、
305    `--keep-going` オプションに応じてエラー処理を行います。
306    最終的に、記録されたエラーの要約を表示し、エラーがあれば非ゼロの終了コードで終了します。
307
308    :returns: None
309    """
310    parser = argparse.ArgumentParser(
311        description="Sphinx import エラー調査用: conf.py と source配下の *.py を順に import する"
312    )
313    parser.add_argument(
314        "--source",
315        default="source",
316        help="Sphinx source ディレクトリ (default: source)",
317    )
318    parser.add_argument(
319        "--add-cwd",
320        action="store_true",
321        help="カレントディレクトリを sys.path 先頭に追加する",
322    )
323    parser.add_argument(
324        "--add-source",
325        action="store_true",
326        help="source ディレクトリを sys.path 先頭に追加する",
327    )
328    parser.add_argument(
329        "--mock",
330        default="",
331        help='conf.py import 前に手動でモックするモジュールを ; 区切りで指定 (例: "whisper;torch;numba")',
332    )
333    parser.add_argument(
334        "--show-skip",
335        action="store_true",
336        help="スキップしたファイルも表示する",
337    )
338    parser.add_argument(
339        "--keep-going",
340        action="store_true",
341        help="エラーが出ても終了せず、最後まで続行して失敗一覧を表示する",
342    )
343    parser.add_argument(
344        "--show-traceback-summary",
345        action="store_true",
346        help="最後の失敗一覧で traceback も全文表示する (--keep-going 向け)",
347    )
348    args = parser.parse_args()
349
350    source_dir = Path(args.source).resolve()
351    conf_py = source_dir / "conf.py"
352    errors: list[dict] = []
353
354    if not source_dir.is_dir():
355        print(f"ERROR: source ディレクトリが存在しません: {source_dir}", file=sys.stderr)
356        sys.exit(1)
357
358    if not conf_py.is_file():
359        print(f"ERROR: conf.py が存在しません: {conf_py}", file=sys.stderr)
360        sys.exit(1)
361
362    if args.add_cwd:
363        cwd = str(Path.cwd().resolve())
364        if cwd not in sys.path:
365            sys.path.insert(0, cwd)
366
367    if args.add_source:
368        sdir = str(source_dir)
369        if sdir not in sys.path:
370            sys.path.insert(0, sdir)
371
372    print(f"[INFO] source_dir = {source_dir}")
373    print(f"[INFO] conf.py    = {conf_py}")
374
375    manual_mocks = parse_mock_arg(args.mock)
376    if manual_mocks:
377        install_mock_modules(manual_mocks)
378        print(f"[INFO] manual mock modules = {manual_mocks}")
379
380    conf_mod = None
381
382    print(f"[IMPORT] {conf_py}")
383    try:
384        conf_mod = load_module_from_file("sphinx_debug.conf", conf_py)
385    except Exception as e:
386        print("\n[ERROR] conf.py の import に失敗しました\n", file=sys.stderr)
387        traceback.print_exc()
388        record_error(errors, "conf.py", conf_py, e)
389        if not args.keep_going:
390            print_error_summary(errors)
391            sys.exit(1)
392
393    if conf_mod is not None:
394        conf_mocks = getattr(conf_mod, "autodoc_mock_imports", [])
395        if conf_mocks:
396            if not isinstance(conf_mocks, (list, tuple)):
397                print(
398                    f"[WARN] autodoc_mock_imports が list/tuple ではありません: {type(conf_mocks).__name__}",
399                    file=sys.stderr,
400                )
401            else:
402                conf_mocks = [str(x).strip() for x in conf_mocks if str(x).strip()]
403                install_mock_modules(conf_mocks)
404                print(f"[INFO] autodoc_mock_imports = {conf_mocks}")
405
406    for py_file in sorted(source_dir.rglob("*.py")):
407        skip, reason = should_skip(py_file)
408        if skip:
409            if args.show_skip:
410                print(f"[SKIP]   {py_file} ({reason})")
411            continue
412
413        module_name = make_module_name(source_dir, py_file)
414        print(f"[IMPORT] {py_file}")
415        try:
416            load_module_from_file(module_name, py_file)
417        except Exception as e:
418            print(f"\n[ERROR] import に失敗しました: {py_file}\n", file=sys.stderr)
419            traceback.print_exc()
420            record_error(errors, "module", py_file, e)
421            if not args.keep_going:
422                print_error_summary(errors)
423                sys.exit(1)
424
425    print_error_summary(errors)
426
427    if args.keep_going and args.show_traceback_summary and errors:
428        print("\n" + "=" * 80)
429        print("[SUMMARY] traceback 詳細")
430        print("=" * 80)
431        for i, err in enumerate(errors, 1):
432            print(f"\n--- {i}. [{err['phase']}] {err['file']} ---")
433            print(err["traceback"])
434
435    if errors:
436        sys.exit(1)
437
438    print("\n[OK] すべての対象 .py ファイルを import できました")
439
440
441if __name__ == "__main__":
442    main()
files2md_tags.py (プログラム)

files2md_tags.py をダウンロード

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

save_clipboard.py をダウンロード

save_clipboard.py
  1"""
  2クリップボードの内容(テキストまたは画像)を取得し、ファイルに保存するスクリプト。
  3
  4このスクリプトはWindows API (User32, Ole32, Kernel32) を使用してクリップボードデータを処理し、
  5PIL (Pillow) ライブラリを使って画像を保存します。
  6テキストデータはUTF-8エンコーディングで保存され、DIB (Device Independent Bitmap) 形式の画像はPNG形式で保存されます。
  7ファイル保存後、reStructuredText (rst) 形式でそのファイルを参照するためのスニペットを標準出力に表示します。
  8
  9Usage:
 10    python save_clipboard.py [出力ファイル名のベース (拡張子なし)]
 11
 12例:
 13    # クリップボードの内容を 'my_output.txt' または 'my_output.png' として保存
 14    python save_clipboard.py my_output
 15    # 引数なしの場合、'clipboard_output.txt' または 'clipboard_output.png' として保存
 16    python save_clipboard.py
 17
 18:doc:`save_clipboard_usage`
 19"""
 20import ctypes
 21import ctypes.wintypes as wintypes
 22from ctypes import POINTER, byref, windll, c_void_p
 23from PIL import Image
 24import io
 25import struct
 26import sys
 27import os
 28
 29# Windows APIの準備
 30ole32 = windll.ole32
 31kernel32 = windll.kernel32
 32user32 = windll.user32 # テキスト取得用に追加
 33
 34# --- 64bitアドレスを正しく扱うための型定義 ---
 35kernel32.GlobalLock.argtypes = [c_void_p]
 36kernel32.GlobalLock.restype = c_void_p
 37kernel32.GlobalUnlock.argtypes = [c_void_p]
 38kernel32.GlobalSize.argtypes = [c_void_p]
 39kernel32.GlobalSize.restype = ctypes.c_size_t
 40
 41# テキスト取得用API定義
 42user32.OpenClipboard.argtypes = [wintypes.HWND]
 43user32.OpenClipboard.restype = wintypes.BOOL
 44user32.CloseClipboard.restype = wintypes.BOOL
 45user32.GetClipboardData.argtypes = [wintypes.UINT]
 46user32.GetClipboardData.restype = wintypes.HANDLE
 47user32.IsClipboardFormatAvailable.argtypes = [wintypes.UINT]
 48user32.IsClipboardFormatAvailable.restype = wintypes.BOOL
 49
 50# 定数定義
 51TYMED_HGLOBAL = 1
 52DVASPECT_CONTENT = 1
 53CF_DIB = 8
 54CF_UNICODETEXT = 13 # WindowsのUnicodeテキスト形式
 55GMEM_MOVEABLE = 0x0002
 56
 57class FORMATETC(ctypes.Structure):
 58    """
 59    OLEデータ転送で使用される形式記述構造体。
 60
 61    クリップボードやOLEオブジェクトからのデータ取得時に、どのような形式
 62    (クリップボード形式、メディアタイプ、表示側面など)のデータを要求するかを指定します。
 63    """
 64    _fields_ = [
 65        ("cfFormat", wintypes.WORD),
 66        ("ptd", c_void_p),
 67        ("dwAspect", wintypes.DWORD),
 68        ("lindex", wintypes.LONG),
 69        ("tymed", wintypes.DWORD),
 70    ]
 71
 72class STGMEDIUM(ctypes.Structure):
 73    """
 74    OLEデータ転送で使用されるストレージメディア構造体。
 75
 76    クリップボードやOLEオブジェクトからのデータ転送時に、実際のデータ
 77    (ハンドル、ポインタ、ストリームなど)とそのタイプを保持します。
 78    `u` メンバは、`tymed` の値に応じてHGLOBALハンドルなどを格納するために `c_void_p` として定義されています。
 79    """
 80    _fields_ = [
 81        ("tymed", wintypes.DWORD),
 82        ("u", c_void_p), # data handle (HGLOBAL)
 83        ("pUnkForRelease", c_void_p),
 84    ]
 85
 86class IDataObject(ctypes.Structure):
 87    """
 88    OLEデータ転送インターフェースの抽象基底クラス。
 89
 90    クリップボードやドラッグ&ドロップ操作など、データ転送オブジェクトが提供するデータを
 91    管理するためのCOMインターフェースです。主に `GetData` メソッドを通じてデータを取得します。
 92    """
 93    pass
 94IDataObject._fields_ = [("lpVtbl", POINTER(c_void_p))]
 95
 96def get_clipboard_text():
 97    """
 98    クリップボードからUnicodeテキストを取得します。
 99
100    User32.dllのOpenClipboard, GetClipboardData, GlobalLock, GlobalUnlock, CloseClipboard関数を使用します。
101    CF_UNICODETEXT形式のデータを取得し、Pythonの文字列として返します。
102    クリップボードがロックされている場合や、テキストデータが見つからない場合はNoneを返します。
103
104    :returns: クリップボード上のテキストデータ (`str`)、またはNone。
105    :rtype: str | None
106    """
107    if not user32.OpenClipboard(None):
108        return None
109    
110    try:
111        if not user32.IsClipboardFormatAvailable(CF_UNICODETEXT):
112            return None
113        
114        h_global = user32.GetClipboardData(CF_UNICODETEXT)
115        if not h_global:
116            return None
117        
118        ptr = kernel32.GlobalLock(c_void_p(h_global))
119        if not ptr:
120            return None
121        
122        try:
123            # Unicode (UTF-16) としてデータを読み込む
124            text = ctypes.c_wchar_p(ptr).value
125            return text
126        finally:
127            kernel32.GlobalUnlock(c_void_p(h_global))
128    finally:
129        user32.CloseClipboard()
130
131def get_clipboard_image_fixed():
132    """
133    クリップボードからDIB (Device Independent Bitmap) 形式の画像を取得し、PIL Imageオブジェクトとして返します。
134
135    Ole32.dllのOleGetClipboardとReleaseStgMedium関数、およびIDataObjectインターフェースのGetDataメソッドを使用します。
136    取得したDIBデータは、BMPファイルヘッダを追加してPIL (Pillow) で読み込み可能な形式に変換されます。
137    この関数を呼び出す前に `ole32.OleInitialize(None)` が呼び出されている必要があります。
138
139    :returns: クリップボード上の画像データ (`PIL.Image.Image`)、またはNone。
140    :rtype: PIL.Image.Image | None
141    """
142    # 既にOleInitializeされている前提、または明示的に呼ぶ
143    # ole32.OleInitialize(None) # メイン側で制御
144    
145    data_obj_ptr = POINTER(IDataObject)()
146    hr = ole32.OleGetClipboard(byref(data_obj_ptr))
147    if hr != 0:
148        return None
149
150    try:
151        # IDataObject::GetData のVtableインデックスは通常3
152        get_data_addr = data_obj_ptr.contents.lpVtbl[3]
153        
154        GetDataProto = ctypes.WINFUNCTYPE(
155            ctypes.HRESULT, 
156            POINTER(IDataObject), 
157            POINTER(FORMATETC), 
158            POINTER(STGMEDIUM)
159        )
160        get_data_func = GetDataProto(get_data_addr)
161    except (IndexError, AttributeError):
162        return None
163    
164    fmt = FORMATETC()
165    fmt.cfFormat = CF_DIB
166    fmt.ptd = None
167    fmt.dwAspect = DVASPECT_CONTENT
168    fmt.lindex = -1
169    fmt.tymed = TYMED_HGLOBAL
170
171    stg = STGMEDIUM()
172    hr = get_data_func(data_obj_ptr, byref(fmt), byref(stg))
173    
174    img = None
175    if hr == 0 and stg.tymed == TYMED_HGLOBAL:
176        h_global = stg.u
177        ptr = kernel32.GlobalLock(c_void_p(h_global))
178        size = kernel32.GlobalSize(c_void_p(h_global))
179        
180        if ptr:
181            try:
182                dib_data = ctypes.string_at(ptr, size)
183                if len(dib_data) >= 40:
184                    header_size = struct.unpack("<I", dib_data[0:4])[0]
185                    offset_to_pixels = 14 + header_size
186                    file_size = 14 + len(dib_data)
187                    
188                    bmp_header = struct.pack("<2sIHHI", b"BM", file_size, 0, 0, offset_to_pixels)
189                    img = Image.open(io.BytesIO(bmp_header + dib_data))
190                    img.load() # メモリ上にデータをロード
191            finally:
192                kernel32.GlobalUnlock(c_void_p(h_global))
193                ole32.ReleaseStgMedium(byref(stg))
194            
195    return img
196
197def print_rst_snippets_for_image(filepath):
198    """
199    指定された画像ファイル用のreStructuredText (rst) スニペットを表示します。
200
201    ファイルリンク、`image` ディレクティブ、`figure` ディレクティブの例を生成し、
202    標準出力に出力します。これはSphinxなどでドキュメントを作成する際に役立ちます。
203
204    :param filepath: スニペットを生成する画像ファイルのパス。
205    :type filepath: str
206    :returns: なし
207    :rtype: None
208    """
209    filename = os.path.basename(filepath)
210    rel_path = "./" + filename # 相対パスと仮定
211    
212    print("\n" + "="*60)
213    print(f"--- reStructuredText (rst) 用表示コード ({filename}) ---")
214    print("="*60)
215    
216    print("\n1. シンプルなファイルリンク:")
217    print(f"`{filename} <{rel_path}>`_")
218    
219    print("\n2. 画像表示 (imageディレクティブ):")
220    print(f".. image:: {rel_path}\n   :alt: {filename}")
221    
222    print("\n3. 図表表示 (figureディレクティブ - キャプション付き):")
223    print(f".. figure:: {rel_path}\n   :alt: {filename}\n   :align: center\n\n   {filename} の図面")
224    print("="*60 + "\n")
225
226def print_rst_snippets_for_text(filepath):
227    """
228    指定されたテキストファイル用のreStructuredText (rst) スニペットを表示します。
229
230    `dropdown` (sphinx-design拡張機能が必要)、`parsed-literal`、`literalinclude`
231    ディレクティブの例を生成し、標準出力に出力します。
232    言語ヒントはファイル拡張子から推測されます。
233
234    :param filepath: スニペットを生成するテキストファイルのパス。
235    :type filepath: str
236    :returns: なし
237    :rtype: None
238    """
239    filename = os.path.basename(filepath)
240    rel_path = "./" + filename # 相対パスと仮定
241    
242    # ファイル拡張子から言語を推測(簡易版)
243    _, ext = os.path.splitext(filename)
244    lang_map = {'.py': 'python', '.js': 'javascript', '.bat': 'bat', '.sh': 'bash', '.cpp': 'cpp', '.h': 'cpp', '.txt': 'text'}
245    language = lang_map.get(ext.lower(), 'text')
246
247    print("\n" + "="*60)
248    print(f"--- reStructuredText (rst) 用表示コード ({filename}) ---")
249    print("="*60)
250    
251    print("\n1. 折り畳み表示 (sphinx-design dropdown) ※要拡張機能:")
252    print(f".. dropdown:: {filename} の内容を表示(クリックで展開)\n   :color: info\n   :icon: file-code\n")
253    print(f"   .. literalinclude:: {rel_path}\n      :language: {language}\n      :linenos:")
254    
255    print("\n2. <pre>タグ相当での埋め込み (parsed-literal):")
256    print(".. parsed-literal::\n")
257    try:
258        with open(filepath, "r", encoding="utf-8") as f:
259            # 最初の数行だけサンプルとして表示
260            lines = f.readlines()
261            for line in lines[:5]:
262                print(f"   {line.rstrip()}")
263            if len(lines) > 5:
264                print("   ...")
265    except Exception:
266         print("   (ファイル内容の読み込みに失敗しました)")
267
268    print("\n3. 通常の外部ファイル取り込み (literalinclude):")
269    print(f".. literalinclude:: {rel_path}\n   :language: {language}")
270    
271    print("="*60 + "\n")
272
273if __name__ == "__main__":
274    # OleInitializeはプロセス内で1回呼ぶ必要がある(OLE API使用のため)
275    ole32.OleInitialize(None)
276    
277    # デフォルトのファイル名ベース
278    base_filename = "clipboard_output"
279    
280    # 引数がある場合はそれをファイル名にする
281    if len(sys.argv) > 1:
282        # 安全なファイル名にするための簡易処理(パス区切り文字などを除去)
283        requested_name = sys.argv[1]
284        base_filename = os.path.splitext(os.path.basename(requested_name))[0]
285
286    try:
287        print(f"--- クリップボードの内容を確認中... ---")
288        
289        # 1. まずテキストがあるか確認(優先度が高い場合が多い)
290        text_content = get_clipboard_text()
291        if text_content is not None:
292            print("[情報] クリップボード内にテキストが見つかりました。")
293            
294            # テキストファイルとして保存
295            out_name = f"{base_filename}.txt"
296            # Windowsの改行コード(CRLF)で保存
297            with open(out_name, "w", encoding="utf-8", newline='\r\n') as f:
298                f.write(text_content)
299            
300            print(f"成功: テキストを '{out_name}' として保存しました (UTF-8)。")
301            print(f"文字数: {len(text_content)}")
302            
303            # rstスニペット表示
304            print_rst_snippets_for_text(out_name)
305            sys.exit(0)
306
307        # 2. 次に画像があるか確認
308        img = get_clipboard_image_fixed()
309        if img:
310            print("[情報] クリップボード内に画像(DIB)が見つかりました。")
311            
312            # 画像ファイルとして保存 (PNG推奨)
313            out_name = f"{base_filename}.png"
314            img.save(out_name, "PNG")
315            
316            print(f"成功: 画像を '{out_name}' として保存しました。")
317            print(f"画像サイズ: {img.size[0]}x{img.size[1]}, モード: {img.mode}")
318            
319            # rstスニペット表示
320            print_rst_snippets_for_image(out_name)
321            sys.exit(0)
322
323        # 3. どちらもない場合
324        print("[情報] クリップボードにテキストまたは画像(CF_DIB)は見つかりませんでした。")
325        # 形式リストを取得して表示(デバッグ用)
326        if user32.OpenClipboard(None):
327            try:
328                formats = []
329                fnum = user32.EnumClipboardFormats(0)
330                while fnum:
331                    formats.append(fnum)
332                    fnum = user32.EnumClipboardFormats(fnum)
333                if formats:
334                    print(f"利用可能な形式ID: {formats}")
335            finally:
336                user32.CloseClipboard()
337
338    except Exception as e:
339        import traceback
340        print(f"\n[エラー] 予期せぬエラーが発生しました。")
341        traceback.print_exc()
342    finally:
343        # OleUninitializeはプロセス終了時に呼ぶ
344        ole32.OleUninitialize()
compare_dir_files.py (プログラム)

compare_dir_files.py をダウンロード

compare_dir_files.py
  1"""
  2ディレクトリ間のファイル構成を比較するスクリプト。
  3
  4このスクリプトは、特定の除外パターンに合致するファイルを無視し、
  52つのディレクトリツリー間でどちらか一方にのみ存在するファイルを報告します。
  6主にソースコードの同期状態や、予期せぬファイルの有無を確認する際に役立ちます。
  7
  8:doc:`compare_dir_files_usage`
  9"""
 10import argparse
 11import os
 12import re
 13from pathlib import Path
 14
 15
 16# 除外パターンの定義 (パス全体に対して判定)
 17exclude_patterns = [
 18        r"/__init__\.py$",
 19        r"/.*_docstring\.",
 20        r"/.*_\d{8}\.",
 21        r"/tklib/",
 22        r"/cms/",
 23        r"/jsap_crystal/",
 24        r"/stastical_physics/",
 25    ]
 26
 27def compare_directories(source_dir, target_dir, extension="*.py"):
 28    """
 29    2つのディレクトリの内容を比較し、一方にのみ存在するファイルを報告する。
 30
 31    この関数は、ネストされた ``is_excluded`` 関数と ``get_filtered_files`` 関数を使用して、
 32    除外パターンにマッチしないファイルを収集します。収集したファイルセットの差分を計算し、
 33    結果を標準出力に表示します。パスのセパレータはPOSIX形式 ('/') に統一されます。
 34
 35    :param source_dir: str 比較元のディレクトリパス。
 36    :param target_dir: str 比較先のディレクトリパス。
 37    :param extension: str 検索するファイルの拡張子パターン (例: "*.py")。
 38    :returns: None 結果は標準出力に表示されるため、明示的な戻り値はない。
 39    """
 40    source_path = Path(source_dir)
 41    target_path = Path(target_dir)
 42
 43    def is_excluded(rel_path_str):
 44        """
 45        相対パスが定義済み除外パターンにマッチするかを判定する。
 46
 47        相対パスの先頭に '/' を付与し、``exclude_patterns`` の各正規表現と照合します。
 48        いずれかのパターンにマッチすれば除外対象と判断します。
 49
 50        :param rel_path_str: str 比較する相対パス文字列 (例: "path/to/file.py")。
 51        :returns: bool 除外パターンにマッチした場合はTrue、そうでなければFalse。
 52        """
 53        # マッチング用に先頭に / をつける
 54        test_path = "/" + rel_path_str
 55        return any(re.search(pattern, test_path) for pattern in exclude_patterns)
 56
 57    def get_filtered_files(base_path):
 58        """
 59        指定されたベースパス以下のファイルをフィルタリングして取得する。
 60
 61        ``Path.rglob()`` を使用して指定された拡張子パターンにマッチするファイルを再帰的に検索します。
 62        各ファイルの相対パスを ``is_excluded`` 関数でチェックし、
 63        除外対象でないファイルのみをセットとして収集します。
 64        相対パスはPOSIX形式 ('/') に統一されます。
 65
 66        :param base_path: Path 検索を開始するベースディレクトリの `Path` オブジェクト。
 67        :returns: set[str] フィルタリングされたファイルの相対パスのセット。
 68        """
 69        file_set = set()
 70        for p in base_path.rglob(extension):
 71            # ベースディレクトリからの相対パスを取得し '/' 区切りに変換
 72            rel_path = p.relative_to(base_path).as_posix()
 73            if not is_excluded(rel_path):
 74                file_set.add(rel_path)
 75        return file_set
 76
 77    source_files = get_filtered_files(source_path)
 78    target_files = get_filtered_files(target_path)
 79
 80    print(f"{'='*60}")
 81    print(f" 比較レポート (Separator: '/')")
 82    print(f"{'='*60}")
 83    print(f"・[A] Source: {source_path.absolute()}")
 84    print(f"・[B] Target: {target_path.absolute()}")
 85    print(f"{'-'*60}\n")
 86
 87    # AにあってBにないもの
 88    only_in_source = sorted(source_files - target_files)
 89    print(f"【 状態: Source [A] にのみ存在 / Target [B] から欠落 】")
 90    if only_in_source:
 91        for f in only_in_source:
 92            print(f"  [MISSING in B] -> {f}")
 93    else:
 94        print("  該当なし")
 95
 96    print("\n" + "-"*60 + "\n")
 97
 98    # BにあってAにないもの
 99    only_in_target = sorted(target_files - source_files)
100    print(f"【 状態: Target [B] にのみ存在 / Source [A] から欠落 】")
101    if only_in_target:
102        for f in only_in_target:
103            print(f"  [MISSING in A] -> {f}")
104    else:
105        print("  該当なし")
106
107def main():
108    """
109    コマンドライン引数を解析し、ディレクトリ比較処理を実行する。
110
111    この関数は ``argparse`` を使用して、比較元ディレクトリ、比較先ディレクトリ、
112    検索拡張子パターンを引数として受け取ります。
113    デフォルト値も設定されており、指定されたパスが有効なディレクトリであるかを確認し、
114    ``compare_directories`` 関数を呼び出します。
115
116    :param None: コマンドライン引数は ``argparse`` によって処理されるため、明示的な引数はない。
117    :returns: None 処理結果は標準出力に表示される。
118    """
119    parser = argparse.ArgumentParser(description="特定のディレクトリやファイルパターンを除外して2つのツリーを比較します。")
120    
121    parser.add_argument("source", 
122                        nargs="?",
123                        default=r"D:\git\tkProg\tkprog_COE",
124                        help="比較元ディレクトリ(A)")
125    parser.add_argument("target", 
126                        nargs="?",
127                        default=r"D:\git\sphinx\tkProg\source",
128                        help="比較先ディレクトリ(B)")
129    parser.add_argument("-e", "--ext", 
130                        default="*.py", 
131                        help="検索パターン (デフォルト: *.py)")
132
133    args = parser.parse_args()
134
135    if not os.path.isdir(args.source) or not os.path.isdir(args.target):
136        print("エラー: 指定されたパスが存在しないか、ディレクトリではありません。")
137        return
138
139    compare_directories(args.source, args.target, args.ext)
140
141if __name__ == "__main__":
142    main()