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

merge_text_files.py をダウンロード

merge_text_files.py
merge_text_files.py
  1#!/usr/bin/env python3
  2# merge_text_files.py
  3"""
  4概要:
  5    指定ディレクトリ内のテキストファイルを検索し、メタデータを付与して1つのファイルに結合します。
  6詳細説明:
  7    拡張子やパターンでファイルを絞り込み、指定した階層まで再帰的に検索することが可能です。
  8    出力ファイルには各ファイルの内容に加えて、パスやファイル種別の情報がヘッダとして記録されます。
  9関連リンク:
 10    merge_text_files_usage
 11"""
 12
 13from __future__ import annotations
 14
 15import argparse
 16import fnmatch
 17from pathlib import Path
 18
 19
 20"""
 21python merge_text_files.py C:\work\tkProg -o launcher_docs_merged.txt -r -d 5 -p "*.txt" "*.md"
 22python merge_text_files.py C:\work\tkProg -o launcher_docs_merged.txt -r -d 5 -p "*.txt;*.md"
 23
 24"""
 25
 26
 27FILE_START = "===== FILE START ====="
 28CONTENT_START = "===== CONTENT ====="
 29FILE_END = "===== FILE END ====="
 30
 31
 32def guess_type(path: Path) -> str:
 33    """
 34    概要:
 35        ファイルパスの拡張子からファイル種別を判定します。
 36    引数:
 37        :param path: 判定するファイルのパス。
 38        :type path: pathlib.Path
 39    戻り値:
 40        :returns: ファイル種別を表す文字列。
 41        :rtype: str
 42    """
 43    ext = path.suffix.lower()
 44    if ext == ".py":
 45        return "python"
 46    if ext == ".ini":
 47        return "ini"
 48    if ext == ".md":
 49        return "md"
 50    if ext == ".txt":
 51        return "txt"
 52    return ext.lstrip(".") or "unknown"
 53
 54
 55def split_patterns(patterns: list[str]) -> list[str]:
 56    """
 57    概要:
 58        セミコロン区切りのパターン文字列を展開し、個別のパターンのリストを作成します。
 59    引数:
 60        :param patterns: 分割前のパターン文字列のリスト。
 61        :type patterns: list[str]
 62    戻り値:
 63        :returns: セミコロンで分割された個別のパターン文字列のリスト。
 64        :rtype: list[str]
 65    """
 66    result: list[str] = []
 67    for item in patterns:
 68        for part in item.split(";"):
 69            part = part.strip()
 70            if part:
 71                result.append(part)
 72    return result
 73
 74
 75def is_within_depth(path: Path, root: Path, max_depth: int) -> bool:
 76    """
 77    概要:
 78        対象ファイルが指定された最大階層の範囲内にあるか判定します。
 79    詳細説明:
 80        max_depth がマイナスの場合は常に真を返します。
 81    引数:
 82        :param path: 判定対象のファイルパス。
 83        :type path: pathlib.Path
 84        :param root: 基準となるルートディレクトリのパス。
 85        :type root: pathlib.Path
 86        :param max_depth: 許可される最大階層。
 87        :type max_depth: int
 88    戻り値:
 89        :returns: 範囲内の場合は真、それ以外は偽。
 90        :rtype: bool
 91    """
 92    if max_depth < 0:
 93        return True
 94
 95    rel = path.relative_to(root)
 96    depth = len(rel.parts) - 1
 97    return depth <= max_depth
 98
 99
