Как стать автором
Обновить

Процесс разработки приложения Python по дедупликации файлов с использованием контрольных сумм

Уровень сложностиПростой
Время на прочтение16 мин
Количество просмотров5.3K
Всего голосов 5: ↑4 и ↓1+5
Комментарии42

Комментарии 42

Есть программка jdupes под Linux. Делает примерно то же самое. Из заметных оптимизаций - она сначала собирает все размеры файлов и считает CRC только если есть файлы одного размера.

Сам я её пользуюсь примерно для того же (выкидывания одинаковых файлов). Запускаю примерно так:

jdupes --recurse --linkhard /path/to/photo/storage

Спасибо за интересную идею, в части оптимизации подсчета CRC файлов, в случае, если различается размер файлов. На вскидку можно собирать словарь, ключом в котором будет размер файла, а значением список путей. Интересно, какой будет эффект от такой оптимизации, насколько я понимаю, у нас потенциально может сократится количество операций полного чтения файлов для вычисления CRC.

Опять же плата за такую оптимизацию - это усложнение логики работы приложения.

Конечно, во многом это зависит от файлов, с которыми работаем.
Но скорее всего контрольная сумма будет считаться сильно реже, так как одинаковый размер файла вплоть до бита встречается не так уж часто.
Условно, даже два очень похожих внешне кадра не будут иметь одинаковый размер в силу механизмов под капотом у формата .jpeg.

Если вы пробегаетесь по дереву файлов правильно, например, так:

path = Path("c:\files")
for p in path.rglob("*"):
     do_it(p)

То у вас размер файла считывается в память вместе с именем, и проверять его несравнимо быстрее, чем дочитывать что-либо с диска.

Эх, был и я когда-то молод, учил питон, а еще качал без меры книжки. Глаза завидущие, руки загребущие. Дубликаты случались. Ну и написал вот это. Это не cli, это для выполнения в IDE, мне так удобно. Смотрел на вывод, выбирал что удалять. Писал для себя, давно было, учился, тапками не кидаться.

Не так чтобы очень сложно.
from os import walk
from os.path import basename, getsize, islink, join
from hashlib import md5
from collections import defaultdict


def duplicates(*roots, findLink=False, extensions=(".pdf", ".djvu", ".djv")):
    """
    usage: duplicates("папка1", "папка2", "папка3")
    """
    uniq_sizes = defaultdict(list)
    for root in roots:
        for pth, folder, files in walk(root):
            for name in [join(pth, f) for f in files if f.lower().endswith(extensions)]:
                if findLink or not islink(name):
                    uniq_sizes[getsize(name)].append(name)
    if uniq_sizes[0]:
        print("файлы-пустышки:", *sorted(uniq_sizes.pop(0)), sep="\n", end="\n\n")
    for v in [v for v in uniq_sizes.values() if 1 < len(v)]:
        duples = defaultdict(list)
        for name in v:
            with open(name, "rb") as f:
                duples[md5(f.read()).hexdigest()].append(name)
        for names in [v for v in duples.values() if 1 < len(v)]:
            print(min(map(basename, names), key=len))
            for name in names:
                print(f'rm "{name}"')
            print()

ps Сейчас запустил, глянул - мама дорогая, что творится-то!

когда-то писал похожее для себя.

там можно ещё промежуточную оптимизацию ввести: при совпадении размеров считать crc от первых N килобайт, и лишь при совпадении даже этих crc можно уже сравнить полное содержимое файлов, а не считать crc от всего объёма.

Зачем так: моя экшн-камера gopro hero black 8 из-за особенностей своей файловой системы нарезает все видео строго кусками не больше 4гб, и в результате у меня терабайты заполнены одинаковыми поразмеру видео. Считать crc32 от всего терабайта - долго, результат заранее почти всегда ясен.

Спасибо за комментарий! Ваша идея тоже очень интересна, и думаю, что так же как и те оптимизации, что предложили выше, оно даст свою выгоду.

Когда отвечал на первый комментарий, то отреагировал на него именно как человек, который разрабатывает приложение, и, наверное, это естественная реакция для тех, кто любит заниматься разработкой, находить различные способы решения задачи, пробовать ускорить время выполнения приложения или уменьшить потребляемые приложением ресурсы. Вот только идея статьи, которая легла в ее основы была иной.

Идея была постараться на относительно простой задаче показать, что даже небольшая утилита командной строки требует комплексного подхода. Необходимо подумать о интерфейсе, то есть о том, как пользователь будет использовать данное приложение. Следующим шагом нужно подумать, как дать возможность пользователю понять, что приложение работает и выполняет полезную работу, при этом дать ему возможность в случает необходимости избавиться от вывода. Важно, еще перед началом разработки понять задачу, формализовать ее, и потом приступать к решению.

Целью статьи не являлось создание самого эффективного способа решения обозначенной задачи, хотя это было бы очень амбициозным проектом, а показать способ начинающим разработчикам, как можно начать процесс разработки. Видимо мне это не удалось.

Почему я вообще позволил себе говорить об этом? В первую очередь потому, что когда-то мне было сложно перейти от решения алгоритмических задач, а разработке приложений. И я помню о том, как это было.

