WinMergeで比較レポートを自動出力する（その２）

## 概要
コード変更を行った際のエビデンスとしてWinMergeの比較レポートを使用して出力しているが、数十ファイルの変更があった際に、レポート出力するには手間と時間がかかるため自動出力したい。
前回できなかったことを追加対応した。

## 前回
https://qiita.com/aopapa1017/items/a63d72074ec24c2cd910

## 追加した内容
・WinMergeの比較結果をExcelで一覧表出力できるようにした。
・WinMergeで出力した比較レポートをExcelファイルにシートとして追加するようにした。

## 事前準備
①下記コードと同一フォルダに「empty.txt」名で空ファイルを用意する（新規ファイルとの比較用）。
②一覧表のテンプレート（template_comp_list.xlsx）を作成して下記コードと同一フォルダに置く。※下記参照
~~③作業用に「temp」フォルダも生成しておく。~~

### 一覧表のテンプレートサンプル（template_comp_list.xlsx）
セルの書式を早退コピーしてみたかったため、お試しでセル「F4」に数式を入れました。
セル「E4」に比較結果がコピーされるため、文字解析して「変更なし／更新／新規作成／削除」を表示します。

```
=IF(COUNTIF($E4,"*同一*"),"変更なし",IF(COUNTIF($E4,"*異なります*"),"更新",IF(COUNTIF($E4,"*右側のみ*"),"新規作成",IF(COUNTIF($E4,"*左側のみ*"),"削除",""))))
```

![screenshot_template_comp_list.xlsx.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/683161/f7327fb3-4ee2-471d-94b0-3f7ca895bc70.png)

## 実行方法
```cmd:コマンド
python main.py ＜変更前のフォルダパス＞ ＜変更後のフォルダパス＞ ＜出力先ファイル＆パス＞ ＜比較パスのフィルタ（正規表現）＞
```
:::note info
・変更前と変更後のフォルダは、階層構造が一致していること。
・＜出力先ファイル＆パス＞は、Excelファイルを指定すること。
・＜比較パスのフィルタ（正規表現）＞は省略可能。省略時は、すべてのフォルダが比較対象になる。
:::

## 出力結果（期待値）
```dir:フォルダ構成
+---after
|   +---01_工程1
|   |   +---01_機能種別1
|   |   |       main1-1.py
|   |   |       main1-2.py
|   |   |       main1-3.py
|   |   |       main1-4.txt
|   |   |       main1-5.txt
|   |   |
|   |   \---02_機能種別2
|   |           main2-1.py
|   |           main2-2.py
|   |
|   \---02_工程2
|       +---01_機能種別1
|       |       sub1-1.py
|       |       sub1-2.py
|       |
|       \---02_機能種別2
|               sub2-1.py
|               sub2-2.py
|
\---before
    +---01_工程1
    |   +---01_機能種別1
    |   |       main1-1.py
    |   |       main1-2.py
    |   |       main1-3.py
    |   |       main1-6
    |   |
    |   \---02_機能種別2
    |           main2-1.py
    |           main2-2.py
    |
    \---02_工程2
        +---01_機能種別1
        |       sub1-1.py
        |       sub1-2.py
        |
        \---02_機能種別2
                sub2-1.py
                sub2-2.py
```
```cmd:コマンド：01_機種種別1の比較レポートを作成する
python main.py .\before .\after .\output.xlsx .*(01_機能種別1)
```
■出力結果イメージ
![screenshot_output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/683161/da687c54-064f-44b4-a1cd-197ac20de6db.png)


