Recherche de doublons dans des dossiers.

Contenu du snippet

Ce script permet de faire une recherche récursive dans un dossier pour y trouver les fichiers doublons.
Ici les fichiers recherchés sont sélectionner selon (.jpg|.png|.gif|.bmp)$
Lancer : lance la recherche
Reset : Remet à zéros l'affichage dans l'interface graphique.

Actions :
Supprimer : Supprime les éléments marqués dans la liste.
Auto. : Sélectionne automatiquement les doublons à supprimer.
Ouvrir : Ouvre tous les fichiers sélectionnés.
Inverser : Inverse la sélection.
Réinit. : Désélectionne tous les éléments.

J'ai utilisé les threads pour augmenter la vitesse d'exécution, et cela au dépend de la stabilité.
Si le nombre de fichiers scannés est supérieur à 1500, il y a 1/5 chances de bugger (Runtime Error).

Vitesse (approx.) :
150 fichiers : 0.65 sec.
250 fichiers : 2.38 sec.
1000 fichiers : 7,9 sec.
1500 fichiers : 12,2 sec.
8500 fichiers : entre 140 et 180 sec

Sous Linux :
Le bind <return> n'est pas supporté.
Aucun problème d'instabilité.
Les performances sont deux fois supérieures en dessous de 600 fichiers,
Par contre elles sont trois fois inférieurs au-dessus.

Merci de votre Feed-Back

Python3k

Source / Exemple :


#!/usr/bin/env python3.1

from tkinter import *
import tkinter.filedialog as filedialog
import tkinter.ttk as ttk
import tkinter.font as tkFont
import tkinter.messagebox as box
import os, stat, re, time, threading