После реализации некоторое количество алгоритмов, структур данных, отдельных функций, которые решают какую-то задачу, бывает сложно перейти к написанию комплексного приложения, законченного продукта, которым будет пользоваться другие. Сложно бывает даже определиться в одном файле написать приложение, или разбить его на несколько модулей.

Здорово, когда есть наставник, который подскажет и направит, но когда его нет, как это было в моем случае, хотелось бы что-бы кто-то показал на что стоит обратить внимание, и о чем не плохо было бы подумать. Причем часто это те вещи, которые профессионалы воспринимают как само собой разумеющееся, и не обращают на них внимание.

А если первые N килобайт совпадают, а где-то в середине или в конце файла кто-то (или что-то) изменил/заменил содержимое, но при этом размер файла не изменился?

душненько. Да, в такой ситуации мы проиграли и сфоллбечились на полное сравнение файлов, как в исходной статье (точнее сравнение crc32 полных файлов). Но я наблюдал, что такая ситуация редка настолько, что ею можно пренебречь.

Я для общего развития интересуюсь, т.к. к разработке отношения не имею.

CRC, как и любой другой hash, может дать одинаковый результат для разных файлов. Так-что, проверка равенства размеров файлов - абсолютно необходима.

Спасибо за комментарий! Благодаря вам засомневался в достаточности вычисления только контрольной суммы с помощью алгоритма MD5, и пошел искать информацию по этому вопросу.

В статье "Cautions (or why it’s hard to write a dupefinder)" (прим. статья на английском), есть раздел "Collision Robustness", где показан практический пример того, когда для двух разных файлов контрольная сумма MD5 совпадает, а вот SHA1 уже отличается. При этом размер файлов также совпадает, так что даже проверки на совпадение размера файлов в дополнении к контрольной сумме может быть недостаточно!

$ mkdir test && cd test

$ wget http://web.archive.org/web/20071226014140/http://www.cits.rub.de/imperia/md/content/magnus/order.ps
$ wget http://web.archive.org/web/20071226014140/http://www.cits.rub.de/imperia/md/content/magnus/letter_of_rec.ps

$ ls -l
total 8
-rwxrwxrwx 1 user user 2029 Jun 19  2007 letter_of_rec.ps
-rwxrwxrwx 1 user user 2029 Jun 19  2007 order.ps

$ md5sum *
a25f7f0b29ee0b3968c860738533a4b9  letter_of_rec.ps
a25f7f0b29ee0b3968c860738533a4b9  order.ps

$ sha1sum *
07835fdd04c9afd283046bd30a362a6516b7e216  letter_of_rec.ps
3548db4d0af8fd2f1dbe02288575e8f9f539bfa6  order.ps

$ sha256sum *
de4e4c6e2b94e95a3c5bd72a9a6af29bc5f83bf759325d9921943a6fc08ea245  letter_of_rec.ps
077046dd66015e05c3e03a43a6e4de129038e0701de5a4103fc7ed91c3782d06  order.ps

$ diff --binary letter_of_rec.ps order.ps
Binary files letter_of_rec.ps and order.ps differ

$ stat *
  File: letter_of_rec.ps
  Size: 2029            Blocks: 8          IO Block: 4096   regular file
Device: eh/14d  Inode: 35184372088972066  Links: 1
Access: (0777/-rwxrwxrwx)  Uid: ( 1000/ user)   Gid: ( 1000/ user)
Access: 2023-12-17 03:40:20.897605000 +0500
Modify: 2007-06-19 14:01:09.000000000 +0600
Change: 2023-12-17 03:37:36.616898300 +0500
 Birth: -
  File: order.ps
  Size: 2029            Blocks: 8          IO Block: 4096   regular file
Device: eh/14d  Inode: 59672695062770586  Links: 1
Access: (0777/-rwxrwxrwx)  Uid: ( 1000/ user)   Gid: ( 1000/ user)
Access: 2023-12-17 03:40:20.897605000 +0500
Modify: 2007-06-19 14:01:09.000000000 +0600
Change: 2023-12-17 03:37:28.041841500 +0500
 Birth: -

Пусть даже, как я уже отметил в одном комментарии, целью статьи не являлось создание самого эффективного способа решения обозначенной задачи, а задача лишь использовалось для демонстрации процесса разработки, но наличие такого уровня ошибки, делают приложение потенциально опасным.

Можно, конечно, заменить алгоритм подсчета контрольной суммы на другой, но, получается, чтобы окончательно исключить возможность удаления различающихся файлов, имеющих одинаковую контрольную сумму из-за коллизии, можно только после побайтового сравнения файлов.

Еще раз хочу поблагодарить за комментарий! Решил повнимательнее посмотреть на утилиту jdupes, обнаружил, что в репозитории Ubuntu 23.04 доступна версия 1.21.3-1. Оказалось, что она делает ровно тоже, что было описано в статье, за исключением удаления пустых каталогов, но это можно решить с помощью утилиты find. То есть сеанс работы может выглядеть следующим образом:

$ # Удаляем дубликаты
$ jdupes --delete --no-prompt --recurse source target

$ # Удаляем пустые каталоги  
$ find target/ -type d -empty -delete

И еще раз хочу обратить внимание на то, что задумкой статьи было показать способ начинающим разработчикам, сам процесс разработки, и продемонстрировать возможные "грабли", на которые мне, если присмотреться к комментариям, удалось не просто наступить, а прям станцевать.

