Files
script_dev/compare_php/compare_php_folders.py

324 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
複数フォルダ配下の PHP ファイルを比較し、A/B/C... の内容グループで Excel に結果を書き出すスクリプト。
- フォルダ一覧はテキストファイル1行: 「ラベル:/absolute/path」 形式。#始まりと空行は無視)
- 比較キーは既定で「ファイル名のみ」(--key name)。相対パスで比較する場合は --key relative。
- 改行コード差CRLF/LFは既定で無視 (--keep-eol を付けると無視しない)。
- --ignore-whitespace で空白/タブ/改行を全無視して比較。
- NEW: --ignore-comments で PHP コメント(//, #, /* */, /** */)を除去して比較。
- 進捗は「n/m 個目のフォルダ」「現在のファイル」を改行なしで表示。
- 出力: Excel (結果シート + 必要に応じて重複シート)
"""
import argparse
import os
from pathlib import Path
import hashlib
import re
from collections import defaultdict, OrderedDict
from typing import Dict, List, Tuple, Optional
from openpyxl import Workbook
from openpyxl.utils import get_column_letter
# -----------------------------
# 入力folders.txt読み込み
# -----------------------------
def read_folder_list(list_file: Path):
"""
folders.txt の形式:
ラベル:/absolute/path/to/folder
を読み込み、[(label, Path), ...] のリストを返す
"""
result = []
for line in list_file.read_text(encoding="utf-8").splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
if ":" not in s:
raise ValueError(f"ラベル:フォルダパス の形式で記述してください → {s}")
label, folder = s.split(":", 1)
label = label.strip()
folder = folder.strip()
p = Path(folder).expanduser().resolve()
if not p.exists() or not p.is_dir():
raise FileNotFoundError(f"フォルダが存在しません: {folder}")
result.append((label, p))
if not result:
raise ValueError("フォルダ一覧が空です。")
return result
# -----------------------------
# ファイル列挙
# -----------------------------
def iter_php_files(root: Path, recursive: bool = True):
"""root 配下の .php を列挙。戻り値: (ファイル名, 相対フォルダパス, 相対パス, 絶対パス)"""
if recursive:
for dirpath, _, filenames in os.walk(root):
dirpath_p = Path(dirpath)
for fn in filenames:
if fn.lower().endswith(".php"):
rel_path = dirpath_p.relative_to(root) / fn if dirpath_p != root else Path(fn)
rel_dir = rel_path.parent.as_posix() if str(rel_path) != fn else ""
yield (fn, rel_dir, rel_path.as_posix(), (dirpath_p / fn))
else:
for fn in os.listdir(root):
if fn.lower().endswith(".php"):
yield (fn, "", fn, (root / fn))
# -----------------------------
# コメント除去(--ignore-comments
# -----------------------------
_COMMENT_BLOCK = re.compile(r"/\*.*?\*/", flags=re.DOTALL) # /* ... */ と /** ... */
_COMMENT_SLASH_SLASH = re.compile(r"//.*?$", flags=re.MULTILINE) # // ...
_COMMENT_HASH = re.compile(r"#.*?$", flags=re.MULTILINE) # # ...
def strip_php_comments_from_bytes(b: bytes) -> bytes:
"""
PHP のコメント(//, #, /* */, /** */)を削除。
- バイナリ→latin1文字列で無損失に扱い、正規表現適用後に再度 bytes 化
- PHP タグ外のテキストでもパターンに合致すれば削除されます(必要に応じ高度化可能)
"""
s = b.decode("latin1")
s = _COMMENT_BLOCK.sub("", s)
s = _COMMENT_SLASH_SLASH.sub("", s)
s = _COMMENT_HASH.sub("", s)
return s.encode("latin1")
# -----------------------------
# 正規化EOL/空白/コメント)
# -----------------------------
def normalize_bytes_for_hash(
data: bytes,
keep_eol: bool,
ignore_whitespace: bool,
ignore_comments: bool,
) -> bytes:
"""比較前のバイト列正規化"""
b = data
# コメント無視先に行うことで、その後のEOL/空白処理の影響も受ける)
if ignore_comments:
try:
b = strip_php_comments_from_bytes(b)
except Exception:
# コメ除去中に失敗しても比較を止めない
pass
if not keep_eol:
# CRLF/LF/CR の差異を LF に正規化
b = b.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
if ignore_whitespace:
# 全ホワイトスペースを除去空白・タブ・LF
table = {ord(' '): None, ord('\t'): None, ord('\n'): None, ord('\r'): None}
s = b.decode('latin1') # バイト値を壊さないダミーエンコーディング
s = s.translate(table)
b = s.encode('latin1')
return b
def file_hash(
path: Path,
keep_eol: bool,
ignore_whitespace: bool,
ignore_comments: bool,
) -> str:
with path.open("rb") as f:
data = f.read()
data = normalize_bytes_for_hash(
data,
keep_eol=keep_eol,
ignore_whitespace=ignore_whitespace,
ignore_comments=ignore_comments,
)
return hashlib.sha256(data).hexdigest()
# -----------------------------
# ユーティリティ
# -----------------------------
def excel_letter_index(n: int) -> str:
"""
0 -> 'A', 1 -> 'B', ..., 25 -> 'Z', 26 -> 'AA' ... のようにグループ記号を生成
Excel列名と同様のロジック
"""
s = []
n += 1
while n > 0:
n, r = divmod(n - 1, 26)
s.append(chr(65 + r))
return "".join(reversed(s))
def auto_width(ws):
col_widths = defaultdict(int)
for row in ws.iter_rows(values_only=True):
for i, val in enumerate(row, start=1):
if val is None:
continue
length = len(str(val))
if length > col_widths[i]:
col_widths[i] = length
for i, w in col_widths.items():
ws.column_dimensions[get_column_letter(i)].width = min(max(w + 2, 10), 80)
# -----------------------------
# メイン
# -----------------------------
def main():
parser = argparse.ArgumentParser(description="複数フォルダの PHP ファイルを内容比較して Excel に出力します。")
parser.add_argument("--list", required=True, help="フォルダ一覧テキストファイルのパス1行: ラベル:/path")
parser.add_argument("--output", default="php_compare.xlsx", help="出力Excelファイル名既定: php_compare.xlsx")
parser.add_argument("--key", choices=["name", "relative"], default="name",
help="比較キー: name=ファイル名, relative=相対パス(既定: name")
parser.add_argument("--no-recursive", action="store_true", help="再帰を無効にして直下のみを対象")
parser.add_argument("--keep-eol", action="store_true", help="改行コードの違いを無視しない(厳密一致)")
parser.add_argument("--ignore-whitespace", action="store_true", help="空白/タブ/改行等のホワイトスペースを無視して比較")
parser.add_argument("--ignore-comments", action="store_true", help="PHPコメント//, #, /* */, /** */)を無視して比較")
args = parser.parse_args()
list_file = Path(args.list)
out_path = Path(args.output).resolve()
folder_items = read_folder_list(list_file)
labels = [item[0] for item in folder_items]
folders = [item[1] for item in folder_items]
recursive = not args.no_recursive
# 各フォルダごと: key -> (abs_path, rel_dir, file_name)
per_folder_maps: List[Dict[str, Tuple[Path, str, str]]] = []
duplicate_report: List[Tuple[int, str, List[str]]] = [] # (folder_idx, key, [abs paths])
total_folders = len(folders)
print(f"対象フォルダ数: {total_folders}")
# フォルダ走査 & ハッシュ計算
per_folder_hashes: List[Dict[str, str]] = [] # key -> hash
for idx, root in enumerate(folders, start=1):
mapping: Dict[str, Tuple[Path, str, str]] = {}
key_to_abslist: Dict[str, List[str]] = defaultdict(list)
files = list(iter_php_files(root, recursive=recursive))
total_files = len(files)
for j, (fn, rel_dir, rel_path, abs_path) in enumerate(files, start=1):
# 進捗を改行せずに出力
print(f"\r[{idx}/{total_folders}] {j}/{total_files} {fn}", end="", flush=True)
key = fn if args.key == "name" else rel_path
key_to_abslist[key].append(str(abs_path))
# 代表(最初に出会ったもの)を保持
if key not in mapping:
mapping[key] = (abs_path, rel_dir, fn)
# 重複(同一フォルダで同じ key が複数)を記録
for key, lst in key_to_abslist.items():
if len(lst) > 1:
duplicate_report.append((idx, key, lst))
# ハッシュ計算
key_hash: Dict[str, str] = {}
for key, (abs_path, _rel_dir, _fn) in mapping.items():
try:
h = file_hash(
abs_path,
keep_eol=args.keep_eol,
ignore_whitespace=args.ignore_whitespace,
ignore_comments=args.ignore_comments,
)
except Exception as e:
h = f"__ERROR__:{e}"
key_hash[key] = h
per_folder_maps.append(mapping)
per_folder_hashes.append(key_hash)
print("") # フォルダ行終端
# 全キー(ファイル名 or 相対パス)のユニオン
all_keys: List[str] = sorted(set().union(*[set(m.keys()) for m in per_folder_maps]))
# Excel ワークブック作成
wb = Workbook()
ws = wb.active
ws.title = "結果"
# ヘッダ行
headers = ["フォルダパス", "ファイル名"]
for label in labels:
headers.append(label)
ws.append(headers)
# 各キー(=行)ごとに A/B/C... を割り当て
for key in all_keys:
group_map: "OrderedDict[str, str]" = OrderedDict() # hash -> letter
next_group_idx = 0
# 代表(最初に見つかった場所の情報)
rep_rel_dir: Optional[str] = None
rep_file_name: Optional[str] = None
for m in per_folder_maps:
if key in m:
_abs, rel_dir, fn = m[key]
rep_rel_dir = rel_dir if rel_dir else ""
rep_file_name = fn
break
if rep_rel_dir is None:
rep_rel_dir = ""
if rep_file_name is None:
rep_file_name = key if args.key == "name" else Path(key).name
row = [rep_rel_dir, rep_file_name]
# 各フォルダ列
for (m, hmap) in zip(per_folder_maps, per_folder_hashes):
if key not in m:
row.append("")
continue
h = hmap.get(key)
if h is None:
row.append("")
continue
if h.startswith("__ERROR__"):
row.append("ERR")
continue
if h not in group_map:
group_map[h] = excel_letter_index(next_group_idx)
next_group_idx += 1
row.append(group_map[h])
ws.append(row)
# 重複シート(同一フォルダ内で同名 or 同相対パスが複数ある)
if duplicate_report:
ws2 = wb.create_sheet(title="重複")
ws2.append(["フォルダ番号(1)", "キーname/relative", "該当ファイル(絶対パス)"])
for folder_idx, key, paths in duplicate_report:
ws2.append([folder_idx, key, paths[0]])
for p in paths[1:]:
ws2.append(["", "", p])
auto_width(ws2)
auto_width(ws)
wb.save(out_path)
print(f"完了: {out_path}")
if __name__ == "__main__":
main()