Утилита для работы с архивными каталогами

Часто возникает желание привести свои фото-видео и просто архивы впорядок, но никогда не хватает терпения сделать это вручную. Решил написать удобную утилитку для поиска дубликатов ( дубликатами считаюся файлы с одинаковым содержимым, точнее с одинаковым md5 hexdigest, хотя они могут иметь и разные имена ) в "архивных"(raw) каталогах и поискать во всех возможных местах "потерянные"(uniq) файлы т.е файлы отсутствуразветвительющие в "архивных"(raw) каталогах. Хотелось набросать это за пару часов и "хотелось, как лучше, а получилось, как всегда". Вместо пары часов ушло пару вечеров. И раз уж все равно потрачено много времени, хочется довести эту утилитку до "более законченного" сосотояния и услышать от Питонистов их менение, замечания и просто пожелания по поводу: оптимизации, стиля, форматирования/оформления кода, именования переменных и т.д. В общем любая конструктивная критика приветствуется. Надеюсь с вашей помощью поучить что-то полезное не только для себя. Возможности настройки и использование должны быть понятны из кода (безусловно по возможности добавлю описание)

Вкратце

Утилита только читает каталоги и ничего не меняет. Пока, не меняет, в следующих версиях возможно будет. Утилита принимает на вход либо конфигурационный файл, либо опции коммандной строки(command-line) Например: ./ardiff.py --config=test.conf , либо и то и то, при этом command-line опции имеют приоритет. Основной параметр это "архивный каталог"(raw) или их список в которых будет осуществляться поиск дубликатов. Например вот так: ./ardiff.py --raw=dir1,dir2 #через запятую без пробелов. Могут быть указаны абсолютные пути или только их "относительная"(name) часть. Относительная по отношению к текущему каталогу или к каталогу, указанному в --raw-root./ardiff.py --raw-root=/home/user/photo-arch --raw=dir1,dir2 Аналогичным способом можно указать "внешние"(raw-sorted) каталоги, где будет осуществряться поиск недостающих(uniq) в "архивных"(raw) каталогах файлов. Еще можно управлять выводом результатов работы, их можно выводить на экран --verbose=2 и/или в файлы см. пример ниже.

Рабочий пример

В этом примере, утилита запускается с опциями, при которых будут найдены все дубликаты файлов в подкаталогах dir1 и dir2, которые находятся в каталоге /home/user/photo-arch, затем будут найдены все файлы, которые есть в подкаталогах dir3, dir4 и dir5 каталога photo-arch на внешнем носителе примонтированном к /media/user/3XX, при этом результаты будут выведены, как на экран, так и в файлы в каталоге ./folder-for-results.


./adiff.py  --raw-root=/home/user/photo-arch --raw=dir1,dir2 --raw-sorted-root=/media/user/3XX/photo-arch
--raw-sorted=dir3,dir5 --dup_prefix=dup- --check_dup=Y --uniq_prefix=uniq- --check_uniq=Y 
--out_root=folder-for-results --verbose=2
Чтобы было не очень скучно сидеть, пока утилита перетряхивает гигабайты ваших арховов, при verbose = 1 на stdout, а при verbose = 2 на stderr, будет выводиться количество просмотренных каталогов, файлов и скорость из обхода в файлах в секунду. Утилитка пока сырая, но в умелых руках, вполне полезная и, как я уже писал, вполне безопасная, потому что ничего не удаляет и не пишет, кроме не очень больших файлов с результатами работы. Да и их порождение можно отключить.
Не судите строго, и это именно тот не частый случай, когда помогать советом - уместно.

Теперь исходники

Файл: ardiff.py

#!/usr/bin/env python
# encoding: utf-8
'''
fsutils.ardiff -- Utility to help cleaning up the files archives/catalogs

fsutils.ardiff is an utility to help cleaning up the files(photo,video, etc.) 
archives/catalogs. For now it is do only look up for duplicates thru the 
"main"/"raw" archives/catalogs. Also this utility can find unique files (files
which not in the "main"/"raw" archives) in external catalogs. 

It defines classes_and_methods

@author:	 Gurman

@copyright:  2014 Gurman Inc. All rights reserved.

@license:	"Copyright 2014 Gurman (Gurman Inc.)    	\
			Licensed under the Apache License 2.0\n		\
			http://www.apache.org/licenses/LICENSE-2.0" 

@contact:	apgurman@gmail.com
@deffield	updated: Updated
'''
import sys
import os
import md5
import time