А сравнить скорость работы с CloneSpy?

Простите, долго писал комментарий, отвечая на предыдущие.

Сравнивать разработку, описанную в статье, с другими приложениями, решающие похожую задачу, нет смысла. Сам по себе подход к решению, описанный в статье, можно назвать "рабоче-крестьянским", то есть минимально возможные вложения для достижения желаемого результата.

Статья преследовала другую цель, и об этом я постарался сказать в комментарии выше.

Дедупликаторов очень много, но мне не встречался ни один в котором можно было бы прописывать правила дедупликации

Типа автоматически удалять фото меньшего размера при одинаковом содержимом или оставлять наиболее глубоко расположенный в файловой системе экземпляр. В общем есть ещё куда работать.

Спасибо за комментарий! Вы подняли очень важный вопрос - вопрос интерфейса и взаимодействия пользователя с программой. Под интерфейсом понимается множество вариантов, начиная от текстового интерфейса командной строки (CLI), до графического интерфейса (GUI).

Многое в этом зависит от квалификации пользователя. Потому что одно дело, если это пользователь GNU/Linux, который привык к консоли, чтению мануалов и т.д., и другое, если это пользователь, который привык только к "интуитивному" графическому интерфейсу.

Допустим, у нас есть ядро, то есть приложение, которое выдает нам список дубликатов, при этом решающее эту задачу максимально эффективным способом. Тогда, вероятно, можно создать некоторый специфичный для этой задачи язык (DSL), на котором можно описывать правила, как обрабатывать данный список. При этом такой DSL может быть реализован в виде ключей, передаваемых при запуске приложения.

Вот только сразу же появляется задача обучения данному языку пользователя приложения, тут одного "интуитивного подхода" уже будет недостаточно. А так же другая задача, это определение поведения по умолчанию.

Опять же, как реализовывать DSL в формате графического интерфейса, слабо могу представить, за исключение того, чтобы дать пользователю возможность описывать правила в виде текстового файла, или набора полей ввода, то есть опять же, с помощью текста. А если же это какой-то вариант, когда можно собирать правила из "кубиков", то, боюсь, интерфейс приложения будет сложнее, чем ядро этого приложения. Да и "собирать кубики" пользователя тоже придется учить. Насколько усилия по разработке такого приложения будут оправданы, и получить ли вообще сделать его привлекательным для пользователя.

Возвращаясь к идее, что у нас есть "ядро", которое умеет эффективно решать задачу нахождения дубликатов, и отдавать нам, например, в виде текста список путей. Тогда используя командный интерпретатор (shell), или язык общего назначения, например, Python, мы можем описать правила обработки получаемого вывода, и нам не нужно будет придумывать DSL для этой задачи. Но, в этом случае, у нас должна быть достаточна высокая квалификация пользователя.

Вообще интересно, а можно ли найти общее решение для данной задачи, или же это задача частного характера, подобно задачи генерации отчетов? И еще ощущение, что это возвращает нас к истокам философии UNIX, когда при выборе решить задачу комбинируя существующие программы или написать отдельную, в большинстве случаев предпочтение отдается первому варианту.

Еще раз хочу вас поблагодарить за комментарий, поскольку это дало возможность порассуждать на данную тему, ведь, именно с интерфейса следует начинать разработку.

Рабочее решение можно подсмотреть в интерфейсе почтовых клиентов, а точнее в редакторе правил распределения писем. А вообще про плачевное состояние нынешних ui и ux, и предпосылки этого в виде той самой философии юникс и капитализма можно не одну статью написать.

Контрольная сумма CRC (cyclic redundancy check) - это алгоритм, используемый для проверки целостности данных. Он вычисляется по определенному алгоритму на основе содержимого файла и используется для сравнения с контрольным значением, сохраненным в файле. Если контрольные суммы совпадают, файл считается неизмененным.

CRC часто используется для проверки целостности файлов при передаче их по сети, а также при резервном копировании и восстановлении данных.

Но CRC не следует использовать для сравнения файлов, если необходимо обнаружить даже небольшие изменения в файле. Это связано с тем, что CRC имеет относительно низкую чувствительность к изменениям в данных.

Вот некоторые причины, по которым не следует использовать CRC для сравнения файлов:

  • CRC нечувствителен к перестановкам байтов. Например, если в файле поменять местами два байта, контрольная сумма CRC не изменится.

  • CRC нечувствителен к изменениям в данных, которые не влияют на их общую сумму. Например, если в файле добавить или удалить один байт, контрольная сумма CRC также не изменится.

  • CRC может давать ложные срабатывания. Это означает, что контрольные суммы двух файлов могут совпадать, даже если файлы содержат разные данные.

Для сравнения файлов, в которых необходимо обнаруживать даже небольшие изменения, рекомендуется использовать другие алгоритмы, такие как MD5, SHA-1 или SHA-256. Эти алгоритмы имеют более высокую чувствительность к изменениям в данных и обеспечивают более высокую точность.

В данном случае можно даже просто последовательно ксорить по n байт. В любом случае по итогам быстрого сравнения нужно будет проводить полное потому что ни один хеш не даёт да и не может давать стопроцентной гарантии отсутствия коллизий

