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