#!/usr/bin/env python
"""
-------------------------------------------------------------------------------
Copyright (C) 2005, 2006  Sylvain Fourmanoit

Some ideas were taken from a initial coding effort by Cameron Daniel
<me@camdaniel.com>.

Released under the GPL, version 2.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies of the Software and its documentation and acknowledgment shall be
given in the documentation and software packages that this Software was
used.

THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-------------------------------------------------------------------------------
This is the adesklet's installer script. Have a look at adesklets
documentation for details.
"""
# -----------------------------------------------------------------------------
# Imports
#
import sys, traceback

try:
    import threading
except ImportError:
    print 'Threading support is REQUIRED by adesklets_installer'
    sys.exist(1)

import os, os.path, re, tarfile, md5, StringIO, cStringIO, copy, time
from urllib import urlopen
from xml.dom.minidom import parseString

# -----------------------------------------------------------------------------
class Driver(threading.Thread):
    """
    Driver embed the installer core logic into a single, reusable
    monothreaded component. It will be reused here by GUI-derivated classes.

    I am not a big fan of multithreaded applications, but this is needed
    for clean integration with most widgets toolkits these days.
    """
    class Desklets(dict):
        def __init__(self, logfile):            
            self.log=logfile
            self.path = os.path.join(os.getenv('HOME',''), '.desklets')
            self.ready = threading.Event()
            self.ready.clear()

        def descriptions(self):
            """
            Returns a preformatted desklets description, suitable
            for monospace printing., in desklets name alphabetic order.
            """
            desc = ['%15s %5s (%s)' % (k, v['version'], v['status'])
                    for k, v in self.iteritems()]
            desc.sort(lambda x, y: cmp(x.strip().lower(), y.strip().lower()))
            return desc

        def run(self):
            def extract_tag(entry):
                def extract_text(tag):
                    if not tag.hasChildNodes():
                        tag = tag.getAttributeNode('href')
                    return tag.firstChild.wholeText.encode('ascii')
                return [extract_text(entry.getElementsByTagName(tag)[0])
                        for tag in ('title', 'link', 'generator')]
            def extract_url(link):
                if 'prdownloads.sourceforge.net' in link:
                    return ('http://dl.sourceforge.net/sourceforge/' +
                            'adesklets/%s' % 
                            re.findall('adesklets/(.+)\?download', link)[0])
                else:
                    return link
            def by_version(x, y):
                def version(ver):
                    return sum([int(v)*100**(2-i)
                                for v, i in zip(ver.split('.'), xrange(3))])
                return version(x[1])-version(y[1])
            
            # Fetch and organize the data
            #
            print >> self.log, 'Retrieving data online...',
            self.log.flush()
            dict.__init__(self,
                          [(title.split()[0],
                            dict(([('version', title.split()[1])] + 
                                  [('url', extract_url(link))] +
                                  [tuple(generator.split(':'))])))
                           for title, link, generator in
                           [extract_tag(entry)
                            for entry in parseString(
                urlopen('http://adesklets.sourceforge.net/desklets.atom').read()
                ).getElementsByTagName('entry')]])
            print >> self.log, 'OK'
        
            # Compare to what's installed, and set the status
            #
            print >> self.log, 'Checking locally installed desklets...',
            self.log.flush()
            if os.path.isdir(self.path):
                inst = [item.split('-')
                        for item in os.listdir(self.path)
                        if os.path.isdir(os.path.join(self.path, item))]
                inst.sort(by_version)
                inst = dict(inst)
            else:
                inst = {}
        
            for desklet in self:
                if desklet in inst:
                    if inst[desklet] == self[desklet]['version']:
                        self[desklet]['status']='installed'
                    else:
                        self[desklet]['status']='out of date'
                else:
                    self[desklet]['status']='uninstalled'
            print >> self.log, 'OK'
            self.ready.set()
        
        def install(self, desklet):
            """
            Perform a desklet installation
            """
            print >> self.log, 'Downloading %s desklet...' % desklet,
            self.log.flush()
            f = cStringIO.StringIO(urlopen(self[desklet]['url']).read())
            print >> self.log, 'OK'
            print >> self.log, 'Verifying download integrity...',
            self.log.flush()
            if md5.new(f.getvalue()).hexdigest() != self[desklet]['md5']:
                raise RuntimeError('bad download checksum')
            print >> self.log, 'OK'
            print >> self.log, 'Opening the downloaded archive...',
            self.log.flush()
            tar = tarfile.open(mode='r|bz2', fileobj=f)
            print >> self.log, 'OK'
            print >> self.log, 'Extracting the archive...',
            self.log.flush()
            if not os.path.isdir(self.path): os.mkdir(self.path)
            for tarinfo in tar: tar.extract(tarinfo, path=self.path)
            self[desklet]['status'] = 'installed'
            print >> self.log, """OK
%(sep)s
desklet %(name)s version %(version)s was installed in:
%(path)s
Change to this directory, see the README,
and follow the instructions.
%(sep)s""" % {'name': desklet,
              'version': self[desklet]['version'],
              'path': '%s/%s-%s' % (self.path, desklet, self[desklet]['version']),
              'sep': '='*30}

    def __init__(self, logfile=sys.stdout):
        self.cond = threading.Condition()
        self.op = None
        self.kw = None
        self.alive = True
        self.ready_state = False
        self.desklets = self.Desklets(logfile)
        threading.Thread.__init__(self)
    def refreshed(self):
        """
        Whenever it returns True, the GUI instance need to
        refresh its desklets lists (it takes care of initial load too)
        """
        if not self.ready_state:
            if self.desklets.ready.isSet():
                self.ready_state = True
                return True
        return False
    def ready(self, block=False):
        """
        Returns True if Driver is ready to process command. It can be used
        in non-blocking (default) or blocking mode. In blocking mode, it
        will eventually block and wait for Driver to be ready before
        continuing.
        """
        if block:
            self.desklets.ready.wait()
            return True
        return self.desklets.ready.isSet()

    def track(self):
        """Traceback tracker"""
        print >> self.desklets.log, \
              '\n!!! An error occured during the operation !!!'
        traceback.print_exc(file=self.desklets.log)
        self.desklets.ready.set()

    def run(self):
        """Main thread loop"""
        try:
            self.desklets.run()
        except:
            self.track()
            
        while self.alive:
            try:
                self.cond.acquire()
                if not self.op: self.cond.wait()
                op = self.op
                kw = self.kw
                self.op = None
                self.kw = None
                self.cond.release()
                if hasattr(self, '_'+op):
                    self.desklets.ready.clear()
                    getattr(self, '_'+op)(**kw)
                    self.desklets.ready.set()
            except:
                self.track()
       
    def command(self, op, **kw):
        """
        Execute a given command. For instance:

        Driver.command('install', desklet='mydesklet')

        NOTE: *DO NOT* call the commands directly
        """
        self.cond.acquire()
        self.op = op
        self.kw = kw
        self.cond.notify()
        self.cond.release()

    def _install(self, desklet):
        self.ready_state=False	    	# <= Used to signal a possible
        self.desklets.install(desklet)  #    refresh of desklets states
    def _quit(self): self.alive = False