Спасибо за уточнение терминологии! В предлагаемом решении для вычисления контрольной суммы был использован именно алгоритм MD5. Я исходил из того, что CRC это не конкретный алгоритм, а термин описывающий множество алгоритмов, в которые как раз входят MD5, SHA-1 или SHA-256 и т.д.

CRC это класс алгоритмов, фактически один алгоритм. MD5 и SHA никоим образом не относятся к CRC. Последние связаны с нахождением остатка от деления, линейный алгоритм.

Ну, собственно, мысль в том, что два разных файла одной длины могут иметь одинаковый MD5. Ну да, вероятность невелика, но реально бывает.

Хм-м-м. Есть пруфы про "перестановку байт" и "удаление байта" в CRC?

except OSError:
logger.error('Deleting of file %s failed', target_file)

А почему не выведена сама ошибка? Плохо. Как потративший тысячи часов на сопровождение больших систем, могу сказать, что такие "информативные" сообщения об ошибках просто бесят. Самый топ, это сообщение типа "ой, что-то пошло не так".

Попытка удалить директорию - ну прямо неправильно. Неправильно по действию - сделать что-то плохое, зная, что не сработает. Неправильно по философии исключений - тут исключение является стандартной ситуацией. То есть, так можно, если нет "более лучшего" варианта.

Ну и в подпрограмме удаления, почему выбран другой метод итерации? Было было логично точно так же построить набор путей, а потом уже с ними работать. Так обе части (с source и с targets) выглядели бы почти идентичными. Даже можно было бы сделать одну подпрограмму и подсовывать ей разные методы действия с файлом (считаем crc и кладем в сет или считаем crc, сравниваем и удаляем)

Ну и конечно, я бы не стал отдельно делать этот список с путями - совершенно ненужный объект, только тратится процессор и память.

Спасибо за комментарий. Отсутствие вывода причины, которое вызвало исключение, а только сам факт исключения, действительно было не лучшим решением, тем более, что исправить это практически ничего не стоит, например, можно сделать следующим образом:

try:
	os.remove(target_file)
	logger.info('File %s was removed', target_file)
except OSError as error:
	logger.error('Deleting of file %s failed, reason: %s', target_file, error)

Что касается использования исключений, как инструмента в рамках нормального потока исполнения, а не обработки действительно исключительных ситуаций, как отмечено в тексте статьи, является очень и очень спорным решением. Хотя подход "проще просить прощения, чем разрешения", мне встретился на одной из международных Python конференций.

Опять же, вы обратили внимание на то, что показывает разницу между скриптом, написанным для личного использования, и продуктовую разработку. Использовать исключения для нормального потока выполнения в продуктовой разработке, скорее можно отнести к "антипатернам".

Выбор иного способа обработки дерева каталогов при удалении, то есть одновременный обход, вычисление контрольной суммы и удаление, в первую очередь был обусловлен желанием показать, что так тоже возможно. То есть в рамках одного примера, показать разные способы решения задачи.

Безусловно вы правы в том, что можно описать обобщенную функцию обхода дерева каталогов, и передавать ей в качестве параметра функцию, которая будет выполняться в зависимости от того, на каком этапе выполнения находится программа. Но это может оказаться сложнее для понимания, и такое решение было бы скорее интересно для опытных разработчиков.

Ну и последнее. Список путей, который формируется, появился в процессе попыток сократить время выполнения приложения за счет использования ProcessPoolExecutor и ThreadPoolExecutor. Вычисление контрольных сумму можно распараллелить, а вот как распараллелить обход дерева каталогов? Поэтому сначала выполняем то, что можно сделать только последовательно, собираем результат, а потом выполняем то, что можно делать одновременно.

проще просить прощения, чем разрешения

И где же тут просят прощения? Не катит ссылка на конференцию.

А пробовали увеличить размер блока с 4К до 256К, например?

Соглашусь с вами, что просто отсылка на конференцию выглядит слабовато. Тогда позвольте сослаться на книгу: Седер Наоми / Python. Экспресс-курс. 3-е изд. — СПб.: Питер, 2019. — 480 с.: ил. — (Серия «Библиотека программиста»). ISBN 978-5-4461-0908-1

Об авторе
Наоми Седер, автор третьего издания книги, занимается программированием на различных языках в течение почти 30 лет. Она работала системным администратором Linux, преподавателем программирования, разработчиком и системным архитектором. Она начала использовать Python в 2001 году, и с тех пор преподавала Python пользователям всех уровней, от 12-летних до профессионалов. Она рассказывает о Python и о достоинствах Python-сообщества каждому, кто готов слушать. В настоящее время Наоми руководит группой разработки для Dick Blick Art Materials и является председателем Python Software Foundation

14.2. Исключения в Python
Подход к обработке ошибок в Python в целом отличается от подхода в таких языках, как, скажем, Java. Эти языки по возможности стараются выявить как можно больше возможных ошибок заранее, поскольку обработка исключений после их возникновения может обойтись достаточно дорого. Такой стиль описан в первой части этой главы; иногда он обозначается сокращением LBYL (Look Before You Leap, то есть «Смотри, прежде чем прыгать»).

С другой стороны, Python скорее полагается на то, что исключения будут обработаны после их возникновения. И хотя такой подход может показаться рискованным, при разумном использовании исключений код получается менее громоздким и лучше читается, а ошибки обрабатываются только в случае их возникновения. Подход Python к обработке ошибок часто описывается сокращением EAFP (Easier to Ask Forgiveness than Permission, то есть «Проще просить прощения, чем разрешения»).

