#!/usr/bin/python
# -*- Mode: python -*-
# vi:si:et:sw=4:sts=4:ts=4

# (C) 2006, Python Software Foundation.
# This code is available under the Python Software Foundation license.

# Author: Anthony Baxter

# Welease... Wuby!
# We haven't got a Wuby, Sir.
# Are you sure? They seem vewy insistent about it.
# We haven't got him.
# Alright, welease... Pewl!
# Er. We haven't got a Pewl, either, sir.
# Vewy well, vewy well. I shall welease... Python!

# A GUI for building Python releases. If a job's worth doing well, it's worth
# overdoing.

# This isn't the prettiest code in the world, but it should be good enough.
# It was written in the space of a couple of hours, and could (and will) get
# a refactoring as I add things to it. Don't look too closely at some of it,
# it's got some ugly bits.

# TODO:
#   Lock out the current button so you don't run the same stage repeatedly.
#   Add more checks to the Check Release stage (see XXX in the list below)
#   Refactor some of the ugliness.
#
#   Stage 2 of releases.
#     Sign the releases (using pygpgme, or gnome-gpg?).
#     Check the signatures.
#     Update the webpage 'Downloads' section.
#     Upload the releases.
#     Format and send the release email. (Just kidding).

# Check Release steps:
# XXX = not implemented yet.
# 
# Check Include/patchlevel.h (fatal if wrong)
# Check for uncommitted changes in the checkout (nonfatal)
# Check the revision in the checkout is the same as repository HEAD (fatal)
# Check Lib/idlelib/idlever.py (fatal)
# Check Misc/NEWS and Lib/idlelib/NEWS.txt (fatal) 
# Check product_codes in Tools/msi/uuids.py (fatal)
# Check PCbuild/BUILDno.txt and PCbuild/pythoncore.vcproj [XXX] [2.4 only]
# Check Doc/commontex/boilerplate.tex [XXX]
# Check various release dates match [XXX]

import gnome
gnomeProgram = gnome.init("Welease", "0.1")
import pygtk
pygtk.require('2.0')

import gtk, gtk.glade

from twisted.internet import gtk2reactor
gtk2reactor.install()
from twisted.internet import reactor, protocol, defer

import os, sys, re, time, calendar
import gettext
import compiler, compiler.ast
import imp


cfg = None

def buildEnv():
    envVars = 'SSH_AUTH_SOCK', 'PATH', 'SSH_AGENT_PID'
    d = {'GZIP':'--best', } # bzip defaults to '--best'
    for e in envVars:
        v = os.getenv(e)
        if v is not None:
            d[e] = v
    return d

