0
1

シン・WinMergeでフォルダ差分を出してExcelにまとめるまでをPythonで自動化

Posted at

背景

これまで2回にわたり、Pythonを使ってWinMergeの比較結果を一発でExcelにまとめる、という記事を書いてきました。

スクリプトの説明と使い方は以下。

その改良版は以下。

普段、まだまだWinMergeのお世話になっています。

あれからまたしばらく経ちましたので、進捗をメモしておこうと思います。

やったこと

前回からの変更点と、できたスクリプトを先にご紹介します。

変更点

  • スクリプト実行時のメッセージ表示を強化
  • ブックが閉じられずメモリリークする問題を修正
  • 差分シートの追加列にオートフィルタを適用する
  • 設定のJSONファイル化

スクリプト

今回作ったPythonスクリプトです。ほぼ400行!

折りたたんでおきますので、必要な方は展開してからコピペして下さい。

★クリックで展開★ WinMergeの差分レポートをエクセルに出力するスクリプト
winmerge_xlsx.py
import sys
import os
import shutil
from pathlib import Path
import subprocess
import json
import win32com.client

VERSION = 'v1.0.0'

WINMERGE_EXE = r'C:\Program Files\WinMerge\WinMergeU.exe'  # WinMergeへのパス
WINMERGE_OPTIONS = [
    '/minimize',                             # ウィンドウ最小化で起動
    '/noninteractive',                       # レポート出力後に終了
    '/cfg',
    'Settings/DirViewExpandSubdirs=1',       # 自動的にサブフォルダーを展開する
    '/cfg',
    'Settings/ViewLineNumbers=1',            # 行番号を表示する
    '/cfg',
    'Settings/WordDifferenceTextColor=255',  # 差分単語を赤色で表示
    '/cfg',
    'ReportFiles/ReportType=2',              # シンプルなHTML形式
    '/cfg',
    'ReportFiles/IncludeFileCmpReport=1',    # ファイル比較レポートを含める
    '/r',                                    # すべてのサブフォルダ内のすべてのファイルを比較
    '/u',                                    # 最近使用した項目リストに追加しない
    '/or',                                   # レポートを出力
]

xlUp = -4162
xlToLeft = -4159
xlOpenXMLWorkbook = 51
xlCenter = -4108
xlContinuous = 1

SUMMARY_WS_NUM = 1        # 一覧シートのワークシート番号
SUMMARY_START_ROW = 6     # 一覧シートの表の開始行
SUMMARY_NAME_COL = 'A'    # 一覧シートの表の名前列
SUMMARY_FOLDER_COL = 'B'  # 一覧シートの表のフォルダー列

HOME_POSITION = 'A1'  # ホームポジション

DIFF_START_ROW = 2                                            # 差分シートの開始行
DIFF_ZOOM_RATIO = 85                                          # 差分シートのズームの倍率
DIFF_FORMATS = {                                              # 差分シートの書式設定
    'no': [                                                   # 行番号列
        {'col': 'A', 'width': 5},                             # 左側
        {'col': 'C', 'width': 5},                             # 右側
    ],
    'code': [                                                 # ソースコード列
        {'col': 'B', 'width': 100, 'font': 'MS ゴシック'},  # 左側
        {'col': 'D', 'width': 100, 'font': 'MS ゴシック'},  # 右側
    ],
    'extra': [                                                # 追加列
        {'col': 'E', 'width': 60, 'header': 'コメント'},
    ],
}