# -----------------------------------------------------------------------------
class UI(object):
    """
    UI is a base class for the installer interfaces.
    """
    class Log(StringIO.StringIO):
        """
        Abstracted log file
        """
        def __init__(self):
            self.lock = threading.Lock()
            self.refresh = False
            StringIO.StringIO.__init__(self)
        def write(self, string):
            self.lock.acquire()
            StringIO.StringIO.write(self, string)
            self.refresh = True
            self.lock.release()
        def pool(self):
            """
            pool() => data OR None

            If the logged information from the driver changed since last call,
            It will return the full log.
            """
            result = None
            if self.lock.acquire(False):
                if self.refresh:
                    result=copy.deepcopy(self.getvalue())
                    self.refresh = False
                self.lock.release()
            return result
                
    def __init__(self):
        # Connect and run components
        #
        self.log = self.Log()
        self.driver = Driver(self.log)
        self.driver.start()
        try:
            try:
                self.run()
            except KeyboardInterrupt: print '(Interrupted)'
        finally:
            self.driver.command('quit')
            self.driver.join()

    def run(self):
        """Main GUI loop: need to be overriden in children"""
        pass

# -----------------------------------------------------------------------------
class rawGUI(UI):
    """Very bare interface for any terminal: should always work"""
    def Log(self): return sys.stdout

    def run(self):
        # Just output a list of choice, wait for the user, then repeat
        # if the choice was not to exit in the first place.
        #
        answer = 0
        while answer != -2:
            self.driver.ready(block=True)
            desklets = [(i, desc)
                        for (i, desc) 
                        in zip(xrange(len(self.driver.desklets)),
                               self.driver.desklets.descriptions())]
            print '%(sep)s\nAvailable desklets\n%(sep)s' % { 'sep': '-'*30 }
            print '\n'.join(['%3d) %s' % item for item in desklets])
            print '  q) %15s' % 'Quit'
            answer = self.input('Enter your selection > ')
            if dict(desklets).has_key(answer):
                self.driver.command('install',
                                    desklet=dict(desklets)[answer].split()[0])
                time.sleep(1)
                self.driver.ready(block=True)
                answer = self.input('<<<Press q, to quit>>> ')

    def input(self, prompt):
        try:
            val = raw_input(prompt)
            return int(val)
        except EOFError:
            print '(End of File)'
            return -2
        except ValueError:
            if val.strip().lower() == 'q':
                return -2;
            return -1

