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
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']
221 for content
in (body
, '\n'.join(
222 ['# Copy of submitted entry on %s.' % info
['date'],
223 '# by %s.' % info
['from'],
226 msg_part
=email
.MIMEText
.MIMEText(content
)
228 msg_part
.add_header('Content-Disposition',
229 'attachment', filename
='description.py')
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',
245 print 'Message sent and marked for deletion:', recip
248 print 'Operation cancelled, message not sent.'
251 # ----------------------------------------------------------------------------
252 # Validation treatment.
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
266 tmp
=os
.path
.join(os
.getenv('HOME'),'unpack')
267 shutil
.rmtree(tmp
, True)
270 except OSError, (errstr
, errno
):
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':
279 type=part
.get_content_type()
280 if type=='text/plain':
281 body
= reduce(lambda x
,y
: "%s\n%s" % (x
, y
),
283 dropwhile(lambda x
:x
.split(' ')[0]!='info',
284 translate(part
.__str
__(),
286 '\r').split('\n')) ])
288 name
=os
.path
.join(tmp
,'part_%d.%s' %
292 'octet-stream':'tar.bz2'}
293 [type.split('/')[1]]))
294 file_list
.append(name
)
296 fd
.write(part
.get_payload(decode
=1))
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'],
311 if yesno('Import description ?'):
314 del code
['__builtins__']
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']):
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'])
328 if not yesno('Invalid description - return address would be ' +
329 "'%s'. Drop email?" % info
['author_email']):
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']):
341 url
= urllib
.urlopen(code
['desklet'][field
])
344 (id, code
['desklet'][field
].split('.')[-1]))
345 file_list
.append(name
)
349 except AttributeError:
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
364 originals
= [ code
['desklet'][field
] for field
in
365 ('thumbnail','screenshot')]
366 images
= [image_props(im
) for im
in file_list
[:3]
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
])\
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])
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...
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:
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
)
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?'):
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
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')),
508 # ----------------------------------------------------------------------------
510 mapping
= [(name
[0],desc
['files'][name
[0]],name
[1])
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]))) \
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']
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']))
550 print '='*80 + '\n' + fd
.read() + '='*80
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.',
559 # ----------------------------------------------------------------------------
564 # ----------------------------------------------------------------------------
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')
585 f
= file(config_name
,'r')
587 del config
['__builtins__']
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'])
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'],
614 # Validation, renaming and description
617 answered
=send_email(describe(*rename(validate(msg
))))
618 except EntryError
, e
:
622 if answered
or yesno('Mark for deletion ?'):
623 s
.store(num
, '+FLAGS.SILENT', '(\DELETED)')
625 # In case of normal mode, preserve the 'unseen' state
626 # of email if it was not answered.
628 s
.store(num
, '-FLAGS.SILENT', '(\SEEN)')
632 if yesno('Expurge messages marked for deletion ?'):
635 # In case of non-interactive use, just validate a single message
636 # from stdin, and remove eveything from disc right away
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 ' +