Так же, есть обсуждение на StackOverflow "What is the EAFP principle in Python?"

Опять же не исключено, что в данном случае я неправильно применил данный подход. Есть повод вернуться и подумать над этим.

Что касается изменения размеров считываемого блока, то нет менять его не пробовал. Если быть честным, то мысль была поэкспериментировать с этим, но тут нужно каким-то образом измерять результаты изменений, а это, мягко говоря, не очень просто.

Когда я пробовал тестировать данный скрипт в Windows 10 22H2, я столкнулся с какой-то "магией" кэширования, причем даже не смог найти где именно оно происходит, когда при первом запуске приложение работает 90 секунд, а вот повторный запуск завершается за 1,5 секунды и дает тот же самый результат.

Конечно используете питоровский исключений неправильно - ключевое слово pass говорит всё - исключение у вас вовсе не исключение.

Насчёт кэширования - интересно (наверное, кэширует ОС) и прекрасно показывает, как мало времени занимает расчёт crc по сравнению с чтением с диска. То есть, оптимизировать надо чтение, а не расчёт.

Для покрытия всего кода тайп хинтами, можно использовать кастомный неймспейс в методе parse_args, либо специальную библиотеку, но я не знаю достаточно проработанных.

Зря вы не выложили код на GitHub можно было бы создать Issues.

Предложил бы добавить вот какие фичи:

  1. Добавить работу с переменными среды MY_PHOTOS_DIR и чтоб можно было указать путь до моей основной коллекции. В коллекции чтоб хранился индекс-файл photos.index с хэшами всех моих файлов моих проиндексированных файлов

  2. Добавить в коллекцию файл removed_files.index. Его назначение хранить хэши файлов, которые я решил удалить когда-либо. Что если у меня еще где-то сохранилась копия фоток? А ведь среди них могут уже быть ранее удаленные мною файлы. Как избежать повторного просмотра? Если я решил удалить, значит все! Смысл возвращаться к этому процессу? Итак фоток столько, что не пересмотреть )))

Спасибо за ваше предложение! Но, если честно, то сейчас, после обсуждения и выявления фундаментального изъяна в алгоритме, который при определении идентичности файлов полагается только на алгоритм MD5, я считаю, хорошо, что исходный текст приложения находится в тексте статьи, и вероятность заметить предупреждение о потенциальной ошибке выше, чем если бы он был выложен на GitHub.

Хотя, предложенный вариант, пусть с оговорками и ограничениями, может быть использован для решения практических задач, все же не имеет смысла создавать дополнительные возможности и развивать приложение, пока не будет устранен описанный выше недостаток.

И еще раз хотел бы упомянуть, что целью статьи не являлось создание самого эффективного способа решения обозначенной задачи, а показать способ начинающим разработчикам, как можно начать процесс разработки.

А вообще у меня есть вариант скрипта для одной из задач, который как раз хранит файл с вычисленными значениями MD5, и при добавлении новых файлов обновляется, это позволяет существенно экономить время работы. Есть вариант, в котором эти процессы разделены, PowerShell скрипт считает контрольные суммы и экспортирует их в файл формата CSV, а скрипт на Python уже на основании этого файла производит обработку.

запустил финальный скрипт (Ubuntu 20.04 Python 3.11.1) просто ради интереса - не работает оно, если бы я предварительно вызовы "os." не закомментировал, то удалило бы мне далеко не пустые дирректории вот в том месте где logger.info('Empty dir %s was removed')

Прошу меня простить, за резкий тон данного ответа, но я утверждаю, что приложение работает так, как и должно. В приложении действительно есть потенциальная ошибка, связанная с коллизиями при использовании алгоритма MD5, о чем указано в дополнении к статье, но в остальном все работает ровно так как задумано и описано.

Почему вы решили, что если бы вы не закомментировали вызов os.rmdir, то были бы удалены не пустые каталоги?

Могу предположить, что вы исходите из того, что после того, как вы закомментировали вызов os.rmdir, в выводе вы увидели строки "Empty dir /some/path/to/dir was removed"?

Если это так, то смею вас уверить, в случае если вызов os.rmdir будет вызван с передачей в качества аргумента непустого каталога, то будет выброшено исключение, и строка logger.info(Empty dir %s was removed) просто не будет выполнена.

Ну и чтобы не быть голословным, ссылка на документацию https://docs.python.org/3/library/os.html?highlight=os rmdir#os.rmdir, в которой явно указано, что если каталог не пустой, то будет выброшено исключение OSError:

os.rmdir(path, *, dir_fd=None)
	Remove (delete) the directory path. If the directory does not exist
	or is not empty, a FileNotFoundError or an OSError is raised respectively.
	In order to remove whole directory trees, shutil.rmtree() can be used.

Поэтому хотел бы попросить вас, создать тестовое окружение, то есть дерево каталогов с исходными файлами, дерево каталогов откуда вы хотите удалить дубликаты, при этом чтобы часть файлов в подкаталогах была уникальна, и запустить приложение в том виде, в котором оно предлагается, без правок, и убедиться в результате работы.

И было бы очень хорошо, если бы вы сообщили результаты своего нового тестирования в ответном комментарии.