class WinMergeXlsx:
    """WinMergeの差分レポートをエクセルに出力
    """
    def __init__(self, base, latest, output='./output.xlsx'):
        self.base = Path(base).absolute()
        self.latest = Path(latest).absolute()
        self.output = Path(output).absolute()

        parent = str(self.output.parent)
        stem = str(self.output.stem)
        self.output_html = Path(parent + '/' + stem + '.html')
        self.output_html_files = Path(parent + '/' + stem + '.files')

        self.setting_json = './setting.json'
        if os.path.exists(self.setting_json):
            with open(self.setting_json, 'r') as f:
                json_load = json.load(f)
            if 'WINMERGE_EXE' in json_load:
                global WINMERGE_EXE
                WINMERGE_EXE = json_load['WINMERGE_EXE']
            if 'WINMERGE_OPTIONS' in json_load:
                global WINMERGE_OPTIONS
                WINMERGE_OPTIONS = json_load['WINMERGE_OPTIONS']
            if 'DIFF_FORMATS' in json_load:
                global DIFF_FORMATS
                DIFF_FORMATS = json_load['DIFF_FORMATS']

        self.sheet_memo = {}
        self.sheet_count = {}

    def generate(self):
        """レポート生成
        """
        self._setup()
        self._generate_html_by_winmerge()
        self._convert_html_to_xlsx()

    def _setup(self):
        """準備
        """
        self._setup_excel_application()
        self._setup_output_files()

    def _setup_excel_application(self):
        """エクセルアプリケーションの準備
        """
        try:
            if win32com.client.GetObject(Class='Excel.Application'):
                self.__message_and_exit('Excelを閉じて下さい。')
        except win32com.client.pywintypes.com_error:
            pass

    def _setup_output_files(self):
        """出力するファイルの準備
        """
        # htmlレポート
        if (os.path.exists(self.output_html)):
            try:
                os.remove(self.output_html)
            except PermissionError:
                message = str(self.output_html) + 'へのアクセス権がありません。'
                self.__message_and_exit(message)
        # WinMergeの中間ファイル
        if (os.path.isdir(self.output_html_files)):
            try:
                shutil.rmtree(self.output_html_files)
            except PermissionError:
                message = str(self.output_html_files) + 'へのアクセス権がありません。'
                self.__message_and_exit(message)
        # エクセルレポートファイル
        if (os.path.exists(self.output)):
            try:
                os.remove(self.output)
            except PermissionError:
                message = str(self.output) + 'へのアクセス権がありません。'
                self.__message_and_exit(message)

    def __message_and_exit(self, message):
        """メッセージを表示して終了
        """
        print('\nError : ' + message)
        sys.exit(-1)

    def _generate_html_by_winmerge(self):
        """WinMergeにてhtmlレポート生成
        """
        print("\n[generate html by winmerge]")

        command = [
            WINMERGE_EXE,
            str(self.base),         # 比較元のフォルダ
            str(self.latest),       # 比較先のフォルダ
            *WINMERGE_OPTIONS,      # WinMergeのコマンドライン実行オプション
            str(self.output_html),  # レポートのパス
        ]
        print(' '.join(command))
        subprocess.run(command)

    def _convert_html_to_xlsx(self):
        """htmlレポートをエクセルファイルに変換する
        """
        try:
            self._open_book()
            self._format_summary_sheet()
            self._copy_html_files()
            self._format_diff_sheets()
            self._set_home_position(self.summary_ws)
            self._save_book()

        finally:
            self.excel.Quit()

    def _open_book(self):
        """ブックを開く
        """
        self.excel = win32com.client.Dispatch('Excel.Application')
        self.wb = self.excel.Workbooks.Open(self.output_html)
        self.summary_ws = self.wb.Worksheets(SUMMARY_WS_NUM)

    def _format_summary_sheet(self):
        """一覧シートの書式調整
        """
        print("\n[format summary sheet]")

        ws = self.summary_ws
        end_row = ws.Cells(ws.Rows.Count, 1).End(xlUp).Row

        # 同名シート有無の確認
        print('- check same filename ...', end='', flush=True)
        for row in range(SUMMARY_START_ROW, end_row+1):
            name_cell = ws.Range(SUMMARY_NAME_COL + str(row))
            if not name_cell.Value:
                break
            if name_cell.Hyperlinks.Count > 0:
                sheet_name = name_cell.Value.lower()
                if sheet_name in self.sheet_memo:
                    self.sheet_memo[sheet_name] += 1
                else:
                    self.sheet_memo[sheet_name] = 1
        print(' done')

        # htmlファイルとハイパーリンクの設定
        for row in range(SUMMARY_START_ROW, end_row+1):
            name_cell = ws.Range(SUMMARY_NAME_COL + str(row))
            if not name_cell.Value:
                break

            if name_cell.Hyperlinks.Count > 0:
                sname_src = sname_dst = name_cell.Value
                sname_src_lower = sname_src.lower()
                if sname_src_lower in self.sheet_count:
                    self.sheet_count[sname_src_lower] += 1
                else:
                    self.sheet_count[sname_src_lower] = 1

                if self.sheet_memo[sname_src_lower] >= 2:
                    sname_dst += f'_{self.sheet_count[sname_src_lower]}'

                self._change_hyperlink(name_cell, sname_dst)
                folder_cell = ws.Range(SUMMARY_FOLDER_COL + str(row)).Value
                self._rename_html_files(sname_src, sname_dst, folder_cell)

    def _change_hyperlink(self, name_cell, name_dst):
        """ハイパーリンクの修正
        """
        for hl in name_cell.Hyperlinks:
            hl.Address = ''
            hl.SubAddress = name_dst + '!' + HOME_POSITION
            hl.TextToDisplay = name_dst

    def _rename_html_files(self, name_src, name_dst, folder):
        """htmlレポートのリネーム
        """
        sheet_name = folder.replace('\\', '_') + '_' + name_src if folder else name_src  # noqa: E501
        src = f'{self.output_html_files}/{sheet_name}.html'
        dst = f'{self.output_html_files}/{name_dst}.html'
        if sheet_name != name_dst:
            os.rename(src, dst)
            print('- ' + sheet_name + ' ---> ' + name_dst)

    def _copy_html_files(self):
        """htmlレポートをエクセルにコピー
        """
        print("\n[copy html files]")

        count = 1
        for html in self.output_html_files.glob('**/*.html'):
            try:
                print(f'- {html.name} ...', end='', flush=True)
                diff_wb = self.excel.Workbooks.Open(html)
                diff_ws = diff_wb.Worksheets(1)
                diff_ws.Copy(Before=None, After=self.wb.Worksheets(count))
                diff_wb.Close()
                count += 1
                print(' done')
            except win32com.client.pywintypes.com_error as e:
                print(' skipped *** unknown error ***')
                print(f'\n{e}\n')

    def _format_diff_sheets(self):
        """差分シートの書式調整
        """
        print("\n[format diff sheets]")

        for i in range(DIFF_START_ROW, self.wb.Worksheets.Count+1):
            ws = self.wb.Worksheets(i)
            print(f'- {ws.Name} ...', end='', flush=True)
            self._set_zoom(ws)
            self._freeze_panes(ws)
            self._remove_hyperlink_from_no(ws)
            self._set_format(ws)
            self._set_autofilter(ws)
            self._set_home_position(ws)
            print(' done')

    def _set_zoom(self, ws):
        """拡大率を設定する
        """
        ws.Activate()
        self.excel.ActiveWindow.Zoom = DIFF_ZOOM_RATIO

    def _freeze_panes(self, ws):
        """ウィンドウ枠を固定する
        """
        ws.Activate()
        ws.Range('A' + str(DIFF_START_ROW)).Select()
        self.excel.ActiveWindow.FreezePanes = True

    def _remove_hyperlink_from_no(self, ws):
        """行番号のハイパーリンクを削除する
        """
        for f in DIFF_FORMATS['no']:
            end_row = ws.Cells(ws.Rows.Count, 1).End(xlUp).Row
            r1 = f['col'] + ':' + f['col']
            r2 = f['col'] + str(DIFF_START_ROW) + ':' + f['col'] + str(end_row)
            ws.Range(r1).Hyperlinks.Delete()
            ws.Range(r2).Interior.Color = int('F0F0F0', 16)
            ws.Range(r2).Font.Size = 12

    def _set_format(self, ws):
        """差分シートの書式調整
        """
        for key in DIFF_FORMATS.keys():
            for f in DIFF_FORMATS[key]:
                r = f['col'] + ':' + f['col']
                ws_range = ws.Range(r)
                if 'width' in f:
                    ws_range.ColumnWidth = f['width']
                if 'font' in f:
                    ws_range.Font.Name = f['font']
                if 'header' in f:
                    self._set_extra_table(ws, f)

    def _set_extra_table(self, ws, f):
        """表を追加する
        """
        end_row = self._get_diff_end_row(ws)

        # 表の見出し
        ws_range = ws.Range(f['col'] + '1')
        ws_range.Value = f['header']
        ws_range.VerticalAlignment = xlCenter
        ws_range.HorizontalAlignment = xlCenter
        ws_range.Interior.Color = int('CCFFCC', 16)

        # 表の罫線
        ws_range = ws.Range(f['col'] + '1:' + f['col'] + str(end_row))
        ws_range.Borders.Color = int('000000', 16)
        ws_range.Borders.LineStyle = xlContinuous

        # 表の中身を一旦"-"で埋める
        ws_range = ws.Range(f['col'] + '2:' + f['col'] + str(end_row))
        ws_range.Value = '-'
        ws_range.Interior.Color = int('E0E0E0', 16)

        # 差分がある箇所を空欄に変更する
        code_col = DIFF_FORMATS['code'][0]['col']
        target = code_col + str(DIFF_START_ROW) + ':' + code_col + str(end_row)
        row = DIFF_START_ROW
        group = 0
        for cell in ws.Range(target):
            if cell.Interior.Color != int('FFFFFF', 16):
                group += 1
            else:
                if group:
                    ws_range = ws.Range(f['col'] + str(row-group) + ':' + f['col'] + str(row-1))  # noqa: E501
                    ws_range.Value = ''
                    ws_range.Interior.Color = int('FFFFFF', 16)
                    group = 0
            row += 1
        if group:
            ws_range = ws.Range(f['col'] + str(end_row-group+1) + ':' + f['col'] + str(end_row))  # noqa: E501
            ws_range.Value = ''
            ws_range.Interior.Color = int('FFFFFF', 16)

    def _set_autofilter(self, ws):
        """オートフィルタを設定する
        """
        start = 'E1'
        end = ws.Cells(1, ws.Columns.Count).End(xlToLeft).Address.split('$')[1] + '1'  # noqa: E501
        if end == start:
            end = 'F1'
        ws.Range(start, end).AutoFilter()

    def _set_home_position(self, ws):
        """ホームポジションを設定する
        """
        ws.Activate()
        ws.Range(HOME_POSITION).Select()

    def _save_book(self):
        """ブックを保存する
        """
        self.wb.SaveAs(str(self.output), FileFormat=xlOpenXMLWorkbook)
        print('\nxlsxへの変換が完了しました。')

    def _get_diff_end_row(self, ws):
        """差分シートの最終行の番号を取得する
        """
        left_row = ws.Cells(ws.Rows.Count, 2).End(xlUp).Row
        right_row = ws.Cells(ws.Rows.Count, 4).End(xlUp).Row
        return max(left_row, right_row) + 1


