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
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.
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
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 -------------------------------------------------------------------------------
59 # --- Libraries --------------------------------------------------------------
64 import email
.MIMEMultipart
70 from textwrap
import fill
71 from string
import translate
, maketrans
72 from itertools
import dropwhile
, izip
, count
74 # --- Terminal management ----------------------------------------------------
80 def __init__(self
, stdout
):
83 self
.stdout
.write(arg
)
87 if os
.isatty(sys
.stdin
.fileno()):
88 mode
= termios
.tcgetattr(sys
.stdin
.fileno())
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
)
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):
106 p
.register(sys
.stdin
.fileno(),select
.POLLIN
)
108 return sys
.stdin
.read(1)
111 if os
.isatty(sys
.stdin
.fileno()):
113 answer
= getch().lower()
119 # --- PIL related functions --------------------------------------------------
121 def image_props(filename
):
123 im
=Image
.open(filename
)
125 (im
.format
=='JPEG' or im
.format
=='PNG',im
.size
))
130 def get_ext(filename
):
132 im
=Image
.open(filename
)
133 ext
= {'JPEG':'jpg','PNG':'png'}[im
.format
]
138 # --- DeskletError exception class -------------------------------------------
139 class EntryError(Exception):
140 def __init__(self
, value
, desc
=None):
144 return str(self
.value
)
146 # ----------------------------------------------------------------------------
147 def send_email(e
=None):
150 # Retrieve email info, depending of e class
152 if e
.__class
__ is EntryError
:
158 info
=e
['code']['info']
160 subject
= 'Re: ' + info
['subject']
164 if e
.__class
__ == EntryError
:
168 subject
= 'Re: ' + info
['subject']
169 # Build an error message
171 body
= '\n'.join(['Hi %s,'% info
['author_name'],
174 'your desklet entry sent on %s was rejected. %s'
175 % (info
['date'], 'The reason was:'),70),
180 'See the documentation; thanks for correcting this. ' +
181 'I am looking forward to include your work ' +
182 'on adesklets site. Regards,',70),
190 subject
= 'Re: ' + info
['subject'] + ' [ACCEPTED]'
192 # Build a success message
194 body
= '\n'.join(['Hi %s,'% info
['author_name'],
197 'your desklet entry sent on %s was sucessfully '
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.'
207 'Thanks again for your contribution! Best regards,',
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'
222 for content
in (body
, '\n'.join(
223 ['# Copy of submitted entry on %s.' % info
['date'],
224 '# by %s.' % info
['from'],
227 msg_part
=email
.MIMEText
.MIMEText(content
)
229 msg_part
.add_header('Content-Disposition',
230 'attachment', filename
='description.py')
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',
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
253 print 'Operation cancelled, message not sent.'
256 # ----------------------------------------------------------------------------
257 # Validation treatment.
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
271 tmp
=os
.path
.join(os
.getenv('HOME'),'unpack')
272 shutil
.rmtree(tmp
, True)
275 except OSError, (errstr
, errno
):
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':
284 type=part
.get_content_type()
285 if type=='text/plain':
286 body
= reduce(lambda x
,y
: "%s\n%s" % (x
, y
),
288 dropwhile(lambda x
:x
.split(' ')[0]!='info',
289 translate(part
.__str
__(),
291 '\r').split('\n')) ])
293 name
=os
.path
.join(tmp
,'part_%d.%s' %
297 'octet-stream':'tar.bz2'}
298 [type.split('/')[1]]))
299 file_list
.append(name
)
301 fd
.write(part
.get_payload(decode
=1))
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'],
316 if yesno('Import description ?'):
319 del code
['__builtins__']
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']):
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'])
333 if not yesno('Invalid description - return address would be ' +
334 "'%s'. Drop email?" % info
['author_email']):
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']):
346 url
= urllib
.urlopen(code
['desklet'][field
])
349 (id, code
['desklet'][field
].split('.')[-1]))
350 file_list
.append(name
)
354 except AttributeError:
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
369 originals
= [ code
['desklet'][field
] for field
in
370 ('thumbnail','screenshot')]
371 images
= [image_props(im
) for im
in file_list
[:3]
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
])\
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])
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...
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:
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
)
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?'):
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
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')),
513 ('md5', md5
), ('code', code
),
516 # ----------------------------------------------------------------------------
518 mapping
= [(name
[0],desc
['files'][name
[0]],name
[1])
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]))) \
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']))
558 print '='*80 + '\n' + fd
.read() + '='*80
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.',
567 # ----------------------------------------------------------------------------
572 # ----------------------------------------------------------------------------
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')
593 f
= file(config_name
,'r')
595 del config
['__builtins__']
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'])
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'],
622 # Validation, renaming and description
625 answered
=send_email(describe(*rename(validate(msg
))))
626 except EntryError
, e
:
630 if answered
or yesno('Mark for deletion ?'):
631 s
.store(num
, '+FLAGS.SILENT', '(\DELETED)')
633 # In case of normal mode, preserve the 'unseen' state
634 # of email if it was not answered.
636 s
.store(num
, '-FLAGS.SILENT', '(\SEEN)')
640 if yesno('Expurge messages marked for deletion ?'):
643 # In case of non-interactive use, just validate a single message
644 # from stdin, and remove eveything from disc right away
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 ' +