Как-то делали такое приложение с одним коллегой в универе. Тоже быстро придумали читать файлы один раз: при наличии файла такой же длины получали контрольную сумму (собирали путь к файлу, его длину и CRC, при наличии уже файла такой же длины в списке). Потом выводили в две панели аля Нортон (ну или Фар, если кто не застал Нортон).

Однако, стоит иметь ввиду, что два разных файла могут иметь одну и ту же контрольную сумму. И вроде кажется, что вероятность небольшая, но это только кажется, т.к. количество коллизий хеш-суммы/CRC равняется двойке в степени разности бит между размером хеш-суммы и итоговых данных. Вообще, проблемы с коллизиями контрольных сумм встречаются достаточно часто (как-то даже на гите было).

Не вижу в требованиях условий про переименования файлов. Файлы в target могут быть переименованы?

Да, приложение, предложенном варианте, не принимает во внимание ни имя файла, ни иные метаданные файла, а опирается только на результат преобразования содержимого файла в контрольную сумму с помощью алгоритма MD5. Иными словами, если обобщить и упростить, то файлы сравниваются по содержимому. Но не стоит забывать о существовании вероятности коллизии при использовании данного подхода.

Действительно, в требованиях это не указано, что именование файлов не должно иметь значение, поскольку опять же это требование воспринималось как само собой разумеющееся, что, судя по вашему вопросу, таковым не является.

Простите, я запустил ваш код, интерпретатор выдал мне:

runfile('C:/Python_Temp/Dublicate/Dublicate.py', ['-h', 'c:/1', 'c:/2'], wdir='C:/Python_Temp/Dublicate')

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\spyder_kernels\customize\spydercustomize.py:528 in runfile
return _exec_file(

File ~\AppData\Local\Programs\Python\Python312\Lib\site-packages\spyder_kernels\customize\spydercustomize.py:555 in _exec_file
raise TypeError("expected a character buffer object")

TypeError: expected a character buffer object

Что это такое?!

Из предоставленного стектрейса видно, что ошибка происходит в одном из модулей пакета spyder-kernels. С данным пакетом мне никогда не приходилось встречаться.

Знакомство с исходным текстом данного пакета в репозитории spyder-ide/spyder-kernels выявило, что данная ошибка выбрасывается в одном месте модуля
spyder_kernels/customize/code_runner.py при вызове метода _exec_file() экземпляра класса SpyderCodeRunner на строке 272 при следующей проверке:

if args is not None and not isinstance(args, str):
	raise TypeError("expected a character buffer object")

Получается что args не является значением None и в тоже время это не строка, которая ожидается в данном месте.

Данный метод вызывается из метода runfile экземпляра класса SpyderCodeRunner и в качестве параметром ему передается args, который получен в результате вызова метода _parse_runfile_argstring() экземпляра класса SpyderCodeRunner, далее в процессе стали появляться методы и декораторы с замечательным словом в названии magic, и стало понятно, что эту цепочку вызовов мне уже не осилить.

Вообще смущает один момент, если из первой строки runfile('C:/Python_Temp/Dublicate/Dublicate.py', ['-h', 'c:/1', 'c:/2'], wdir='C:/Python_Temp/Dublicate') считать что ['-h', 'c:/1', 'c:/2'] это аргументы, которые передается скрипту при его вызове, то в этом месте ожидается не список, в смысле тип list, а именно строка, то есть тип str, потому что в итоге оно попадает в функцию shlex.split.

В общем, насколько я понимаю, до вызова и исполнения предложенного мной в статье кода дело даже не дошло.

Очень жаль, что не смог помочь!

Я попробовал из командной строки, в папках 1 и 2 расположил одинаковые файлы и написал:

C:\Users\NN\AppData\Local\Programs\Python\Python312>python C:\Python_Temp\Dublicate\Dublicate.py 'C:\Python_Temp\Dublicate\1', 'C:\Python_Temp\Dublicate\2'

Вот, что выдает программа:
2023-12-31 17:00:53,905 [INFO] Creating set of hashes for "'C:\Python_Temp\Dublicate\1'," has begun
2023-12-31 17:00:53,906 [INFO] Directory tree traversal 'C:\Python_Temp\Dublicate\1', started
2023-12-31 17:00:53,907 [INFO] Checksums calculation for a set of paths has begun
2023-12-31 17:00:53,909 [INFO] Processing of the target directory 'C:\Python_Temp\Dublicate\2' has begun

По логики программы из каталога 2 должны быть удалены копии, что содержаться в каталоге 1. Но ничего не происходит. Автор помоги пожалуйста. У меня есть диск с повторяющейся информацией -и эта программа более чем актуальна.

Хотелось бы понять, а много ли файлов в тестовых каталогах? Потому что исполнение приложение происходит очень быстро, то есть с момента запуска до момента завершения проходит всего 4 сотых секунды.

Предлагаю добавить отладочное логирование, чтобы посмотреть более детально на ход исполнения приложения. Чтобы не возиться с "заплатками", предлагаю полные текст приложения:

Текст приложения с отладочным выводом
#!/usr/bin/env python3
"""Application to remove duplicates and empty directories"""

import argparse
import sys
import logging
import os
import hashlib

from pathlib import Path
from typing import List, Optional


logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(formatter)
logger.addHandler(handler)


READING_BLOCK_SIZE = 4096


def calculate_crc(path: Path) -> Optional[str]:
    """Returns CRC file for the path"""
    crc = hashlib.md5()
    try:
        with open(path, 'rb') as fd:
            while True:
                chunk = fd.read(READING_BLOCK_SIZE)
                if not chunk:
                    break
                crc.update(chunk)
    except OSError as err:
        logger.error("Processing attempt: %s failed with error: %s", path, err)
        return None

    return crc.hexdigest()


def create_set_of_crc(source: Path) -> set:
    """Return a set of files hashes for the path"""

    logger.info('Directory tree traversal %s started', source)
    paths = [Path(root, filename)
                for root, __, files in os.walk(source, topdown=False)
                for filename in files]
    logger.debug('In directore %s was found %s files', source, len(paths))

    logger.info('Checksums calculation for a set of paths has begun')
    hashes = set(calculate_crc(path) for path in paths)
    logger.dedug('Checksums for %s paths were calculated')

    if None in hashes:
        hashes.remove(None)

    return hashes


def remove_duplicates(target: Path, hashes: set):
    """Remove file duplicates and empty dirs by the path"""

    for root, folders, files in os.walk(target, topdown=False):
        for filename in files:
            target_file = Path(root, filename)
            crc = calculate_crc(target_file)
            if crc in hashes:
                try:
                    os.remove(target_file)
                    logger.info('File %s was removed', target_file)
                except OSError:
                    logger.error('Deleting of file %s failed', target_file)

        for folder in folders:
            target_folder = Path(root, folder)
            try:
                os.rmdir(target_folder)
                logger.info('Empty dir %s was removed', target_folder)
            except OSError:
                pass


def main(source: Path, targets: List[Path]) -> int:

    logger.info('Creating set of hashes for "%s" has begun', source)
    hashes = create_set_of_crc(source)

    for target in targets:
        logger.info('Processing of the target directory %s has begun', target)
        if source.resolve() == target.resolve():
            logger.error('The source and target directory are the same: %s',
                         source.resolve())
            continue

        remove_duplicates(target, hashes)
        logger.info('Removing duplicates of the directory %s has ended', target)

    return 0


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)

    parser.add_argument('source', type=Path,
        help='path to the directory with sample files')

    parser.add_argument('targets', type=Path, nargs='+',
        help='paths to directories from which duplicates need to be removed')

    args = parser.parse_args()

    exit_code = main(args.source, args.targets)

    sys.exit(exit_code)

