


Хороший небольшой пет-проект на Python можно довести до законченного сценария: передать папку, найти файлы с одинаковым содержимым и получить отчёт. В этом разборе утилита ничего не удаляет и не перемещает. Решение о том, нужна ли копия, остаётся за человеком.
Здесь будут работа с файловой системой, чтение по частям, хеширование, обработка ошибок и тесты. На Mentoring.digital можно найти наставника для разбора собственного решения. Сам пример запускается на Python 3.12 и использует только стандартную библиотеку.
Дубликатом будем считать совпадение байтов, даже если имена и расширения разные. Похожие фотографии, одинаковый текст в разных кодировках и документы с разными метаданными в эту задачу не входят. Для них нужны другие правила сравнения.
Для проверки создайте отдельную учебную папку. В неё положите две копии небольшого файла, другой файл того же размера и пару пустых файлов. Не начинайте эксперимент со всего диска.
Размер позволяет быстро исключить заведомо разные файлы: у побайтово одинаковых файлов он совпадает. Только для групп с одинаковым размером вычислим SHA-256. Совпавший хеш затем подтвердим сравнением байтов. Так логика проекта не зависит от предположения, что совпадение хеша всегда доказывает равенство.
Файл читается блоками по одному мегабайту. Это не загружает его целиком в оперативную память. Список путей всё равно хранится в памяти, поэтому пример не является готовым промышленным сканером многомиллионного архива.
Сохраните следующий код в duplicates.py. Функция поиска возвращает данные, а вывод JSON находится отдельно. Это позволяет проверять алгоритм без запуска командной строки.
import argparse
import hashlib
import json
import os
from collections import defaultdict
from pathlib import Path
def digest(path):
before = path.stat()
h = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
h.update(block)
after = path.stat()
if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
raise OSError("file changed during reading")
return h.digest()
def same_bytes(left, right):
with left.open("rb") as a, right.open("rb") as b:
while True:
x, y = a.read(1024 * 1024), b.read(1024 * 1024)
if x != y:
return False
if not x:
return True
def find_duplicates(root):
root = Path(root).absolute()
if root.is_symlink() or not root.is_dir():
raise ValueError("root must be an existing directory, not a symlink")
sizes, hashes = defaultdict(list), defaultdict(list)
errors, seen = [], set()
def failed(error):
errors.append(str(error))
for folder, dirs, names in os.walk(root, onerror=failed, followlinks=False):
dirs[:] = sorted(d for d in dirs if not (Path(folder) / d).is_symlink())
for name in sorted(names):
p = Path(folder) / name
try:
if p.is_symlink() or not p.is_file():
continue
stat = p.stat()
identity = (stat.st_dev, stat.st_ino)
if stat.st_ino and identity in seen:
continue
if stat.st_ino:
seen.add(identity)
sizes[stat.st_size].append(p)
except OSError as error:
failed(error)
for paths in sizes.values():
if len(paths) < 2:
continue
for p in paths:
try:
hashes[digest(p)].append(p)
except OSError as error:
failed(error)
result = []
for candidates in hashes.values():
groups = []
for p in candidates:
try:
for group in groups:
if same_bytes(group[0], p):
group.append(p)
break
else:
groups.append([p])
except OSError as error:
failed(error)
result.extend(sorted(str(p.relative_to(root)) for p in g)
for g in groups if len(g) > 1)
return {"groups": sorted(result), "errors": errors}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("folder")
args = parser.parse_args()
try:
report = find_duplicates(args.folder)
except ValueError as error:
parser.error(str(error))
print(json.dumps(report, ensure_ascii=False, indent=2))
Запуск из папки с программой:
python duplicates.py ./sample
Если хотите сохранить отчёт, направьте вывод в файл вне проверяемой папки:
python duplicates.py ./sample > duplicates-report.json
Не записывайте отчёт внутрь сканируемого дерева во время его обхода: файл будет изменяться, пока программа читает данные. Пути в группах указаны относительно выбранной папки. На Windows разделитель пути может отличаться от Linux.
Основные случаи: разные имена при одинаковом содержимом; одинаковый размер при разных байтах; пустые файлы; несуществующая папка. Дополнительно принудительно подменим функцию хеширования одинаковым значением: разные файлы всё равно должны остаться разными. Ошибку чтения смоделируем отдельно, чтобы проверить попадание в отчёт.
Минимальный тест положите в test_duplicates.py рядом с программой:
import tempfile
import unittest
from pathlib import Path
from duplicates import find_duplicates
class DuplicateTests(unittest.TestCase):
def test_equal_files(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
(root / "a.txt").write_bytes(b"same")
(root / "b.bin").write_bytes(b"same")
report = find_duplicates(root)
self.assertEqual(report["groups"], [["a.txt", "b.bin"]])
self.assertEqual(report["errors"], [])
if __name__ == "__main__":
unittest.main()
Команда python -m unittest -v запускает тесты из текущей папки. Добавьте остальные случаи самостоятельно: это полезнее, чем только увидеть успешный запуск одного примера.
Изменение размера или времени модификации во время вычисления хеша фиксируется как ошибка. Но это не атомарный снимок файловой системы: другой процесс может изменить файл между проверками. Работайте с неподвижным набором учебных данных. Для рабочей системы понадобятся отдельные решения по снимкам, гонкам, специальным файлам и особенностям ссылок.
Программа не оценивает, какую копию можно удалить. Одинаковые файлы могут быть нужны разным проектам или служить резервными копиями. Список ошибок тоже важен: пустой результат при ошибках доступа не доказывает отсутствие дубликатов.
В README опишите критерий дубликата, команду запуска, небольшой пример отчёта и ограничения. Приложите тестовые данные, которые можно свободно распространять. Объясните, зачем нужны три этапа сравнения и почему удаление не включено в первую версию.
Следующие улучшения выбирайте по конкретной проблеме: исключение папок, прогресс, ограничение размера обхода, более подробные ошибки. Не добавляйте интерфейс и многопоточность только ради списка технологий: сначала измерьте, где именно тратится время.
Если пока сложно связать функции, коллекции и файловый ввод, пригодятся курсы Python с индивидуальным планом. Когда программа уже работает, наставник по Python поможет обсудить обработку ошибок, тестируемость и следующий обоснованный шаг в развитии проекта.