if __name__ == '__main__':
    if len(sys.argv) < 3:
        print(f'Usage : {sys.argv[0]} <base> <latest> [<output>]')
        sys.exit(1)

    import time

    print(f'{sys.argv[0]} {VERSION}')

    start = time.perf_counter()
    WinMergeXlsx(*sys.argv[1:4]).generate()
    end = time.perf_counter()

    print(f'elp = {end-start:.3f}s')

以下のGitHubにも、同じものを置いております。テスト用のフォルダも必要でしたらこちらからどうぞ。

変更点の説明

今回の変更点について、簡単に説明を残します。

スクリプト実行時のメッセージ表示を強化

メッセージ表示を強化して、進捗状況が以前よりも分かるようにしました。

各実行フェーズを明記して、処理中のファイル名を逐次表示します。

実行例
C:\winmerge_xlsx>winmerge_xlsx.exe folder1 folder2
winmerge_xlsx.exe v1.0.0

[generate html by winmerge]
C:\Program Files\WinMerge\WinMergeU.exe C:\winmerge_xlsx\folder1 C:\winmerge_xlsx\folder2 /minimize /noninteractive /cfg Settings/DirViewExpandSubdirs=1 /cfg Settings/ViewLineNumbers=1 /cfg Settings/WordDifferenceTextColor=255 /cfg ReportFiles/ReportType=2 /cfg ReportFiles/IncludeFileCmpReport=1 /r /u /or C:\winmerge_xlsx\output.html
WinMerge: レポートの生成に成功しました。