class WeleaseWindow:
    currentBranch = 'trunk'
    currentProcess = None
    warnDefer = None

    def __init__(self):
        domain = gettext.textdomain()
        gtk.glade.textdomain(domain)
        self.xml = gtk.glade.XML("welease.glade", None, gettext.textdomain())
        self.xml.signal_autoconnect(self)
        win = self.xml.get_widget('weleaseMainWindow')
        output = self.xml.get_widget("outputText")
        state = gtk.STATE_NORMAL
        output.modify_base(state, gtk.gdk.Color(10000,10000,10000))
        output.modify_text(state, gtk.gdk.Color(20000,65535,20000))
        self.logger = TextViewer(output)
        self.logger.set_visible(self.xml.get_widget('textscroll'))
        win.show_all()

    def warnPopup(self, message, fatal=True, d=None):
        if d is None: print "**", message
        if self.warnDefer is not None:
            # chain warnings
            nd = defer.Deferred()
            def makepopup(x):
                self.warnPopup(message, fatal=fatal, d=nd)
                return x
            self.warnDefer.addCallbacks(makepopup, makepopup)
            return nd

        self.xml.get_widget('warningContinueButton').set_sensitive(not fatal)
        self.xml.get_widget('warningText').set_text(message)
        self.xml.get_widget('warningWindow').show_all()
        if d is None:
            d = defer.Deferred()
        self.warnDefer = d
        return self.warnDefer

    def on_warningContinueButton_clicked(self, e):
        self.xml.get_widget('warningWindow').hide_all()
        d, self.warnDefer = self.warnDefer, None
        d.callback(True)

    def on_warningStopButton_clicked(self, e):
        self.xml.get_widget('warningWindow').hide_all()
        d, self.warnDefer = self.warnDefer, None
        d.callback(False)

    def on_weleaseMainWindow_delete_event(self, win, event):
        reactor.stop()

    def on_quit_activate(self, e):
        reactor.stop()

    def on_about_activate(self, e):
        self.xml.get_widget('aboutWindow').show_all()

    def on_closeAboutButton_clicked(self, e):
        self.xml.get_widget('aboutWindow').hide_all()

    def on_releaseSelector_changed(self, e):
        relsel = self.xml.get_widget('releaseSelector')
        model = relsel.get_model()
        active = relsel.get_active()
        self.currentBranch = model[active][0]

    def on_currentCommandAbort_clicked(self, e):
        if self.currentProcess is not None:
            self.currentProcess.signalProcess('KILL')
            self._clearCurrentProcess(None)

    def do_command(self, cmdargs, capture=False):
        pprint_cmd = ''
        for x in cmdargs:
            if ' ' in x:
                pprint_cmd += "'%s' "%x
            else:
                pprint_cmd += '%s '%x

        self.logger.write('Executing "%s"\n'%pprint_cmd)
        if capture:
            pp = ProcessCapture(self.logger)
        else:
            pp = ProcessOutput(self.logger)
        self.currentProcess = reactor.spawnProcess(pp, cmdargs[0], cmdargs, 
                                                   buildEnv())
        ccmdtext = self.xml.get_widget('currentCommandText')
        abortButton = self.xml.get_widget('currentCommandAbort')
        abortButton.set_sensitive(True)
        ccmdtext.set_text(pprint_cmd)
        d = pp.defer
        d.addCallback(self._clearCurrentProcess)
        return d

    def _clearCurrentProcess(self, x):
        self.currentProcess = None
        self.xml.get_widget('currentCommandText').set_text('')
        self.xml.get_widget('currentCommandAbort').set_sensitive(False)
        return x

    def on_checkReleaseButton_clicked(self, e):
        if not self.currentBranch:
            return
        self.newRelease = self.xml.get_widget('newRelease').get_text()
        if not self.newRelease:
            return self.warnPopup('You need to specify the release name!')

        self.checkReleaseBuildTestVersion()

    def checkReleaseBuildTestVersion(self):
        checkout = cfg.CheckoutPaths[self.currentBranch]
        buildtest = "/tmp/welease_testversion%s"%(os.getpid())
        open("%s.c"%(buildtest), "w").write(TestVersionCode)
        d = self.do_command(["gcc", "-I%s"%checkout, "-o", buildtest,
                            "%s.c"%buildtest])
        d.addCallback(lambda x:self.do_command([buildtest,], capture=True))
        d.addCallback(self.checkReleaseTestVersion)

    def checkReleaseTestVersion(self, (output, error)):
        if output != '%s %s\n'%(self.newRelease, self.newRelease):
            return self.warnPopup('Include/patchlevel.h is not correct')
        checkout = cfg.CheckoutPaths[self.currentBranch]
        os.chdir(checkout)
        d = self.do_command(["svn", "stat"], capture=True)
        d.addCallback(self.checkReleaseFindUnmergedChanges)

    def checkReleaseFindUnmergedChanges(self, (stdout, stderr)):
        checkout = cfg.CheckoutPaths[self.currentBranch]
        unmerged = set()
        badstats = ('M', 'A', 'C', 'D', 'G', 'R', '~', '!')
        for line in stdout.splitlines():
            line = line.strip().split()
            if not line:
                continue
            stat = line[0]
            for c in badstats:
                if c in stat:
                    unmerged.add(line[-1])
        if not unmerged:
            self.checkReleaseIdlelibVer()
        else:
            d = self.warnPopup('The following have uncommitted changes in\n' +
                               '%s\n'%checkout +
                               ' '.join(unmerged),
                               fatal=False)
            d.addCallback(lambda x: x==True and self.checkReleaseIdlelibVer())
            return

    def checkReleaseIdlelibVer(self):
        checkout = cfg.CheckoutPaths[self.currentBranch]
        libpath = os.path.join(checkout, 'Lib')
        if libpath in sys.modules:
            print "already added %s to sys.path??"%libpath
        else:
            sys.path.insert(0, libpath)
        from idlelib.idlever import IDLE_VERSION
        unimportModules('idlelib', 'idlelib.idlever')
        # Now compare Python and Idle versions.
        if not compareIdleVsPython(IDLE_VERSION, self.newRelease):
            return self.warnPopup('Lib/idlelib/idlever.py is not correct')
        self.checkReleaseGetSvnRevisions()

    def checkReleaseGetSvnRevisions(self):
        checkout = cfg.CheckoutPaths[self.currentBranch]
        reposurl = cfg.SvnPaths[self.currentBranch]
        d = self.do_command(["svn", "info", reposurl, checkout], capture=True)
        d.addCallback(self.checkReleaseCurrentSvn)

    def checkReleaseCurrentSvn(self, (stdout,stderr)):
        # Examine the "svn info" output, compare the revisions.
        checkout = cfg.CheckoutPaths[self.currentBranch]
        reposurl = cfg.SvnPaths[self.currentBranch]
        crev = localrev = reposrev = None
        for line in stdout.splitlines():
            line = line.strip().split(':', 1)
            #print line
            if line[0] == "Path":
                #print "Path:", line[1]
                if line[1].strip() in (os.path.basename(reposurl), reposurl):
                    crev = 'remote'
                elif line[1].strip() == checkout:
                    crev = 'local'
            if line[0] == "Revision":
                r = line[1].strip()
                if crev == 'remote':
                    reposrev = r
                elif crev == 'local':
                    localrev = r
                else:
                    print "revision is for neither local nor remote??"
                crev = None
        if localrev != reposrev:
            return self.warnPopup('SVN checkout is not current\n' +
                                  checkout + '\n' +
                                  'repos: %s, local: %s'%(reposrev, localrev))
        self.checkReleaseCheckNewsFiles()
    
    def checkReleaseCheckNewsFiles(self):
        checkout = cfg.CheckoutPaths[self.currentBranch]
        vers, date, err = parseNewsFile(os.path.join(checkout, "Misc/NEWS"))
        if err:
            return self.warnPopup('Checking Misc/NEWS, got error\n' + err)
        if vers != self.newRelease:
            return self.warnPopup('Misc/NEWS has version %s, not %s\n'%(vers,
                                                            self.newRelease))

        vers, date, err = parseNewsFile(os.path.join(checkout, 
                                                     "Lib/idlelib/NEWS.txt"))
        if err:
            return self.warnPopup('Checking Lib/idlelib/NEWS.txt, error:\n' + 
                                                                         err)
        if not compareIdleVsPython(vers, self.newRelease):
            return self.warnPopup('Lib/idlelib/NEWS.txt has wrong version')
        self.checkMsiFile()

    def checkMsiFile(self):
        checkout = cfg.CheckoutPaths[self.currentBranch]
        msiDir = os.path.join(checkout, 'Tools/msi/')
        version = checkMsiFileForVersion(msiDir, self.newRelease)
        if version is None:
            return self.warnPopup('Tools/msi/msi.py is missing product_code', fatal=False)
        self.checkReleaseDone()

    def checkReleaseDone(self):
        buildtest = "/tmp/welease_testversion%s"%(os.getpid())
        os.unlink(buildtest)
        os.unlink('%s.c'%buildtest)
        self.xml.get_widget('checkReleaseLabel').set_text('Ok!')
        self.xml.get_widget('makeTagButton').set_sensitive(True)



    def on_makeTagButton_clicked(self, e):
        tagName = 'r' + self.newRelease.replace('.', '')
        self.tagPath = '%s/tags/%s'%(cfg.SVNROOT, tagName)
        d = self.do_command(['svn', 'info', self.tagPath], capture=True)
        d.addCallback(self.makeTagCheckSvnExists)

    def makeTagCheckSvnExists(self, (stdout, stderr)):
        if 'Node Kind' in stdout:
            d = self.warnPopup('Tag %s is already in SVN!'%(self.tagPath) +
                               'svn rm first to recreate it',
                               fatal=False)
            d.addCallback(lambda x: x==True and self.makeTagDone(0))
            return

        svnroot = cfg.SvnPaths[self.currentBranch]
        tagName = 'r' + self.newRelease.replace('.', '')

        d = self.do_command(['svn', 'cp',
                    '-m', 'Tagging for release of Python %s'%(self.newRelease),
                    svnroot, self.tagPath])
        d.addCallback(self.makeTagDone)

    def makeTagDone(self, exitval):
        if exitval == 0:
            self.xml.get_widget('makeTagLabel').set_text('Ok!')
            self.xml.get_widget('exportSvnButton').set_sensitive(True)
        else:
            self.warnPopup("svn cp exited with code %d"%(exitval))

    def on_exportSvnButton_clicked(self, e):
        self.exportPath = cfg.BuildHome + '/Python-%s'%(self.newRelease)
        if os.path.exists(self.exportPath):
            d = self.warnPopup('%s already exists'%self.exportPath, fatal=False)
            d.addCallback(lambda x: x==True and self.exportSvnDone(0))
            return
        d = self.do_command(['svn', 'export', self.tagPath, self.exportPath])
        d.addCallback(self.touchAsdlFiles)

    def touchAsdlFiles(self, exitval):
        # These files need to be 'touched' to avoid make trying to rebuild
        # them. You can't force a no-change checkin in svn to update timestamps :-(
        # (I checked with gstein about this)
        if exitval != 0:
            self.warnPopup("svn export exited with code %d"%(exitval))
        else:
            touchedFiles = [ 'Include/Python-ast.h', 'Python/Python-ast.c' ]
            touchedFiles = [ os.path.join(self.exportPath, x) for x in touchedFiles ]
            d = self.do_command(['touch'] + touchedFiles)
            d.addCallback(self.exportSvnDone)

    def exportSvnDone(self, exitval):
        if exitval == 0:
            self.xml.get_widget('exportSvnLabel').set_text('Ok!')
            self.xml.get_widget('buildTarballsButton').set_sensitive(True)
        else:
            self.warnPopup("touch ASDL files exited with code %d"%(exitval))

    def on_buildTarballsButton_clicked(self, e):
        os.chdir(cfg.BuildHome)
        rdir = 'Python-%s'%self.newRelease
        d = self.do_command(["tar", "--gzip", "-cf", "%s.tgz"%rdir, rdir])
        d.addCallback(lambda x: x == 0 and self.do_command(
                ["tar", "--bzip2", "-cf", "%s.tar.bz2"%rdir, rdir]))
        d.addCallback(self.buildTarballsDone)


    def buildTarballsDone(self, exitcode):
        self.xml.get_widget('buildTarballsLabel').set_text('Ok!')

    def __getattr__(self, name):
        if not name.startswith('_'):
            print "WeleaseMainWindow has no attribute %s"%(name)
        raise AttributeError(name)

