1+ """Duplicate File Finder.
2+
3+ Scans a directory tree and reports groups of files that are byte-for-byte
4+ identical, based on content hashing rather than filename or size alone.
5+
6+ Usage:
7+ python duplicate_finder.py <directory> [--delete]
8+
9+ Example:
10+ python duplicate_finder.py ~/Downloads
11+ python duplicate_finder.py ~/Downloads --delete
12+ """
13+
14+ import argparse
15+ import hashlib
16+ import os
17+ from collections import defaultdict
18+
19+
20+ def hash_file (file_path : str , chunk_size : int = 8192 ) -> str :
21+ """Return the SHA-256 hash of a file's contents.
22+
23+ NOTE: files are read in chunks so large files don't get loaded into
24+ memory all at once.
25+ """
26+ hasher = hashlib .sha256 ()
27+ with open (file_path , 'rb' ) as f :
28+ for chunk in iter (lambda : f .read (chunk_size ), b'' ):
29+ hasher .update (chunk )
30+ return hasher .hexdigest ()
31+
32+
33+ def find_duplicates (root_dir : str ) -> dict [str , list [str ]]:
34+ """Walk root_dir and group files by content hash.
35+
36+ Files are first grouped by size as a cheap pre-filter before hashing,
37+ since files of different sizes can never be duplicates.
38+ """
39+ size_groups : dict [int , list [str ]] = defaultdict (list )
40+
41+ for dirpath , _ , filenames in os .walk (root_dir ):
42+ for filename in filenames :
43+ full_path = os .path .join (dirpath , filename )
44+ try :
45+ file_size = os .path .getsize (full_path )
46+ except OSError :
47+ continue
48+ size_groups [file_size ].append (full_path )
49+
50+ hash_groups : dict [str , list [str ]] = defaultdict (list )
51+ for candidates in size_groups .values ():
52+ if len (candidates ) < 2 :
53+ continue
54+ for file_path in candidates :
55+ try :
56+ file_hash = hash_file (file_path )
57+ except OSError :
58+ continue
59+ hash_groups [file_hash ].append (file_path )
60+
61+ return {h : paths for h , paths in hash_groups .items () if len (paths ) > 1 }
62+
63+
64+ def print_report (duplicates : dict [str , list [str ]]) -> None :
65+ """Print a human-readable summary of duplicate groups."""
66+ if not duplicates :
67+ print ('No duplicate files found.' )
68+ return
69+
70+ total_wasted_bytes = 0
71+ for file_hash , paths in duplicates .items ():
72+ wasted = os .path .getsize (paths [0 ]) * (len (paths ) - 1 )
73+ total_wasted_bytes += wasted
74+
75+ print (f'\n Duplicate group ({ len (paths )} files, hash { file_hash [:10 ]} ...):' )
76+ for path in paths :
77+ print (f' { path } ' )
78+
79+ print (f'\n Total duplicate groups: { len (duplicates )} ' )
80+ print (f'Space that could be reclaimed: { total_wasted_bytes / 1024 :.1f} KB' )
81+
82+
83+ def delete_duplicates (duplicates : dict [str , list [str ]]) -> None :
84+ """Delete all but the first file in each duplicate group."""
85+ for paths in duplicates .values ():
86+ for path in paths [1 :]:
87+ os .remove (path )
88+ print (f'Deleted: { path } ' )
89+
90+
91+ def main () -> None :
92+ parser = argparse .ArgumentParser (description = 'Find duplicate files by content.' )
93+ parser .add_argument ('directory' , help = 'Directory to scan for duplicates' )
94+ parser .add_argument (
95+ '--delete' ,
96+ action = 'store_true' ,
97+ help = 'Delete duplicates, keeping only the first file found in each group' ,
98+ )
99+ args = parser .parse_args ()
100+
101+ if not os .path .isdir (args .directory ):
102+ print (f'Error: { args .directory } is not a valid directory' )
103+ return
104+
105+ duplicates = find_duplicates (args .directory )
106+ print_report (duplicates )
107+
108+ if args .delete and duplicates :
109+ confirm = input ('\n Delete duplicate files listed above? [y/N]: ' )
110+ if confirm .lower () == 'y' :
111+ delete_duplicates (duplicates )
112+ else :
113+ print ('Skipped deletion.' )
114+
115+
116+ if __name__ == '__main__' :
117+ main ()
0 commit comments