CSVファイルの解析環境構築（その２：Excelの機能でソートする）

## はじめに
前の記事でExcelのソート機能が使えず、バブルソートしてセル（列）の切り取り＆挿入で実現していたが、列の数が数千行の場合にとんでもない時間がかかってしまい実用的でない。
このため、Excelのソート機能を調べて処理を変更してみた。

## 今回やってみたこと
* Excelのソート機能を使って列の入れ替えを行う
* ソースコードを機能別にファイル分割
* Excel制御部をクラスファイル化
* コーディング規約「PEP8」に基づいて実装

## 今後のレベルアップ項目
まだ知識（技術）不足で実現できていないが、下記の手順も自動化していきたい。
* 表紙などのシートを追加したい
* データの並べ替えルールをユーザ定義できるようにしたい
* 条件付き書式設定を追加したい

## 実装
### ファイル構成
```dir:フォルダ構成
+---csv2xlsx
    result_code.py　　　 # 共通の戻り値定義
    command_line.py　　　# コマンドライン解析用
    const_excel.py　　　 # Excel VBAの定数定義
    simple_excel.py　　　# 簡易Excel制御クラス
    main.py　　　        # メイン処理
```

### サンプルコード
#### result_code.py : 共通の戻り値定義
ソースコード間で戻り値を共通化するために作成。

```python:result_code.py
from enum import IntEnum

class ResultCode(IntEnum):
    """ 戻り値 """
    SUCCESS = 0
    ERROR = -1
```

#### command_line.py : コマンドライン解析用
コマンドラインの解析処理をメイン処理と分離するため別ファイルで処理した。
いつも似たようなコード書いているので、作成するアプリごとに編集して使いまわせればと思っています。

```python:command_line.py
import os
import argparse
from result_code import ResultCode

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


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


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

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

    returns:
        ResultCode.SUCCESS  : 正常
        ResultCode.ERROR    : 引数エラー
    """
    result = ResultCode.SUCCESS

    # コマンドライン パラメータ
    parser = argparse.ArgumentParser(
        description='Aout WinMerge Tool', add_help=True)
    parser.add_argument('arg1', help='CSVファイルのパス')
    parser.add_argument('arg2', help='出力先ファイル名＆パス')
    args = parser.parse_args()

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

    # コマンドラインパラメータの保存
    if result == ResultCode.SUCCESS:
        param.path_csvfile = args.arg1
        param.path_output = args.arg2

    return result
```