# -----------------------------------------------------------------------------
try:
    import Tkinter
    
    class _TkGUI(UI):
        """Tk-based interface"""
        def run(self):
            # Initialize the various widgets
            #
            self.win = Tkinter.Tk()
            self.win.resizable(False, False)
            self.win.title('adesklets installer')
            self.desklet = ''
            f = Tkinter.Frame(self.win)
            f.pack()
            s = Tkinter.Scrollbar(f)
            s.pack(side=Tkinter.RIGHT, fill=Tkinter.Y)
            self.listbox = Tkinter.Listbox(f, yscrollcommand=s.set,
                                           font="fixed", width=40)
            self.listbox.pack(fill=Tkinter.Y)
            s.config(command=self.listbox.yview)

            f = Tkinter.Frame(self.win)
            f.pack()
            self.binstall = Tkinter.Button(f, \
                               text='Waiting for online description',
                                           state=Tkinter.DISABLED,
                                           command=self.install)
            self.binstall.pack(side=Tkinter.LEFT)
            w = Tkinter.Button(f, text='Quit',command=self.win.quit)
            w.pack(side=Tkinter.RIGHT)

            f = Tkinter.Frame(self.win)
            f.pack(fill=Tkinter.Y)
            s = Tkinter.Scrollbar(f)
            s.pack(side=Tkinter.RIGHT, fill=Tkinter.Y)
            self.logtext = Tkinter.Text(f, yscrollcommand=s.set,
                                        wrap=None,width=50, height=6)
            self.logtext.pack(fill=Tkinter.BOTH)
            s.config(command=self.logtext.yview)

            # Then, register a pooling callback, and start the main loop
            #
            self.win.after(0, self.pool)
            self.win.mainloop()

        def refresh_list(self):
            self.listbox.delete(0, Tkinter.END)
            for desc in self.driver.desklets.descriptions():
                self.listbox.insert(Tkinter.END, desc)
            self.binstall.config(text='Select a desklet')
                
        def pool(self):
            """
            Periodic pooling method that refreshes the GUI
            """
            # Refresh the desklet list
            #
            if self.driver.refreshed(): self.refresh_list()

            # Refresh the installation button (state and text)
            #
            s = self.listbox.curselection()
            if len(s)>0:
                desklet = self.listbox.get(s[0])
                if self.desklet != desklet:
                    self.desklet = desklet
                    self.binstall.config(
                        text ='Install ' + \
                        ' '.join(self.desklet.split()[:2]))
                if self.driver.ready():
                    self.binstall.config(state=Tkinter.ACTIVE)

            # Refresh the log output
            #
            text = self.log.pool()
            if text is not None:
                self.logtext.config(state=Tkinter.NORMAL)
                self.logtext.delete(1.0, Tkinter.END)
                self.logtext.insert(Tkinter.END, text)
                self.logtext.config(state=Tkinter.DISABLED)

            # Call again the routine in 1/10th of a second
            #
            self.win.after(100, self.pool)
            
        def install(self):
            """Button installation callback"""
            self.binstall.config(state=Tkinter.DISABLED)
            self.driver.command('install', desklet=self.desklet.split()[0])

    def TkGUI():
        """Just a fallback wrapper"""
        try:
            return _TkGUI()
        except Tkinter.TclError:
            print 'Could not instanciate the Tk interface'

