Утилита для работы с архивными каталогами
Часто возникает желание привести свои фото-видео и просто архивы впорядок, но никогда не хватает терпения сделать это вручную. Решил написать удобную утилитку для поиска дубликатов ( дубликатами считаюся файлы с одинаковым содержимым, точнее с одинаковым 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