[format summary sheet]
- check same filename ... done
- src_fizzbuzz.c ---> fizzbuzz.c
- src_hello_world.c ---> hello_world.c

[copy html files]
- fizzbuzz.c.html ... done
- folder.txt.html ... done
- hello_world.c.html ... done

[format diff sheets]
- fizzbuzz.c ... done
- folder.txt ... done
- hello_world.c ... done

xlsxへの変換が完了しました。
elp = 6.962s

ブックが閉じられずメモリリークする問題を修正

WinMergeで出力したhtmlファイルを取り込む際に、差分ファイルの数だけExcelブックをオープンし、クローズし忘れていました。

これにより、スクリプト終了まで、差分ファイルの数だけExcelを立ち上げてしまい、メモリを大量に消費する問題が出ておりました。

以下のように、diff_wb.Close() を入れました。

    def _copy_html_files(self):
        """htmlレポートをエクセルにコピー
        """
        print("\n[copy html files]")

        count = 1
        for html in self.output_html_files.glob('**/*.html'):
            try:
                print(f'- {html.name} ...', end='', flush=True)
                diff_wb = self.excel.Workbooks.Open(html)
                diff_ws = diff_wb.Worksheets(1)
                diff_ws.Copy(Before=None, After=self.wb.Worksheets(count))
                diff_wb.Close()
                count += 1
                print(' done')
            except win32com.client.pywintypes.com_error as e:
                print(' skipped *** unknown error ***')
                print(f'\n{e}\n')