## サンプルコード
### WinMergeのファイル比較一覧レポート用クラス
```python:wm_filelist.py
import os
import csv
import re
from enum import Enum


class FilterType(Enum):
    """
        比較結果のフィルタ種別
    """
    All = ''                           # すべて
    Before = r'(?!.*同一|右側のみ)'     # 比較元に変更があるもの
    After = r'(?!.*同一|左側のみ)'      # 比較先に変更があるもの


class WinMergeFileList():
    def __init__(self):
        """
        コンストラクタ
        """
        pass

    def __init__(self,
                 filepath: str):
        """
        コンストラクタ

        Args:
            filepath (str)          : (in) ファイルパス
        """
        self.__filelist = []
        keys = ['name', 'path', 'result',
                'before_date', 'after_date', 'extension']
        with open(filepath, encoding='shift_jis') as list_file:
            reader = csv.reader(list_file)
            for index, line in enumerate(reader):
                if index == 0:
                    # 比較元と比較先のパスを解析
                    values = line[0].split(" ")
                    self.__before_path = values[0]
                    self.__after_path = values[2]
                elif (index >= 1) and (index <= 2):
                    # 実行日時、列タイトルは不要
                    pass
                else:
                    # ファイルのみリストに登録する
                    items = dict(zip(keys, line))
                    if self.__is_file(items) == True:
                        self.__filelist.append(items)
                    pass

    def __is_file(self,
                  items: dict) -> bool:
        """
        比較した内容がファイルであるかチェックする

        Args:
            items (dict)        : (in) WinMergeの比較結果(1行分)

        returns:
            True                : ファイル
            False               : フォルダ
        """
        result = True

        if items['extension'] == '':
            # フォルダの可能性あり
            # 比較元のパスでフォルダなのか確認
            check_path = os.path.join(
                self.__before_path, items['path'], items['name'])
            if os.path.isdir(check_path) == True:
                result = False
            else:
                # 比較先のパスでフォルダなのか確認
                check_path = os.path.join(
                    self.__after_path, items['path'], items['name'])
                if os.path.isdir(check_path) == True:
                    result = False

        return result

    def get_list(self,
                 filter_result: FilterType = FilterType.All,
                 filter_path: str = "",
                 filter_extension: str = "") -> list:
        """
        ファイルリストをフィルタして返却する

        Args:
            filter_result (enum)    : (in) 比較結果のフィルタ
            filter_path (str)       : (in) パスのフィルタ
            filter_extension (dict) : (in) 拡張子のフィルタ

        returns:
            フィルタ後のファイルリスト
        """
        result = self.__filelist

        if filter_result != FilterType.All:
            # 結果が不一致のファイルでフィルタする
            result = [items for items in result if re.match(
                filter_result.value, items['result'])]

        if filter_path != '':
            # パス名でフィルタする
            result = [items for items in result if re.match(
                filter_path, items['path'])]

        if filter_extension != '':
            # 拡張子でフィルタする
            result = [items for items in result if re.match(
                filter_extension, items['extension'])]

        return result
```