class TextViewer:
    MAXLINES = 5000
    DELETECHUNK = 100

    def __init__(self, widget):
        self.buffer = widget.get_buffer()
        self.widget = widget
        self.scroll = None
        self.textTag = gtk.TextTag('default')
        self.textTag.set_property('family', 'Monospace')
        #self.textTag.set_property('background', 'black')
        #self.textTag.set_property('foreground', 'white')
        self.buffer.get_tag_table().add(self.textTag)

    def set_visible(self, scroll):
        self.widget.place_cursor_onscreen()
        #self.scroll = scroll
        #if scroll:
            #adj = self.scroll.get_vadjustment()
            #adj.set_value(adj.upper)

    def write(self, text):
        b = self.buffer
        b.insert_with_tags(b.get_end_iter(), text, self.textTag)
        lines = b.get_line_count()
        if lines > self.MAXLINES:
            b.delete(b.get_start_iter(),
                     b.get_iter_at_line_offset(self.DELETECHUNK,0))
        mark = b.create_mark("end", b.get_end_iter(), False)
        self.widget.scroll_to_mark(mark, 0.05, True, 0.0, 1.0)

    def flush(self):
        pass

class ProcessOutput(protocol.ProcessProtocol):
    def __init__(self, output):
        self.output = output
        self.defer = defer.Deferred()

    def connectionMade(self):
        self.output.write("[process spawned]\n")

    def outReceived(self, data):
        self.output.write(data)

    def errReceived(self, data):
        self.output.write(data)

    def processEnded(self, status_object):
        exitval = status_object.value.exitCode
        self.output.write("[process ended with exitcode %s]\n"%(exitval))
        self.triggerDeferred(exitval)

    def triggerDeferred(self, exitval):
        self.defer.callback(exitval)
        self.defer = None