Вообще, если вам нужно удалить дубликаты, то имеет смысл воспользоваться приложением jdupes, которое упоминал @yarolig в первом комментарии. Это приложение доступно для операционной системы Windows jdupes-1.27.3-win64.zip

Пример использования утилиты jdupes

Для тестирования утилиты было создано два каталога source и target, эти каталоги были наполнены 10 файлами по 128Кб

PS C:\Users\penguin\Desktop\jdupes_test> ls .\source\


    Каталог: C:\Users\penguin\Desktop\jdupes_test\source


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----        31.12.2023     21:33         131072 blob01
-a----        31.12.2023     21:33         131072 blob02
-a----        31.12.2023     21:33         131072 blob03
-a----        31.12.2023     21:33         131072 blob04
-a----        31.12.2023     21:33         131072 blob05
-a----        31.12.2023     21:33         131072 blob06
-a----        31.12.2023     21:33         131072 blob07
-a----        31.12.2023     21:33         131072 blob08
-a----        31.12.2023     21:33         131072 blob09

PS C:\Users\penguin\Desktop\jdupes_test> ls .\target\


    Каталог: C:\Users\penguin\Desktop\jdupes_test\target


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----        31.12.2023     21:34         131072 blob_target_01
-a----        31.12.2023     21:34         131072 blob_target_02
-a----        31.12.2023     21:34         131072 blob_target_03
-a----        31.12.2023     21:34         131072 blob_target_04
-a----        31.12.2023     21:34         131072 blob_target_05
-a----        31.12.2023     21:34         131072 blob_target_06
-a----        31.12.2023     21:34         131072 blob_target_07
-a----        31.12.2023     21:34         131072 blob_target_08
-a----        31.12.2023     21:34         131072 blob_target_09

Далее из каталога source в каталог target были скопированы первые 5 файлов:

PS C:\Users\penguin\Desktop\jdupes_test> ls .\target\


    Каталог: C:\Users\penguin\Desktop\jdupes_test\target


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----        31.12.2023     21:33         131072 blob01
-a----        31.12.2023     21:33         131072 blob02
-a----        31.12.2023     21:33         131072 blob03
-a----        31.12.2023     21:33         131072 blob04
-a----        31.12.2023     21:33         131072 blob05
-a----        31.12.2023     21:34         131072 blob_target_01
-a----        31.12.2023     21:34         131072 blob_target_02
-a----        31.12.2023     21:34         131072 blob_target_03
-a----        31.12.2023     21:34         131072 blob_target_04
-a----        31.12.2023     21:34         131072 blob_target_05
-a----        31.12.2023     21:34         131072 blob_target_06
-a----        31.12.2023     21:34         131072 blob_target_07
-a----        31.12.2023     21:34         131072 blob_target_08
-a----        31.12.2023     21:34         131072 blob_target_09