except ImportError: pass

# -----------------------------------------------------------------------------
try:
    import curses

    class _cursesGUI(UI):
        """Curses-based interface"""

        class TextZone(object):
            """
            Curses is fairly low-level, so let's start by defining our
            listbox-like widget.
            """
            def __init__(self, *args):
                self.h  , self.w,  \
                self.sy , self.sx, \
                self.sh , self.sw  = args
                self.hl  = 0
                self.cur = 0
                self.lines = ['']

                self.win = curses.newwin(self.sh-self.sy, self.w,
                                         self.sy, self.sx)
                self.win.box()
                self.win.refresh()
                self.pad = curses.newpad(self.h-2, self.w-2)
                self.pad.keypad(1)
                self.pad.nodelay(1)

            def refresh(self, data = None):
                """Refresh the widget content, as needed"""
                if data is not None:
                    self.pad.erase()
                    if type(data) is type(''):
                        self.lines = data.splitlines()
                    else:
                        self.lines = data
                    for i, line in zip(range(len(self.lines)), self.lines):
                        self.pad.addstr(i, 0, line)
                    self.scroll(0)
                else:
                    self.pad.refresh(self.cur, 0,
                                     self.sy+1, self.sx+1,
                                     self.sh-2, self.sw-1)
            def status(self, status):
                """Set the widget status"""
                self.win.box()
                self.win.addstr(0, 2, '[ %s ]' % status)
                self.win.refresh()
                self.refresh()

            def toggle(self):
                """Toggle widget in active/inactive mode"""
                self.hl = (0, curses.A_REVERSE)[self.hl == 0]
                self.scroll(0)
                
            def scroll(self, delta):
                """Perform a scroll"""
                new = self.cur + delta
                if new < 0:
                    new = 0
                elif new >= len(self.lines):
                    new = len(self.lines)-1

                if len(self.lines)>1:
                    self.pad.addstr(self.cur, 0, self.lines[self.cur])
                    self.pad.addstr(new, 0, self.lines[new], self.hl)
                self.cur = new
                self.refresh()

            def pool(self):
                """
                Pool the keyboard: scrolling actions are self-contained,
                selection, widget circulation and quitting are not.
                """
                c = self.pad.getch()
                if c!=-1:
                    if c == curses.KEY_UP or c == ord('8'):
                         self.scroll(-1)
                    elif c == curses.KEY_DOWN or c == ord('2'):
                         self.scroll(1)
                    elif c == curses.KEY_PPAGE or c == ord('9'):
                        self.scroll((self.sy-self.sh)/2)
                    elif c == curses.KEY_NPAGE or c == ord('3'):
                        self.scroll((self.sh-self.sy)/2)
                    elif c == ord('\t'):
                        return 'next'
                    elif c == curses.KEY_ENTER or c == ord('\n'):
                        return 'select'
                    elif c == ord('q') or c == 27:
                        return 'quit'
                
        def run(self):
            curses.wrapper(self._run)
            
        def _run(self, scr):
            # Check out terminal real-estate
            #
            if scr.getmaxyx()[0]<20 or scr.getmaxyx()[1]<50:
                raise curses.error('Terminal size should be at least 50x20')
            curses.curs_set(0)

            # Instanciate our two widgets: a desklets pad and a log pad
            #
            dpad = self.TextZone(100, scr.getmaxyx()[1],
                                 0, 0,
                                 scr.getmaxyx()[0]-10, scr.getmaxyx()[1])
            lpad = self.TextZone(200, scr.getmaxyx()[1],
                                 scr.getmaxyx()[0]-10, 0,
                                 scr.getmaxyx()[0], scr.getmaxyx()[1])

            # Start with the desklets pad active
            #
            dpad.toggle()
            dpad.status('Waiting for desklets description')
            pad = dpad

            # Now loop indefinitively
            #
            while True:
                # Refresh the desklet list
                #
                if self.driver.refreshed():
                    dpad.status('Ready')
                    dpad.refresh(self.driver.desklets.descriptions())

                # Refresh the log output
                #
                text = self.log.pool()
                if text is not None: lpad.refresh(text)

                # React to keyboard...
                #
                action = pad.pool()
                if action is not None:
                    # ...By changing the active TextZone
                    if action == 'next':
                        for item in (dpad, lpad): item.toggle()
                        pad = (dpad, lpad)[pad is dpad]
                    # ... By performing the installation, as needed
                    elif action == 'select' and pad is dpad:
                        desklet = pad.lines[pad.cur].split()[0]
                        if (self.driver.desklets.has_key(desklet) and
                            self.driver.ready()):
                            pad.status('Installing...')
                            self.driver.command('install', desklet=desklet)
                    #... And of course by exiting as needed
                    elif action == 'quit':
                        break
                time.sleep(.01)

    def cursesGUI():
        """Just a fallback wrapper"""
        try:
            return _cursesGUI()
        except curses.error:
            print 'Could not instanciate the curses interface'
        