class Interface (Tk) :
    def __init__ (self):
        Tk.__init__(self)
        self.grid ()

        self.col = ("OX","Nom","Groupe","Chemin","Taille","Date")                           # Titre des colonnes
        self.colWid = {"OX":15,"Nom":170,"Groupe":25,"Chemin":350,"Taille":80,"Date":140}   # Taille des colonnes
        self.app = Duplicate()      #Crée une instance de Duplicate, utilisée plus loin.
        
        self.d = threading.Thread() #Instancie un thread pour vérifier d.isAlive()

        ### FRAMES #######################################################
        self.Left = Frame(self, width=600)
        self.Right = Frame(self)
        self.Bottom = Frame (self)
        self.Bar = Frame(self,relief="sunken", width=600)
        
        self.Left.grid (row=1,column=0,columnspan=4, sticky=NW)
        self.Right.grid(row=0,column=3,sticky=NW)
        self.Bottom.grid(row=2,column=0,columnspan=2,sticky=NW)
        self.Bar.grid(row=3,column=0,columnspan=2,sticky=NW)

        ### Choix du Chemin ############################################
        self.EntryStr = StringVar ()
        self.EntryPath = Entry(self, textvariable=self.EntryStr,width=70)
        self.EntryPath.grid(row=0,column=0,sticky="NSEW")

        ### Boutons supérieurs #########################################
        Button(self, text='Lancer',command=self.Launch).grid(row=0,column=1,sticky='NEW')   # Exécution.
        Button(self, text='Reset',command=self.Reset).grid(row=0,column=2,sticky='NEW')     # Réinitialise StringVar et Treeview.

        ### Treeview - Multilist #######################################
        self.tree = ttk.Treeview(self.Left,columns=self.col, show="headings", height=30)
        vsb = ttk.Scrollbar(self.Left,orient="vertical", command=self.tree.yview)
        hsb = ttk.Scrollbar(self.Left,orient="horizontal", command=self.tree.xview)
        self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
        self.tree.grid(column=0, row=1, sticky='nsew')
        vsb.grid(column=1, row=1,sticky='nsw')
        hsb.grid(column=0, row=2, sticky='ew')

        # Crée les titres des colonnes :
        for col in self.col:
            self.tree.heading(col, text=col.title(),
                              command=lambda c=col: sortby(self.tree, c, 0))
            self.tree.column(col, width=self.colWid[col])

        ### INFO #######################################################
        # INFO Barre  Titles:
    
        self.Info_file_Titles = Label (self.Right, text="Fichiers",
                                       anchor=NW).grid(row=0,column=0, sticky=NE)
        self.Info_Original_Titles = Label (self.Right, text="Doublons",
                                           anchor=NW).grid(row=1,column=0, sticky=NE)
        self.Info_Size_Titles = Label (self.Right, text="Tailles",
                                       anchor=NW).grid(row=2,column=0, sticky=NE)
        
        # INFO Barre Values :
        Size_Info = 20
        
        self.Info_file_Str = StringVar ()           # Nombre d'éléments (selon le pattern de re.compile, ici fichiers images)
        self.Info_Original_Str = StringVar ()       # Nombre de groupes de doublons trouvés.
        self.Info_Size_Str = StringVar ()           # Taille totales des fichiers uniques '+' taille des doublons.
        
        self.Info_file = Label (self.Right, textvariable=self.Info_file_Str,
                                anchor=NW,
                                padx=10,
                                width=Size_Info,
                                relief='groove',
                                bg='blue',
                                fg="white",).grid(row=0,column=1, sticky=NW)
        self.Info_Original = Label (self.Right, textvariable=self.Info_Original_Str,
                                    anchor=NW,
                                    padx=10,
                                    width=Size_Info,
                                    relief='groove',
                                    bg='blue',
                                    fg="white").grid(row=1,column=1, sticky=NW)
        self.Info_Size = Label (self.Right, textvariable=self.Info_Size_Str,
                                anchor=NW,
                                padx=10,
                                width=Size_Info,
                                relief='groove',
                                bg='blue',
                                fg="white").grid(row=2,column=1, sticky=NW)

        ### Actions : ##################################################
        Label(self.Bottom,text='Actions : ').grid(row=0,column=0)
        Button(self.Bottom, text='Supprimer', command=self.DelSelected).grid(row=0,column=1)    # Supprimer
        Button(self.Bottom, text='Auto.',command=self.Auto).grid(row=0,column=2)                # Sélection automatique
        Button(self.Bottom, text='Ouvrir',command=self.OpenFile).grid(row=0,column=3)           # Ouvrir les objets sélectionnés
        Button(self.Bottom, text='Inverser', command=self.Reverse).grid(row=0,column=4)         # Inverser la sélection
        Button(self.Bottom, text='Réinit.',command=self.ResetTree).grid(row=0,column=5)         # 'O' pour tous (aucun élément sélecionné)

        ### Barre d'état ###############################################
        self.Bar_File_Str = StringVar ()
        self.Bar_File = Label(self.Bar, textvariable=self.Bar_File_Str)                         # Affichera le temps après exécution.
        self.Bar_File.grid(row=0,column=0)

        ### Images #####################################################
        self.check = PhotoImage(file="check.gif")                                               # Censé remplacer les caractères 'O' et 'X'.
        self.checked = PhotoImage(file="checked.gif")                                           # Foncionne pas encore.

        ### item.Tags ##################################################
        self.tree.tag_configure(0,background='#F2F2F2',foreground='black')                      # Tag-1 Eléments Impairs
        self.tree.tag_configure(1,background="#BDBDBD",foreground='black')                      # Tag-2 Eléments Pairs
        self.tree.tag_configure(2,background="#FF6600",foreground='black')                      # Tag-3 Eléments supprimés
        
        ### Bind #######################################################
        self.EntryPath.bind('<Return>',lambda event:self.Launch())                              # "Entrée" lance la recherche (comme le bouton lancer).
        self.EntryPath.bind('<Button-1>',lambda event:self.Path())                              # Cliquer dans la zone Entry lance le selecteur de dossier.
        self.tree.bind('<<TreeviewSelect>>',lambda event:self.Select())                         # Cliquer sur un item changera sa valeur -> val ? O : X.
        self.tree.bind('<Double-Button-1>',lambda event:self.OpenDir())                         # Un double clique sur un item ouvrira son emplacement dans son dossier et mettra le focus sur lui.

    def Insert (self) : # Remplis self.tree
        Row = list()
        group = 1
        for i in self.app.doublon :
            for a in i[1] :  
                F = File(a,ext=1)
                Row+=[['O', F.Name, group,
                       ' '+F.Dir, self.ParseOctect(F.len),
                       time.strftime("%H:%M:%S %d/%b/%Y",time.localtime(F.LastChange))]]
            group += 1

        for item in Row :
            if item[2]%2==0:d=0 
            else : d=1
            self.tree.insert('', 'end', values=item,tags=d)#,image=self.check)

        info = self.app.Info() # Récupère les informations de la session Duplicate (self.app)

        self.Info_file_Str.set(info[0])
        self.Info_Original_Str.set(info[1])
        self.Info_Size_Str.set('{0} + {1}'.format(self.ParseOctect(info[2]-info[3]),self.ParseOctect(info[3])))
        
        #self.app.Close()   # vérifier si Supprimer les variables est plus ou moins lent.
        self.bell()         # Jouer un son quand l'exécution est finie.
        
    def Path (self):
        """
        Si le thread n'est pas déjà actif
        on affiche le sélecteur de dossier.
        Le résultat est affiché dans self.EntryPath
        """
        if self.d.isAlive():
            return False
        self.EntryStr.set(filedialog.askdirectory())
        return True
    
    def Launch (self):
        """
        Si le thread n'est pas actif :
        self.app.time -> chronométrer.
        Fonction récursive sur le dossier,
        Thread secondaire (principal : Tk())
        """
        if self.d.isAlive():
            return False
        self.app.t = time.time()
        en = os.path.realpath(self.EntryPath.get())
        self.Reset ()
        self.EntryStr.set(en)
        if en == "":
            return 'Error'
        
        #self.app.SearchFile(en)
        self.d=threading.Thread(None,self.app.SearchDuplicate,None,[en])
        self.d.start()

    def Select (self):
        """
        Bind '<<TreeviewSelect>>'
        cliquer sur un élément alternera les valeurs 'O' - 'X',
        sauf si le fichier a été supprimé ('-')
        """
        
        item = self.tree.item(self.GetCur())["values"]
        check = item[0]

        if check == '-' : return
        elif check == 'O': check = 'X'
        else : check = 'O'
        item[0] = check
        self.tree.item(self.GetCur(), values=item)

    def ParseOctect (self,Oc) :
        """
        Parser de valeur octale.
        retourne : (\d*?,\d*)(o|Ko|Mo|Go)
        """
        if Oc < pow(10,3) :
            return '{0} o'.format(Oc)
        elif Oc > pow(10,3) and Oc < pow(10,6):
            return '{0} Ko'.format(Oc/pow(10,3))
        elif Oc >= pow(10,6) and Oc < pow(10,9):
            return '{0} Mo'.format(round(Oc/pow(10,6),3))
        else : 
            return '{0} Go'.format(round(Oc/pow(10,9),3))
        
    def GetCur (self):
        # Renvoie les items selectionnés, plus tellement utile.

        return self.tree.selection()

    def GetSelected (self) :
        # Retourne tous les éléments selectionné selon 'X'.
        checked = list()
        for i in self.tree.get_children() :
            if self.tree.item(i)["values"][0] =='X':
                checked+=[i]
        return checked
    
    def DelSelected (self):
        """
        Récupère la liste des éléments marqué 'X'
        Fenêtre dialogue affichant le nombre et le noms des éléments à supprimer.
        L'élémetns a supprimé est marqué '-', prend la couleur de tag-2
        """
        checked = self.GetSelected()
        if len(checked)==0 :
            box.showerror(title='Erreur',
                          message='Aucun item sélectionné pour suppression.')
            return
        
        show = str()
        for i in checked :
            show += '\n   {0}'.format(self.tree.item(i)["values"][1])
        if not box.askokcancel(title='Supprimer',
                               message='Vous allez supprimer {0} éléments.{1}'.format(len(checked),show)) : return
        
        for i in checked :
            item = self.tree.item(i)["values"]
            item[0] = '-'
            self.tree.item(i, tags=2,values=item)
            try :
                os.remove(item[3][1:]+os.sep+item[1])
            except : box.showerror('Erreur - Suppression','Impossible de supprimer : {0}'.format(item[1]))

    def Reverse (self):
        # Inverse toutes les valeurs de Check selon X ? O : X
        checked = self.tree.get_children()
        for i in checked :
            item = self.tree.item(i)["values"]
            if item[0]=='O': item[0]='X'
            elif item[0]=='X' : item[0] = 'O'
            self.tree.item(i,values=item)
            
    def ResetTree (self):
        # Toutes les valeurs de Check deviennent 'O'
        checked = self.tree.get_children()
        for i in checked :
            item = self.tree.item(i)["values"]
            if item[0]=='X': item[0]='O'
            else : continue
            self.tree.item(i,values=item)

    def Auto (self):
        '''
        Choix basé sur la personnalisation du fichier,
        le nom contenant le moins de chiffres est sélectionné.
        '''
        groupe = int() #Tab-2
        self.ResetTree()
        Sel = self.tree.get_children()
        for i in Sel :
            item = self.tree.item(i)["values"]
            itemNext = self.tree.item(self.tree.next(i))["values"]
            
            if groupe == item[2] : continue # Même groupe ?
            else : groupe = item[2]

            if item[0]=='-' or itemNext[0]=='-' : continue # Déjà supprimé ?
            
            if len(re.findall('(\d)',item[1])) >\
               len(re.findall('(\d)',itemNext[1])) :
                item[0]='X'
                self.tree.item(i,values=item)
            else : 
                itemNext[0]='X'
                self.tree.item(self.tree.next(i),values=itemNext)
                
    def OpenDir (self):
        # Double cliquer sur un éléments l'affiche dans explorer
        i = self.tree.item(self.GetCur())["values"]
        Iarg = i[3][1:]+os.sep+i[1]
        os.spawnl(os.P_NOWAIT,'C:/Windows/explorer','/n','/select,"{0}"'.format(Iarg))

    def OpenFile (self):
        # Ouvre tous les fichiers selectionné dans la liste.
        check = self.GetSelected()
        if len(check) ==0 :
            box.showerror(title='Erreur',message='Aucun item sélectionné à ouvrir.')
        for i in check :
            item = self.tree.item(i)["values"]
            f = item[3][1:]
            g = item[1]
            os.startfile('"'+f+os.sep+g+'"')
    
    def Reset (self) :
        # Remet à zéro la partie graphique.
        if len(self.tree.get_children())>0:
            for i in self.tree.get_children() :
                self.tree.delete(i)
            
        self.Info_file_Str.set('')
        self.Info_Original_Str.set('')
        self.Info_Size_Str.set('')
        self.Bar_File_Str.set('')
        self.EntryStr.set('')