#### const_excel.py：Excel VBAの定数定義
Excel制御で使用する定数を定義する。下記のサイトを参考に必要な定義のみ抜き出しました。（地味に面倒な作業なので、とても助かりました）
・[Python pywin32(win32com) Excel 操作備忘録](https://qiita.com/feo52/items/150745ae0cc17cb5c866)

```python:const_excel.py
# region XlFileFormat定義：ファイル保存フォーマット
xlCSV = 6
xlHtml = 44
xlWorkbookDefault = 51
xlOpenXMLWorkbook = 51
xlOpenXMLWorkbookMacroEnabled = 52
xlWorkbookNormal = -4143
xlCurrentPlatformText = -4158
# endregion

# region XlYesNoGuess定義：Yes/No
xlGuess = 0
xlYes = 1
xlNo = 2
# endregion

# region XlSortOrder定義：ソート順
xlAscending = 1
xlDescending = 2
xlManual = -4135
# endregion

# region XlSortOrientation定義：ソート方向（列・行）
xlSortColumns = 1
xlSortRows = 2
# endregion

# region XlSortMethod定義：ソート条件：ふりがなの使用・不使用
xlPinYin = 1
xlStroke = 2
# endregion
```

#### simple_excel.py：簡易Excel制御クラス
Excel制御する処理をクラス化しました。レベルアップ項目もあるので、少しずつ拡張できればと思っています。
ソート機能については、下記のサイトが参考にしつつトライ＆エラーで実現できました。
・[エクセルの神髄 - 第88回.並べ替え（Sort）](https://excel-ubara.com/excelvba1/EXCELVBA388.html)


```python:simple_excel.py
import os
import win32com.client
import const_excel as xl
from result_code import ResultCode

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

# 定数定義
# region デフォルト定義
DEFAULT_VISIBLE = True                      # Excel表示
DEFAULT_FORCED_WRITE = False                # 上書き保存の強制有無
# endregion


class SimpleExcel:
    """
    Excel制御クラス
    """

    # region コンストラクタ
    def __init__(self,
                 visibled: bool = DEFAULT_VISIBLE):
        """
        コンストラクタ
        """
        self.__xlapp = None
        self.__visibled = visibled
    # endregion

    # region デストラクタ
    def __del__(self):
        """
        デストラクタ
        """
        try:
            # ワークブックを閉じる
            if self.__wbook is not None:
                self.__wbook.Close(SaveChanges=False)

            # Excelを閉じる
            if self.__xlapp is not None:
                self.__xlapp.Quit()
        except Exception as e:
            # すでにExcel終了済みならエラー無視する
            if type(e).__name__ != 'com_error':
                print(f"終了処理に失敗しました\n{e}")
    # endregion

    # region プロパティ
    # region Excel表示On/Off
    @property
    def visibled(self) -> bool:
        """
        Excel表示On/Off
        """
        return self.__visibled

    @visibled.setter
    def visibled(self,
                 value: bool):
        """
        Excel表示On/Off

        Args:
            value               : (in) True = 表示On
        """
        self.__visibled = value
        if self.__xlapp is not None:
            self.__xlapp.Visible = value
    # endregion

    # region アクティブシートで使用されている列数
    @property
    def get_count_columns(self) -> int:
        """
        アクティブシートで使用されている列数
        """
        result = self.__wsheet.UsedRange.Columns(
            self.__wsheet.UsedRange.Columns.Count).Column
        return result
    # endregion

    # region アクティブシートで使用されている行数
    @property
    def get_count_rows(self) -> int:
        """
        アクティブシートで使用されている行数
        """
        result = self.__wsheet.UsedRange.Rows(
            self.__wsheet.UsedRange.Rows.Count).Row
        return result
    # endregion
    # endregion

    # region 公開メソッド
    # region Excelでファイルを開く
    def open(self,
             file_path: str) -> ResultCode:
        """
        Excelでファイルを開く

        Args:
            file_path           : (in) 読込むファイル名&パス

        returns:
            ResultCode.SUCCESS  : 正常
            ResultCode.ERROR    : オープンエラー
        """
        result = ResultCode.SUCCESS

        if not os.path.isfile(file_path):
            print(f"{file_path} が見つかりません")
            result = ResultCode.ERROR
        else:
            try:
                # Excelを起動してファイルを開く
                if self.__xlapp is None:
                    self.__xlapp = win32com.client.Dispatch(
                        'Excel.Application')
                    self.__xlapp.Visible = self.__visibled

                self.__wbook = self.__xlapp.Workbooks.Open(file_path)
                self.select_sheet(1)

            except Exception as e:
                print(f"ファイルオープンに失敗しました\n{e}")
                result = ResultCode.ERROR

        return result
    # endregion

    # region ワークブックを閉じる
    def close(self):
        """
        ワークブックを閉じる

        Args:
            なし

        returns:
            ResultCode.SUCCESS  : 正常
            ResultCode.ERROR    : クローズエラー（未定義）
        """
        result = ResultCode.SUCCESS

        if self.__wbook is not None:
            self.__wbook.Close(SaveChanges=False)

        return result
    # endregion

    # region 名前を付けて保存
    def save_as(self,
                file_path: str,
                format: xl = xl.xlOpenXMLWorkbook,
                forced_write: bool = DEFAULT_FORCED_WRITE) -> ResultCode:
        """
        名前を付けて保存

        Args:
            file_path           : (in) 保存するファイル名&パス
            format              : (in) 保存形式
            forced_write        : (in) True=強制的に上書きする

        returns:
            ResultCode.SUCCESS  : 正常
            ResultCode.ERROR    : 保存エラー
        """
        result = ResultCode.SUCCESS

        # Excelファイル(xlsx)形式で保存する
        if self.__wsheet is not None:
            try:
                if forced_write:
                    # 警告メッセージを非表示にする（強制的に上書きする）
                    self.__xlapp.DisplayAlerts = False

                # 名前を付けて保存する
                self.__wsheet.SaveAs(file_path, FileFormat=format)

                if forced_write:
                    # 警告メッセージ表示を元に戻す
                    self.__xlapp.DisplayAlerts = True

            except Exception as e:
                print(f"ファイルの書き込みに失敗しました\n{e}")
                result = ResultCode.ERROR

        return result
    # endregion

    # region シートを選択する
    def select_sheet(self,
                     name: any) -> any:
        """
        シートを選択する

        Args:
            name                : (in) シート名 or シート番号

        returns:
            シートオブジェクト
        """
        # シート名またはシート番号での指定
        self.__wsheet = self.__wbook.Worksheets(name)
        return self.__wsheet
    # endregion

    # region 列の並び替えをする
    def sort_column(self,
                    key1_row: int,
                    order1: int = xl.xlAscending) -> ResultCode:
        """
        列の並び替えをする

        Args:
            key1_row            : (in) 並べ替えのキーになる行番号（１～）
            order1              : (in) 並べ替え順序

        returns:
            ResultCode.SUCCESS  : 正常
            ResultCode.ERROR    : 並べ替えエラー（未定義）
        """
        result = ResultCode.SUCCESS

        # 並び替えキーのカラム文字作成(行指定）
        col_str = self.conv_r1c1_to_a1(key1_row, 1)
        range_key = self.__wsheet.Range(col_str)

        # 並び替え範囲のカラム文字作成(シートの使用領域全体）
        range_sort = self.covn_range_r1c1_to_a1(
            1, 1, self.get_count_rows, self.get_count_columns)

        # シートの並び替え
        self.__wsheet.Sort.SortFields.Clear
        self.__wsheet.Sort.SortFields.Add(Key=range_key, Order=order1)
        self.__wsheet.Sort.SetRange(self.__wsheet.Range(range_sort))
        self.__wsheet.Sort.Header = xl.xlYes
        self.__wsheet.Sort.Orientation = xl.xlSortRows
        self.__wsheet.Sort.MatchCase = False
        self.__wsheet.Sort.SortMethod = xl.xlPinYin
        self.__wsheet.Sort.Apply()

        return result
    # endregion

    # region カラム文字変換
    # region 列番号 → A1形式のカラム文字に変換
    def conv_colindex_to_addr(self,
                              column: int) -> str:
        """
        列番号 → A1形式のカラム文字に変換

        Args:
            column              : (in) 列番号

        returns:
            A1形式のカラム文字
        """
        return (self.__wsheet.Cells(1, column).Address).split("$")[1]
    # endregion

    # region R1C1指定 → A1形式に変換
    def conv_r1c1_to_a1(self,
                        row: int,
                        column: int) -> str:
        """
        R1C1指定 → A1形式に変換

        Args:
            row                 : (in) 行番号
            column              : (in) 列番号

        returns:
            A1形式
        """
        if row < 1:
            # 列指定
            result = f'{self.conv_colindex_to_addr(column)}'
        elif column < 1:
            # 行指定
            result = f'{row}'
        else:
            # 行＆列指定
            result = f'{self.conv_colindex_to_addr(column)}{row}'

        return result
    # endregion

    # region R1C1指定 → A1形式の範囲文字に変換
    def covn_range_r1c1_to_a1(self,
                              row1: int,
                              column1: int,
                              row2: int,
                              column2: int) -> str:
        """
        R1C1指定 → A1形式の範囲文字に変換

        Args:
            row1                : (in) 行番号(左上)
            column1             : (in) 列番号(左上)
            row2                : (in) 行番号(右下)
            column2             : (in) 列番号(右下)

        returns:
            A1形式の範囲文字
        """
        return \
            f'{self.conv_r1c1_to_a1(row1, column1)}:' \
            f'{self.conv_r1c1_to_a1(row2, column2)}'
    # endregion
    # endregion
    # endregion
```

#### main.py：メイン処理
```python:main.py
import os
import command_line
from result_code import ResultCode
from command_line import CommandParam
from simple_excel import SimpleExcel, xl

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


def main():
    """
    CSVファイルを読込み、1行目をキーに並べ替えをして
    Excel形式(xlsx)でファイル保存するサンプル
    """
    # コマンドラインチェック
    param = CommandParam()
    ret = command_line.check_commandline(param)

    # CSVファイルを開く
    excel_csv = None
    if ret == ResultCode.SUCCESS:
        # インスタンス生成してファイルを開く
        excel_csv = SimpleExcel()
        ret = excel_csv.open(param.path_csvfile)

    # 1行目をキーに列の並び替えをする
    if ret == ResultCode.SUCCESS:
        ret = excel_csv.sort_column(1, xl.xlDescending)

    # Excelファイル(xlsx)形式で保存する
    if ret == ResultCode.SUCCESS:
        # ※forced_writeをTrueにすることで強制的に上書きする
        ret = excel_csv.save_as(param.path_output, forced_write=False)
        if ret == ResultCode.SUCCESS:
            print(f'{param.path_output} に保存しました')

    # 終了処理
    if excel_csv is not None:
        del excel_csv


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


### 実行方法

```dos:コマンド
python main.py <変更元のCSVファイルのパス> <保存先ファイル名&パス>
```
```dos:コマンド入力例
python main.py sample.csv sample.xlsx
```
本コマンド実行することで、sample.csv が読み込まれ、1行目をキーに列入れ替えした結果が sample.xlsx ファイルに保存されます。

■sample.csv
![sample.csv.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/683161/0f79ac65-5a68-4c06-8569-89d9b864e9b8.png)

■sample.xlsx
![sample.xlsx.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/683161/45db6f8c-2832-45e1-b7a2-03133142876d.png)


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

・[Python pywin32(win32com) Excel 操作備忘録](https://qiita.com/feo52/items/150745ae0cc17cb5c866)
・[エクセルの神髄 - 第88回.並べ替え（Sort）](https://excel-ubara.com/excelvba1/EXCELVBA388.html)

・[Pythonで自分がよく使うコードサンプル](https://qiita.com/aopapa1017/items/c5de2df6a39986c68f63)