Code cleanup
[adesklets.git] / utils / adesklets_checkin
blobfc03983c91ef8c1193143b815f3d64f416b5d679
1 #! /usr/bin/env python
2 """
3 -------------------------------------------------------------------------------
4 Copyright (C) 2005 Sylvain Fourmanoit
6 Released under the GPL, version 2.
8 Permission is hereby granted, free of charge, to any person obtaining a copy
9 of this software and associated documentation files (the "Software"), to
10 deal in the Software without restriction, including without limitation the
11 rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
12 sell copies of the Software, and to permit persons to whom the Software is
13 furnished to do so, subject to the following conditions:
15 The above copyright notice and this permission notice shall be included in
16 all copies of the Software and its documentation and acknowledgment shall be
17 given in the documentation and software packages that this Software was
18 used.
20 THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
23 THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
24 IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
25 CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26 -------------------------------------------------------------------------------
27 This is the adesklet's desklet check in script. Have a look at
28 adesklets documentation for details.
30 WARNING: This script was not made to be as portable as adesklets_submit,
31 since it basically only has to run on the maintainer machine.
32 It was made public because it can also be usefull to locally asserting the
33 validity of entries before submission. It is the exact script
34 the maintainer use for checking in incoming submissions.
36 To run, it needs:
38 - Python >=2.3, as adesklets
39 - PIL (Python image library) installed
40 - tar, supporting the '-j' flag (piping through bzip2). (Not tested
41 on anything but GNU tar).
42 - a 'less' able to work on terminal in non-canonical mode (Not tested
43 on anything but GNU less)
44 - GNU fileutils for rm with the '-r' GNU extension
45 - Write access to $HOME/unpack
47 To run in batch mode (direct fetch of data from a mail server, as the maintainer
48 do), you will also need:
50 - Access to a working imap over ssl mail account
51 - SSL support compiled in Python
52 - An X connection and xterm in your path if you want to interactively test
53 the code
55 This code was not tested on anything, but a Linux 2.6.11 x86 machine with
56 glibc 2.3.4, running Python 2.4.0 (Final) compiled on gcc 3.4.3.
57 -------------------------------------------------------------------------------
58 """
59 # --- Libraries --------------------------------------------------------------
60 import getopt
61 import imaplib
62 import email
63 import email.Utils
64 import email.MIMEMultipart
65 import email.MIMEText
66 import smtplib
67 import urllib
68 import os
69 import shutil
70 from textwrap import fill
71 from string import translate, maketrans
72 from itertools import dropwhile, izip, count
74 # --- Terminal management ----------------------------------------------------
75 import termios
76 import select
77 import sys
79 class _stdout:
80 def __init__(self, stdout):
81 self.stdout=stdout
82 def write(self, arg):
83 self.stdout.write(arg)
84 self.stdout.flush()
86 def term_setraw():
87 if os.isatty(sys.stdin.fileno()):
88 mode = termios.tcgetattr(sys.stdin.fileno())
89 new = mode
90 new[3]=new[3] & ~ (termios.ECHO | termios.ICANON)
91 new[6][termios.VMIN] = 1
92 new[6][termios.VTIME] = 0
93 termios.tcsetattr(sys.stdin.fileno(),termios.TCSAFLUSH,new)
94 sys.stdout = _stdout(sys.stdout)
95 else:
96 mode=None
97 return mode
99 def term_unsetraw(mode):
100 if os.isatty(sys.stdin.fileno()):
101 mode[3]=mode[3] | termios.ECHO | termios.ICANON
102 termios.tcsetattr(sys.stdin.fileno(),termios.TCSAFLUSH,mode)
104 def getch(delay=None):
105 p = select.poll()
106 p.register(sys.stdin.fileno(),select.POLLIN)
107 p.poll()
108 return sys.stdin.read(1)
110 def yesno(msg=None):
111 if os.isatty(sys.stdin.fileno()):
112 if msg: print msg,
113 answer = getch().lower()
114 print answer
115 else:
116 answer = 'y'
117 return answer=='y'
119 # --- PIL related functions --------------------------------------------------
120 import Image
121 def image_props(filename):
122 try:
123 im=Image.open(filename)
124 result = (filename,
125 (im.format=='JPEG' or im.format=='PNG',im.size))
126 except:
127 result = None
128 return result
130 def get_ext(filename):
131 try:
132 im=Image.open(filename)
133 ext = {'JPEG':'jpg','PNG':'png'}[im.format]
134 except:
135 ext = 'tar.bz2'
136 return ext
138 # --- DeskletError exception class -------------------------------------------
139 class EntryError(Exception):
140 def __init__(self, value, desc=None):
141 self.value=value
142 self.desc=desc
143 def __str__(self):
144 return str(self.value)
146 # ----------------------------------------------------------------------------
147 def send_email(e=None):
148 result = False
149 if e:
150 # Retrieve email info, depending of e class
152 if e.__class__ is EntryError:
153 if e.desc:
154 info=e.desc['info']
155 else:
156 return
157 else:
158 info=e['code']['info']
160 subject = 'Re: ' + info['subject']
162 # Build body
164 if e.__class__ == EntryError:
165 if e.desc:
166 # Build subject
168 subject = 'Re: ' + info['subject']
169 # Build an error message
171 body = '\n'.join(['Hi %s,'% info['author_name'],
173 fill(
174 'your desklet entry sent on %s was rejected. %s'
175 % (info['date'], 'The reason was:'),70),
177 fill(str(e),70),
179 fill(
180 'See the documentation; thanks for correcting this. ' +
181 'I am looking forward to include your work ' +
182 'on adesklets site. Regards,',70),
184 '--',
185 'Sylvain Fourmanoit'
187 else:
188 # Build Subject
190 subject = 'Re: ' + info['subject'] + ' [ACCEPTED]'
192 # Build a success message
194 body = '\n'.join(['Hi %s,'% info['author_name'],
196 fill(
197 'your desklet entry sent on %s was sucessfully '
198 % info['date'] +
199 'scheduled for inclusion on adesklets web site. ' +
200 'It should appear there as soon as I ' +
201 'will update the site: it will usually occur within ' +
202 'the next minutes, but It may be delayed due to ressources ' +
203 'availability problems. Do not hesitate to contact me at ' +
204 '<syfou@users.sourceforge.net> if you feel something went wrong.'
205 ,70),
207 'Thanks again for your contribution! Best regards,',
209 '--',
210 'Sylvain Fourmanoit'
213 # Build message out of newly created raw strings
215 msg=email.MIMEMultipart.MIMEMultipart()
216 msg['Subject']=subject
217 msg['From']='adesklets@mailworks.org'
218 msg['To']=info['from']
219 msg.epilogue=''
221 for content in (body, '\n'.join(
222 ['# Copy of submitted entry on %s.' % info['date'],
223 '# by %s.' % info['from'],
224 '#',
225 info['body_old']])):
226 msg_part=email.MIMEText.MIMEText(content)
227 if content!=body:
228 msg_part.add_header('Content-Disposition',
229 'attachment', filename='description.py')
230 msg.attach(msg_part)
232 # Finally, send message if needed
234 print '='*80,'\n'+msg.as_string()+'\n','='*80
235 if yesno('Send this message now?'):
236 recip=[config['bcc']]
237 if e.__class__==EntryError or info['send_confirmation']:
238 recip.append(info['author_email'])
240 s = smtplib.SMTP(config['smtp_host'])
241 s.sendmail('adesklets@mailworks.org',
242 recip,
243 msg.as_string())
244 s.close()
245 print 'Message sent and marked for deletion:', recip
246 result=True
247 else:
248 print 'Operation cancelled, message not sent.'
249 return result
251 # ----------------------------------------------------------------------------
252 # Validation treatment.
254 def validate(msg):
256 Validate a message given as a string, either interactively or not.
257 If stdin is not a terminal, interactivity will be turned of, and
258 all manual validation will always succeed. Those includes:
260 - inspection of textual body of email, containing the 'info'
261 and 'desklet' dictionaries.
262 - validation of README content
263 - manual check of the code
265 file_list=[]
266 tmp=os.path.join(os.getenv('HOME'),'unpack')
267 shutil.rmtree(tmp, True)
268 try:
269 os.mkdir(tmp)
270 except OSError, (errstr, errno):
271 if errno!=17:
272 raise
274 # Walk through the message, unpacking parts as needed
276 for id, part in izip(count(),msg.walk()):
277 if part.get_content_maintype()== 'multipart':
278 continue
279 type=part.get_content_type()
280 if type=='text/plain':
281 body = reduce(lambda x,y: "%s\n%s" % (x, y),
282 [ line for line in
283 dropwhile(lambda x:x.split(' ')[0]!='info',
284 translate(part.__str__(),
285 maketrans('',''),
286 '\r').split('\n')) ])
287 else:
288 name=os.path.join(tmp,'part_%d.%s' %
289 (id,
290 {'jpeg':'jpg',
291 'png':'png',
292 'octet-stream':'tar.bz2'}
293 [type.split('/')[1]]))
294 file_list.append(name)
295 fd=file(name,'w')
296 fd.write(part.get_payload(decode=1))
297 fd.close()
299 # In interactive mode, show the description and ask what to do about it
301 if os.isatty(sys.stdin.fileno()):
302 print '%(hr)s\n%(body)s\n%(hr)s' % {'hr':'='*80, 'body':body}
303 info = { 'author_name' : email.Utils.parseaddr(msg['From'])[0],
304 'author_email': email.Utils.parseaddr(msg['From'])[1],
305 'from' : msg['From'],
306 'date' : msg['Date'],
307 'subject' : msg['Subject'],
308 'body_old' : body
310 code = {}
311 if yesno('Import description ?'):
312 try:
313 exec body in code
314 del code['__builtins__']
315 except:
316 # No message send back in this case
317 if not yesno('Unparsable entry - return address would be ' +
318 "'%s'. Drop email?" % info['author_email']):
319 code['info']=info
320 raise EntryError('Entry could not be parsed',
321 (None,code)[len(code)!=0])
322 code['info']['date']=info['date']
323 code['info']['subject']=info['subject']
324 code['info']['body_old']=info['body_old']
325 code['info']['from']='%s <%s>' % (code['info']['author_name'],
326 code['info']['author_email'])
327 else:
328 if not yesno('Invalid description - return address would be ' +
329 "'%s'. Drop email?" % info['author_email']):
330 code['info']=info
331 raise EntryError('Entry manually rejected as invalid ' +
332 'before any processing. Contact the maintainer ' +
333 'I you believe this to be unjustified.',
334 (None,code)[len(code)!=0])
336 # Now try to download all that can be downloaded
338 for id, field in izip(count(id+1),
339 ['thumbnail','screenshot','download']):
340 try:
341 url = urllib.urlopen(code['desklet'][field])
342 name = os.path.join(
343 tmp,'part_%d.%s' %
344 (id, code['desklet'][field].split('.')[-1]))
345 file_list.append(name)
346 fd = file(name,'w')
347 fd.write(url.read())
348 fd.close()
349 except AttributeError:
350 pass
351 except IOError, (str, no):
352 if field=='download' and \
353 not code['desklet']['host_on_sourceforge']:
354 raise EntryError('You asked your package not to be hosted ' +
355 'on sourceforge, but it could not be ' +
356 'retrieved from the provided URL. ' +
357 'If it was a transient network problem, ' +
358 'please submit your entry again.', code)
360 # We now have all the file info in file_list[0:3]: let's match
361 # them to 'thumbnail', 'screenshot' and 'download' by looking
362 # at their content
364 originals = [ code['desklet'][field] for field in
365 ('thumbnail','screenshot')]
366 images = [image_props(im) for im in file_list[:3]
367 if image_props(im)]
368 download = [name for name in file_list[:3]
369 if not name in [im[0] for im in images]]
371 # =====================================
372 # Now, we can perform the tests:
373 # =====================================
374 # Is there less images than it should?
376 if len([field for field in originals if field])\
377 != len(images):
378 raise EntryError('At least one of your submitted image' +
379 'could not get fetched or properly ' +
380 'decoded (possible corruption?)', code)
382 # Is there an image that is not in png or jpeg?
384 if len([image for image in images if not image[1][0]]):
385 raise EntryError('At least one of your submitted image ' +
386 'is not a valid jpg or png', code)
388 # Identify the thumbnail and the screenshot based on size
390 images_ord=[(image[0],image[1][1][0]*image[1][1][1], image[1][1])
391 for image in images]
392 images_ord.sort(lambda x,y: x[1]-y[1])
393 images_ord= {'thumbnail': (None,images_ord[0])
394 [code['desklet']['thumbnail']!=None],
395 'screenshot': (None,images_ord[-1])
396 [code['desklet']['screenshot']!=None]}
398 # Verify that thumbnail size is in acceptable range
400 if images_ord['thumbnail']:
401 if not images_ord['thumbnail'][2][0] in range(190,231) or \
402 not images_ord['thumbnail'][2][1] in range(35,111):
403 raise EntryError('The thumbnail size is out of range: ' +
404 'it is %dx%d ' % images_ord['thumbnail'][2] +
405 'where it should be between ' +
406 '190x35 and 230x110', code)
408 # Verify the screenshot size is 640x480
410 if images_ord['screenshot']:
411 if (images_ord['screenshot'][2][0]!=640 or
412 images_ord['screenshot'][2][1]!=480):
413 raise EntryError('The screenshot size is incorrect: ' +
414 'it is %dx%d ' % images_ord['screenshot'][2] +
415 'where it should be 640x480', code)
417 # Verify the desklet package...
418 # ...Do we have it?
419 if len(download)==0:
420 raise EntryError('Your desklet package could not be' +
421 'retrieved. Either you submitted ' +
422 'an unavailable url, or your email ' +
423 'was corrupted. Please recheck things and ' +
424 'resubmit it.', code)
426 # ...Can we extract it?
428 if os.system('tar tjf %s &> /dev/null' % download[0])!=0:
429 md5=os.popen('md5sum %s | cut -d" " -f1 2> /dev/null' %
430 download[0]).readlines()[0].strip()
431 size=os.popen('find %s -printf "%%s\n"' %
432 download[0]).readlines()[0].strip()
433 raise EntryError('Tarball could not be extracted - ' +
434 'you should provide a conformant ANSI or V7 ' +
435 'archive, compressed using ' +
436 'the Burrows-Wheeler block sorting ' +
437 'algorithm (bz2). File was %s bytes, ' % size +
438 'and md5 sum was %s.' % md5, code)
440 # ...Is the structure ok?
442 dirs=[ dir.strip() for dir in
443 os.popen(' | '.join(['tar tjf ' + download[0],
444 'sed "s/\([^\/]*\)\/.*/\\1/"',
445 'sort', 'uniq'])).readlines() ]
446 dir='%s-%s' % (code['desklet']['name'],code['desklet']['version'])
447 if len(dirs)!=1 or dirs[0] != dir:
448 print dirs, dir
449 raise EntryError('Tarball internal structure is incorrect. ' +
450 'It should contain one but only one ' +
451 "directory, named '%s'." % dir, code)
453 # ...Does it have a README in base source directory?
454 # If yes, is it valid?
456 readme=os.path.join(dir,'README')
457 if os.system('tar -O -xjf %s %s &> /dev/null' %
458 (download[0], readme))==0:
459 # In case of interactive use, look at README
460 if (os.isatty(sys.stdin.fileno())):
461 yesno('Piping %s %s README through less...'
462 % (code['desklet']['name'],
463 code['desklet']['version']))
464 os.system('tar -O -xjf %s %s | less'
465 % (download[0], readme))
466 if not yesno('README complete?'):
467 raise EntryError('README does not contains all the ' +
468 'required information.', code)
469 if not yesno('README up to date?'):
470 raise EntryError('README does not appear to be ' +
471 'up to date for version ' +
472 code['desklet']['version'] + ' of ' +
473 code['desklet']['name'] + '.', code)
474 else:
475 raise EntryError('Tarball does not contain the required ' +
476 "'%s' file." % readme, code)
478 # Finally, give a chance to perform manual check of the code...
480 if (os.isatty(sys.stdin.fileno())):
481 if yesno('Extract for manual check of the code (need X) ?'):
482 os.system('tar -C %s -xvjf %s' % (tmp, download[0]))
483 os.system('export DISPLAY=:0.0 && cd %s && xterm' %
484 os.path.join(tmp,dir))
485 if not yesno('code ok?'):
486 raise EntryError(
487 'Your entry is still pending. '
488 'While your submission was found valid ' +
489 'by the automated script, the maintainer propably '
490 'experienced some drawback when trying out '
491 'your code. You should receive some non-automated ' +
492 'explanation by email in the upcoming hours.', code)
494 # We now know the entry to be ok - sending back the corresponding files
495 # on disc.
497 return dict([('files',
498 dict([ (image[0],image[1][0]) for image in
499 images_ord.iteritems() if image[1] ] +
500 [ (image[0],None) for image in
501 images_ord.iteritems() if not image[1]] +
502 [('download',download[0]),
503 ('description', os.path.join(tmp,'desc.xml')),
504 ('tmpdir', tmp)])),
505 ('code',code),
508 # ----------------------------------------------------------------------------
509 def rename(desc):
510 mapping = [(name[0],desc['files'][name[0]],name[1])
511 for name in
512 (('thumbnail','%s_thumb' %
513 desc['code']['desklet']['name']),
514 ('screenshot','%s_screen' %
515 desc['code']['desklet']['name']),
516 ('download','%s-%s' %
517 (desc['code']['desklet']['name'],
518 desc['code']['desklet']['version'])))
519 if desc['files'][name[0]]]
520 mapping = [(name[0],name[1],'%s.%s' % (name[2],get_ext(name[1]))) \
521 for name in mapping]
522 for dummy, name,filename in mapping:
523 shutil.move(name, os.path.join(desc['files']['tmpdir'],filename))
524 os.system('rm -f %s/part_?.* &> /dev/null' % desc['files']['tmpdir'])
525 return (desc, mapping)
527 # ----------------------------------------------------------------------------
528 def describe(desc,mapping):
529 mapping=dict([('thumbnail', 'default_thumb.jpg'),
530 ('screenshot', 'default_screen.jpg')] +
531 [(name[0], name[2]) for name in mapping])
532 fd = file(desc['files']['description'],'w+')
534 print >> fd, '<desklet name="%s" version="%s"' % \
535 (desc['code']['desklet']['name'], desc['code']['desklet']['version'])
536 print >> fd, '\tthumbnail="%s"\n\tscreenshot="%s"' % \
537 tuple([os.path.join('images',mapping[name]) for name
538 in ('thumbnail','screenshot')])
539 if not desc['code']['desklet']['host_on_sourceforge']:
540 print >> fd, '\tdownload="%s"' % \
541 desc['code']['desklet']['download']
542 print >> fd, '>'
543 print >> fd, '<author name="%s" email="%s" />' % \
544 (desc['code']['info']['author_name'],
545 desc['code']['info']['author_email'])
546 print >> fd, desc['code']['desklet']['description'], '\n</desklet>'
547 if not desc['code']['desklet']['host_on_sourceforge']:
548 os.unlink(os.path.join(desc['files']['tmpdir'],mapping['download']))
549 fd.seek(0)
550 print '='*80 + '\n' + fd.read() + '='*80
551 fd.close()
552 if not yesno('Accept this entry (last chance to reject it!) ?'):
553 raise EntryError('The entry was manually rejected ' +
554 'for unspecified reasons. The maintainer ' +
555 'should provide you an explanation by email soon.',
556 desc)
557 return desc
559 # ----------------------------------------------------------------------------
560 # Set terminal
562 mode=term_setraw()
564 # ----------------------------------------------------------------------------
565 # Main Loop
567 try:
568 # Read in the configuration.from $HOME/.adesklets_checkin
570 # This configuration file does not have to exist for non-interactive
571 # use, so a desklet developer can basically ignore it when checking
572 # configuration produced with adesklets_submit.
574 # A working configuration entry would look like:
576 # smtp_host = 'smtp.devil.net',
577 # imap_host = 'mail.devil.net'
578 # imap_user = 'leviathan'
579 # imap_passwd = '666_666'
580 # bcc = 'leviathan_backup@devil.net'
582 config_name=os.path.join(os.getenv('HOME'),'.adesklets_checkin')
583 config={}
584 try:
585 f = file(config_name,'r')
586 exec f in config
587 del config['__builtins__']
588 f.close()
589 except IOError:
590 pass
592 # In case of interactive use, look up messages
593 # on an imap over ssl server
595 if os.isatty(sys.stdin.fileno()):
596 # Set behavior: look only at unseen (default), or at all messages
598 opts, args = getopt.getopt(sys.argv[1:],'',['all'])
599 sel = ('UNSEEN','ALL')[len(opts)==1]
601 # Connect to imap server
603 s=imaplib.IMAP4_SSL(config['imap_host'])
604 s.login(config['imap_user'],config['imap_passwd'])
605 s.select()
607 for num in s.search(None,sel)[1][0].split():
608 msg=email.message_from_string(s.fetch(num,'(RFC822)')[1][0][1])
609 print '%s\nFrom: %s\nDate: %s' % (msg['Subject'],
610 msg['From'],
611 msg['Date'])
612 answered=False
613 if yesno('Read ?'):
614 # Validation, renaming and description
616 try:
617 answered=send_email(describe(*rename(validate(msg))))
618 except EntryError , e :
619 send_email(e)
620 # Deletion
622 if answered or yesno('Mark for deletion ?'):
623 s.store(num, '+FLAGS.SILENT', '(\DELETED)')
624 else:
625 # In case of normal mode, preserve the 'unseen' state
626 # of email if it was not answered.
627 if sel=='UNSEEN':
628 s.store(num, '-FLAGS.SILENT', '(\SEEN)')
630 # Mailbox expurge
632 if yesno('Expurge messages marked for deletion ?'):
633 s.expunge()
634 s.logout()
635 # In case of non-interactive use, just validate a single message
636 # from stdin, and remove eveything from disc right away
638 else:
639 print 'Validation started. Please wait.'
640 msg = email.message_from_string(sys.stdin.read())
641 shutil.rmtree(validate(msg)['files']['tmpdir'])
642 print fill('Everything seems fine, but keep in mind a few things ' +
643 'cannot be verified without human intervention. See documentation ' +
644 'for details.',70)
645 except:
646 term_unsetraw(mode)
647 raise
648 term_unsetraw(mode)