20c028840d3f38f43fb57475301a3e3b9aaf760e
[rarslave2.git] / rarslave.py
1 #!/usr/bin/env python
2 # vim: set ts=4 sts=4 sw=4 textwidth=80:
3
4 """
5 The main program of the rarslave project
6
7 This handles all of the commandline and configuration file work, then tries to
8 repair, extract, and delete any PAR2Sets that it finds.
9 """
10
11 __author__    = "Ira W. Snyder (devel@irasnyder.com)"
12 __copyright__ = "Copyright (c) 2006-2008 Ira W. Snyder (devel@irasnyder.com)"
13 __license__   = "GNU GPL v2 (or, at your option, any later version)"
14
15 #    rarslave.py -- a usenet autorepair and autoextract utility
16 #
17 #    Copyright (C) 2006-2008  Ira W. Snyder (devel@irasnyder.com)
18 #
19 #    This program is free software; you can redistribute it and/or modify
20 #    it under the terms of the GNU General Public License as published by
21 #    the Free Software Foundation; either version 2 of the License, or
22 #    (at your option) any later version.
23 #
24 #    This program is distributed in the hope that it will be useful,
25 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
26 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
27 #    GNU General Public License for more details.
28 #
29 #    You should have received a copy of the GNU General Public License
30 #    along with this program; if not, write to the Free Software
31 #    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
32
33 VERSION = "2.1.0"
34 PROGRAM = "rarslave"
35
36 import os, sys, optparse, logging, ConfigParser
37 from subprocess import CalledProcessError
38 import PAR2Set
39
40 ################################################################################
41
42 # A simple-ish configuration class
43 class RarslaveConfig(object):
44
45     DEFAULT_CONFIG_FILE = PAR2Set.utils.absolutePath(
46         os.path.join('~', '.config', 'rarslave', 'rarslave.conf'))
47
48     def __init__(self, fileName=DEFAULT_CONFIG_FILE):
49
50         # Make sure that the fileName is in absolute form
51         self.fileName = os.path.abspath(os.path.expanduser(fileName))
52
53         # Open it with ConfigParser
54         self.config = ConfigParser.SafeConfigParser()
55         self.config.read(fileName)
56
57         # Setup the default dictionary
58         self.defaults = dict()
59
60         # Add all of the defaults
61         self.add_default('directories', 'start',
62                          os.path.join('~', 'downloads'),
63                          PAR2Set.utils.absolutePath)
64         self.add_default('options', 'recursive', True, self.toBool)
65         self.add_default('options', 'interactive', False, self.toBool)
66         self.add_default('options', 'verbosity', 0, self.toInt)
67         self.add_default('options', 'delete', True, self.toBool)
68
69     # Add a new default value
70     def add_default(self, section, key, value, typeConverter):
71
72         self.defaults[(section, key)] = (value, typeConverter)
73
74     # Get the default value
75     def get_default(self, section, key):
76
77         (value, typeConverter) = self.defaults[(section, key)]
78         return value
79
80     # Coerce the value from a string into the correct type
81     def coerceValue(self, section, key, value):
82
83         (defaultValue, typeConverter) = self.defaults[(section, key)]
84
85         # Try the coercion, error and exit if there is a problem
86         try:
87             return typeConverter(value)
88         except:
89             sys.stderr.write('Unable to parse configuration file\n')
90             sys.stderr.write('-> at section: %s\n' % section)
91             sys.stderr.write('-> at key: %s\n' % key)
92             sys.exit(2)
93
94     # Return the value
95     def get(self, section, key):
96
97         try:
98             # Get the user-provided value
99             value = self.config.get(section, key)
100         except:
101             # Oops, they didn't provide it, use the default
102             # NOTE: if you get an exception here, check your code ;)
103             value = self.defaults[(section, key)]
104
105         # Try to evaluate some safe things, for convenience
106         return self.coerceValue(section, key, value)
107
108     # Convert a string to an int (any base)
109     def toInt(s):
110         return int(s, 0)
111
112     # Mark it static
113     toInt = staticmethod(toInt)
114
115     # Convert a string to a bool
116     def toBool(s):
117         if s in ['t', 'T', 'True', 'true', 'yes', '1']:
118             return True
119
120         if s in ['f', 'F', 'False', 'false', 'no', '0']:
121             return False
122
123         raise ValueError
124
125     # Mark it static
126     toBool = staticmethod(toBool)
127
128 ################################################################################
129
130 # Global configuration, read from default configuration file
131 config = RarslaveConfig()
132
133 ################################################################################
134
135 # A tiny class to hold logging output until we're finished
136 class DelayedLogger (object):
137
138     """A small class to hold logging output until the program is finished running.
139        It emulates sys.stdout in the needed ways for the logging module."""
140
141     def __init__ (self, output=sys.stdout.write):
142         self.__messages = []
143         self.__output = output
144
145     def write (self, msg):
146         self.__messages.append (msg)
147
148     def flush (self):
149         pass
150
151     def size (self):
152         """Returns the number of messages queued for printing"""
153         return len (self.__messages)
154
155     def close (self):
156         """Print all messages, clear the queue"""
157         map(self.__output, self.__messages)
158         self.__messages = []
159
160 ################################################################################
161
162 # Convert from the verbose command line option to the logging level that
163 # will be used by the logging class to print messages
164 def findLogLevel(options):
165
166     level = options.verbose - options.quiet
167
168     if level < -3:
169         level = -3
170
171     if level > 1:
172         level = 1
173
174     LEVELS = {
175          1 : logging.DEBUG,
176          0 : logging.INFO,
177         -1 : logging.WARNING,
178         -2 : logging.ERROR,
179         -3 : logging.CRITICAL
180     }
181
182     return LEVELS[level]
183
184 ################################################################################
185
186 def parseCommandLineOptions():
187
188     # Build the OptionParser
189     parser = optparse.OptionParser()
190     parser.add_option('-n', '--not-recursive', dest='recursive', action='store_false',
191                         default=config.get('options', 'recursive'),
192                         help="Don't run recursively")
193
194     parser.add_option('-d', '--directory', dest='directory', type='string',
195                         default=config.get('directories', 'start'),
196                         help="Start working at DIR", metavar='DIR')
197
198     parser.add_option('-i', '--interactive', dest='interactive', action='store_true',
199                         default=config.get('options', 'interactive'),
200                         help="Confirm before removing files")
201
202     parser.add_option('--no-delete', dest='delete', action='store_false',
203                         default=config.get('options', 'delete'),
204                         help="Do not delete files used to repair")
205
206     parser.add_option('-q', '--quiet', dest='quiet', action='count',
207                         default=0, help="Output fatal messages only")
208
209     parser.add_option('-v', '--verbose', dest='verbose', action='count',
210                         default=config.get('options', 'verbosity'),
211                         help="Output extra information")
212
213     parser.add_option('-V', '--version', dest='version', action='store_true',
214                         default=False, help="Output version information")
215
216     parser.version = VERSION
217
218     # Parse the given options
219     (options, args) = parser.parse_args()
220
221     # Postprocess the options, basically sanitizing them
222     options.directory = PAR2Set.utils.absolutePath(options.directory)
223
224     # Make sure that the directory is valid
225     if not os.path.isdir (options.directory):
226         sys.stderr.write ('\"%s\" is not a valid directory. Use the \"-d\"\n' % options.directory)
227         sys.stderr.write ('option to override the working directory temporarily, or edit the\n')
228         sys.stderr.write ('configuration file to override the working directory permanently.\n')
229         sys.exit (1)
230
231     if options.version:
232         print PROGRAM + ' - ' + VERSION
233         print
234         print 'Copyright (c) 2005-2008 Ira W. Snyder (devel@irasnyder.com)'
235         print
236         print 'This program comes with ABSOLUTELY NO WARRANTY.'
237         print 'This is free software, and you are welcome to redistribute it'
238         print 'under certain conditions. See the file COPYING for details.'
239         sys.exit (0)
240
241     return (options, args)
242
243 ################################################################################
244
245 # Find each unique CompareSet in the given directory and set of files
246 def findUniqueSets(directory, files):
247
248     regex = r'^.*\.par2'
249     s = []
250
251     for f in PAR2Set.utils.findMatches(regex, files):
252
253         try:
254             c = PAR2Set.CompareSet(directory, f)
255         except:
256             # We just ignore any errors that happen, such as
257             # parsing the PAR file
258             pass
259         else:
260             # Ok, we got a valid set, add it to s
261             if c not in s:
262                 s.append(c)
263
264     return s
265
266 ################################################################################
267
268 # Run each PAR2Set type on a CompareSet
269 def runEachType(cs, options):
270
271     types = (
272         PAR2Set.JoinProtected,
273         PAR2Set.Join,
274         PAR2Set.ZIP,
275         PAR2Set.OldRAR,
276         PAR2Set.NewRAR,
277         PAR2Set.ExtractFirstOldRAR,
278         PAR2Set.ExtractFirstNewRAR,
279         PAR2Set.NoExtract,
280     )
281
282     detected = False
283
284     # Try to detect each type in turn
285     for t in types:
286         try:
287             instance = t(cs, options)
288         except TypeError:
289             logging.debug('%s not detected for %s' % (t.__name__, cs.parityFile))
290             continue
291         else:
292             detected = True
293             logging.debug('%s detected for %s' % (t.__name__, cs.parityFile))
294
295         # We detected something, try to run it
296         try:
297             instance.run()
298         except (OSError, CalledProcessError):
299             logging.critical('Failure: %s' % instance)
300         else:
301             # Leave early, we're done
302             logging.info('Success: %s' % instance)
303             return
304
305     # Check that at least one detection worked
306     if not detected:
307         logging.critical('Detection failed: %s' % cs.parityFile)
308         logging.debug('The following information will help to create a detector')
309         logging.debug('===== BEGIN CompareSet RAW INFO =====')
310         logging.debug(str(cs))
311         logging.debug('===== END CompareSet RAW INFO =====')
312
313     # If we got here, either the detection didn't work or the run itself didn't
314     # work, so print out the message telling the user that we were unsuccessful
315     logging.critical('Unsuccessful: %s' % cs.parityFile)
316
317 ################################################################################
318
319 def runDirectory(directory, files, options):
320
321     logging.debug('Running in directory: %s' % directory)
322     sets = findUniqueSets(directory, files)
323
324     for cs in sets:
325         try:
326             runEachType(cs, options)
327         except Exception, e:
328             import traceback
329             logging.error('Unknown Exception: %s' % cs.parityFile)
330             logging.error('===== BEGIN Bactrace =====')
331             [logging.error(l) for l in traceback.format_exc(e).split('\n')]
332             logging.error('===== END Bactrace =====')
333
334 ################################################################################
335
336 def main ():
337
338     # Parse all of the command line options
339     (options, args) = parseCommandLineOptions()
340
341     # Set up the logger
342     logger = DelayedLogger()
343     logging.basicConfig(stream=logger, level=logging.WARNING, \
344                         format='%(levelname)-8s %(message)s')
345     logging.getLogger().setLevel (findLogLevel(options))
346
347     # Run recursively
348     if options.recursive:
349         for (directory, subDirectories, files) in os.walk(options.directory):
350             runDirectory(directory, files, options)
351
352     # Non-recursive
353     else:
354         directory = options.directory
355         files = os.listdir(directory)
356
357         runDirectory(directory, files, options)
358
359     # Print out all of the messages that have been accumulating
360     # in the DelayedLogger()
361     if logger.size() > 0:
362         print
363         print 'Log'
364         print '=' * 80
365         logger.close()
366
367 # Check if we were called directly
368 if __name__ == '__main__':
369     main ()
370