Skip to content

Commit 8809ab7

Browse files
authored
PR #98: Duplicate File Finder
Add Duplicate File Finder mini-project Merge pull request #98 from zain-cs/duplicate-file-finder
2 parents 003d62f + 01a2685 commit 8809ab7

3 files changed

Lines changed: 247 additions & 0 deletions

File tree

Duplicate-File-Finder/README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Duplicate File Finder
2+
3+
A command-line tool that scans a directory (recursively) and finds files
4+
that are byte-for-byte identical, based on content hashing rather than
5+
just filenames.
6+
7+
## How it works
8+
9+
1. Files are first grouped by size — files of different sizes can never
10+
be duplicates, so this is a cheap way to skip most comparisons.
11+
2. Remaining candidates are hashed using SHA-256 (read in chunks, so
12+
large files don't get loaded into memory all at once).
13+
3. Files that share a hash are reported as duplicates.
14+
15+
## Usage
16+
17+
```bash
18+
python duplicate_finder.py <directory>
19+
```
20+
21+
Example:
22+
23+
```bash
24+
python duplicate_finder.py ~/Downloads
25+
```
26+
27+
Output:
28+
29+
```
30+
Duplicate group (2 files, hash 3a7bd3e2ff...):
31+
/home/user/Downloads/report.pdf
32+
/home/user/Downloads/report (1).pdf
33+
34+
Total duplicate groups: 1
35+
Space that could be reclaimed: 245.3 KB
36+
```
37+
38+
### Optional: delete duplicates
39+
40+
```bash
41+
python duplicate_finder.py <directory> --delete
42+
```
43+
44+
This keeps the first file found in each duplicate group and asks for
45+
confirmation before deleting the rest.
46+
47+
## Running tests
48+
49+
```bash
50+
pip install pytest
51+
pytest test_duplicate_finder.py -v
52+
```
53+
54+
## Requirements
55+
56+
None — uses only the Python standard library.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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'\nDuplicate group ({len(paths)} files, hash {file_hash[:10]}...):')
76+
for path in paths:
77+
print(f' {path}')
78+
79+
print(f'\nTotal 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('\nDelete 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()
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Tests for duplicate_finder.py"""
2+
3+
import os
4+
import tempfile
5+
6+
from duplicate_finder import find_duplicates, hash_file
7+
8+
9+
def test_hash_file_is_consistent():
10+
with tempfile.NamedTemporaryFile(delete=False, mode='w') as f:
11+
f.write('hello world')
12+
path = f.name
13+
14+
try:
15+
assert hash_file(path) == hash_file(path)
16+
finally:
17+
os.remove(path)
18+
19+
20+
def test_find_duplicates_detects_identical_content():
21+
with tempfile.TemporaryDirectory() as tmp_dir:
22+
path_a = os.path.join(tmp_dir, 'a.txt')
23+
path_b = os.path.join(tmp_dir, 'b.txt')
24+
path_c = os.path.join(tmp_dir, 'c.txt')
25+
26+
with open(path_a, 'w') as f:
27+
f.write('same content')
28+
with open(path_b, 'w') as f:
29+
f.write('same content')
30+
with open(path_c, 'w') as f:
31+
f.write('different content')
32+
33+
duplicates = find_duplicates(tmp_dir)
34+
35+
assert len(duplicates) == 1
36+
(group,) = duplicates.values()
37+
assert set(group) == {path_a, path_b}
38+
39+
40+
def test_find_duplicates_ignores_unique_files():
41+
with tempfile.TemporaryDirectory() as tmp_dir:
42+
with open(os.path.join(tmp_dir, 'a.txt'), 'w') as f:
43+
f.write('content one')
44+
with open(os.path.join(tmp_dir, 'b.txt'), 'w') as f:
45+
f.write('content two')
46+
47+
duplicates = find_duplicates(tmp_dir)
48+
49+
assert duplicates == {}
50+
51+
52+
def test_find_duplicates_handles_nested_directories():
53+
with tempfile.TemporaryDirectory() as tmp_dir:
54+
nested_dir = os.path.join(tmp_dir, 'nested')
55+
os.makedirs(nested_dir)
56+
57+
path_a = os.path.join(tmp_dir, 'a.txt')
58+
path_b = os.path.join(nested_dir, 'b.txt')
59+
60+
with open(path_a, 'w') as f:
61+
f.write('shared content')
62+
with open(path_b, 'w') as f:
63+
f.write('shared content')
64+
65+
duplicates = find_duplicates(tmp_dir)
66+
67+
assert len(duplicates) == 1
68+
(group,) = duplicates.values()
69+
assert set(group) == {path_a, path_b}
70+
71+
72+
def test_find_duplicates_empty_directory():
73+
with tempfile.TemporaryDirectory() as tmp_dir:
74+
assert find_duplicates(tmp_dir) == {}

0 commit comments

Comments
 (0)