class ProcessCapture(ProcessOutput):

    def __init__(self, output):
        ProcessOutput.__init__(self, output)
        self.stdout = ""
        self.stderr = ""

    def outReceived(self, data):
        self.stdout += data
        ProcessOutput.outReceived(self, data)

    def errReceived(self, data):
        self.stderr += data
        ProcessOutput.outReceived(self, data)

    def triggerDeferred(self, exitval):
        self.defer.callback((self.stdout, self.stderr))
        self.defer = None

def unimportModules(*modules):
    for mod in modules:
        if mod in sys.modules:
            del sys.modules[mod]

def compareIdleVsPython(idlever, pyver):
        pyserial = idleserial = ''
        # check for alpha/beta/rc
        if pyver[-2] in 'abc':
            pyver, pyserial = pyver[:-2], pyver[-2:]
        if idlever[-2] in 'abc':
            idlever, idleserial = idlever[:-2], idlever[-2:]
        pyver = pyver.split('.') + [0,]
        idlever = idlever.split('.') + [0,]
        if (int(pyver[0]) - 1  != int(idlever[0]) or 
            int(pyver[1]) - 3  != int(idlever[1]) or 
            pyver[2] != idlever[2] or
            idleserial != pyserial):
            return False
        else:
            return True

def parseReleaseLine(line):
    # ick
    vline = re.compile(r"^what's new in (python|idle) (?P<major>\d\.\d(\.\d)?)( ?\(?(?P<level>alpha|beta|release candidate|final|rc|[abc]) ?(?P<serial>\d))?\)?\??", re.I)
    levels = {'alpha':'a', 'beta':'b', 'rc':'c', 'release candidate':'c'}
    m = vline.search(line)
    if m:
        major,level,serial=m.group('major'), m.group('level'), m.group('serial')        
        if level and len(level) != 1:
            level = levels.get(level)
        if not serial and not level:
            # final
            return major
        else:
            return '%s%s%s'%(major, level, serial)


