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