from kcommon import OptionParserWraper
from kcommon import err_report
from kcommon import CatalogsWalker
from kcommon import OutSplitter

__all__ = []
__version__ = "v0.1"
__date__ = '2014-11-24'
__update__='2014-11-24'

__version_string__ = '%%prog %s (%s)' % (__version__, __update__)

DEBUG = 1

__SORTED_SUFFIX__= "sorted.txt"
__ASIS_SUFFIX__= "asis.txt"

class FileInfo(object):
	""" Class to hold file info """
	
	def __init__(self,_path,_name=None):
		""" if _path only passed the _path == path+name """
		self.__size, self.__hexdigest = None, None
		if _name is None:
			self.__path = os.path.dirname(_path)
			self.__name = os.path.basename(_path)		  
		else:
			self.__path, self.__name = _path, _name
		
			
	@property		
	def path(self):
		return self.__path
	
	@property		
	def name(self):
		return self.__name
	
	@property
	def full_name(self):
		return os.path.join(self.path,self.name)
	
	@property
	def size(self):
		""" calculate if need and return size for file """
		if self.__size is None:
			self.__size = os.stat(self.full_name).st_size
		return self.__size
	
	@property
	def hexdigest(self):
		""" calculate if need and return md5 hexdigest for file """
		if self.__hexdigest is None:
			_md5 = md5.new()
			_file = open(self.full_name, 'rb')
			_block = _file.read(_md5.block_size)
			while _block:
				_md5.update(_block)
				_block = _file.read(_md5.block_size)
			self.__hexdigest = _md5.hexdigest()
		return self.__hexdigest
	
	@hexdigest.setter
	def hexdigest(self,_hxd):
		""" hexdigest setter if we want to use something specific """
		self.__hexdigest = _hxd
		return self.__hexdigest
	
	def __eq__(self,finfo):
		if finfo.size == self.size:
			if self.name == finfo.name:
				return True
			else:
				if self.hexdigest == finfo.hexdigest:
					return True
		return False
	
	def __ne__(self, o):
		return not self.__eq__(o)
	
	def is_in(self,_flist):
		for finfo in _flist:
			if self.__eq__(finfo):
				return True
		return False
						

