【Python】filecmp.cmp 詳細解説

## はじめに

業務中に「`filecmp.cmp`って公式ドキュメントを読むに信憑性薄そうなんだよね。ちゃんとこれファイル内の比較してるんですか？」ような話を受けて、「どうだったっけ？」となったので一通り確認してみたといった記事です。
本稿では`filecmp.cmp`の仕組みや使い方を含め、詳細解説をします。

### 検証環境
- Python 3.11
- Windows10(他OSはパス周りを置き換えてください)

## 発端

公式ドキュメントの下記のような文面に疑問を持ったようです。

https://docs.python.org/ja/3/library/filecmp.html


> 名前が f1 および f2 のファイルを比較し、二つのファイルが同じらしければ True を返し、そうでなければ False を返します。

「同じらしい」とは…？確かに曖昧な記述ですね。どういう比較をして「同じらしさ」を担保しているのかソースコードを見る必要がありそうですね。

## 基本的な`filecmp.cmp`の使い方
ソースコードを掘り下げる前に、基本的な使い方をおさらいしましょう。

```py
def cmp(f1, f2, shallow=True):
    """Compare two files.

    Arguments:

    f1 -- First file name

    f2 -- Second file name

    shallow -- treat files as identical if their stat signatures (type, size,
               mtime) are identical. Otherwise, files are considered different
               if their sizes or contents differ.  [default: True]

    Return value:

    True if the files are the same, False otherwise.

    This function uses a cache for past comparisons and the results,
    with cache entries invalidated if their stat information
    changes.  The cache may be cleared by calling clear_cache().

    """
    ...
```

ざっくり`filecmp.cmp`は上記のようになっています。
簡単に説明すると比較したいファイルのパス(パス文字列かパスライクオブジェクト)を渡すと、「同じらしい」ファイルであれば、`True`、そうでなければ`false`を返します。

```py
import filecmp

a = r"C:\Users\hoge\hoge.csv"
b = r"C:\Users\fuga\fuga.csv"
print(filecmp.cmp(a, b)) # True or False
```

引数 `shallow` は後述します。

## 「同じらしさ」とは？

軽くおさらいもしましたし、コードを掘り下げていきましょう。目標は「同じらしさ」を解き明かすことです。では、見ていきましょう。

```py
    ...
    s1 = _sig(os.stat(f1))
    s2 = _sig(os.stat(f2))
    if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
        return False
    if shallow and s1 == s2:
        return True
    if s1[1] != s2[1]:
        return False

    outcome = _cache.get((f1, f2, s1, s2))
    if outcome is None:
        outcome = _do_cmp(f1, f2)
        if len(_cache) > 100:      # limit the maximum size of the cache
            clear_cache()
        _cache[f1, f2, s1, s2] = outcome
    return outcome
```

実装は上記のようになっています。コードだけを見ると何が何やらといった感じになると思うのでフロー図にしてみました。

```mermaid
graph LR
  Start([Start])-->B{Shallowかどうか}
  B-->|Yes| C{os.stat内容が一致するか}
  C-->|Yes| End1([return True])
  C-->|No:size不一致| End2([return False])
  C-->|No:size一致| D{readされた内容が一致するか}
  B-->|No| D
  D-->|Yes| End1
  D-->|No| End2
```

やっていることはシンプルです。

1. os.statで取得したファイルのstatを元に比較(shallowがデフォルトまたはTrue指定の場合)
1. ファイルを読み込んでバイトでの比較

この比較が「同じらしさ」を担保しています。それぞれを細かく見ていきます。

### os.stat箇所のロジック

ロジックを抽出すると下記のようになります。

```py
...
    s1 = _sig(os.stat(f1))
    s2 = _sig(os.stat(f2))
    if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
        return False
    if shallow and s1 == s2:
        return True
    if s1[1] != s2[1]:
        return False
...
def _sig(st):
    return (stat.S_IFMT(st.st_mode),
            st.st_size,
            st.st_mtime)
```

`os.stat`で取れる内容のうち、必要な要素を`_sig`関数で抽出しています。
`os.stat`で取れる要素ってどんな感じでしたっけな方向けに取れる情報を見てみると

https://docs.python.org/ja/3/library/os.html#os.stat_result