def parseNewsFile(filename):
    vers = releasedate = err = None
    for line in open(filename):
        line = line.strip()
        if line.lower().startswith("what's new in "):
            if vers and not releasedate:
                err = "no recognisable release date line"
                break
            vers = parseReleaseLine(line)
        if line.lower().startswith("*release date:"):
            if vers is None:
                err = "no 'whats new' line before release date"
            date = line.split()[-1].rstrip('*')
            try:
                date = time.strptime(date, '%d-%b-%Y')
                releasedate = time.strftime('%d-%b-%Y', date)
            except ValueError:
                err = "failed to parse %s"%date
                break
        if vers and releasedate:
            break
    return vers, releasedate, err


# There has _got_ to be a better way to handle this!
# Best would be if msi.py was at least importable on non-Windows.
# Or I can parse the file by hand - blech.
def extractDictFromAST(ast, dictname):
    products = None
    for item in ast.getChildren():
        if isinstance(item, compiler.ast.Assign):
            kids = item.getChildren()
            if ( isinstance(kids[0], compiler.ast.AssName) and 
                 isinstance(kids[1], compiler.ast.Dict) and 
                 kids[0].getChildren()[0] == dictname):
                products = kids[1].getChildren()
    if not products:
        print "XXX FIXME"
        return None
    products = (x.getChildren()[0] for x in products)
    dd = {}
    while True:
        try:
            k, v = products.next(), products.next()
            dd[k] = v
        except StopIteration:
            break
    return dd