class RawCatalogs(object):

	"""Class to manipulate and hold list of raw(archive) catalogs - main storage """

	def __init__(self, opts ):
		self.__count = 0
		self.__opts=opts
		
		ctlgs=opts.raw.split(',')
		self.__raw_catalogs = [os.path.join(opts.raw_root,x) for x in ctlgs] if opts.raw_root else ctlgs
		
		self.__out_splitter = OutSplitter([sys.stdout]) if opts.verbose == 1  else OutSplitter([sys.stderr])
		
		self.__raw_files, self.__raw_dup_files, self.__uniq_files = None, None, None

	@property
	def raw_files(self):
		if self.__raw_files  is None:
			self.__raw_files = {}
			self._add_arhs(self.__raw_catalogs)
		return self.__raw_files
	
	@property			
	def raw_dup_files(self):
		if self.__raw_dup_files is None:
			self.__raw_dup_files = {}
			self.raw_files
		return self.__raw_dup_files
	
	@property
	def uniq_files(self):
		if self.__uniq_files is None:
			self.__uniq_files = {}
		return self.__uniq_files
	
	def _check_duplicate(self, finfo):
		""" checks if the file's info already in archives """
		for i in self.__raw_files[finfo.size]:
			if finfo.hexdigest == i.hexdigest:
				#First duplicate
				if finfo.hexdigest not in self.__raw_dup_files:
					d = []; d.append(i)
					self.__raw_dup_files[finfo.hexdigest] = d
				else:	#More then one duplicate
					pass
				#Remembering all duplicates
				self.__raw_dup_files[finfo.hexdigest].append(finfo)
			else: #Same size but different files 
				pass
		
	def _add_to_arh_dict(self, finfo):
		""" add file's information to archives """
		if finfo.size not in self.raw_files:
			self.raw_files[finfo.size] = []
		elif self.__opts.check_dup == 'Y' :	
			self._check_duplicate(finfo)
			
		self.raw_files[finfo.size].append(finfo)
		
	def _add_arhs(self,_catalogs, out_splitter=None):
		""" main loop thru the archives to create internal structures """
		
		if out_splitter is None: out_splitter = self.__out_splitter
		
		for p,n in CatalogsWalker(_catalogs, out_splitter):
			finfo = FileInfo(p,n)
			self._add_to_arh_dict(finfo)
	
	def check_for_uniq_files(self,_catalogs,rfs=None,out_splitter=None):
		""" look up external catalogs for files which isn't in archives """
		uniq_files={}
		
		rfs = self.raw_files if rfs is None else rfs
		
		if out_splitter is None: out_splitter = self.__out_splitter
		
		cw=CatalogsWalker(_catalogs,out_splitter)
		for p,n in cw:
			
			finfo = FileInfo(p,n)
			
			if finfo.size in rfs:
				if not finfo.is_in(rfs[finfo.size]):
					if finfo.hexdigest not in uniq_files:
						x = []; x.append(finfo)
						uniq_files[finfo.hexdigest] = x
								
			elif finfo.hexdigest not in uniq_files:
					x = []; x.append(finfo)
					uniq_files[finfo.hexdigest] = x
					
		return uniq_files
	
	def group_by_folders(self,d):
		pd={}
		for i in d:
			for z in d[i]:
				if z.path not in pd:
					pd[z.path]=[]
				pd[z.path].append(z)
		return pd

	def print_as_is(self,n,d,outs):
		if not hasattr(outs, 'write') : return
		w = outs.write
		w(n)
		for i in d:
			w("\n")
			for x in d[i]:
				w(x.hexdigest+repr(x.size).rjust(12)+(x.full_name).rjust(64)+'\n')
		w("\n")	
						
	def print_sorted_by_full_name(self,d,outs):
		if not hasattr(outs, 'write') : return
		for i in sorted(d.items(), key=lambda x: x[1][0].full_name):
			print '\n',
			for z in d[i]:
				print z.full_name,"\t", z.size,"\t",z.hexdigest
		print "\n"
	
	def print_sorted_by_catalogs_and_names(self, n, d,outs):
		if not hasattr(outs, 'write') : return
		w = outs.write
		w(n)
		for i in d:
			w('\n\npath='+ i+ "\nfiles=")
			for z in sorted(d[i], key=lambda x: x.name):
				w(z.name + ',') #,"\t", z.size,"\t",z.hexdigest
		w("\n")
				
def _time_str():
	gmt=time.gmtime()
	return  '-'+str(gmt.tm_year)+'-'+str(gmt.tm_mon)+'-'+str(gmt.tm_mday)+'-'+str(gmt.tm_hour)+\
	 	 '-'+str(gmt.tm_min)+'-'+str(gmt.tm_sec)+'-'
		
