001bf0f0cbfd7097b8ab222cf4f6ba37770cc03b
[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.Join,
273         PAR2Set.ZIP,
274         PAR2Set.OldRAR,
275         PAR2Set.NewRAR,
276         PAR2Set.ExtractFirstOldRAR,
277         PAR2Set.ExtractFirstNewRAR,
278         PAR2Set.NoExtract,
279     )
280
281     detected = False
282
283     # Try to detect each type in turn
284     for t in types:
285         try:
286             instance = t(cs, options)
287         except TypeError:
288             logging.debug('%s not detected for %s' % (t.__name__, cs.parityFile))
289             continue
290         else:
291             detected = True
292             logging.debug('%s detected for %s' % (t.__name__, cs.parityFile))
293
294         # We detected something, try to run it
295         try:
296             instance.run()
297         except (OSError, CalledProcessError):
298             logging.critical('Failure: %s' % instance)
299         else:
300             # Leave early, we're done
301             logging.info('Success: %s' % instance)
302             return
303
304     # Check that at least one detection worked
305     if not detected:
306         logging.critical('Detection failed: %s' % cs.parityFile)
307         logging.debug('The following information will help to create a detector')
308         logging.debug('===== BEGIN CompareSet RAW INFO =====')
309         logging.debug(str(cs))
310         logging.debug('===== END CompareSet RAW INFO =====')
311
312     # If we got here, either the detection didn't work or the run itself didn't
313     # work, so print out the message telling the user that we were unsuccessful
314     logging.critical('Unsuccessful: %s' % cs.parityFile)
315
316 ################################################################################
317
318 def runDirectory(directory, files, options):
319
320     logging.debug('Running in directory: %s' % directory)
321     sets = findUniqueSets(directory, files)
322
323     for cs in sets:
324         try:
325             runEachType(cs, options)
326         except Exception, e:
327             import traceback
328             logging.error('Unknown Exception: %s' % cs.parityFile)
329             logging.error('===== BEGIN Bactrace =====')
330             [logging.error(l) for l in traceback.format_exc(e).split('\n')]
331             logging.error('===== END Bactrace =====')
332
333 ################################################################################
334
335 def main ():
336
337     # Parse all of the command line options
338     (options, args) = parseCommandLineOptions()
339
340     # Set up the logger
341     logger = DelayedLogger()
342     logging.basicConfig(stream=logger, level=logging.WARNING, \
343                         format='%(levelname)-8s %(message)s')
344     logging.getLogger().setLevel (findLogLevel(options))
345
346     # Run recursively
347     if options.recursive:
348         for (directory, subDirectories, files) in os.walk(options.directory):
349             runDirectory(directory, files, options)
350
351     # Non-recursive
352     else:
353         directory = options.directory
354         files = os.listdir(directory)
355
356         runDirectory(directory, files, options)
357
358     # Print out all of the messages that have been accumulating
359     # in the DelayedLogger()
360     if logger.size() > 0:
361         print
362         print 'Log'
363         print '=' * 80
364         logger.close()
365
366 # Check if we were called directly
367 if __name__ == '__main__':
368     main ()
369