class Duplicate () :
    def __init__ (self):
        self.found = list()

        #self.pat = re.compile ('(.jpg|.png|.gif|.bmp)$', re.I) # re.I -> Insensible à la casse.
        self.ext = ['.jpg','.png','.gif','.bmp']
        
    def SearchFile (self,path):
        # Obsolète
        dos = os.listdir(os.path.realpath(path))
        
        for i in dos :
            if os.path.isdir(path+os.sep+i) :
                self.SearchFile(path+os.sep+i)
            elif os.path.isfile(path+os.sep+i) and re.findall(self.pat,i):
                self.found.append(path+os.sep+i)
            #else : print (i)

        return len(self.found)

    def SearchDuplicate (self,path):
        self.doublon = {}
        self.Size= int()
        self.SizeDouble = int()
        self.preRes = list()

        for root, dirs, files in os.walk(os.path.realpath(path)):
            for f in files :
                if os.path.splitext(f)[1] in self.ext:
                    self.found.append(root+os.sep+f)                                            # Ajout  à la liste des fichiers
                    Fstream = open(root+os.sep+f,'rb')                                          
                    stat = os.stat(root+os.sep+f)                                               
                    self.preRes.append([str(stat.st_size)+str(Fstream.read(100)),root+os.sep+f])# Ajout à la liste temporaire
                    Fstream.close()
                    if len(self.found)%50==0:
                        app.Info_file_Str.set('fichiers {0}'.format(len(self.found)))
        
        self.Len = len(self.found)
        
        app.Info_Size_Str.set('Ok - {0}'.format(round(time.time()-self.t,5)))
        app.Info_file_Str.set('0 sur {0}'.format(self.Len))
        
        # calcul la taille totale en attendant.
        threading.Thread(None,self.DefSize,None,()).start()
        
        app.Info_file_Str.set('Ok - {0}'.format(round(time.time()-self.t,5)))       # Affiche Ok et le temps écoulé dans la case 'Fichiers'
        app.Info_Original_Str.set('0 sur {0}'.format(self.Len))
        
        count=0
        for i in self.preRes :
            count+=1
            if count%10==0:
                app.Info_Original_Str.set('{0} % - doublons : {1}'.format(int(count/self.Len*100),len(self.doublon)))  # tous les dix éléments on affiche le pourcentage calculé.

            if i[0] in self.doublon:
                self.doublon[i[0]].append(i[1])
            else:
                self.doublon[i[0]] = [i[1]]

        self.doublon = [k for k in self.doublon.items() if len(k[1])>1]

        for i in self.doublon: # Cacule la taille totale des doublons.
            for a in i[1]:
                f1 = File(a)
                self.SizeDouble += f1.len

        app.Info_Original_Str.set('Ok - {0}'.format(round(time.time()-self.t,5)))
        
        self.LenTotal = len(self.found)
        self.found = list()
            
        app.Bar_File_Str.set('{0} seccondes'.format(round(time.time()-self.t,5)))
        
        self.t = round(time.time()-self.t,5)        
        ### statistiques #################################################
        f = open('DoublonStat.txt','a+')
        #   >> Nbr d'éléments, Nbr doublons, Taille totale, Temps total
        f.write('\n{0},\t{1},\t{2},\t{3}'.format(self.Len,len(self.doublon),self.Size,self.t))
        f.close()
        
        app.Insert() # On reprend la classe principale pour y insérer les résultats.
        return True

    def DefSize (self):
        for i in self.found :
            f1 = File(i,ext=2)
            self.Size += f1.len

    def Info (self):
        return [self.LenTotal,      # Nb fichiers
                len(self.doublon),  # Nb doublons
                self.Size,          # Taille totale
                self.SizeDouble]    # Taille doublons

    def Close (self):
        self.found= list()
         
        del (self.LenTotal,
             self.doublon,
             self.Size,
             self.SizeDouble,
             self.t,
             self.Len,
             self.preRes)
        
