18
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

はじめに

業務中に「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

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

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

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

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

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_modest_sizest_mtimeです。

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

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

  1. サイズと最終内容更新時刻が完全一致する場合True
  2. サイズが一致しない場合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
sample_1.csv
1,2,3
4,,5
,5,6
sample_2.csv
1,2,3
4,,5
,5,8

read箇所のロジック

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

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

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

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

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で内容をバイト比較することが前提になりますが、csvxlsxなどに限ってはpandasdataframeなどで比較するほうがデバッグ内容やスタックトレースがリッチに出るのでそちらの方が有効です。

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

おわりに

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

18
7
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
18
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?