./configure --prefix support for perl
[adesklets.git] / utils / adesklets_checkin
blob3dfed104702d2f9b0a3434152a2a804f0228a28d
1 #! /usr/bin/env python
2 """
3 -------------------------------------------------------------------------------
4 Copyright (C) 2005, 2006 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['Reply-To']='syfou@users.sourceforge.net'
220 msg.epilogue=''
222 for content in (body, '\n'.join(
223 ['# Copy of submitted entry on %s.' % info['date'],
224 '# by %s.' % info['from'],
225 '#',
226 info['body_old']])):
227 msg_part=email.MIMEText.MIMEText(content)
228 if content!=body:
229 msg_part.add_header('Content-Disposition',
230 'attachment', filename='description.py')
231 msg.attach(msg_part)
233 # Finally, send message if needed
235 print '='*80,'\n'+msg.as_string()+'\n','='*80
236 if yesno('Send this message now?'):
237 recip=[config['bcc']]
238 if e.__class__==EntryError or info['send_confirmation']:
239 recip.append(info['author_email'])
241 if config.has_key('smtp_host'):
242 s = smtplib.SMTP(config['smtp_host'])
243 s.sendmail('adesklets@mailworks.org',
244 recip,
245 msg.as_string())
246 s.close()
247 else:
248 print >> file('mail.dump','w'), msg.as_string()
249 print 'Message dumped to mail.dump'
250 print 'Message sent and marked for deletion:', recip
251 result=True
252 else:
253 print 'Operation cancelled, message not sent.'
254 return result
256 # ----------------------------------------------------------------------------
257 # Validation treatment.
259 def validate(msg):
261 Validate a message given as a string, either interactively or not.
262 If stdin is not a terminal, interactivity will be turned of, and
263 all manual validation will always succeed. Those includes:
265 - inspection of textual body of email, containing the 'info'
266 and 'desklet' dictionaries.
267 - validation of README content
268 - manual check of the code
270 file_list=[]
271 tmp=os.path.join(os.getenv('HOME'),'unpack')
272 shutil.rmtree(tmp, True)
273 try:
274 os.mkdir(tmp)
275 except OSError, (errstr, errno):
276 if errno!=17:
277 raise
279 # Walk through the message, unpacking parts as needed
281 for id, part in izip(count(),msg.walk()):
282 if part.get_content_maintype()== 'multipart':
283 continue
284 type=part.get_content_type()
285 if type=='text/plain':
286 body = reduce(lambda x,y: "%s\n%s" % (x, y),
287 [ line for line in
288 dropwhile(lambda x:x.split(' ')[0]!='info',
289 translate(part.__str__(),
290 maketrans('',''),
291 '\r').split('\n')) ])
292 else:
293 name=os.path.join(tmp,'part_%d.%s' %
294 (id,
295 {'jpeg':'jpg',
296 'png':'png',
297 'octet-stream':'tar.bz2'}
298 [type.split('/')[1]]))
299 file_list.append(name)
300 fd=file(name,'w')
301 fd.write(part.get_payload(decode=1))
302 fd.close()
304 # In interactive mode, show the description and ask what to do about it
306 if os.isatty(sys.stdin.fileno()):
307 print '%(hr)s\n%(body)s\n%(hr)s' % {'hr':'='*80, 'body':body}
308 info = { 'author_name' : email.Utils.parseaddr(msg['From'])[0],
309 'author_email': email.Utils.parseaddr(msg['From'])[1],
310 'from' : msg['From'],
311 'date' : msg['Date'],
312 'subject' : msg['Subject'],
313 'body_old' : body
315 code = {}
316 if yesno('Import description ?'):
317 try:
318 exec body in code
319 del code['__builtins__']
320 except:
321 # No message send back in this case
322 if not yesno('Unparsable entry - return address would be ' +
323 "'%s'. Drop email?" % info['author_email']):
324 code['info']=info
325 raise EntryError('Entry could not be parsed',
326 (None,code)[len(code)!=0])
327 code['info']['date']=info['date']
328 code['info']['subject']=info['subject']
329 code['info']['body_old']=info['body_old']
330 code['info']['from']='%s <%s>' % (code['info']['author_name'],
331 code['info']['author_email'])
332 else:
333 if not yesno('Invalid description - return address would be ' +
334 "'%s'. Drop email?" % info['author_email']):
335 code['info']=info
336 raise EntryError('Entry manually rejected as invalid ' +
337 'before any processing. Contact the maintainer ' +
338 'I you believe this to be unjustified.',
339 (None,code)[len(code)!=0])
341 # Now try to download all that can be downloaded
343 for id, field in izip(count(id+1),
344 ['thumbnail','screenshot','download']):
345 try:
346 url = urllib.urlopen(code['desklet'][field])
347 name = os.path.join(
348 tmp,'part_%d.%s' %
349 (id, code['desklet'][field].split('.')[-1]))
350 file_list.append(name)
351 fd = file(name,'w')
352 fd.write(url.read())
353 fd.close()
354 except AttributeError:
355 pass
356 except IOError, (str, no):
357 if field=='download' and \
358 not code['desklet']['host_on_sourceforge']:
359 raise EntryError('You asked your package not to be hosted ' +
360 'on sourceforge, but it could not be ' +
361 'retrieved from the provided URL. ' +
362 'If it was a transient network problem, ' +
363 'please submit your entry again.', code)
365 # We now have all the file info in file_list[0:3]: let's match
366 # them to 'thumbnail', 'screenshot' and 'download' by looking
367 # at their content
369 originals = [ code['desklet'][field] for field in
370 ('thumbnail','screenshot')]
371 images = [image_props(im) for im in file_list[:3]
372 if image_props(im)]
373 download = [name for name in file_list[:3]
374 if not name in [im[0] for im in images]]
376 # =====================================
377 # Now, we can perform the tests:
378 # =====================================
379 # Is there less images than it should?
381 if len([field for field in originals if field])\
382 != len(images):
383 raise EntryError('At least one of your submitted image' +
384 'could not get fetched or properly ' +
385 'decoded (possible corruption?)', code)
387 # Is there an image that is not in png or jpeg?
389 if len([image for image in images if not image[1][0]]):
390 raise EntryError('At least one of your submitted image ' +
391 'is not a valid jpg or png', code)
393 # Identify the thumbnail and the screenshot based on size
395 images_ord=[(image[0],image[1][1][0]*image[1][1][1], image[1][1])
396 for image in images]
397 images_ord.sort(lambda x,y: x[1]-y[1])
398 images_ord= {'thumbnail': (None,images_ord[0])
399 [code['desklet']['thumbnail']!=None],
400 'screenshot': (None,images_ord[-1])
401 [code['desklet']['screenshot']!=None]}
403 # Verify that thumbnail size is in acceptable range
405 if images_ord['thumbnail']:
406 if not images_ord['thumbnail'][2][0] in range(190,231) or \
407 not images_ord['thumbnail'][2][1] in range(35,111):
408 raise EntryError('The thumbnail size is out of range: ' +
409 'it is %dx%d ' % images_ord['thumbnail'][2] +
410 'where it should be between ' +
411 '190x35 and 230x110', code)
413 # Verify the screenshot size is 640x480
415 if images_ord['screenshot']:
416 if (images_ord['screenshot'][2][0]!=640 or
417 images_ord['screenshot'][2][1]!=480):
418 raise EntryError('The screenshot size is incorrect: ' +
419 'it is %dx%d ' % images_ord['screenshot'][2] +
420 'where it should be 640x480', code)
422 # Verify the desklet package...
423 # ...Do we have it?
424 if len(download)==0:
425 raise EntryError('Your desklet package could not be' +
426 'retrieved. Either you submitted ' +
427 'an unavailable url, or your email ' +
428 'was corrupted. Please recheck things and ' +
429 'resubmit it.', code)
431 # Compute the md5 sum of the tarball
433 md5 = os.popen('md5sum %s | cut -d" " -f1 2> /dev/null' %
434 download[0]).readlines()[0].strip()
436 # ...Can we extract it?
438 if os.system('tar tjf %s &> /dev/null' % download[0])!=0:
439 size=os.popen('find %s -printf "%%s\n"' %
440 download[0]).readlines()[0].strip()
441 raise EntryError('Tarball could not be extracted - ' +
442 'you should provide a conformant ANSI or V7 ' +
443 'archive, compressed using ' +
444 'the Burrows-Wheeler block sorting ' +
445 'algorithm (bz2). File was %s bytes, ' % size +
446 'and md5 sum was %s.' % mp5, code)
448 # ...Is the structure ok?
450 dirs=[ dir.strip() for dir in
451 os.popen(' | '.join(['tar tjf ' + download[0],
452 'sed "s/\([^\/]*\)\/.*/\\1/"',
453 'sort', 'uniq'])).readlines() ]
454 dir='%s-%s' % (code['desklet']['name'],code['desklet']['version'])
455 if len(dirs)!=1 or dirs[0] != dir:
456 print dirs, dir
457 raise EntryError('Tarball internal structure is incorrect. ' +
458 'It should contain one but only one ' +
459 "directory, named '%s'." % dir, code)
461 # ...Does it have a README in base source directory?
462 # If yes, is it valid?
464 readme=os.path.join(dir,'README')
465 if os.system('tar -O -xjf %s %s &> /dev/null' %
466 (download[0], readme))==0:
467 # In case of interactive use, look at README
468 if (os.isatty(sys.stdin.fileno())):
469 yesno('Piping %s %s README through less...'
470 % (code['desklet']['name'],
471 code['desklet']['version']))
472 os.system('tar -O -xjf %s %s | less'
473 % (download[0], readme))
474 if not yesno('README complete?'):
475 raise EntryError('README does not contains all the ' +
476 'required information.', code)
477 if not yesno('README up to date?'):
478 raise EntryError('README does not appear to be ' +
479 'up to date for version ' +
480 code['desklet']['version'] + ' of ' +
481 code['desklet']['name'] + '.', code)
482 else:
483 raise EntryError('Tarball does not contain the required ' +
484 "'%s' file." % readme, code)
486 # Finally, give a chance to perform manual check of the code...
488 if (os.isatty(sys.stdin.fileno())):
489 if yesno('Extract for manual check of the code (need X) ?'):
490 os.system('tar -C %s -xvjf %s' % (tmp, download[0]))
491 os.system('export DISPLAY=:0.0 && cd %s && xterm' %
492 os.path.join(tmp,dir))
493 if not yesno('code ok?'):
494 raise EntryError(
495 'Your entry is still pending. '
496 'While your submission was found valid ' +
497 'by the automated script, the maintainer propably '
498 'experienced some drawback when trying out '
499 'your code. You should receive some non-automated ' +
500 'explanation by email in the upcoming hours.', code)
502 # Now we know the entry to be ok - sending back the corresponding files
503 # on disc.
505 return dict([('files',
506 dict([ (image[0],image[1][0]) for image in
507 images_ord.iteritems() if image[1] ] +
508 [ (image[0],None) for image in
509 images_ord.iteritems() if not image[1]] +
510 [('download',download[0]),
511 ('description', os.path.join(tmp,'desc.xml')),
512 ('tmpdir', tmp)])),
513 ('md5', md5), ('code', code),
516 # ----------------------------------------------------------------------------
517 def rename(desc):
518 mapping = [(name[0],desc['files'][name[0]],name[1])
519 for name in
520 (('thumbnail','%s_thumb' %
521 desc['code']['desklet']['name']),
522 ('screenshot','%s_screen' %
523 desc['code']['desklet']['name']),
524 ('download','%s-%s' %
525 (desc['code']['desklet']['name'],
526 desc['code']['desklet']['version'])))
527 if desc['files'][name[0]]]
528 mapping = [(name[0],name[1],'%s.%s' % (name[2],get_ext(name[1]))) \
529 for name in mapping]
530 for dummy, name,filename in mapping:
531 shutil.move(name, os.path.join(desc['files']['tmpdir'],filename))
532 os.system('rm -f %s/part_?.* &> /dev/null' % desc['files']['tmpdir'])
533 return (desc, mapping)
535 # ----------------------------------------------------------------------------
536 def describe(desc,mapping):
537 mapping=dict([('thumbnail', 'default_thumb.jpg'),
538 ('screenshot', 'default_screen.jpg')] +
539 [(name[0], name[2]) for name in mapping])
540 fd = file(desc['files']['description'],'w+')
542 print >> fd, '<desklet name="%s" version="%s"' % \
543 (desc['code']['desklet']['name'], desc['code']['desklet']['version'])
544 print >> fd, '\tthumbnail="%s"\n\tscreenshot="%s"' % \
545 tuple([os.path.join('images',mapping[name]) for name
546 in ('thumbnail','screenshot')])
547 if not desc['code']['desklet']['host_on_sourceforge']:
548 print >> fd, '\tdownload="%s"' % \
549 desc['code']['desklet']['download']
550 print >> fd, ' md5="%s">' % desc['md5']
551 print >> fd, '<author name="%s" email="%s" />' % \
552 (desc['code']['info']['author_name'],
553 desc['code']['info']['author_email'])
554 print >> fd, desc['code']['desklet']['description'], '\n</desklet>'
555 if not desc['code']['desklet']['host_on_sourceforge']:
556 os.unlink(os.path.join(desc['files']['tmpdir'],mapping['download']))
557 fd.seek(0)
558 print '='*80 + '\n' + fd.read() + '='*80
559 fd.close()
560 if not yesno('Accept this entry (last chance to reject it!) ?'):
561 raise EntryError('The entry was manually rejected ' +
562 'for unspecified reasons. The maintainer ' +
563 'should provide you an explanation by email soon.',
564 desc)
565 return desc
567 # ----------------------------------------------------------------------------
568 # Set terminal
570 mode=term_setraw()
572 # ----------------------------------------------------------------------------
573 # Main Loop
575 try:
576 # Read in the configuration.from $HOME/.adesklets_checkin
578 # This configuration file does not have to exist for non-interactive
579 # use, so a desklet developer can basically ignore it when checking
580 # configuration produced with adesklets_submit.
582 # A working configuration entry would look like:
584 # smtp_host = 'smtp.devil.net',
585 # imap_host = 'mail.devil.net'
586 # imap_user = 'leviathan'
587 # imap_passwd = '666_666'
588 # bcc = 'leviathan_backup@devil.net'
590 config_name=os.path.join(os.getenv('HOME'),'.adesklets_checkin')
591 config={}
592 try:
593 f = file(config_name,'r')
594 exec f in config
595 del config['__builtins__']
596 f.close()
597 except IOError:
598 pass
600 # In case of interactive use, look up messages
601 # on an imap over ssl server
603 if os.isatty(sys.stdin.fileno()):
604 # Set behavior: look only at unseen (default), or at all messages
606 opts, args = getopt.getopt(sys.argv[1:],'',['all'])
607 sel = ('UNSEEN','ALL')[len(opts)==1]
609 # Connect to imap server
611 s=imaplib.IMAP4_SSL(config['imap_host'])
612 s.login(config['imap_user'],config['imap_passwd'])
613 s.select()
615 for num in s.search(None,sel)[1][0].split():
616 msg=email.message_from_string(s.fetch(num,'(RFC822)')[1][0][1])
617 print '%s\nFrom: %s\nDate: %s' % (msg['Subject'],
618 msg['From'],
619 msg['Date'])
620 answered=False
621 if yesno('Read ?'):
622 # Validation, renaming and description
624 try:
625 answered=send_email(describe(*rename(validate(msg))))
626 except EntryError , e :
627 send_email(e)
628 # Deletion
630 if answered or yesno('Mark for deletion ?'):
631 s.store(num, '+FLAGS.SILENT', '(\DELETED)')
632 else:
633 # In case of normal mode, preserve the 'unseen' state
634 # of email if it was not answered.
635 if sel=='UNSEEN':
636 s.store(num, '-FLAGS.SILENT', '(\SEEN)')
638 # Mailbox expurge
640 if yesno('Expurge messages marked for deletion ?'):
641 s.expunge()
642 s.logout()
643 # In case of non-interactive use, just validate a single message
644 # from stdin, and remove eveything from disc right away
646 else:
647 print 'Validation started. Please wait.'
648 msg = email.message_from_string(sys.stdin.read())
649 shutil.rmtree(validate(msg)['files']['tmpdir'])
650 print fill('Everything seems fine, but keep in mind a few things ' +
651 'cannot be verified without human intervention. See documentation ' +
652 'for details.',70)
653 except:
654 term_unsetraw(mode)
655 raise
656 term_unsetraw(mode)