class File :
    """ File, Ext =None
    """
    def __init__ (self, file, ext=None):
        file = os.path.realpath(file)
        if not os.path.isfile(file):
            raise IOError("Le fichier n'existe pas.\n{0}".format(file))

        self.ext = ext
        self.stat = os.stat(file)
        
        self.mode = self.stat[stat.ST_MODE]
        self.len = self.stat.st_size                    # Taille du fichier

        if ext == 1 :
            self.Name = os.path.basename(file)          # Nom du fichier
            self.Dir = os.path.dirname(file)            # Chemin du fichier
            self.LastChange = self.stat[stat.ST_ATIME]  # Date du fichier
            #self.LastAccess = self.stat[stat.ST_MTIME]  # Date de dernier accès du fichier (pas utilisé ici)
        elif ext == 0 :
            
            if self.len > pow(10,8) :   #100 000 000
                self.hash = False       # Si le fichier est supérieur à 100 Mo on ne s'en occupe pas.
                print(file)
            else :
                self.file = open (file,'rb')
                #self.hash = hash(self.file.read())
                self.hash = str(self.len)+str(self.file.read(100))
    
def sortby(tree, col, descending):
    """Sort tree contents when a column is clicked on."""
    # grab values to sort
    data = [(tree.set(child, col), child) for child in tree.get_children('')]

    # reorder data
    data.sort(reverse=descending)
    for indx, item in enumerate(data):
        tree.move(item[1], '', indx)

    # switch the heading so that it will sort in the opposite direction
    tree.heading(col,
        command=lambda col=col: sortby(tree, col, int(not descending)))
    
if __name__ == "__main__":
    app = Interface ()
    app.title('Recherche de doublons')
    app.resizable(False,False)
    try : app.iconbitmap("ErwIco.ico")
    except : pass
    app.mainloop()

A voir également

Vous n'êtes pas encore membre ?

inscrivez-vous, c'est gratuit et ça prend moins d'une minute !

Les membres obtiennent plus de réponses que les utilisateurs anonymes.

Le fait d'être membre vous permet d'avoir un suivi détaillé de vos demandes et codes sources.

Le fait d'être membre vous permet d'avoir des options supplémentaires.

Du même auteur (Rano Its)