Formatting
The code formatting is good. The only major gripe I have is that there are some long lines (117 chars). Part of this is due to the high level of indenation, which we'll solve in another way, but even with heavily indented code, lines can be shorter. I use the black code formatter (with a linelength of 79) to format my code, so I don't have to worry about spacing, long lines or inconsistent quote characters any more
Try-except
You should surround the part that can fail (reading the filesize and file_hash) as closely as possible with the try-except
clause. If you want to print a debug message and prevent the rest of that iteration to be executed, you can do that by adding a continue
after the print
globals
as you question it, globals are not the correct way to pass information here. A different approach would be to have parts that
- generate the paths to the files to check
- return the hash
- look for duplicates in the hashes
- report the duplicates
Each part can receive the result of the previous step as input
generate the paths
Since python 3.4 there is a more convenient way to handle file paths: pathlib.Path
from pathlib import Path
def walking(path):
return Path(path).glob("**/*")
This returns a generator that yields Path
s to all of the files under path
. Since it is so simple, it can be inlined.
hashing
def hashing(paths, chunk=False):
for path in paths:
filesize = path.stat().st_size
if chunk:
file_hash = md5_sum(fname, 8192)
else:
file_hash = md5_simple(fname, file_size > 1000 * 1024 * 1024)
yield path, file_hash
magic numbers
This previous function has some magic numbers 8192
and 1000 * 1024 * 1024
.
Better would be to define those a module level constants:
CHUNKSIZE_DEFAULT = 8192
MAX_SIZE_BEFORE_CHUNK = 1000 * 1024 * 1024 # files larger than this need to be hashed in chuncks
def hashing(paths, chunk=False):
for path in paths:
filesize = path.stat().st_size
if chunk or file_size > MAX_SIZE_BEFORE_CHUNK:
file_hash = md5_sum(fname, CHUNKSIZE_DEFAULT )
else:
file_hash = md5_simple(fname)
yield path, file_hash
I'm not 100% happy with MAX_SIZE_BEFORE_CHUNK
as variable name, but can't immediately think of a better on
hoist the OI
If you look at this talk by Brandon Rhodes, it makes sense not to open the file in the method that calculates the hash, but have it accept a filehandle. This is also the approach taken in this code review answer
So to reuse the slightly adapted code from this answer
def read_chunks(file_handle, chunk_size=CHUNKSIZE_DEFAULT):
while True:
data = file_handle.read(chunk_size)
if not data:
break
yield data
def md5(file_handle, chunk_size=None, hex_representation=False):
if chunk_size is None:
hasher = hashlib.md5(file_handle.read())
else:
hasher = hashlib.md5()
for chunk in read_chunks(file_handle, chunk_size):
hasher.update(chunk)
return hasher.digest() if not hex_representation else hasher.hexdigest()
This gets called like this:
def hashing(paths, chunk=False, hex_representation=False):
for path in paths:
file_size = path.stat().st_size
with path.open("rb") as file_handle:
chunk_size = (
CHUNKSIZE_DEFAULT
if chunk or file_size > MAX_SIZE_BEFORE_CHUNK
else None
)
file_hash = md5(
file_handle,
chunk_size=chunk_size,
hex_representation=hex_representation,
)
yield path, file_hash
looking for duplicates
Instead of keeping a long string with all the duplicates, you can keep a set of the Path
s for each file_hash
. A collections.defaultdict(set)
is the suited container for this. You add each path to the set at the in the dict. At the end of the function, you filter the keys that have more than 1 entry:
def duplicates(hashings):
duplicates = defaultdict(set)
for path, file_hash in hashings:
duplicates[file_hash].add(path)
return {
filehash: paths
for filehash, paths in duplicates.items()
if len(paths) > 1
}
report the filesizes
instead of formating the csv-file yourself, you can use the csv
module. It even has a writerows
. Instead of defining the lambda function to srt by value, you can use operator.itemgetter
import csv
from operator import itemgetter
def report_results(file_handle, filesizes):
writer = csv.writer(file_handle, delimiter=",", lineterminator="\n")
sorted_sizes = sorted(filesizes.items(), key=itemgetter(1))
writer.writerows(sorted_sizes)
This method can also be used to save the file_hashes
reporting the duplicates
def report_duplicates(file_handle, duplicates):
writer = csv.writer(file_handle, delimiter=",", lineterminator="\n")
for file_hash, duplicate_files in duplicates.items():
for file_name in duplicate_files:
writer.writerow((file_hash, str(file)))
Here I exported a csv
file with the hash and filenames of the duplicates. If you want it in another format, you can easily change this method
bringing it together
def main(path, chunk=False):
files = [file for file in Path(path).glob("**/*") if file.is_file()]
filesizes = {str(path): path.stat().st_size for path in files}
with open("test_filesizes.csv", "w") as file_handle:
report_results(file_handle, filesizes)
filehashes = dict(hashing(files, chunk=chunk))
with open("test_hashes.csv", "w") as file_handle:
report_results(file_handle, filehashes)
duplicates = find_duplicates(filehashes.items())
with open("test_duplicates.csv", "w") as file_handle:
report_duplicates(file_handle, duplicates)
And then we put everything behind a __main__
guard:
if __name__ == "__main__":
main(<path>)