### 比較レポート自動生成ツールのメイン処理
```python:main.py
import argparse
import os
import shutil
import openpyxl
import subprocess
import win32com.client
from enum import Enum
from copy import copy
from openpyxl.formula.translate import Translator
from wm_filelist import WinMergeFileList, FilterType


# カレントディレクトリ・ファイルパス
APP_PATH = os.getcwd()
FILE_PATH = os.path.dirname(__file__)
TEMP_PATH = os.path.join(APP_PATH, 'temp')

# 定数定義
WINMERGE_PATH = r'"C:\Program Files\WinMerge\WinMergeU.exe"'
EMPTY_FILE_PATH = os.path.join(APP_PATH, 'empty.txt')
CONPARE_LIST_PATH = os.path.join(APP_PATH, 'compare_list.csv')

# 更新ファイル一覧 定数定義
# 更新ファイル一覧のテンプレート
TEMPLATE_FILE_PATH = os.path.join(APP_PATH, 'template_comp_list.xlsx')
CONPARE_LIST_SHEET_NAME = '更新ファイル一覧'        # シート名

# 一覧表の書出し位置(行、列)
CONPARE_LIST_TABLE_TOP = 4
CONPARE_LIST_TABLE_LEFT = 2


class ResultCode(Enum):
    """ 戻り値 """
    OK = 0
    NG = -1


class CommandParam():
    """ コマンドパラメータクラス """
    path_before: str
    path_after: str
    path_output: str
    path_filter: str


def main():
    """
    自動WinMerge比較レポート出力メイン処理
    """
    # 初期処理
    if os.path.isdir(TEMP_PATH) == False:
        # テンポラリフォルダを生成する
        os.makedirs(TEMP_PATH)

    # コマンドラインチェック
    command_param = CommandParam()
    ret = check_commandline(command_param)

    # ファイル一覧を取得
    if ret == ResultCode.OK:
        # フォルダごとの変更ファイル一覧を作成する
        ret = create_compare_list(command_param, CONPARE_LIST_PATH)
        if ret == ResultCode.OK:
            winmerge_filelist = WinMergeFileList(CONPARE_LIST_PATH)

    # 更新ファイル一覧を作成する
    if ret == ResultCode.OK:
        comp_list = winmerge_filelist.get_list(
            filter_path=command_param.path_filter, filter_result=FilterType.All)
        ret = create_comp_list_table(command_param, comp_list)

    # ファイル比較シートを作成する
    if ret == ResultCode.OK:
        comp_list = winmerge_filelist.get_list(
            filter_path=command_param.path_filter, filter_result=FilterType.After)
        ret = create_comp_sheets(command_param, comp_list)


def check_commandline(param: CommandParam) -> ResultCode:
    """
    コマンドラインチェック

    Args:
        param (class)       : (out)コマンドパラメータ

    returns:
        ResultCode.OK       : 正常
        ResultCode.NG       : 引数エラー
    """
    result = ResultCode.OK

    # コマンドライン パラメータ
    parser = argparse.ArgumentParser(
        description='Aout WinMerge Tool', add_help=True)
    parser.add_argument('arg1', help='変更前のパス')
    parser.add_argument('arg2', help='変更後のパス')
    parser.add_argument('arg3', help='出力先のパス')
    parser.add_argument('--arg4', help='比較パスのフィルタ', default='.*')
    args = parser.parse_args()

    # パスチェック
    if os.path.isdir(args.arg1) == False:
        print(f'{args.arg1} が見つかりません')
        result = ResultCode.NG

    # パスチェック
    if os.path.isdir(args.arg2) == False:
        print(f'{args.arg2} が見つかりません')
        result = ResultCode.NG

    if result == ResultCode.OK:
        param.path_before = args.arg1
        param.path_after = args.arg2
        param.path_output = args.arg3
        param.path_filter = args.arg4

    return result


def create_compare_list(param: CommandParam,
                        path_file_list: str):
    """
    フォルダごとの変更ファイル一覧作成

    Args:
        param (class)       : (in) コマンドパラメータ
        path_file_list (str): (in) ファイル一覧の保存先パス

    returns:
        ResultCode.OK       : 正常
        ResultCode.NG       : 引数エラー
    """
    result = ResultCode.OK

    # 実行前にファイル一覧を削除する
    if os.path.isfile(path_file_list):
        os.remove(path_file_list)

    # WinMergeを起動して、比較レポートを生成する
    # *Settings/DirViewExpandSubdirs=1 : 自動的にサブフォルダーを展開する
    command = f"{WINMERGE_PATH} /s {param.path_before} {param.path_after} " \
        f"/noninteractive /r /u /cfg Settings/DirViewExpandSubdirs=1 " \
        f"/or {path_file_list}"
    print(command)
    subprocess.run(command, shell=True)

    # ファイル一覧を確認
    if os.path.isfile(path_file_list) == False:
        result = ResultCode.NG

    return result


def create_comp_list_table(param: CommandParam,
                           comp_list: list) -> ResultCode:
    """
    更新ファイル一覧をを生成する

    Args:
        param (class)       : (in) コマンドパラメータ
        comp_list (list)    : (in) 更新ファイルリスト

    returns:
        ResultCode.OK       : 正常
        ResultCode.NG       : 引数エラー
    """
    result = ResultCode.OK

    # テンプレートファイルをコピーする
    shutil.copy(TEMPLATE_FILE_PATH, param.path_output)

    workbook = openpyxl.load_workbook(param.path_output, data_only=False)
    sheet = workbook[CONPARE_LIST_SHEET_NAME]

    for index, items in enumerate(comp_list):
        # 更新ファイル一覧の更新位置(行)
        row_pos = CONPARE_LIST_TABLE_TOP + index

        if index > 0:
            for col_pos in range(CONPARE_LIST_TABLE_LEFT, sheet.max_column + 1):
                # 1つ上のセルの書式をコピーする
                sheet.cell(row_pos, col_pos)._style = \
                    copy(sheet.cell(row_pos - 1, col_pos)._style)

                # 1つ上のセルが数式なら数式をコピーする（数式の相対コピー）
                before_cell_str = sheet.cell(row_pos - 1, col_pos).coordinate
                if sheet[before_cell_str].data_type == 'f':
                    cell_str = sheet.cell(row_pos, col_pos).coordinate
                    sheet[cell_str] = Translator(
                        sheet[before_cell_str].value, before_cell_str).translate_formula(cell_str)

        # 更新ファイルリストの結果を表に書き出す
        sheet.cell(row_pos, 2).value = items['name']
        sheet.cell(row_pos, 3).value = items['path']
        sheet.cell(row_pos, 4).value = items['extension']
        sheet.cell(row_pos, 5).value = items['result']

    # 更新ファイル一覧を保存する
    workbook.save(param.path_output)

    return result


def create_comp_sheets(param: CommandParam,
                       comp_list: list) -> ResultCode:
    """
    更新ファイル一覧にファイル比較シートを登録する

    Args:
        param (class)       : (in) コマンドパラメータ
        comp_list (list)    : (in) 更新ファイルリスト

    returns:
        ResultCode.OK       : 正常
        ResultCode.NG       : 引数エラー
    """
    result = ResultCode.OK

    # 比較レポートを追加するExcelファイル(更新ファイル一覧)を開く
    excel = win32com.client.Dispatch('Excel.Application')
    workbook = excel.Workbooks.Open(param.path_output)

    for index, items in enumerate(comp_list):
        # WinMergeを使用して比較レポートを作成する
        path_before = os.path.join(
            param.path_before, items['path'], items['name'])
        path_after = os.path.join(
            param.path_after, items['path'], items['name'])
        path_output = os.path.join(TEMP_PATH, items['name'] + '.html')
        ret = create_winmerge_report(path_before, path_after, path_output)
        if ret == ResultCode.OK:
            # 比較レポートをExcelで開く（ファイル比較シート作成）
            workbook_html = excel.Workbooks.Open(path_output)
            sheet = workbook_html.Worksheets(1)

            # ファイル比較シートを一覧表の最後にコピーする
            sheet.Copy(Before=None,
                       After=workbook.Worksheets(workbook.Worksheets.count))
            workbook_html.close

    # 更新ファイル一覧を保存して閉じる
    workbook.Worksheets(1).select
    workbook.save
    workbook.close

    return result


def create_winmerge_report(path_before: str,
                           path_after: str,
                           path_output: str) -> ResultCode:
    """
    WinMergeを使用して比較レポートを作成する

    Args:
        path_before (str)   : (in) 変更前のパス
        path_after (str)    : (in) 変更後のパス
        path_output (str)   : (in) 出力先のパス

    returns:
        ResultCode.OK       : 正常
        ResultCode.NG       : 引数エラー
    """
    result = ResultCode.OK

    # 比較元に比較先と同名のファイルがあるかチェック
    if os.path.isfile(path_before) == False:
        # ファイルがない(＝ 新規作成のファイル）場合、空ファイルを指定する
        path_before = EMPTY_FILE_PATH

    # WinMergeを起動して、比較レポートを生成する
    command = f"{WINMERGE_PATH} {path_before} {path_after} " \
        f"/noninteractive /u /or {path_output}"
    subprocess.run(command, shell=True)

    if os.path.isfile(path_output) == False:
        # 比較レポートが作成できていない
        result = ResultCode.NG

    return result


if __name__ == "__main__":
    main()
```

## 参考にしたサイト
下記サイトの情報がとても参考になりました。ありがとうございます。

・[ArgumentParserの使い方を簡単にまとめた](https://qiita.com/kzkadc/items/e4fc7bc9c003de1eb6d0)
・[PythonでCSVファイルを読み書きする方法（CSV, Pandas）](https://techplay.jp/column/567)
・[分かりやすいpythonの正規表現の例](https://qiita.com/luohao0404/items/7135b2b96f9b0b196bf3)
・[正規表現：文字列を「含まない」否定の表現まとめ](https://www-creators.com/archives/1827)
・[PythonでExcelファイルを作成，書き込み](https://qiita.com/si-tora/items/439cbce030ba962ea815)
・[【Python】数式をセルに設定する。](https://qiita.com/rihu-do/items/6f264984d1d1a7cbd33c)
・[PythonからExcelをwin32comで操作する](https://qiita.com/kumarstack55/items/9ae3432446afca06497f)