```py
>>> import os
>>> statinfo = os.stat('somefile.txt')
>>> statinfo
os.stat_result(st_mode=33188, st_ino=7876932, st_dev=234881026,
st_nlink=1, st_uid=501, st_gid=501, st_size=264, st_atime=1297230295,
st_mtime=1297230027, st_ctime=1297230027)
>>> statinfo.st_size
264
```

特に`filecmp.cmp`のstatとして重要視されているのは、`st_mode`と`st_size`、`st_mtime`です。

- `st_mode` : ファイルモード。ファイルタイプとファイルモードのビット （権限）
- `st_size` : ファイルが通常のファイルまたはシンボリックリンクの場合、そのファイルのバイト単位でのサイズです。シンボリックリンクのサイズは、含まれるパス名の長さで、null バイトで終わることはありません
- `st_mtime` : 秒で表した最終内容更新時刻

`st_mode`は通常ファイルかどうかの識別に使われているだけです。どちらかというと`st_size`と`st_mtime`が比較の肝になっています。処理方法としては、

1. サイズと最終内容更新時刻が完全一致する場合`True`
1. サイズが一致しない場合`False`

となっています。基本的にはこれで「同じらしい」といえそうですが、`shutil.copystat`でstat内容を移植することで同一サイズの異なる内容のファイルを一致させることは可能です。

```py
import filecmp
import shutil

a = r"C:\workspace\sample_1.csv"
b = r"C:\workspace\sample_2.csv"

shutil.copystat(a,b)
print(filecmp.cmp(a,b)) # True
```
```csv:sample_1.csv
1,2,3
4,,5
,5,6
```
```csv:sample_2.csv
1,2,3
4,,5
,5,8
```

### read箇所のロジック

引数`shallow=False`の場合や、os_stat箇所で下記のようにすり抜けた場合に走ります。

- 通常ファイルである場合
- 抽出したstatが完全一致しない場合
- `st_size`が一致する場合

条件が結構シビアなので、ファイル内容で比較したい場合は引数`shallow`を`False`にすることをおすすめします。

ロジックを抽出すると下記のようになります。

```py
def _do_cmp(f1, f2):
    bufsize = BUFSIZE
    with open(f1, 'rb') as fp1, open(f2, 'rb') as fp2:
        while True:
            b1 = fp1.read(bufsize)
            b2 = fp2.read(bufsize)
            if b1 != b2:
                return False
            if not b1:
                return True
```

やっていることはバイナリファイルの読込とbytesオブジェクトの比較になります。
また、最初のファイルに書き込みがないと`True`になります。

また、内容比較以外にもキャッシュの概念があります。

```py
    outcome = _cache.get((f1, f2, s1, s2))
    if outcome is None:
        outcome = _do_cmp(f1, f2)
        if len(_cache) > 100:      # limit the maximum size of the cache
            clear_cache()
        _cache[f1, f2, s1, s2] = outcome
    return outcome
```

同条件の比較が2回以上行われる場合は、キャッシュされた結果が優先されます。
ユニットテストなどでファイルを使いまわしながら`filecmp.cmp`を使用する場合は注意しましょう。

## `filecmp.cmp`の使い道

比較処理になるので条件分岐や`assert`で利用されます。その際に注意すべき点は、「ファイルそのものの同一性」が重要なのか、「内容自体の同一性」が重要なのかは事前に理解して利用すべきです。

例えばあるファイルが特定の状態になっていることを証明する場合は、`filecmp.cmp`は有効です。
が、あるファイルと別のファイルの整合性を確認する場合は、一概に有効とは限りません。
その場合、`shallow=False`で内容をバイト比較することが前提になりますが、`csv`や`xlsx`などに限っては`pandas`の`dataframe`などで比較するほうがデバッグ内容やスタックトレースがリッチに出るのでそちらの方が有効です。

また、`filecmp.cmp`の良さはshallowでの実行速度や正確性における**コスパの良さ**だと思うので、サクッとファイル比較したい場合にはかなり有効だと思います。

## おわりに

`filecmp.cmp`の「同じらしさ」は想定していたより信憑性のあるものだなといった印象を受けました。
ただ想定ケースによってはshallowなどを駆使して利用すべきだなと思いました。