Скачиваем архив с утилитой в удобное место и распаковываем его (в моем случае это Рабочий стол)

PS C:\Users\penguin\Desktop\jdupes-1.27.3-win64> ls


    Каталог: C:\Users\penguin\Desktop\jdupes-1.27.3-win64


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
------        27.08.2023     10:45          19698 CHANGES.txt
------        27.08.2023     10:46         119808 jdupes-barebones.exe
------        27.08.2023     10:46         195584 jdupes-loud.exe
------        27.08.2023     10:46         162816 jdupes-lowmem.exe
------        27.08.2023     10:46         179712 jdupes.exe
------        27.08.2023     10:45           1101 LICENSE.txt
------        27.08.2023     10:45          34235 README.md

Далее запускаем jdupes

PS C:\Users\penguin\Desktop\jdupes-1.27.3-win64> .\jdupes.exe C:\Users\penguin\Desktop\jdupes_test\source C:\Users\penguin\Desktop\jdupes_test\target

Scanning: 23 files, 2 items (in 3 specified)
C:\Users\penguin\Desktop\jdupes_test\source\blob05
C:\Users\penguin\Desktop\jdupes_test\target\blob05

C:\Users\penguin\Desktop\jdupes_test\source\blob04
C:\Users\penguin\Desktop\jdupes_test\target\blob04

C:\Users\penguin\Desktop\jdupes_test\source\blob03
C:\Users\penguin\Desktop\jdupes_test\target\blob03

C:\Users\penguin\Desktop\jdupes_test\source\blob02
C:\Users\penguin\Desktop\jdupes_test\target\blob02

C:\Users\penguin\Desktop\jdupes_test\source\blob01
C:\Users\penguin\Desktop\jdupes_test\target\blob01

В выводе видим ожидаемые дубликаты файлов. На данный момент они не удалены, нам только показывают найденное.

Чтобы удалить нужно воспользоваться ключом -d или --delete, в этом режиме на каждый найденный дубликат будет задан вопрос, какой именно файл удалить

PS C:\Users\penguin\Desktop\jdupes-1.27.3-win64> .\jdupes.exe --delete C:\Users\penguin\Desktop\jdupes_test\source C:\Users\penguin\Desktop\jdupes_test\target

Scanning: 23 files, 2 items (in 3 specified)
[1] C:\Users\penguin\Desktop\jdupes_test\source\blob05
[2] C:\Users\penguin\Desktop\jdupes_test\target\blob05

Set 1 of 5: keep which files? (1 - 2, [a]ll, [n]one, [l]ink all):

Если нет желание отвечать на данные вопросы, то можно воспользоваться ключом -N или --no-prompt, при этом будет оставлен первый файл в выводе дубликатов.

PS C:\Users\penguin\Desktop\jdupes-1.27.3-win64> .\jdupes.exe --delete --no-prompt C:\Users\penguin\Desktop\jdupes_test\source C:\Users\penguin\Desktop\jdupes_test\target

Scanning: 23 files, 2 items (in 3 specified)

   [+] C:\Users\penguin\Desktop\jdupes_test\source\blob05
   [-] C:\Users\penguin\Desktop\jdupes_test\target\blob05


   [+] C:\Users\penguin\Desktop\jdupes_test\source\blob04
   [-] C:\Users\penguin\Desktop\jdupes_test\target\blob04


   [+] C:\Users\penguin\Desktop\jdupes_test\source\blob03
   [-] C:\Users\penguin\Desktop\jdupes_test\target\blob03


   [+] C:\Users\penguin\Desktop\jdupes_test\source\blob02
   [-] C:\Users\penguin\Desktop\jdupes_test\target\blob02


   [+] C:\Users\penguin\Desktop\jdupes_test\source\blob01
   [-] C:\Users\penguin\Desktop\jdupes_test\target\blob01

Итоговый вид каталогов source и target

PS C:\Users\penguin\Desktop\jdupes-1.27.3-win64> ls C:\Users\penguin\Desktop\jdupes_test\source\


    Каталог: C:\Users\penguin\Desktop\jdupes_test\source


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----        31.12.2023     21:33         131072 blob01
-a----        31.12.2023     21:33         131072 blob02
-a----        31.12.2023     21:33         131072 blob03
-a----        31.12.2023     21:33         131072 blob04
-a----        31.12.2023     21:33         131072 blob05
-a----        31.12.2023     21:33         131072 blob06
-a----        31.12.2023     21:33         131072 blob07
-a----        31.12.2023     21:33         131072 blob08
-a----        31.12.2023     21:33         131072 blob09

PS C:\Users\penguin\Desktop\jdupes-1.27.3-win64> ls C:\Users\penguin\Desktop\jdupes_test\source\


    Каталог: C:\Users\penguin\Desktop\jdupes_test\source


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----        31.12.2023     21:33         131072 blob01
-a----        31.12.2023     21:33         131072 blob02
-a----        31.12.2023     21:33         131072 blob03
-a----        31.12.2023     21:33         131072 blob04
-a----        31.12.2023     21:33         131072 blob05
-a----        31.12.2023     21:33         131072 blob06
-a----        31.12.2023     21:33         131072 blob07
-a----        31.12.2023     21:33         131072 blob08
-a----        31.12.2023     21:33         131072 blob09
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории