Как удалить дубликаты файлов python

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

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

  2. Помогите разобраться с тем кодом что написала я

Код с учётом сравнивания хешей файлов:

from collections import defaultdict
import hashlib
import os
import sys


def chunk_reader(fobj, chunk_size=1024):
    """Generator that reads a file in chunks of bytes"""
    while True:
        chunk = fobj.read(chunk_size)
        if not chunk:
            return
        yield chunk


def get_hash(filename, first_chunk_only=False, hash=hashlib.sha1):
    hashobj = hash()
    file_object = open(filename, 'rb')

    if first_chunk_only:
        hashobj.update(file_object.read(1024))
    else:
        for chunk in chunk_reader(file_object):
            hashobj.update(chunk)
    hashed = hashobj.digest()

    file_object.close()
    return hashed


def check_for_duplicates(paths, hash=hashlib.sha1):
    hashes_by_size = defaultdict(list)  # dict of size_in_bytes: [full_path_to_file1, full_path_to_file2, ]
    hashes_on_1k = defaultdict(list)  # dict of (hash1k, size_in_bytes): [full_path_to_file1, full_path_to_file2, ]
    hashes_full = {}   # dict of full_file_hash: full_path_to_file_string
    
    
    for path in paths:
        log_filename = "log.txt"
        #f = open(os.path.join(path, log_filename), 'w')
        #f.close()
        for dirpath, dirnames, filenames in os.walk(path):
            # get all files that have the same size - they are the collision candidates
            for filename in filenames:
                full_path = os.path.join(dirpath, filename)
                try:
                    # if the target is a symlink (soft one), this will
                    # dereference it - change the value to the actual target file
                    full_path = os.path.realpath(full_path)
                    file_size = os.path.getsize(full_path)
                    hashes_by_size[file_size].append(full_path)
                except (OSError,):
                    # not accessible (permissions, etc) - pass on
                    continue


    # For all files with the same file size, get their hash on the 1st 1024 bytes only
    for size_in_bytes, files in hashes_by_size.items():
        if len(files) < 2:
            continue    # this file size is unique, no need to spend CPU cycles on it

        for filename in files:
            try:
                small_hash = get_hash(filename, first_chunk_only=True)
                # the key is the hash on the first 1024 bytes plus the size - to
                # avoid collisions on equal hashes in the first part of the file
                # credits to @Futal for the optimization
                hashes_on_1k[(small_hash, size_in_bytes)].append(filename)
            except (OSError,):
                # the file access might've changed till the exec point got here
                continue

    
    # For all files with the hash on the 1st 1024 bytes, get their hash on the full file - collisions will be duplicates
    for __, files_list in hashes_on_1k.items():
        f = open(os.path.join(path, log_filename), 'a')
        if len(files_list) < 2:
            continue    # this hash of fist 1k file bytes is unique, no need to spend cpy cycles on it

        for filename in files_list:
            try:
                full_hash = get_hash(filename, first_chunk_only=False)
                duplicate = hashes_full.get(full_hash)
                if duplicate:
                    os.remove(filename)
                    f.write('duplicated file "' + filename +'" was removed \n')
                    f.write('unique file - "' + duplicate +'"\n')
                    #print(duplicate)
                    #print(filename)
                    #print("Duplicate found: {} and {}".format(filename, duplicate))
                    
                else:
                    hashes_full[full_hash] = filename
                    
            except (OSError,):
                # the file access might've changed till the exec point got here
                continue
    f.write('Checked for duplicate files \n')
    f.close()        


if __name__ == "__main__":
    if sys.argv[1:]:
        check_for_duplicates(sys.argv[1:])
    else:
        print("Please pass the paths to check as parameters to the script")

Да, я видела этот код в интернет, коллеги его просто скопировали и сами его не писали.

Вот мой код:

from collections import defaultdict
import hashlib
import os
import sys


# При запуске код спрашивает путь к папке в котрой делать проверку 
route = input('Укажите полный путь к папке, в которой будет проходить проверка, затем нажмите Enter: ')

# На случай лишних пробелом удаляем все пробельные символы в начале и в конце строки
route = route.strip() # в начале строки
route = route.rstrip() # в конце строки
# Теперь полный путь записан в переменной route

# переходим в заданную директорию
os.chdir(route)



# Вытаскивам пути ко всем директориям
# ************************************************************************************************************************

from pathlib import Path 

path = Path(route) 
folder = [] # абсолютные пути вложенных папок первого уровня

for x in path.iterdir(): 
    if x.is_dir(): 
        folder.append(x) 


# Вытащим пути файлов из вложенных директорий первого уровня
# ************************************************************************************************************************

file_paths = [] # абсолютные пути ко всем файлам из вложенных папок первого уровня

for t in range(0, len(folder)):
    ested_folder = Path(str(folder[t]))
    for d in ested_folder.iterdir(): 
        file_paths.append(d)

# Сделаем список только из названий файлов с расширением
# ************************************************************************************************************************

file_names = [] # названия всех файлов из всех вложенных папок первого уровня с расширениями

for t in range(0, len(file_paths)):
    file_names.append(Path(str(file_paths[t])).name)


# Сделаем список размеров файлов
# ************************************************************************************************************************

file_sizes = [] # размеры всех файлов из всех вложенных папок первого уровня в байтах

for t in range(0, len(file_paths)):
    file_sizes.append(os.path.getsize(str(file_paths[t])))


# Сделаем список последних редактирований файлов в секундах
# ************************************************************************************************************************

file_modification_time = [] # время в секундах последних редактирований всех файлов

for t in range(0, len(file_paths)):
    file_modification_time.append(os.path.getmtime(str(file_paths[t])))

# Берём первый элемент из списка названий файлов и ищем есть ли такое же название,               
# если есть, то вытаскиваем индексы, сравниваем размеры, если одинаковые, то                      
# смотрим даты редактирований,                                                                    
# записываем в файл путь старшего удаляемого файла и оставленного файла,                          
# затем по индексу старшего файла, заменяем на индексы соответствующие элементы                    
# если совпадения по имени нет, переходим к следующему элементу списка                            
#                                                                                                 
#        СПИСКИ:                                                                                  
#                                                                                                 
# folder - абсолютные пути вложенных папок первого уровня                                         
# file_paths - абсолютные пути ко всем файлам из вложенных папок первого уровня                   
# file_names - названия всех файлов из всех вложенных папок первого уровня с расширениями         
# file_sizes - размеры всех файлов из всех вложенных папок первого уровня в байтах               
# file_modification_time - время в секундах последних редактирований всех файлов                  

# comparison_value - значение для сравнения 
# duplicates_names - список для работы с дубликатами
 
duplicates_names = []

duplicates_file_txt = open('duplicates.txt', 'w+') # создаём файл, куда запишем пути дубликатов

for q in range(0, len(file_names)):
# берём по-этапно каждое название файлов

    # записываем в переменную имя файла с которым будем работать
    comparison_value = file_names[q]

    for w in range(q + 1, len(file_names)):
    # перебираем все следующие имена файлов пока не найдем такое же название
        
        if file_names[w] == comparison_value:
        # ищем такое же название
            
            # если есть дублированное название, то записываем индексы дубликатов в список
            duplicates_names.append(w) 
            duplicates_names.append(q)

            #print(duplicates_names) # int - каждый элемент
            
            if file_sizes[duplicates_names[-2]] == file_sizes[duplicates_names[-1]]: 
            # сравниваем размеры файлов
            
                if file_modification_time[duplicates_names[-2]] > file_modification_time[duplicates_names[-1]]: 
                # сравниваем последнее время редактирования
                    
                    # удаляем - duplicates_names[-1]
                    # оставляем - duplicates_names[-2]

                    # записываем в файл какие файлы удаляем, а какие оставляем
                    duplicates_file_txt.write('Удаляемый файл: ' + str(file_paths[duplicates_names[-1]]) + '\n') 
                    duplicates_file_txt.write('Оставленный файл: ' + str(file_paths[duplicates_names[-2]]) + '\n')
                    
                    try:
                        os.remove(file_paths[duplicates_names[-1]]) # удаляем
                    except FileNotFoundError:
                        continue

                    # вместо пути удалённого файла записываем его индекс
                    file_names[duplicates_names[-1]] = duplicates_names[-1]
                    
                else:
                    
                    # удаляем - duplicates_names[-2]
                    # оставляем - duplicates_names[-1]
                    
                    duplicates_file_txt.write('Удаляемый файл: ' + str(file_paths[duplicates_names[-2]]) + '\n')
                    duplicates_file_txt.write('Оставленный файл: ' + str(file_paths[duplicates_names[-1]]) + '\n')
                    try:
                        os.remove(file_paths[duplicates_names[-1]]) # удаляем
                    except FileNotFoundError:
                        continue
                    file_names[duplicates_names[-2]] = duplicates_names[-2] 

duplicates_file_txt.close()

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

А уровней папок не больше двух? Если больше, то надо как-нибудь рекурсивно.

Ну или может с циклами перебора что-то не то.


Я бы создал dict, где ключи — то, по чему сравнивать (имя + размер), а значения — списки путей соответствующих ключу.

a.txt123: [mydir/a.txt, mydir/dir2/a.txt],
b.txt456: [mydir/b.txt, mydir/dir2/b.txt],
...

И удалить все кроме первого.

from pathlib import Path

root_dir = '/home/alex/Documents/duplicates/'

files_dict = {}

for path in Path(root_dir).rglob('*'):
    if not path.is_file():
        continue
    key = path.name + str(path.stat().st_size)
    if key not in files_dict:
        files_dict[key] = []
    files_dict[key].append(path)

for filepaths in files_dict.values():
    for filepath in filepaths[1:]:
        filepath.unlink()
Файлы
alex@pop-os:~/Documents/duplicates$ tree -h
.
├── [4.0K]  d1
│   ├── [  14]  a.txt
│   ├── [  14]  b.txt
│   ├── [  14]  c.txt
│   └── [4.0K]  d2
│       ├── [  14]  a.txt
│       ├── [  14]  b.txt
│       ├── [  14]  c.txt
│       └── [4.0K]  d3
│           ├── [  14]  a.txt
│           ├── [  14]  b.txt
│           └── [  14]  c.txt
├── [4.0K]  d4
│   ├── [  21]  a.txt
│   ├── [  14]  b.txt
│   ├── [  14]  c.txt
│   └── [  14]  d.txt
├── [4.0K]  d5
│   ├── [  14]  a.txt
│   └── [  14]  b.txt
└── [4.0K]  d6
    ├── [  14]  a.txt
    └── [  20]  c.txt

6 directories, 17 files
Результат
alex@pop-os:~/Documents/duplicates$ tree -h
.
├── [4.0K]  d1
│   ├── [  14]  a.txt
│   ├── [  14]  b.txt
│   ├── [  14]  c.txt
│   └── [4.0K]  d2
│       └── [4.0K]  d3
├── [4.0K]  d4
│   ├── [  21]  a.txt
│   └── [  14]  d.txt
├── [4.0K]  d5
└── [4.0K]  d6
    └── [  20]  c.txt

6 directories, 6 files
1 симпатия

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

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