From 7519e108583d217e9d53f6f49fc46fc7659bcc31 Mon Sep 17 00:00:00 2001 From: "beville@gmail.com" Date: Mon, 5 Nov 2012 19:20:13 +0000 Subject: [PATCH] Added beginnings of RAR read/write support git-svn-id: http://comictagger.googlecode.com/svn/trunk@8 6c5673fe-1810-88d6-992b-cd32ca31540c --- UnRAR2/__init__.py | 177 ++++++++++++++++++ UnRAR2/rar_exceptions.py | 30 +++ UnRAR2/test_UnRAR2.py | 139 ++++++++++++++ UnRAR2/unix.py | 175 ++++++++++++++++++ UnRAR2/windows.py | 309 +++++++++++++++++++++++++++++++ comicarchive.py | 387 ++++++++++++++++++++++++++++----------- taggerwindow.py | 16 +- 7 files changed, 1125 insertions(+), 108 deletions(-) create mode 100644 UnRAR2/__init__.py create mode 100644 UnRAR2/rar_exceptions.py create mode 100644 UnRAR2/test_UnRAR2.py create mode 100644 UnRAR2/unix.py create mode 100644 UnRAR2/windows.py diff --git a/UnRAR2/__init__.py b/UnRAR2/__init__.py new file mode 100644 index 0000000..4095d1c --- /dev/null +++ b/UnRAR2/__init__.py @@ -0,0 +1,177 @@ +# Copyright (c) 2003-2005 Jimmy Retzlaff, 2008 Konstantin Yegupov +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +pyUnRAR2 is a ctypes based wrapper around the free UnRAR.dll. + +It is an modified version of Jimmy Retzlaff's pyUnRAR - more simple, +stable and foolproof. +Notice that it has INCOMPATIBLE interface. + +It enables reading and unpacking of archives created with the +RAR/WinRAR archivers. There is a low-level interface which is very +similar to the C interface provided by UnRAR. There is also a +higher level interface which makes some common operations easier. +""" + +__version__ = '0.99.2' + +try: + WindowsError + in_windows = True +except NameError: + in_windows = False + +if in_windows: + from windows import RarFileImplementation +else: + from unix import RarFileImplementation + + +import fnmatch, time, weakref + +class RarInfo(object): + """Represents a file header in an archive. Don't instantiate directly. + Use only to obtain information about file. + YOU CANNOT EXTRACT FILE CONTENTS USING THIS OBJECT. + USE METHODS OF RarFile CLASS INSTEAD. + + Properties: + index - index of file within the archive + filename - name of the file in the archive including path (if any) + datetime - file date/time as a struct_time suitable for time.strftime + isdir - True if the file is a directory + size - size in bytes of the uncompressed file + comment - comment associated with the file + + Note - this is not currently intended to be a Python file-like object. + """ + + def __init__(self, rarfile, data): + self.rarfile = weakref.proxy(rarfile) + self.index = data['index'] + self.filename = data['filename'] + self.isdir = data['isdir'] + self.size = data['size'] + self.datetime = data['datetime'] + self.comment = data['comment'] + + + + def __str__(self): + try : + arcName = self.rarfile.archiveName + except ReferenceError: + arcName = "[ARCHIVE_NO_LONGER_LOADED]" + return '' % (self.filename, arcName) + +class RarFile(RarFileImplementation): + + def __init__(self, archiveName, password=None): + """Instantiate the archive. + + archiveName is the name of the RAR file. + password is used to decrypt the files in the archive. + + Properties: + comment - comment associated with the archive + + >>> print RarFile('test.rar').comment + This is a test. + """ + self.archiveName = archiveName + RarFileImplementation.init(self, password) + + def __del__(self): + self.destruct() + + def infoiter(self): + """Iterate over all the files in the archive, generating RarInfos. + + >>> import os + >>> for fileInArchive in RarFile('test.rar').infoiter(): + ... print os.path.split(fileInArchive.filename)[-1], + ... print fileInArchive.isdir, + ... print fileInArchive.size, + ... print fileInArchive.comment, + ... print tuple(fileInArchive.datetime)[0:5], + ... print time.strftime('%a, %d %b %Y %H:%M', fileInArchive.datetime) + test True 0 None (2003, 6, 30, 1, 59) Mon, 30 Jun 2003 01:59 + test.txt False 20 None (2003, 6, 30, 2, 1) Mon, 30 Jun 2003 02:01 + this.py False 1030 None (2002, 2, 8, 16, 47) Fri, 08 Feb 2002 16:47 + """ + for params in RarFileImplementation.infoiter(self): + yield RarInfo(self, params) + + def infolist(self): + """Return a list of RarInfos, descripting the contents of the archive.""" + return list(self.infoiter()) + + def read_files(self, condition='*'): + """Read specific files from archive into memory. + If "condition" is a list of numbers, then return files which have those positions in infolist. + If "condition" is a string, then it is treated as a wildcard for names of files to extract. + If "condition" is a function, it is treated as a callback function, which accepts a RarInfo object + and returns boolean True (extract) or False (skip). + If "condition" is omitted, all files are returned. + + Returns list of tuples (RarInfo info, str contents) + """ + checker = condition2checker(condition) + return RarFileImplementation.read_files(self, checker) + + + def extract(self, condition='*', path='.', withSubpath=True, overwrite=True): + """Extract specific files from archive to disk. + + If "condition" is a list of numbers, then extract files which have those positions in infolist. + If "condition" is a string, then it is treated as a wildcard for names of files to extract. + If "condition" is a function, it is treated as a callback function, which accepts a RarInfo object + and returns either boolean True (extract) or boolean False (skip). + DEPRECATED: If "condition" callback returns string (only supported for Windows) - + that string will be used as a new name to save the file under. + If "condition" is omitted, all files are extracted. + + "path" is a directory to extract to + "withSubpath" flag denotes whether files are extracted with their full path in the archive. + "overwrite" flag denotes whether extracted files will overwrite old ones. Defaults to true. + + Returns list of RarInfos for extracted files.""" + checker = condition2checker(condition) + return RarFileImplementation.extract(self, checker, path, withSubpath, overwrite) + +def condition2checker(condition): + """Converts different condition types to callback""" + if type(condition) in [str, unicode]: + def smatcher(info): + return fnmatch.fnmatch(info.filename, condition) + return smatcher + elif type(condition) in [list, tuple] and type(condition[0]) in [int, long]: + def imatcher(info): + return info.index in condition + return imatcher + elif callable(condition): + return condition + else: + raise TypeError + + diff --git a/UnRAR2/rar_exceptions.py b/UnRAR2/rar_exceptions.py new file mode 100644 index 0000000..d90d1c8 --- /dev/null +++ b/UnRAR2/rar_exceptions.py @@ -0,0 +1,30 @@ +# Copyright (c) 2003-2005 Jimmy Retzlaff, 2008 Konstantin Yegupov +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# Low level interface - see UnRARDLL\UNRARDLL.TXT + + +class ArchiveHeaderBroken(Exception): pass +class InvalidRARArchive(Exception): pass +class FileOpenError(Exception): pass +class IncorrectRARPassword(Exception): pass +class InvalidRARArchiveUsage(Exception): pass diff --git a/UnRAR2/test_UnRAR2.py b/UnRAR2/test_UnRAR2.py new file mode 100644 index 0000000..a1a20e4 --- /dev/null +++ b/UnRAR2/test_UnRAR2.py @@ -0,0 +1,139 @@ +import os, sys + +import UnRAR2 +from UnRAR2.rar_exceptions import * + + +def cleanup(dir='test'): + for path, dirs, files in os.walk(dir): + for fn in files: + os.remove(os.path.join(path, fn)) + for dir in dirs: + os.removedirs(os.path.join(path, dir)) + + + + + +# reuse RarArchive object, en +cleanup() +rarc = UnRAR2.RarFile('test.rar') +rarc.infolist() +for info in rarc.infoiter(): + saveinfo = info + assert (str(info)=="""""") + break +rarc.extract() +assert os.path.exists('test'+os.sep+'test.txt') +assert os.path.exists('test'+os.sep+'this.py') +del rarc +assert (str(saveinfo)=="""""") +cleanup() + +# extract all the files in test.rar +cleanup() +UnRAR2.RarFile('test.rar').extract() +assert os.path.exists('test'+os.sep+'test.txt') +assert os.path.exists('test'+os.sep+'this.py') +cleanup() + +# extract all the files in test.rar matching the wildcard *.txt +cleanup() +UnRAR2.RarFile('test.rar').extract('*.txt') +assert os.path.exists('test'+os.sep+'test.txt') +assert not os.path.exists('test'+os.sep+'this.py') +cleanup() + + +# check the name and size of each file, extracting small ones +cleanup() +archive = UnRAR2.RarFile('test.rar') +assert archive.comment == 'This is a test.' +archive.extract(lambda rarinfo: rarinfo.size <= 1024) +for rarinfo in archive.infoiter(): + if rarinfo.size <= 1024 and not rarinfo.isdir: + assert rarinfo.size == os.stat(rarinfo.filename).st_size +assert file('test'+os.sep+'test.txt', 'rt').read() == 'This is only a test.' +assert not os.path.exists('test'+os.sep+'this.py') +cleanup() + + +# extract this.py, overriding it's destination +cleanup('test2') +archive = UnRAR2.RarFile('test.rar') +archive.extract('*.py', 'test2', False) +assert os.path.exists('test2'+os.sep+'this.py') +cleanup('test2') + + +# extract test.txt to memory +cleanup() +archive = UnRAR2.RarFile('test.rar') +entries = UnRAR2.RarFile('test.rar').read_files('*test.txt') +assert len(entries)==1 +assert entries[0][0].filename.endswith('test.txt') +assert entries[0][1]=='This is only a test.' + + +# extract all the files in test.rar with overwriting +cleanup() +fo = open('test'+os.sep+'test.txt',"wt") +fo.write("blah") +fo.close() +UnRAR2.RarFile('test.rar').extract('*.txt') +assert open('test'+os.sep+'test.txt',"rt").read()!="blah" +cleanup() + +# extract all the files in test.rar without overwriting +cleanup() +fo = open('test'+os.sep+'test.txt',"wt") +fo.write("blahblah") +fo.close() +UnRAR2.RarFile('test.rar').extract('*.txt', overwrite = False) +assert open('test'+os.sep+'test.txt',"rt").read()=="blahblah" +cleanup() + +# list big file in an archive +list(UnRAR2.RarFile('test_nulls.rar').infoiter()) + +# extract files from an archive with protected files +cleanup() +UnRAR2.RarFile('test_protected_files.rar', password="protected").extract() +assert os.path.exists('test'+os.sep+'top_secret_xxx_file.txt') +cleanup() +errored = False +try: + UnRAR2.RarFile('test_protected_files.rar', password="proteqted").extract() +except IncorrectRARPassword: + errored = True +assert not os.path.exists('test'+os.sep+'top_secret_xxx_file.txt') +assert errored +cleanup() + +# extract files from an archive with protected headers +cleanup() +UnRAR2.RarFile('test_protected_headers.rar', password="secret").extract() +assert os.path.exists('test'+os.sep+'top_secret_xxx_file.txt') +cleanup() +errored = False +try: + UnRAR2.RarFile('test_protected_headers.rar', password="seqret").extract() +except IncorrectRARPassword: + errored = True +assert not os.path.exists('test'+os.sep+'top_secret_xxx_file.txt') +assert errored +cleanup() + +# make sure docstring examples are working +import doctest +doctest.testmod(UnRAR2) + +# update documentation +import pydoc +pydoc.writedoc(UnRAR2) + +# cleanup +try: + os.remove('__init__.pyc') +except: + pass diff --git a/UnRAR2/unix.py b/UnRAR2/unix.py new file mode 100644 index 0000000..9d87b18 --- /dev/null +++ b/UnRAR2/unix.py @@ -0,0 +1,175 @@ +# Copyright (c) 2003-2005 Jimmy Retzlaff, 2008 Konstantin Yegupov +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# Unix version uses unrar command line executable + +import subprocess +import gc + +import os, os.path +import time, re + +from rar_exceptions import * + +class UnpackerNotInstalled(Exception): pass + +rar_executable_cached = None + +def call_unrar(params): + "Calls rar/unrar command line executable, returns stdout pipe" + global rar_executable_cached + if rar_executable_cached is None: + for command in ('unrar', 'rar'): + try: + subprocess.Popen([command], stdout=subprocess.PIPE) + rar_executable_cached = command + break + except OSError: + pass + if rar_executable_cached is None: + raise UnpackerNotInstalled("No suitable RAR unpacker installed") + + assert type(params) == list, "params must be list" + args = [rar_executable_cached] + params + try: + gc.disable() # See http://bugs.python.org/issue1336 + return subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + finally: + gc.enable() + +class RarFileImplementation(object): + + def init(self, password=None): + self.password = password + + + + stdoutdata, stderrdata = self.call('v', []).communicate() + + for line in stderrdata.splitlines(): + if line.strip().startswith("Cannot open"): + raise FileOpenError + if line.find("CRC failed")>=0: + raise IncorrectRARPassword + accum = [] + source = iter(stdoutdata.splitlines()) + line = '' + while not (line.startswith('Comment:') or line.startswith('Pathname/Comment')): + if line.strip().endswith('is not RAR archive'): + raise InvalidRARArchive + line = source.next() + while not line.startswith('Pathname/Comment'): + accum.append(line.rstrip('\n')) + line = source.next() + if len(accum): + accum[0] = accum[0][9:] + self.comment = '\n'.join(accum[:-1]) + else: + self.comment = None + + def escaped_password(self): + return '-' if self.password == None else self.password + + + def call(self, cmd, options=[], files=[]): + options2 = options + ['p'+self.escaped_password()] + soptions = ['-'+x for x in options2] + return call_unrar([cmd]+soptions+['--',self.archiveName]+files) + + def infoiter(self): + + stdoutdata, stderrdata = self.call('v', ['c-']).communicate() + + for line in stderrdata.splitlines(): + if line.strip().startswith("Cannot open"): + raise FileOpenError + + accum = [] + source = iter(stdoutdata.splitlines()) + line = '' + while not line.startswith('--------------'): + if line.strip().endswith('is not RAR archive'): + raise InvalidRARArchive + if line.find("CRC failed")>=0: + raise IncorrectRARPassword + line = source.next() + line = source.next() + i = 0 + re_spaces = re.compile(r"\s+") + while not line.startswith('--------------'): + accum.append(line) + if len(accum)==2: + data = {} + data['index'] = i + data['filename'] = accum[0].strip() + info = re_spaces.split(accum[1].strip()) + data['size'] = int(info[0]) + attr = info[5] + data['isdir'] = 'd' in attr.lower() + data['datetime'] = time.strptime(info[3]+" "+info[4], '%d-%m-%y %H:%M') + data['comment'] = None + yield data + accum = [] + i += 1 + line = source.next() + + def read_files(self, checker): + res = [] + for info in self.infoiter(): + checkres = checker(info) + if checkres==True and not info.isdir: + pipe = self.call('p', ['inul'], [info.filename]).stdout + res.append((info, pipe.read())) + return res + + + def extract(self, checker, path, withSubpath, overwrite): + res = [] + command = 'x' + if not withSubpath: + command = 'e' + options = [] + if overwrite: + options.append('o+') + else: + options.append('o-') + if not path.endswith(os.sep): + path += os.sep + names = [] + for info in self.infoiter(): + checkres = checker(info) + if type(checkres) in [str, unicode]: + raise NotImplementedError("Condition callbacks returning strings are deprecated and only supported in Windows") + if checkres==True and not info.isdir: + names.append(info.filename) + res.append(info) + names.append(path) + proc = self.call(command, options, names) + stdoutdata, stderrdata = proc.communicate() + if stderrdata.find("CRC failed")>=0: + raise IncorrectRARPassword + return res + + def destruct(self): + pass + + diff --git a/UnRAR2/windows.py b/UnRAR2/windows.py new file mode 100644 index 0000000..bb92481 --- /dev/null +++ b/UnRAR2/windows.py @@ -0,0 +1,309 @@ +# Copyright (c) 2003-2005 Jimmy Retzlaff, 2008 Konstantin Yegupov +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# Low level interface - see UnRARDLL\UNRARDLL.TXT + +from __future__ import generators + +import ctypes, ctypes.wintypes +import os, os.path, sys +import Queue +import time + +from rar_exceptions import * + +ERAR_END_ARCHIVE = 10 +ERAR_NO_MEMORY = 11 +ERAR_BAD_DATA = 12 +ERAR_BAD_ARCHIVE = 13 +ERAR_UNKNOWN_FORMAT = 14 +ERAR_EOPEN = 15 +ERAR_ECREATE = 16 +ERAR_ECLOSE = 17 +ERAR_EREAD = 18 +ERAR_EWRITE = 19 +ERAR_SMALL_BUF = 20 +ERAR_UNKNOWN = 21 + +RAR_OM_LIST = 0 +RAR_OM_EXTRACT = 1 + +RAR_SKIP = 0 +RAR_TEST = 1 +RAR_EXTRACT = 2 + +RAR_VOL_ASK = 0 +RAR_VOL_NOTIFY = 1 + +RAR_DLL_VERSION = 3 + +# enum UNRARCALLBACK_MESSAGES +UCM_CHANGEVOLUME = 0 +UCM_PROCESSDATA = 1 +UCM_NEEDPASSWORD = 2 + +architecture_bits = ctypes.sizeof(ctypes.c_voidp)*8 +dll_name = "unrar.dll" +if architecture_bits == 64: + dll_name = "x64\\unrar64.dll" + + +try: + unrar = ctypes.WinDLL(os.path.join(os.path.split(__file__)[0], 'UnRARDLL', dll_name)) +except WindowsError: + unrar = ctypes.WinDLL(dll_name) + + +class RAROpenArchiveDataEx(ctypes.Structure): + def __init__(self, ArcName=None, ArcNameW=u'', OpenMode=RAR_OM_LIST): + self.CmtBuf = ctypes.c_buffer(64*1024) + ctypes.Structure.__init__(self, ArcName=ArcName, ArcNameW=ArcNameW, OpenMode=OpenMode, _CmtBuf=ctypes.addressof(self.CmtBuf), CmtBufSize=ctypes.sizeof(self.CmtBuf)) + + _fields_ = [ + ('ArcName', ctypes.c_char_p), + ('ArcNameW', ctypes.c_wchar_p), + ('OpenMode', ctypes.c_uint), + ('OpenResult', ctypes.c_uint), + ('_CmtBuf', ctypes.c_voidp), + ('CmtBufSize', ctypes.c_uint), + ('CmtSize', ctypes.c_uint), + ('CmtState', ctypes.c_uint), + ('Flags', ctypes.c_uint), + ('Reserved', ctypes.c_uint*32), + ] + +class RARHeaderDataEx(ctypes.Structure): + def __init__(self): + self.CmtBuf = ctypes.c_buffer(64*1024) + ctypes.Structure.__init__(self, _CmtBuf=ctypes.addressof(self.CmtBuf), CmtBufSize=ctypes.sizeof(self.CmtBuf)) + + _fields_ = [ + ('ArcName', ctypes.c_char*1024), + ('ArcNameW', ctypes.c_wchar*1024), + ('FileName', ctypes.c_char*1024), + ('FileNameW', ctypes.c_wchar*1024), + ('Flags', ctypes.c_uint), + ('PackSize', ctypes.c_uint), + ('PackSizeHigh', ctypes.c_uint), + ('UnpSize', ctypes.c_uint), + ('UnpSizeHigh', ctypes.c_uint), + ('HostOS', ctypes.c_uint), + ('FileCRC', ctypes.c_uint), + ('FileTime', ctypes.c_uint), + ('UnpVer', ctypes.c_uint), + ('Method', ctypes.c_uint), + ('FileAttr', ctypes.c_uint), + ('_CmtBuf', ctypes.c_voidp), + ('CmtBufSize', ctypes.c_uint), + ('CmtSize', ctypes.c_uint), + ('CmtState', ctypes.c_uint), + ('Reserved', ctypes.c_uint*1024), + ] + +def DosDateTimeToTimeTuple(dosDateTime): + """Convert an MS-DOS format date time to a Python time tuple. + """ + dosDate = dosDateTime >> 16 + dosTime = dosDateTime & 0xffff + day = dosDate & 0x1f + month = (dosDate >> 5) & 0xf + year = 1980 + (dosDate >> 9) + second = 2*(dosTime & 0x1f) + minute = (dosTime >> 5) & 0x3f + hour = dosTime >> 11 + return time.localtime(time.mktime((year, month, day, hour, minute, second, 0, 1, -1))) + +def _wrap(restype, function, argtypes): + result = function + result.argtypes = argtypes + result.restype = restype + return result + +RARGetDllVersion = _wrap(ctypes.c_int, unrar.RARGetDllVersion, []) + +RAROpenArchiveEx = _wrap(ctypes.wintypes.HANDLE, unrar.RAROpenArchiveEx, [ctypes.POINTER(RAROpenArchiveDataEx)]) + +RARReadHeaderEx = _wrap(ctypes.c_int, unrar.RARReadHeaderEx, [ctypes.wintypes.HANDLE, ctypes.POINTER(RARHeaderDataEx)]) + +_RARSetPassword = _wrap(ctypes.c_int, unrar.RARSetPassword, [ctypes.wintypes.HANDLE, ctypes.c_char_p]) +def RARSetPassword(*args, **kwargs): + _RARSetPassword(*args, **kwargs) + +RARProcessFile = _wrap(ctypes.c_int, unrar.RARProcessFile, [ctypes.wintypes.HANDLE, ctypes.c_int, ctypes.c_char_p, ctypes.c_char_p]) + +RARCloseArchive = _wrap(ctypes.c_int, unrar.RARCloseArchive, [ctypes.wintypes.HANDLE]) + +UNRARCALLBACK = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_uint, ctypes.c_long, ctypes.c_long, ctypes.c_long) +RARSetCallback = _wrap(ctypes.c_int, unrar.RARSetCallback, [ctypes.wintypes.HANDLE, UNRARCALLBACK, ctypes.c_long]) + + + +RARExceptions = { + ERAR_NO_MEMORY : MemoryError, + ERAR_BAD_DATA : ArchiveHeaderBroken, + ERAR_BAD_ARCHIVE : InvalidRARArchive, + ERAR_EOPEN : FileOpenError, + } + +class PassiveReader: + """Used for reading files to memory""" + def __init__(self, usercallback = None): + self.buf = [] + self.ucb = usercallback + + def _callback(self, msg, UserData, P1, P2): + if msg == UCM_PROCESSDATA: + data = (ctypes.c_char*P2).from_address(P1).raw + if self.ucb!=None: + self.ucb(data) + else: + self.buf.append(data) + return 1 + + def get_result(self): + return ''.join(self.buf) + +class RarInfoIterator(object): + def __init__(self, arc): + self.arc = arc + self.index = 0 + self.headerData = RARHeaderDataEx() + self.res = RARReadHeaderEx(self.arc._handle, ctypes.byref(self.headerData)) + if self.res==ERAR_BAD_DATA: + raise IncorrectRARPassword + self.arc.lockStatus = "locked" + self.arc.needskip = False + + def __iter__(self): + return self + + def next(self): + if self.index>0: + if self.arc.needskip: + RARProcessFile(self.arc._handle, RAR_SKIP, None, None) + self.res = RARReadHeaderEx(self.arc._handle, ctypes.byref(self.headerData)) + + if self.res: + raise StopIteration + self.arc.needskip = True + + data = {} + data['index'] = self.index + data['filename'] = self.headerData.FileName + data['datetime'] = DosDateTimeToTimeTuple(self.headerData.FileTime) + data['isdir'] = ((self.headerData.Flags & 0xE0) == 0xE0) + data['size'] = self.headerData.UnpSize + (self.headerData.UnpSizeHigh << 32) + if self.headerData.CmtState == 1: + data['comment'] = self.headerData.CmtBuf.value + else: + data['comment'] = None + self.index += 1 + return data + + + def __del__(self): + self.arc.lockStatus = "finished" + +def generate_password_provider(password): + def password_provider_callback(msg, UserData, P1, P2): + if msg == UCM_NEEDPASSWORD and password!=None: + (ctypes.c_char*P2).from_address(P1).value = password + return 1 + return password_provider_callback + +class RarFileImplementation(object): + + def init(self, password=None): + self.password = password + archiveData = RAROpenArchiveDataEx(ArcNameW=self.archiveName, OpenMode=RAR_OM_EXTRACT) + self._handle = RAROpenArchiveEx(ctypes.byref(archiveData)) + self.c_callback = UNRARCALLBACK(generate_password_provider(self.password)) + RARSetCallback(self._handle, self.c_callback, 1) + + if archiveData.OpenResult != 0: + raise RARExceptions[archiveData.OpenResult] + + if archiveData.CmtState == 1: + self.comment = archiveData.CmtBuf.value + else: + self.comment = None + + if password: + RARSetPassword(self._handle, password) + + self.lockStatus = "ready" + + + + def destruct(self): + if self._handle and RARCloseArchive: + RARCloseArchive(self._handle) + + def make_sure_ready(self): + if self.lockStatus == "locked": + raise InvalidRARArchiveUsage("cannot execute infoiter() without finishing previous one") + if self.lockStatus == "finished": + self.destruct() + self.init(self.password) + + def infoiter(self): + self.make_sure_ready() + return RarInfoIterator(self) + + def read_files(self, checker): + res = [] + for info in self.infoiter(): + if checker(info) and not info.isdir: + reader = PassiveReader() + c_callback = UNRARCALLBACK(reader._callback) + RARSetCallback(self._handle, c_callback, 1) + tmpres = RARProcessFile(self._handle, RAR_TEST, None, None) + if tmpres==ERAR_BAD_DATA: + raise IncorrectRARPassword + self.needskip = False + res.append((info, reader.get_result())) + return res + + + def extract(self, checker, path, withSubpath, overwrite): + res = [] + for info in self.infoiter(): + checkres = checker(info) + if checkres!=False and not info.isdir: + if checkres==True: + fn = info.filename + if not withSubpath: + fn = os.path.split(fn)[-1] + target = os.path.join(path, fn) + else: + raise DeprecationWarning, "Condition callbacks returning strings are deprecated and only supported in Windows" + target = checkres + if overwrite or (not os.path.exists(target)): + tmpres = RARProcessFile(self._handle, RAR_EXTRACT, None, target) + if tmpres==ERAR_BAD_DATA: + raise IncorrectRARPassword + + self.needskip = False + res.append(info) + return res + + diff --git a/comicarchive.py b/comicarchive.py index e470f76..f749d44 100644 --- a/comicarchive.py +++ b/comicarchive.py @@ -5,6 +5,13 @@ A python class to represent a single comic, be it file or folder of images import zipfile import os import struct +import sys +import tempfile +from subprocess import call + +sys.path.insert(0, os.path.abspath(".") ) +import UnRAR2 +from UnRAR2.rar_exceptions import * from options import Options, MetaDataStyle from comicinfoxml import ComicInfoXml @@ -76,20 +83,86 @@ def writeZipComment( filename, comment ): class ComicArchive: + class ArchiveType: + Zip, Rar, Folder, Unknown = range(4) + def __init__( self, path ): self.path = path self.ci_xml_filename = 'ComicInfo.xml' - - def isZip( self ): - return zipfile.is_zipfile( self.path ) - - def isFolder( self ): - return False + self.rar_exe_path = None - def isNonWritableArchive( self ): - # TODO check for rar, maybe others - # also check permissions - return False + if self.zipTest(): + self.archive_type = self.ArchiveType.Zip + self.getArchiveComment = self.getArchiveComment_zip + self.setArchiveComment = self.setArchiveComment_zip + self.readArchiveFile = self.readArchiveFile_zip + self.writeArchiveFile = self.writeArchiveFile_zip + self.removeArchiveFile = self.removeArchiveFile_zip + self.getArchiveFilenameList = self.getArchiveFilenameList_zip + + elif self.rarTest(): + self.archive_type = self.ArchiveType.Rar + self.getArchiveComment = self.getArchiveComment_rar + self.setArchiveComment = self.setArchiveComment_rar + self.readArchiveFile = self.readArchiveFile_rar + self.writeArchiveFile = self.writeArchiveFile_rar + self.removeArchiveFile = self.removeArchiveFile_rar + self.getArchiveFilenameList = self.getArchiveFilenameList_rar + + elif os.path.isdir( self.path ): + self.archive_type = self.ArchiveType.Folder + self.getArchiveComment = self.getArchiveComment_folder + self.setArchiveComment = self.setArchiveComment_folder + self.readArchiveFile = self.readArchiveFile_folder + self.writeArchiveFile = self.writeArchiveFile_folder + self.removeArchiveFile = self.removeArchiveFile_folder + self.getArchiveFilenameList = self.getArchiveFilenameList_folder + + else: + self.archive_type = self.ArchiveType.Unknown + self.getArchiveComment = self.getArchiveComment_unknown + self.setArchiveComment = self.setArchiveComment_unknown + self.readArchiveFile = self.readArchiveFile_unknown + self.writeArchiveFile = self.writeArchiveFile_unknown + self.removeArchiveFile = self.removeArchiveFile_unknown + self.getArchiveFilenameList = self.getArchiveFilenameList_unknown + + + def setExternalRarProgram( self, rar_exe_path ): + self.rar_exe_path = rar_exe_path + + def zipTest( self ): + return zipfile.is_zipfile( self.path ) + + def rarTest( self ): + try: + rarc = UnRAR2.RarFile( self.path ) + except InvalidRARArchive: + return False + else: + return True + + + def isZip( self ): + return self.archive_type == self.ArchiveType.Zip + + def isRar( self ): + return self.archive_type == self.ArchiveType.Rar + + def isFolder( self ): + return self.archive_type == self.ArchiveType.Folder + + def isWritable( self ): + if self.archive_type == self.ArchiveType.Unknown : + return False + + elif self.isRar() and self.rar_exe_path is None: + return False + + elif not os.access(self.path, os.W_OK): + return False + + return True def seemsToBeAComicArchive( self ): # TODO this will need to be fleshed out to support RAR and Folder @@ -97,9 +170,17 @@ class ComicArchive: ext = os.path.splitext(self.path)[1].lower() if ( - ( self.isZip() ) and - ( ext in [ '.zip', '.cbz' ] ) and - ( self.getNumberOfPages() > 3) + ( ( ( self.isZip() ) and + ( ext in [ '.zip', '.cbz' ] )) + or + (( self.isRar() ) and + ( ext in [ '.rar', '.cbr' ] )) + or + ( self.isFolder() ) ) + + and + ( self.getNumberOfPages() > 3) + ): return True else: @@ -141,10 +222,8 @@ class ComicArchive: if self.getNumberOfPages() == 0: return None - zf = zipfile.ZipFile (self.path, 'r') - # get the list file names in the archive, and sort - files = zf.namelist() + files = self.getArchiveFilenameList() files.sort() # find the first image file, assume it's the cover @@ -152,8 +231,7 @@ class ComicArchive: if ( name[-4:].lower() in [ ".jpg", "jpeg", ".png" ] ): break - image_data = zf.read( name ) - zf.close() + image_data = self.readArchiveFile( name ) return image_data @@ -161,24 +239,18 @@ class ComicArchive: count = 0 - if self.isZip(): - zf = zipfile.ZipFile (self.path, 'r') - for item in zf.infolist(): - if ( item.filename[-4:].lower() in [ ".jpg", "jpeg", ".png" ] ): - count += 1 - zf.close() + for item in self.getArchiveFilenameList(): + if ( item[-4:].lower() in [ ".jpg", "jpeg", ".png" ] ): + count += 1 return count def readCBI( self ): if ( not self.hasCBI() ): - print self.path, " isn't a zip or doesn't has CBI data!" return GenericMetadata() - zf = zipfile.ZipFile( self.path, "r" ) - cbi_string = zf.comment - zf.close() + cbi_string = self.getArchiveComment() metadata = ComicBookInfo().metadataFromString( cbi_string ) return metadata @@ -186,101 +258,46 @@ class ComicArchive: def writeCBI( self, metadata ): cbi_string = ComicBookInfo().stringFromMetadata( metadata ) - writeZipComment( self.path, cbi_string ) + self.setArchiveComment( cbi_string ) def removeCBI( self ): - print "ATB --->removing CBI" - writeZipComment( self.path, "" ) + self.setArchiveComment( "" ) def readCIX( self ): - - # !!!ATB TODO add support for folders - if (not self.isZip()) or ( not self.hasCIX()): - print self.path, " isn't a zip or doesn't has ComicInfo.xml data!" + if not self.hasCIX(): + print self.path, "doesn't has ComicInfo.xml data!" return GenericMetadata() - - zf = zipfile.ZipFile( self.path, 'r' ) - cix_string = zf.read( self.ci_xml_filename ) - zf.close() + + cix_string = self.readArchiveFile( self.ci_xml_filename ) metadata = ComicInfoXml().metadataFromString( cix_string ) return metadata def writeCIX(self, metadata): - # Passing in None for metadata will remove the CIX file from the archive - - # !!!ATB TODO add support for folders - if (not self.isZip()): - print self.path, "isn't a zip archive!" - return - - if metadata == None: - cix_string = "" - copy_cix = False - else: + if metadata is not None: cix_string = ComicInfoXml().stringFromMetadata( metadata ) - copy_cix = True - - # check if an XML file already exists in archive - if not self.hasCIX() and copy_cix: - - #simple case: just add the new archive file - zf = zipfile.ZipFile(self.path, mode='a', compression=zipfile.ZIP_DEFLATED ) - zf.writestr( self.ci_xml_filename, cix_string ) - zf.close() - - else: - # If we need to replace it, well, at the moment, no other option - # but to rebuild the whole zip again. - # very sucky, but maybe another solution can be found - - print "{0} already exists in {1}. Rebuilding it...".format( self.ci_xml_filename, self.path) - zin = zipfile.ZipFile (self.path, 'r') - zout = zipfile.ZipFile ('tmpnew.zip', 'w') - for item in zin.infolist(): - buffer = zin.read(item.filename) - if ( item.filename != self.ci_xml_filename ): - zout.writestr(item, buffer) - - # now write out the new xml file, if there is one - if copy_cix: - zout.writestr( self.ci_xml_filename, cix_string ) - - #preserve the old comment - zout.comment = zin.comment - - zout.close() - zin.close() - - # replace with the new file - os.remove( self.path ) - os.rename( 'tmpnew.zip', self.path ) + self.writeArchiveFile( self.ci_xml_filename, cix_string ) def removeCIX( self ): - self.writeCIX( None ) + self.removeArchiveFile( self.ci_xml_filename ) - def hasCIX(self): - - has = False - - zf = zipfile.ZipFile( self.path, 'r' ) - if self.ci_xml_filename in zf.namelist(): - has = True - zf.close() - - return has + if not self.seemsToBeAComicArchive(): + return False + elif self.ci_xml_filename in self.getArchiveFilenameList(): + return True + else: + return False def hasCBI(self): - if (not self.isZip() ): + + if ( not ( self.isZip() or self.isRar()) or not self.seemsToBeAComicArchive() ): return False - zf = zipfile.ZipFile( self.path, 'r' ) - comment = zf.comment - zf.close() - + + comment = self.getArchiveComment() return ComicBookInfo().validateString( comment ) def metadataFromFilename( self ): @@ -301,4 +318,168 @@ class ComicArchive: metadata.isEmpty = False - return metadata \ No newline at end of file + return metadata + + #--------------- + # Zip implementation + #--------------- + + def getArchiveComment_zip( self ): + zf = zipfile.ZipFile( self.path, 'r' ) + comment = zf.comment + zf.close() + return comment + + def setArchiveComment_zip( self, comment ): + writeZipComment( self.path, comment ) + + def readArchiveFile_zip( self, archive_file ): + zf = zipfile.ZipFile( self.path, 'r' ) + data = zf.read( archive_file ) + zf.close() + return data + + def removeArchiveFile_zip( self, archive_file ): + self.rebuildZipFile( [ archive_file ] ) + + def writeArchiveFile_zip( self, archive_file, data ): + # At the moment, no other option but to rebuild the whole + # zip archive w/o the indicated file. Very sucky, but maybe + # another solution can be found + self.rebuildZipFile( [ archive_file ] ) + + #now just add the archive file as a new one + zf = zipfile.ZipFile(self.path, mode='a', compression=zipfile.ZIP_DEFLATED ) + zf.writestr( archive_file, data ) + zf.close() + + def getArchiveFilenameList_zip( self ): + zf = zipfile.ZipFile( self.path, 'r' ) + namelist = zf.namelist() + zf.close() + return namelist + + # zip helper func + def rebuildZipFile( self, exclude_list ): + + # TODO: use tempfile.mkstemp + # this recompresses the zip archive, without the files in the exclude_list + print "Rebuilding zip {0} without {1}".format( self.path, exclude_list ) + zin = zipfile.ZipFile (self.path, 'r') + zout = zipfile.ZipFile ('tmpnew.zip', 'w') + for item in zin.infolist(): + buffer = zin.read(item.filename) + if ( item.filename not in exclude_list ): + zout.writestr(item, buffer) + + #preserve the old comment + zout.comment = zin.comment + + zout.close() + zin.close() + + # replace with the new file + os.remove( self.path ) + os.rename( 'tmpnew.zip', self.path ) + + #--------------- + # RAR implementation + #--------------- + + def getArchiveComment_rar( self ): + + rarc = UnRAR2.RarFile( self.path ) + return rarc.comment + + def setArchiveComment_rar( self, comment ): + + if self.rar_exe_path is not None: + # write comment to temp file + tmp_fd, tmp_name = tempfile.mkstemp() + f = os.fdopen(tmp_fd, 'w+b') + f.write( comment ) + f.close() + + # use external program to write comment to Rar archive + call([self.rar_exe_path, 'c', '-z' + tmp_name, self.path]) + + os.remove( tmp_name) + + def readArchiveFile_rar( self, archive_file ): + + entries = UnRAR2.RarFile( self.path ).read_files( archive_file ) + + #entries is a list of of tuples: ( rarinfo, filedata) + if (len(entries) == 1): + return entries[0][1] + else: + return "" + + def writeArchiveFile_rar( self, archive_file, data ): + + if self.rar_exe_path is not None: + + tmp_folder = tempfile.mkdtemp() + + tmp_file = os.path.join( tmp_folder, archive_file ) + + f = open(tmp_file, 'w') + f.write( data ) + f.close() + + # use external program to write comment to Rar archive + call([self.rar_exe_path, 'a', '-ep', self.path, tmp_file]) + + os.remove( tmp_file) + os.rmdir( tmp_folder) + + + + def removeArchiveFile_rar( self, archive_file ): + if self.rar_exe_path is not None: + + # use external program to remove file from Rar archive + call([self.rar_exe_path, 'd', self.path, archive_file]) + + + def getArchiveFilenameList_rar( self ): + + rarc = UnRAR2.RarFile( self.path ) + + return [ item.filename for item in rarc.infolist() ] + + + #--------------- + # Folder implementation + #--------------- + + def getArchiveComment_folder( self ): + pass + def setArchiveComment_folder( self, comment ): + pass + def readArchiveFile_folder( self ): + pass + def writeArchiveFile_folder( self, archive_file, data ): + pass + def removeArchiveFile_folder( self, archive_file ): + pass + def getArchiveFilenameList_folder( self ): + pass + + #--------------- + # Unknown implementation + #--------------- + + def getArchiveComment_unknown( self ): + return "" + def setArchiveComment_unknown( self, comment ): + return + def readArchiveFile_unknown( self ): + return "" + def writeArchiveFile_unknown( self, archive_file, data ): + return + def removeArchiveFile_unknown( self, archive_file ): + return + def getArchiveFilenameList_unknown( self ): + return [] + \ No newline at end of file diff --git a/taggerwindow.py b/taggerwindow.py index a965763..e927dd0 100644 --- a/taggerwindow.py +++ b/taggerwindow.py @@ -66,15 +66,20 @@ class TaggerWindow( QtGui.QMainWindow): def updateAppTitle( self ): - if self.dirtyFlag: - dirty_str = " [modified]" - else: - dirty_str = "" if self.comic_archive is None: self.setWindowTitle( self.appName ) else: - self.setWindowTitle( self.appName + " - " + self.comic_archive.path + dirty_str) + mod_str = "" + ro_str = "" + + if self.dirtyFlag: + mod_str = " [modified]" + + if not self.comic_archive.isWritable(): + ro_str = " [read only ]" + + self.setWindowTitle( self.appName + " - " + self.comic_archive.path + mod_str + ro_str) def configMenus( self): @@ -166,6 +171,7 @@ class TaggerWindow( QtGui.QMainWindow): return ca = ComicArchive( path ) + ca.setExternalRarProgram( "/usr/bin/rar" ) if ca is not None and ca.seemsToBeAComicArchive():