はじめに
業務中に「filecmp.cmp
って公式ドキュメントを読むに信憑性薄そうなんだよね。ちゃんとこれファイル内の比較してるんですか?」ような話を受けて、「どうだったっけ?」となったので一通り確認してみたといった記事です。
本稿ではfilecmp.cmp
の仕組みや使い方を含め、詳細解説をします。
検証環境
- Python 3.11
- Windows10(他OSはパス周りを置き換えてください)
発端
公式ドキュメントの下記のような文面に疑問を持ったようです。
名前が f1 および f2 のファイルを比較し、二つのファイルが同じらしければ True を返し、そうでなければ False を返します。
「同じらしい」とは…?確かに曖昧な記述ですね。どういう比較をして「同じらしさ」を担保しているのかソースコードを見る必要がありそうですね。
基本的なfilecmp.cmp
の使い方
ソースコードを掘り下げる前に、基本的な使い方をおさらいしましょう。
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
を返します。
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
は後述します。
「同じらしさ」とは?
軽くおさらいもしましたし、コードを掘り下げていきましょう。目標は「同じらしさ」を解き明かすことです。では、見ていきましょう。
...
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
実装は上記のようになっています。コードだけを見ると何が何やらといった感じになると思うのでフロー図にしてみました。
やっていることはシンプルです。
- os.statで取得したファイルのstatを元に比較(shallowがデフォルトまたはTrue指定の場合)
- ファイルを読み込んでバイトでの比較
この比較が「同じらしさ」を担保しています。それぞれを細かく見ていきます。
os.stat箇所のロジック
ロジックを抽出すると下記のようになります。
...
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
で取れる要素ってどんな感じでしたっけな方向けに取れる情報を見てみると
>>> 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
が比較の肝になっています。処理方法としては、
- サイズと最終内容更新時刻が完全一致する場合
True
- サイズが一致しない場合
False
となっています。基本的にはこれで「同じらしい」といえそうですが、shutil.copystat
でstat内容を移植することで同一サイズの異なる内容のファイルを一致させることは可能です。
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
1,2,3
4,,5
,5,6
1,2,3
4,,5
,5,8
read箇所のロジック
引数shallow=False
の場合や、os_stat箇所で下記のようにすり抜けた場合に走ります。
- 通常ファイルである場合
- 抽出したstatが完全一致しない場合
-
st_size
が一致する場合
条件が結構シビアなので、ファイル内容で比較したい場合は引数shallow
をFalse
にすることをおすすめします。
ロジックを抽出すると下記のようになります。
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
になります。
また、内容比較以外にもキャッシュの概念があります。
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などを駆使して利用すべきだなと思いました。