def checkMsiFileForVersion(dirname, version):
    # we don't do windows releases anymore with this tool
    return 0
    if os.path.exists(os.path.join(dirname, 'uuids.py')):
        sys.path.insert(0, dirname)
        from uuids import product_codes
        unimportModules('uuids')
    else:
        filename = os.path.join(dirname, 'msi.py')
        ast = compiler.parseFile(filename).getChildren()[1]
        product_codes = extractDictFromAST(ast, 'product_codes')
    #levels = extractDictFromAST(ast, 'levels')
    levels = { 'a': '10', 'b': '11', 'c': '12', }
    r = re.compile(r'(?P<major>[\d\.]+)(?P<serial>[abc]\d)?')
    maj, serial = r.match(version).groups()
    if len(maj) == 3:
        maj = maj + '.'
    if not serial:
        code = maj + '150' # final, serial 0
    else:
        code = maj + levels[serial[0]] + serial[1:]
    #print code, product_codes
    return product_codes.get(code)

def main():
    global cfg
    cfgFileName = os.path.expanduser('~/.weleaserc')

    win = WeleaseWindow()

    if not os.path.exists(cfgFileName):
        open(cfgFileName, 'w').write(DefaultConfig)
        win.warnPopup('Default config file written to %s. You will need to '
                      'edit this, then restart this program'%cfgFileName)
    # Eurgh.
    cfg = imp.load_module('cfg', open(cfgFileName), cfgFileName, ('.py','U',1))
    # End eurgh.

    curpath = os.getcwd()
    reactor.run()
    os.chdir(curpath)




# Yeah, this is ugly code. Just slapped something together quickly.
TestVersionCode = r"""
#include <Include/patchlevel.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    char level, *release=malloc(20), *rel2=malloc(4);

    int release_level = PY_RELEASE_LEVEL;

    switch (release_level) {
        case PY_RELEASE_LEVEL_ALPHA:
                    level = 'a';
                    break;
        case PY_RELEASE_LEVEL_BETA:
                    level = 'b';
                    break;
        case PY_RELEASE_LEVEL_GAMMA:
                    level = 'c';
                    break;
        default:
                    level = '?';
                    break;
    }

    if (release_level == PY_RELEASE_LEVEL_FINAL && PY_RELEASE_SERIAL != 0) {
        printf("error: FINAL releases should not have a serial!\n");
        exit(1);
    }
    if (release_level != PY_RELEASE_LEVEL_FINAL) {
        sprintf(rel2, "%c%d", level, PY_RELEASE_SERIAL);
    }
    if (PY_MICRO_VERSION) {
        sprintf(release, "%d.%d.%d%s", PY_MAJOR_VERSION, PY_MINOR_VERSION, PY_MICRO_VERSION, rel2);
    } else {
        sprintf(release, "%d.%d%s", PY_MAJOR_VERSION, PY_MINOR_VERSION, rel2);
    }

    printf("%s %s\n", PY_VERSION, release);
    exit(0);
}
"""

# Default config file.
DefaultConfig = """
from os.path import expanduser

# CheckoutPaths is where the local checkouts for the trunk and branches live
# You almost certainly will want to change these.

CheckoutHome = expanduser('~/projects/python')
CheckoutPaths = {
    'trunk': CheckoutHome + '/pytrunk/python',
    'release24-maint': CheckoutHome + '/release24-maint',
    'release25-maint': CheckoutHome + '/release25-maint',
}

# You probably don't need to change these.
SVNROOT = 'svn+ssh://pythondev@svn.python.org/python'
SvnPaths = {
    'trunk': SVNROOT + '/trunk',
    'release24-maint': SVNROOT + '/branches/release24-maint',
    'release25-maint': SVNROOT + '/branches/release25-maint',
}

# BuildHome is where the builds will happen. It can be an empty directory.
BuildHome = expanduser('~/projects/python/build')
"""
# End config

if __name__ == "__main__":
    # issue 7980
    time.strptime('','')
    main()