except ImportError: pass

# -----------------------------------------------------------------------------
if __name__ == '__main__':

    # Parse the command line
    #
    from optparse import OptionParser
    def version(option, opt, value, parser):
        print """%s %s
Copyright (C) 2005, 2006 Sylvain Fourmanoit <syfou@users.sourceforge.net>
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
""" % (parser.get_prog_name(), os.getenv('ADESKLETS_VERSION', ''))
        parser.exit()

    p = OptionParser()
    p.add_option('-v', '--version', action='callback', callback=version,
                 help='print out the version info and exit')
    p.add_option('-r', '--raw', action='store_true', dest='raw',
                 help='force the use of the raw terminal interface ' +
                 '(always available)')
    p.add_option('-c', '--curses', action='store_true', dest='curses',
                 help='force the use of the curses interface, when available')
    p.add_option('-t', '--tk', action='store_true', dest='Tk',
                 help='force the use of the Tk interface, when available')
    p.set_defaults(raw=False, curses=False, Tk=False)
    opts, args= p.parse_args()

    # Just instanciate the right GUI to start up the application
    # In order, we try to instanciate the Tk, curses and raw
    # interface, falling to the next in case of unavailability or
    # initialization errors
    #    
    select = opts.raw + opts.curses + opts.Tk
    for ui in ('Tk', 'curses', 'raw'):
        if (globals().has_key('%sGUI' % ui) and
            (getattr(opts, ui) or select==0 or ui=='raw')):
                if globals()['%sGUI' % ui](): break

# -----------------------------------------------------------------------------
