Pythonで重複ファイルを探す

重複ファイルがいやだぁ、という状態に陥るのは多分こんな理由だ:

  1. 「こわいので編集前にバックアップ取っとこうっと」式。
  2. 「ダウンロード魔、インストール魔の管理不行き届き」式。


というだけではなくて、まぁブラウザのキャッシュであるとか、Windows の場合は「システムファイル」にも重複が多かったりもするのではあるが。

なお、「こわいので編集前にバックアップ取っとこうっと」を「良いこと」として触れ回るのはもうやめにして欲しい。リビジョン管理ツールを日常に取り入れるべきだ。

で、一昔前は、ディスク容量なんてありあまってはいなかった、デショ。全部で 4GB だった頃には「なんて幸せな時代になったものだ、これでいくらでも画像が入る」と思ったものだが、それすらも今は懐かしい。ので、あんまり重複ファイルを気にしなくなってしまった。

そうはいっても、重複ファイルは、ディスクを圧迫することはあまりなくなったとしても、厄介者であることは確かで、「同じ」である間は害はなくても、双方が独自に進化し始めると混乱が始まる。「どれが最新なんだ、いったい?」。

こういうの、探せばいくらでも出てくるはずです。けど例によって「Pythonしか持っていなくてインターネットにも繋げることが出来ない環境で生きる」ためのネタ。

前置きが長くなった。プログラムは以下:

  1 # -*- coding: utf-8 -*-
  2 #
  3 # 重複ファイルを探す、というお題。
  4 #
  5 # ワタシ、あんまりコメント書かないヒトなんだけれど、さすがに
  6 # ダラダラと長くなってしまったのでコメントいっぱい入れた。
  7 #
  8 # 行儀悪いと思うんで、「良いもの」としての参考にはしないで
  9 # くださいな。
 10 #
 11 import os
 12 import sys
 13 import multiprocessing
 14 import pickle
 15 from multiprocessing import Process, Queue
 16 from Queue import Empty
 17 
 18 # --------------------------------------------------------
 19 #
 20 #
 21 def _build_key(root, basename):
 22     # Windows 固有と思われるが長過ぎるファイル名が「置けて、列挙
 23     # 出来るのに読めない」ことが起こる。_build_key なんて関数は
 24     # ダルいから作りたくはないが、try/except するのでやむなく。
 25     # (UNC 形式なら 16bit 長までパス名を持てるが非UNC形式では
 26     # MAXPATH(=240だっけ?)文字を超えられない、という迷惑な仕様の
 27     # せい。Windows XP ではエクスプローラすらこのバグを持っていた。)
 28     fn = os.path.join(root, basename)
 29     try:
 30         return os.path.getsize(fn), fn
 31     except Exception as e:
 32         return None, None
 33 
 34 
 35 # --------------------------------------------------------
 36 #
 37 #
 38 def _calc_hash(fn):
 39     import hashlib
 40     import mmap
 41 
 42     # mmapが速い、のかどうかまでは試してはいない。ので、
 43     # mmapでなくてもいいのかも。ただ、チャンク読みしないと膨大
 44     # なメモリを抱え込んでしまい、速度も出ないことだけは確か。
 45     with open(fn, "r+b") as fi:
 46         d = hashlib.md5()
 47         mm = mmap.mmap(fi.fileno(), 0)
 48         chunksz = 16 * 1024
 49         for idx in range(0, mm.size(), chunksz):
 50             d.update(mm[idx:idx + chunksz])
 51         mm.close()
 52         hd = d.hexdigest()
 53         return hd
 54 
 55 
 56 # --------------------------------------------------------
 57 #
 58 #
 59 def _child(q_from_par, q_to_par):
 60     # 並列の子。親が Queue で渡してくるものをチマチマとハッシュ。
 61     # ちなみに、親が put する速度が何万倍も速いのであって、一個処理
 62     # している間に Queue には必要なタスクが全部詰まってることになる
 63     # と思ふ。
 64     result = []
 65     while True:
 66         try:
 67             r = q_from_par.get(block=False, timeout=60*1000)
 68             if not r:
 69                 q_from_par.put(None)  # notify for other child
 70                 break
 71             sz, fn = r
 72             result.append(
 73                 (_calc_hash(fn),
 74                  sz,
 75                  fn.decode(sys.stdout.encoding)))
 76         except IOError:
 77             pass  # ignore `permission denied'
 78         except Empty as e:
 79             pass
 80 
 81     # tempfile.mkstemp()で衝突しない名前のファイルに pickle。
 82     # 親には Queue を介して書き出したファイル名をお知らせ。
 83     import tempfile
 84     wrfn = tempfile.mkstemp()[1]
 85     pickle.dump(result, open(wrfn, "wb"))
 86     q_to_par.put(wrfn)
 87 
 88     
 89 # --------------------------------------------------------
 90 #
 91 #
 92 if __name__ == '__main__':
 93     # ファイルサイズをキーにファイル名収集(直列実行)
 94     list_by_size = {}
 95     for root, dirs, files in os.walk(sys.argv[1]):
 96         for bn in files:
 97             # 特定の拡張子だけ、などしたければ、fnmatch でどうぞ。
 98             # ここではやってないけど。
 99 
100             sz, fn = _build_key(root, bn)
101             if not sz:
102                 continue
103             if sz not in list_by_size:
104                 list_by_size[sz] = []
105             list_by_size[sz].append(fn)
106 
107     # 同一ファイルサイズにファイルが一つしかなければ重複ファイルでは
108     # ないので用済み
109     for sz in list_by_size.keys():
110         if len(list_by_size[sz]) == 1:
111             del list_by_size[sz]
112 
113     # ここから並列。子は本気でハッシュする。ハッシュ結果は各々 pickle
114     # で書き出し、書き出したファイル名を(q_to_par経由で)返却する。
115     q_from_par, q_to_par = Queue(), Queue()
116     procs = []
117     for i in range(multiprocessing.cpu_count()):
118         # このループは子を作って「タスク待ち開始」させてるだけ。
119         p = Process(target=_child,
120                     args=(q_from_par, q_to_par))
121         procs.append(p)
122         p.start()
123     for sz in list_by_size.keys():
124         # このループで、子にハッシュ指示を。
125         for fn in list_by_size[sz]:
126             q_from_par.put((sz, fn))
127     q_from_par.put(None)  # エンドマーカ。行儀良く死ね。
128 
129     # あとは子がお仕事終えるまで待つ
130     for p in procs:
131         p.join()
132 
133     # 子が個々に書き出した結果をマージする。
134     merged = {}
135     while not q_to_par.empty():
136         wrfn = q_to_par.get()
137         result = pickle.load(open(wrfn, "rb"))
138         for line in result:
139             h, sz, fn = line
140             if (sz, h) not in merged:
141                 merged[(sz, h)] = []
142             merged[(sz, h)].append(fn)
143         os.unlink(wrfn)
144     # ここでも同一キーに一つしかないものは重複でないので用済み
145     for k in merged.keys():
146         if len(merged[k]) == 1:
147             del merged[k]
148 
149     # ここから先はお好きにどうぞ。
150     for sz, h in reversed(sorted(merged.keys())):
151         print(u"%ld, %s, %s" % (sz, h, merged[(sz, h)]))

鬱陶しいほど長くなってしまった。すまん。

まぁまぁ速いです。並列化はしてるけれどそっちの効果ではなくて。あぁ、かなりデカいファイル持ってる場合じゃないと速い実感はないと思います。小さいファイルばかりの場合は速くはないと思う。