def main_scenario(opts,args):
	
	rctlgs=RawCatalogs(opts)
	
	stdouts = sys.stdout if opts.verbose == 2 else None

	if opts.check_dup == 'Y' :
		rctlgs.raw_dup_files
		
		r, p = opts.out_root, opts.dup_prefix
		fout_prefix = ( os.path.join(r, p+_time_str()) if r else p + _time_str() ) if p else None 			
		
		if fout_prefix or stdouts: 
			fout_prefix_ = fout_prefix + __SORTED_SUFFIX__ if fout_prefix else None
			out_splitter = OutSplitter([stdouts, fout_prefix_])
			if out_splitter.pipes:
				rctlgs.print_sorted_by_catalogs_and_names("\n[SORTED DUPLICATES]\n",
					 	 	 rctlgs.group_by_folders(rctlgs.raw_dup_files), 
						 	 out_splitter)
			fout_prefix_ = fout_prefix + "-asis.txt" if fout_prefix else None	
			out_splitter = OutSplitter([stdouts, fout_prefix_])
			if out_splitter.pipes:
				rctlgs.print_as_is("\n[DUPLICATES]\n", 
								rctlgs.raw_dup_files, 
								out_splitter)

	if opts.check_uniq == "Y":
		_catalogs=opts.raw_sorted.split(',')
		_catalogs=[os.path.join(opts.raw_sorted_root,x) for x in _catalogs] if opts.raw_sorted_root else _catalogs
		rctlgs.check_for_uniq_files(_catalogs)
		
		r, p = opts.out_root, opts.uniq_prefix
		fout_prefix = ( os.path.join(r, p) if r else p ) if p else None 

		if fout_prefix or stdouts: 
			out_splitter = OutSplitter([stdouts, fout_prefix+__ASIS_SUFFIX__])
			if out_splitter.pipes:
				rctlgs.print_as_is("\n[UNIQS]\n",rctlgs.uniq_files, out_splitter) 
	
	print("\nThe end")
			
def main(argv=None):			
	
	try:
		
		opt_parser = OptionParserWraper(argv)#, version_string = __version_string__)
		
		main_scenario(opt_parser.opts,opt_parser.args)
	
	except Exception:
		err_report()
		return 2		


if __name__ == "__main__":
	if DEBUG:
		if len(sys.argv) == 1:
			sys.argv.append("--config=test.conf")
			pass

	sys.exit(main())

Файл: kcommon.py

#!/usr/bin/env python
# encoding: utf-8

import sys
import os
import time
from sets import Set

from optparse import OptionParser

__def_vers__ = "v0.1"
__def_duild_date__ = "2014-11-24"

def err_report():
    
    if sys.exc_info() != (None,None,None) : 
        last_type, last_value, last_traceback = sys.exc_info()
    else : 
        last_type, last_value, last_traceback = sys.last_type, sys.last_value, sys.last_traceback 
    
    tb, descript = last_traceback, []
    
    while tb :
        fname, lno = tb.tb_frame.f_code.co_filename, tb.tb_lineno
        descript.append('\tfile "%s", line %s, in %s\n'%(fname, lno, tb.tb_frame.f_code.co_name))
        tb = tb.tb_next
        
    descript.append('%s : %s\n'%(last_type.__name__, last_value))
    
    for i in descript : 
        sys.stderr.write(i),


class Error(Exception):
    """Base class for ardiff exceptions."""

    def __init__(self, msg=None):
        self.msg = msg

    def __str__(self):
        return self.msg
    
    #def __repr__(self, *args, **kwargs):
    #    return Exception.__repr__(self, *args, **kwargs)
    
class ConfigNotFoundError(Error):
    """Raised if config file passed but not found."""

    def __str__(self):
        return "call expression when config file passed but not found"
    
class OutSplitter(object):
    """ splitter """
    
    def __init__(self, out_splitter):
        self.__pipes=None
        if isinstance(out_splitter, OutSplitter):
                self.__pipes = Set(out_splitter.pipes)
        elif out_splitter:
            if hasattr(out_splitter,"__iter__") : #
                for pn in out_splitter:
                    self.add_pipe(pn)
            else:
                self.add_pipe(out_splitter)
                   
    @property
    def pipes(self):
        return self.__pipes
    
    def add_pipe(self, pn):
        if isinstance(pn, basestring):
            if pn == 'stderr': x = sys.stderr
            elif pn == 'stdout': x = sys.stdout
            else: 
                p = os.path.dirname(pn)
                if p and not os.path.exists(p):
                    os.makedirs(p)
                x = open(pn,'w+')
        else: x = pn
        
        if hasattr(x, 'write') and hasattr(x, 'flush'):
            if self.__pipes is None:
                self.__pipes=Set()
            self.__pipes.add(x)
    
    def write(self,s):
        if self.__pipes:
            for p in self.__pipes:
                p.write(s)
    
    def flush(self):
        if self.__pipes:
            for p in self.__pipes:
                p.flush()
                