100def match_any(path: Path, root: Path, patterns: list[str]) -> bool:
101    """
102    概要:
103        ファイルのパスがいずれかのパターンに一致するか判定します。
104    詳細説明:
105        ファイル名のみ、またはルートディレクトリからの相対パスに対してマッチングを行います。
106    引数:
107        :param path: 判定対象のファイルパス。
108        :type path: pathlib.Path
109        :param root: 基準となるルートディレクトリのパス。
110        :type root: pathlib.Path
111        :param patterns: マッチングに使用するワイルドカードパターンのリスト。
112        :type patterns: list[str]
113    戻り値:
114        :returns: いずれかのパターンに一致した場合は真、それ以外は偽。
115        :rtype: bool
116    """
117    rel_posix = path.relative_to(root).as_posix()
118    name = path.name
119
120    return any(
121        fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(rel_posix, pat)
122        for pat in patterns
123    )
124
125
126def collect_files(
127    root: Path,
128    patterns: list[str],
129    recursive: bool,
130    max_depth: int,
131    output_path: Path | None,
132) -> list[Path]:
133    """
134    概要:
135        指定された条件に基づき、対象となるファイルを収集します。
136    詳細説明:
137        ルートディレクトリ以下を検索し、階層やパターンに一致するファイルのリストを返します。
138        出力ファイル自身のパスが含まれる場合は除外します。
139    引数:
140        :param root: 検索を開始するルートディレクトリのパス。
141        :type root: pathlib.Path
142        :param patterns: 検索対象となるファイル名やパスのパターンリスト。
143        :type patterns: list[str]
144        :param recursive: サブディレクトリを再帰的に検索するかどうか。
145        :type recursive: bool
146        :param max_depth: 検索する最大階層。
147        :type max_depth: int
148        :param output_path: 除外対象となる出力ファイルのパス。
149        :type output_path: pathlib.Path | None
150    戻り値:
151        :returns: 条件に一致したファイルパスのソート済みリスト。
152        :rtype: list[pathlib.Path]
153    """
154    iterator = root.rglob("*") if recursive else root.glob("*")
155
156    files: list[Path] = []
157    for path in iterator:
158        if not path.is_file():
159            continue
160
161        if output_path is not None:
162            try:
163                if path.resolve() == output_path.resolve():
164                    continue
165            except FileNotFoundError:
166                pass
167
168        if not is_within_depth(path, root, max_depth):
169            continue
170
171        if match_any(path, root, patterns):
172            files.append(path)
173
174    return sorted(files, key=lambda p: p.relative_to(root).as_posix().lower())
175
176
177def read_text_file(path: Path, encoding: str) -> str:
178    """
179    概要:
180        指定されたエンコーディングでテキストファイルを読み込みます。
181    詳細説明:
182        デコードエラーが発生した場合は utf-8-sig で代替文字を使って読み直します。
183    引数:
184        :param path: 読み込むファイルのパス。
185        :type path: pathlib.Path
186        :param encoding: 初回に使用するテキストのエンコーディング。
187        :type encoding: str
188    戻り値:
189        :returns: ファイルから読み込んだテキストデータ。
190        :rtype: str
191    """
192    try:
193        return path.read_text(encoding=encoding)
194    except UnicodeDecodeError:
195        return path.read_text(encoding="utf-8-sig", errors="replace")
196
197
198def merge_files(
199    root: Path,
200    files: list[Path],
201    output_path: Path,
202    encoding: str,
203    blank_lines: int,
204) -> None:
205    """
206    概要:
207        収集したファイルを読み込み、メタデータを付加して1つのファイルに結合します。
208    詳細説明:
209        各ファイルの内容は開始と終了の区切り文字列で囲まれ、指定した空行数で区切られます。
210    引数:
211        :param root: ファイルの相対パスを計算するためのルートディレクトリ。
212        :type root: pathlib.Path
213        :param files: 結合対象となるファイルパスのリスト。
214        :type files: list[pathlib.Path]
215        :param output_path: 結合結果を書き込む出力ファイルのパス。
216        :type output_path: pathlib.Path
217        :param encoding: 読み書きに使用するテキストのエンコーディング。
218        :type encoding: str
219        :param blank_lines: ファイル間に挿入する空行の数。
220        :type blank_lines: int
221    戻り値:
222        :returns: なし。
223        :rtype: None
224    """
225    separator = "\n" * blank_lines
226
227    with output_path.open("w", encoding=encoding, newline="\n") as out:
228        for i, path in enumerate(files):
229            rel_path = path.relative_to(root).as_posix()
230            file_type = guess_type(path)
231            text = read_text_file(path, encoding)
232
233            if i > 0:
234                out.write(separator)
235
236            out.write(f"{FILE_START}\n")
237            out.write(f"path: {rel_path}\n")
238            out.write(f"type: {file_type}\n")
239            out.write(f"tags: []\n")
240            out.write(f"{CONTENT_START}\n")
241            out.write(text)
242
243            if text and not text.endswith("\n"):
244                out.write("\n")
245
246            out.write(f"{FILE_END}\n")
247
248
249def main() -> None:
250    """
251    概要:
252        コマンドライン引数を解析し、ファイルの検索と結合処理を実行します。
253    詳細説明:
254        引数で指定されたルートディレクトリやパターンをもとにファイルを収集し、結合して出力します。
255        引数のチェックを行い、条件を満たさない場合は例外を発生させます。
256    戻り値:
257        :returns: なし。
258        :rtype: None
259    例外:
260        :raises FileNotFoundError: ルートディレクトリが存在しない場合。
261        :raises NotADirectoryError: ルートパスがディレクトリではない場合。
262        :raises ValueError: blank_lines オプションに5未満が指定された場合。
263    """
264    parser = argparse.ArgumentParser(
265        description="Merge .txt/.md/etc files with path metadata."
266    )
267
268    parser.add_argument(
269        "root",
270        help="Root directory to search.",
271    )
272
273    parser.add_argument(
274        "-o",
275        "--output",
276        default="merged_files.txt",
277        help="Output merged file path. Default: merged_files.txt",
278    )
279
280    parser.add_argument(
281        "-p",
282        "--patterns",
283        nargs="+",
284        default=["*.txt", "*.md"],
285        help='Wildcard patterns. Example: -p "*.txt" "*.md" or -p "*.txt;*.md"',
286    )
287
288    parser.add_argument(
289        "-r",
290        "--recursive",
291        action="store_true",
292        help="Search recursively.",
293    )
294
295    parser.add_argument(
296        "-d",
297        "--max-depth",
298        type=int,
299        default=-1,
300        help="Maximum directory depth from root. -1 means unlimited. Default: -1",
301    )
302
303    parser.add_argument(
304        "--encoding",
305        default="utf-8",
306        help="Text encoding for input/output. Default: utf-8",
307    )
308
309    parser.add_argument(
310        "--blank-lines",
311        type=int,
312        default=5,
313        help="Blank lines between FILE END and next FILE START. Default: 5",
314    )
315
316    args = parser.parse_args()
317
318    root = Path(args.root).expanduser().resolve()
319    output_path = Path(args.output).expanduser().resolve()
320    patterns = split_patterns(args.patterns)
321
322    if not root.exists():
323        raise FileNotFoundError(f"Root directory not found: {root}")
324
325    if not root.is_dir():
326        raise NotADirectoryError(f"Root is not a directory: {root}")
327
328    if args.blank_lines < 5:
329        raise ValueError("--blank-lines must be 5 or more.")
330
331    files = collect_files(
332        root=root,
333        patterns=patterns,
334        recursive=args.recursive,
335        max_depth=args.max_depth,
336        output_path=output_path,
337    )
338
339    output_path.parent.mkdir(parents=True, exist_ok=True)
340
341    merge_files(
342        root=root,
343        files=files,
344        output_path=output_path,
345        encoding=args.encoding,
346        blank_lines=args.blank_lines,
347    )
348
349    print(f"Merged {len(files)} files")
350    print(f"Output: {output_path}")
351
352
353if __name__ == "__main__":
354    main()
355    input("\nPress ENTER to terminate>>\n")