差分シートの追加列にオートフィルタを適用する

差分シート右側の追加列へ、オートフィルタを適用するようにしました。

autofilter.png

    def _set_autofilter(self, ws):
        """オートフィルタを設定する
        """
        start = 'E1'
        end = ws.Cells(1, ws.Columns.Count).End(xlToLeft).Address.split('$')[1] + '1'  # noqa: E501
        if end == start:
            end = 'F1'
        ws.Range(start, end).AutoFilter()

設定のJSONファイル化

WinMergeのオプション設定と、差分シートの追加列のカスタマイズ設定を、JSONファイル化しました。

スクリプトと同じフォルダに setting.json という名前で、以下のようなファイルを置いておくと、設定が反映されます。(ファイル名は決め打ちです)

ファイルがない場合は、デフォルト設定(いままでと同じ)となります。

(設定例)

setting.json
{
    "WINMERGE_EXE":"C:\\Program Files\\WinMerge\\WinMergeU.exe",
    "WINMERGE_OPTIONS":[
        "/minimize",
        "/noninteractive",
        "/cfg",
        "Settings/DirViewExpandSubdirs=1",
        "/cfg",
        "Settings/ViewLineNumbers=1",
        "/cfg",
        "Settings/WordDifferenceTextColor=255",
        "/cfg",
        "ReportFiles/ReportType=2",
        "/cfg",
        "ReportFiles/IncludeFileCmpReport=1",
        "/r",
        "/u",
        "/or"
    ],
    "DIFF_FORMATS":{
        "no":[
            {"col": "A", "width": 5},
            {"col": "C", "width": 5}
        ],
        "code":[
            {"col": "B", "width": 100, "font": "MS ゴシック"},
            {"col": "D", "width": 100, "font": "MS ゴシック"}
        ],
        "extra":[
            {"col": "E", "width": 10, "header": "担当"},
            {"col": "F", "width": 10, "header": "結果"},
            {"col": "G", "width": 60, "header": "コメント"}
        ]
    }
}

↑は extra の内容をデフォルトからカスタマイズしたものとなっています。(先ほどのオートフィルタの章で紹介したシートと、同じ見た目になります)

class WinMergeXlsx:
    """WinMergeの差分レポートをエクセルに出力
    """
    def __init__(self, base, latest, output='./output.xlsx'):
        :
        :

        self.setting_json = './setting.json'
        if os.path.exists(self.setting_json):
            with open(self.setting_json, 'r') as f:
                json_load = json.load(f)
            if 'WINMERGE_EXE' in json_load:
                global WINMERGE_EXE
                WINMERGE_EXE = json_load['WINMERGE_EXE']
            if 'WINMERGE_OPTIONS' in json_load:
                global WINMERGE_OPTIONS
                WINMERGE_OPTIONS = json_load['WINMERGE_OPTIONS']
            if 'DIFF_FORMATS' in json_load:
                global DIFF_FORMATS
                DIFF_FORMATS = json_load['DIFF_FORMATS']

その他の修正

その他、軽微な修正をしました。

  • 大文字と小文字の違いだけの同名ファイルがある場合にエラー終了する問題を修正
  • WinMergeの列番号を"なし"に設定した場合、追加列の行数が不足する問題を修正

exe化について

Pyinstaller にて本スクリプトを exe 化できることを確認しております。

インストール方法
$ pip install pyinstaller
exe化方法
$ pyinstaller winmerge_xlsx.xls --onefile

動作確認環境

  • Windows10
  • Excel
  • WinMerge Version 2.16.12.1
  • python 3.10.5
  • pywin32 304
  • Pyinstaller 5.2

まとめ

今後も少しずつ改善していければと考えておりますので、ご意見などもしあれば是非お願いします。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1