compare_update_diff.py ダウンロード/コピー
compare_update_diff.py をダウンロード
compare_update_diff.py
compare_update_diff.py
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3
4"""
5概要:
6 2つのディレクトリツリーを再帰的に比較し、更新対象のファイルを抽出します。
7
8詳細説明:
9 以下のルールでファイルを判定します。
10 ファイルが root_dir1 にのみ存在する場合は created として処理されます。
11 ファイルが両方のディレクトリに存在し、root_dir1 の方が新しい場合は updated として処理されます。
12 ファイルが root_dir2 にのみ存在する場合は deleted として処理されます。
13 それ以外は報告されません。
14
15 filemask 引数には、複数のワイルドカードマスクをセミコロン区切りで指定できます。
16
17 引数の順序は、最初の位置引数が古い基準ディレクトリ、2番目の位置引数が新しい現在のディレクトリとなります。
18"""
19
20import argparse
21import difflib
22import fnmatch
23import os
24import sys
25import time
26from datetime import datetime
27from pathlib import Path
28
29
30VERSION = "2026.09.07-1"
31
32
33def parse_masks(filemask):
34 """
35 概要:
36 セミコロンで区切られたマスク文字列を分割します。
37
38 引数:
39 :param filemask: セミコロン区切りのマスク文字列
40 :type filemask: str
41
42 戻り値:
43 :returns: 分割されたマスク文字列のリスト
44 :rtype: list
45 """
46 masks = [m.strip() for m in filemask.split(";") if m.strip()]
47 return masks or ["*.py"]
48
49
50def matches_mask(path, root, masks):
51 """
52 概要:
53 ベース名と相対POSIXパスの両方でマスクと照合します。
54
55 詳細説明:
56 単純なファイル名マスクだけでなく、ディレクトリを含むマスクもサポートします。
57
58 引数:
59 :param path: 判定対象のファイルパス
60 :type path: pathlib.Path
61 :param root: 基準となるルートディレクトリ
62 :type root: pathlib.Path
63 :param masks: 照合に使用するマスク文字列のリスト
64 :type masks: list
65
66 戻り値:
67 :returns: いずれかのマスクに一致した場合はTrue、それ以外はFalse
68 :rtype: bool
69 """
70 rel = path.relative_to(root).as_posix()
71 name = path.name
72
73 return any(
74 fnmatch.fnmatch(name, mask) or fnmatch.fnmatch(rel, mask)
75 for mask in masks
76 )
77
78
79def collect_files(root, masks, label="root", progress_interval=1.0):
80 """
81 概要:
82 条件に一致するファイルの相対パスと絶対パスのマッピングを取得します。
83
84 詳細説明:
85 スキャン中、遅いローカルやネットワーク環境でも処理が停止しているように見えないよう、
86 定期的に進捗状況を出力します。
87
88 引数:
89 :param root: 検索を開始するルートディレクトリ
90 :type root: str or pathlib.Path
91 :param masks: 照合に使用するマスク文字列のリスト
92 :type masks: list
93 :param label: 進捗出力に用いるラベル文字列
94 :type label: str
95 :param progress_interval: 進捗を出力する秒間隔
96 :type progress_interval: float
97
98 戻り値:
99 :returns: 相対パスをキー、絶対パスを値とする辞書
100 :rtype: dict
101 """
102 files = {}
103 n_scanned = 0
104 last_progress = time.monotonic()
105
106 print(f"Scanning {label}: {root}", flush=True)
107
108 for dirpath, dirnames, filenames in os.walk(root):
109 base = Path(dirpath)
110
111 for filename in filenames:
112 n_scanned += 1
113 path = base / filename
114
115 if matches_mask(path, root, masks):
116 rel = path.relative_to(root).as_posix()
117 files[rel] = path
118
119 if progress_interval > 0:
120 now = time.monotonic()
121 if now - last_progress >= progress_interval:
122 try:
123 current = base.relative_to(root)
124 current_text = "." if str(current) == "." else str(current)
125 except ValueError:
126 current_text = str(base)
127
128 print(
129 f" {label}: scanned={n_scanned}, matched={len(files)}, "
130 f"current={current_text}",
131 flush=True,
132 )
133 last_progress = now
134
135 print(
136 f" {label}: done, scanned={n_scanned}, matched={len(files)}",
137 flush=True,
138 )
139 return files
140
141
142def fmt_date(timestamp):
143 """
144 概要:
145 タイムスタンプを YYYY/M/D 形式でフォーマットします。
146
147 引数:
148 :param timestamp: フォーマット対象のタイムスタンプ
149 :type timestamp: float
150
151 戻り値:
152 :returns: フォーマットされた日付文字列
153 :rtype: str
154 """
155 dt = datetime.fromtimestamp(timestamp)
156 return f"{dt.year}/{dt.month}/{dt.day}"
157
158
159def fmt_datetime(timestamp):
160 """
161 概要:
162 タイムスタンプを YYYY/M/D H:MM:SS 形式でフォーマットします。
163
164 引数:
165 :param timestamp: フォーマット対象のタイムスタンプ
166 :type timestamp: float
167
168 戻り値:
169 :returns: フォーマットされた日時文字列
170 :rtype: str
171 """
172 dt = datetime.fromtimestamp(timestamp)
173 return f"{dt.year}/{dt.month}/{dt.day} {dt.hour}:{dt.minute:02d}:{dt.second:02d}"
174
175
176def latest_mtime(files):
177 """
178 概要:
179 指定されたファイル群の中で最も新しい更新日時を返します。
180
181 引数:
182 :param files: パス情報を値として持つ辞書
183 :type files: dict
184
185 戻り値:
186 :returns: 最新のタイムスタンプ値、ファイルがない場合はNone
187 :rtype: float or None
188 """
189 if not files:
190 return None
191 return max(path.stat().st_mtime for path in files.values())
192
193
194
195def read_text_lines(path):
196 """
197 概要:
198 ソースやテキストファイルを読み込み、行のリストを返します。
199
200 詳細説明:
201 UTF-8およびcp932でデコードを試みます。どちらも失敗した場合は、
202 差分生成を継続できるように置換文字を使用したUTF-8で読み込みます。
203
204 引数:
205 :param path: 読み込むファイルのパス
206 :type path: str or pathlib.Path
207
208 戻り値:
209 :returns: 読み込んだ行のリストと使用されたエンコーディングのタプル
210 :rtype: tuple
211 """
212 encodings = ("utf-8-sig", "utf-8", "cp932")
213
214 for enc in encodings:
215 try:
216 # Use universal newline translation (newline=None, the default),
217 # so CRLF/LF differences alone are not treated as text changes.
218 with open(path, "r", encoding=enc) as f:
219 return f.readlines(), enc
220 except UnicodeDecodeError:
221 pass
222
223 with open(path, "r", encoding="utf-8", errors="replace") as f:
224 return f.readlines(), "utf-8(replace)"
225
226
227def make_compact_diff(old_path, new_path, context=2):
228 """
229 概要:
230 コンソールやログ出力用のコンパクトなテキスト差分を生成します。
231
232 詳細説明:
233 変更された行とわずかなコンテキストのみを保持します。
234 unified diff の管理行は含まれず、変更箇所は区切り文字で分けられます。
235
236 引数:
237 :param old_path: 比較元のファイルパス
238 :type old_path: pathlib.Path
239 :param new_path: 比較先のファイルパス
240 :type new_path: pathlib.Path
241 :param context: 保持するコンテキストの行数
242 :type context: int
243
244 戻り値:
245 :returns: コンパクトにフォーマットされた差分の文字列リスト
246 :rtype: list
247 """
248 old_lines, _ = read_text_lines(old_path)
249 new_lines, _ = read_text_lines(new_path)
250
251 diff_lines = list(
252 difflib.unified_diff(
253 old_lines,
254 new_lines,
255 fromfile="old",
256 tofile="new",
257 n=max(0, int(context)),
258 lineterm="\n",
259 )
260 )
261
262 compact = []
263 seen_hunk = False
264
265 for line in diff_lines:
266 if line.startswith("--- ") or line.startswith("+++ "):
267 continue
268
269 if line.startswith("@@ "):
270 if seen_hunk and compact and compact[-1] != "...":
271 compact.append("...")
272 seen_hunk = True
273 continue
274
275 if line.startswith("\\ No newline at end of file"):
276 continue
277
278 # unified_diff body consists of:
279 # + added line
280 # - deleted line
281 # unchanged context line
282 if line.endswith("\n"):
283 line = line[:-1]
284
285 compact.append(line)
286
287 # Remove unnecessary leading/trailing separators or blank lines.
288 while compact and compact[0] == "...":
289 compact.pop(0)
290 while compact and compact[-1] == "...":
291 compact.pop()
292
293 return compact
294
295
296def compare_trees(
297 root_dir1,
298 root_dir2,
299 masks,
300 progress_interval=1.0,
301 show_diff=True,
302 diff_context=2,
303):
304 """
305 概要:
306 2つのディレクトリツリーを比較し、ファイルの作成、更新、削除の情報を抽出します。
307
308 引数:
309 :param root_dir1: 新しいディレクトリツリーのルート
310 :type root_dir1: pathlib.Path
311 :param root_dir2: 古いディレクトリツリーのルート
312 :type root_dir2: pathlib.Path
313 :param masks: 照合に使用するマスク文字列のリスト
314 :type masks: list
315 :param progress_interval: 進捗表示を行う間隔
316 :type progress_interval: float
317 :param show_diff: 差分を出力するかどうかのフラグ
318 :type show_diff: bool
319 :param diff_context: 差分出力時に保持するコンテキスト行数
320 :type diff_context: int
321
322 戻り値:
323 :returns: 出力メッセージのリストと各ツリーのマッチしたファイル数のタプル
324 :rtype: tuple
325 """
326 files1 = collect_files(
327 root_dir1, masks, label="root_dir1",
328 progress_interval=progress_interval
329 )
330 files2 = collect_files(
331 root_dir2, masks, label="root_dir2",
332 progress_interval=progress_interval
333 )
334
335 print(f"Comparing {len(set(files1) | set(files2))} relative paths...", flush=True)
336
337 messages = []
338 all_relpaths = sorted(set(files1) | set(files2))
339
340 for rel in all_relpaths:
341 in1 = rel in files1
342 in2 = rel in files2
343
344 if in1 and not in2:
345 t1 = files1[rel].stat().st_mtime
346 messages.append(
347 f"{rel}: created (new file updated on {fmt_date(t1)})"
348 )
349
350 # created files are reported only as newly created files.
351 # unified diff output is intentionally skipped.
352
353 elif in1 and in2:
354 t1 = files1[rel].stat().st_mtime
355 t2 = files2[rel].stat().st_mtime
356
357 if t1 > t2:
358 messages.append(
359 f"{rel}: updated (updated on {fmt_date(t1)})"
360 )
361
362 if show_diff:
363 diff_lines = make_compact_diff(
364 files2[rel],
365 files1[rel],
366 context=diff_context,
367 )
368
369 if diff_lines:
370 messages.extend(diff_lines)
371 else:
372 messages.append(
373 "(timestamp is newer, but no text difference was found)"
374 )
375 messages.append("")
376
377 elif not in1 and in2:
378 t2 = files2[rel].stat().st_mtime
379 messages.append(
380 f"{rel}: deleted (old file updated on {fmt_date(t2)})"
381 )
382
383 # deleted files are reported only as removed files.
384 # unified diff output is intentionally skipped.
385
386 latest1 = latest_mtime(files1)
387 latest2 = latest_mtime(files2)
388
389 messages.append("---")
390
391 if latest1 is None:
392 messages.append("root_dir1 has no matching files")
393 else:
394 messages.append(
395 f"root_dir1 was lastly updated on {fmt_datetime(latest1)}"
396 )
397
398 if latest2 is None:
399 messages.append("root_dir2 has no matching files")
400 else:
401 messages.append(
402 f"root_dir2 was lastly updated on {fmt_datetime(latest2)}"
403 )
404
405 return messages, len(files1), len(files2)
406
407def main():
408 """
409 概要:
410 コマンドライン引数を解析し、ディレクトリの比較処理を実行します。
411 """
412 # Handle --version before argparse checks required positional arguments.
413 if "--version" in sys.argv[1:]:
414 print(f"{Path(sys.argv[0]).name} {VERSION}")
415 print(f"program: {Path(__file__).resolve()}")
416 return
417
418 parser = argparse.ArgumentParser(
419 description=(
420 "Recursively compare OLD_DIR and NEW_DIR, and report files that "
421 "are created, updated, or deleted in NEW_DIR."
422 )
423 )
424
425 parser.add_argument(
426 "old_dir",
427 help="Old/reference directory tree"
428 )
429 parser.add_argument(
430 "new_dir",
431 help="New/current directory tree"
432 )
433 parser.add_argument(
434 "--filemask", "-m",
435 default="*.py",
436 help='Wildcard mask(s), separated by ";" (default: "*.py")'
437 )
438 parser.add_argument(
439 "--log", "-l",
440 default="update.log",
441 help='Output log file (default: "update.log")'
442 )
443 parser.add_argument(
444 "--progress",
445 type=float,
446 default=1.0,
447 help=(
448 "Progress display interval in seconds "
449 "(default: 1.0; use 0 to disable periodic progress)"
450 )
451 )
452 parser.add_argument(
453 "--no-diff",
454 action="store_true",
455 help="Do not output compact diff details"
456 )
457 parser.add_argument(
458 "--diff-context",
459 type=int,
460 default=2,
461 help=(
462 "Number of unchanged context lines around each diff hunk "
463 "(default: 2)"
464 )
465 )
466
467 args = parser.parse_args()
468
469 old_dir = Path(args.old_dir).expanduser().resolve()
470 new_dir = Path(args.new_dir).expanduser().resolve()
471 log_file = Path(args.log).expanduser()
472
473 if not old_dir.is_dir():
474 parser.error(f"old_dir is not a directory: {old_dir}")
475
476 if not new_dir.is_dir():
477 parser.error(f"new_dir is not a directory: {new_dir}")
478
479 # Keep the historical log/internal convention for compatibility with
480 # changelog_from_update_log.py:
481 # root_dir1 = new/current
482 # root_dir2 = old/reference
483 root_dir1 = new_dir
484 root_dir2 = old_dir
485
486 masks = parse_masks(args.filemask)
487
488 messages, n1, n2 = compare_trees(
489 root_dir1,
490 root_dir2,
491 masks,
492 progress_interval=max(0.0, args.progress),
493 show_diff=not args.no_diff,
494 diff_context=max(0, args.diff_context),
495 )
496
497 header = [
498 f"program : {Path(__file__).resolve()}",
499 f"version : {VERSION}",
500 f"old_dir : {old_dir}",
501 f"new_dir : {new_dir}",
502 # Compatibility fields used by changelog_from_update_log.py.
503 f"root_dir1: {root_dir1}",
504 f"root_dir2: {root_dir2}",
505 f"filemask : {';'.join(masks)}",
506 f"files : root_dir1={n1}, root_dir2={n2}",
507 f"diff : {'off' if args.no_diff else f'compact, context={max(0, args.diff_context)}'}",
508 "---",
509 ]
510
511 output = "\n".join(header + messages) + "\n"
512
513 # Console output
514 print(output, end="")
515
516 # Log output
517 log_file.parent.mkdir(parents=True, exist_ok=True)
518 log_file.write_text(output, encoding="utf-8")
519
520 print(f"\nLog saved to: {log_file.resolve()}")
521
522
523if __name__ == "__main__":
524 main()