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