class CatalogsWalker(OutSplitter):
    
    def __init__(self,catalogs,out_splitter=None):
        OutSplitter.__init__(self,out_splitter)        
        self.__catalogs = catalogs
        self.__file_counter, self.__catalog_counter, self.__start_time, self.__speed = 0, 0, 0, 0
        
    def __iter__(self):
        self.__file_counter, self.__catalog_counter, self.__start_time, self.__speed = 0, 0, 0, 0
        self.__start_time=time.time()
        for catalog in self.__catalogs:
            for p,d,ns in os.walk(catalog): 
                _ = d;
                self.__catalog_counter+=1
                for n in ns:
                    self.__file_counter+=1
                    self.__speed=self.__file_counter/(time.time()-self.__start_time)
                    self.fanny_indicator(self.__catalog_counter, self.__file_counter, self.__speed)
                    yield p,n
                    
    @property
    def catalog_counter(self):
        return self.__catalog_counter    
    
    @property
    def file_counter(self):
        return self.__file_counter
    
    @property
    def start_time(self):
        return self.__start_time
    
    @property
    def speed(self):
        return self.__speed    
    
    def fanny_indicator(self,p,n,s):
        if self.pipes:
            _result="\rcataloges:"+repr(p).rjust(8)+"\tfiles:"+repr(n).rjust(13)+"\tspeed: %12.3f"%s+" files/s"
            self.write(_result)
            self.flush()    

class OptionParserWraper(OptionParser):
    def __init__(self,argv,
                 version_string = '%%prog %s (%s)' % (__def_vers__, __def_duild_date__),
                 longdesc = '''''', # optional - give further explanation about what the program does
                 license_ = "Copyright 2014 Gurman (Gurman Inc.)                                            \
                    Licensed under the Apache License 2.0\nhttp://www.apache.org/licenses/LICENSE-2.0"                 
                 ):
        '''Command line options.'''
        # setup option parser
        OptionParser.__init__(self,
                              version=version_string, 
                              epilog=longdesc, 
                              description=license_)
        
        x=self.add_option
        x('--config', dest='config', default=None, metavar='FILE', 
          help='config file')
        x('--raw_root', dest='raw_root', default=None, metavar='PATH', 
          help='prefix(root path) for raw catalogs')        
        x('--raw', dest='raw', default='raw', metavar='DIRS', 
          help='list of raw catalogs names - archives names')
        x('--raw_sorted_root', dest='raw_sorted_root',default= None, metavar='PATH', 
          help='prefix(root path) for sorted raw catalogs')      
        x('--raw_sorted', dest='raw_sorted', default='raw_sorted', metavar='DIRS', 
          help='list of sorted raw catalogs names')
        x('--verbose',  dest='verbose', type=int, default=2, metavar='int', 
          help='level of verbosity')
        x('--check_dup', dest='check_dup', default='Y', metavar='Y/n', 
          help='Y for check duplicates default[Y]')
        x('--out_root', dest='out_root', default=None, metavar='FILE', 
          help='root dir to results output')
        x('--dup_prefix', dest='dup_prefix', default=None, metavar='FILE', 
          help='filenames prefix to out list of duplicate files')
        x('--check_uniq', dest='check_uniq', default='n', metavar='Y/n', 
          help='Y for check uniq files default[n]')
        x('--uniq_prefix', dest='uniq_prefix', default=None, metavar='FILE', 
          help='filenames prefix to out list of uniq files')
                       
        def config_parser_and_options_merger(filename, argv=None):
    
            cllps = (lambda s: ' '.join(s.split()))
            dopts = {}
        
            fconf = open(filename, 'r')
            for ln in fconf.read().split('\n'):
                
                if ln and ln[0] ==' #' or len(ln) 
Пример содержимого test.conf:

--raw_root=/home/user/photo/ 
--raw=dir1,dir2
#comments 1
--raw_sorted_root=/media/user/3XX/photo/ 
--raw_sorted=dir2,dir3
--out_root=results
--dup_prefix=dup-
--uniq_prefix=uniq-
--check_uniq=Y
--check_dup=Y
--verbose=2