#!/usr/bin/env python

# Twisted, the Framework of Your Internet
# Copyright (C) 2001 Matthew W. Lefkowitz
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of version 2.1 of the GNU Lesser General Public
# License as published by the Free Software Foundation.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

from __future__ import nested_scopes

### Twisted Preamble
# This makes sure that users don't have to set up their environment
# specially in order to run these programs from bin/.
import sys, os, string, time, glob
if string.find(os.path.abspath(sys.argv[0]), os.sep+'Twisted') != -1:
    sys.path.insert(0, os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]), os.pardir, os.pardir)))
sys.path.insert(0, os.curdir)
### end of preamble

from twisted.python import usage, util, failure

import shutil, re

defaultSVNBase = 'svn+ssh://svn.twistedmatrix.com/svn/Twisted'

debug = False

class Options(usage.Options):
    optParameters = [
        ['base-version', None, None,
         "(required) The BASE of the version of this release"],
        ['type', None, None,
         "(required) The type of this release (alpha, rc, or final)"],
        ['tinyver', None, None,
         "(optional) The alpha or rc revision."],
        ['lastdeb', None, None,
         "(required) The version of the previous Debian package."],
        ['commands', None, None,
         "(optional) The individual steps to run; You should only use "
         "this if something broke"],
        ['qotrfile', None, None,
         "(optional) The filename containing the QOTR to use."],
##        ['mergefiles', None, None,
##         "(optional) The filenames (relative to the root of the repo) to "
##         "merge from trunk for this release"],
        ['svnurl', None, defaultSVNBase,
         "The BASE of the SVN root. trunk, branches/foo, etc, will "
         "automatically be added"],
        ['releasedir', None, '/twisted/Releases',
         "The directory to put tarballs."],
        ['docdir', None, '/twisted/TwistedDocs',
         "The directory to deploy documentation to."],
        ['webdir', None, os.path.expanduser('~/Projects/WebSite/twistedmatrix.com'),
         "The directory where your working copy of the tm.com website is."],
        ['sfname', None, None,
         "Your username on sourceforge."],
        ]

    optFlags = [
        ['notpermanent', None,
         "Don't do anything 'permanent': finalize tags, upload tarballs, etc."]]

    def opt_debug(self):
        """
        Turn on debug mode (Ask before running commands).
        """
        global debug
        debug = True

    def postOptions(self):
        ## assertions ##
        for reqkey in ['base-version', 'type', 'lastdeb']:
            if self[reqkey] is None:
                raise usage.UsageError("--%s is required!" % reqkey)

        typemustbe = ('alpha', 'rc', 'final')
        if self['type'] not in typemustbe:
            raise usage.UsageError("--type must be one of %r."
                                   % (typemustbe,))

        if self['type'] in ('alpha', 'rc'):
            if self['tinyver'] is None:
                raise usage.UsageError("When doing an alpha or RC, "
                                       "--tinyver is required.")
##        if self['type'] == 'alpha':
##            if self['mergefiles'] is not None:
##                raise usage.UsageError("--mergefiles is only usable "
##                                       "for rc and final.")

        ## set up some convenience ##

        self['full-version'] = (self['base-version']
                           + self['type']
                           + self['tinyver'])

        self['trunkurl'] = self['svnurl'] + '/trunk'
        self['wipurl'] = '%s/branches/wip-%s' % (self['svnurl'],
                                                 self['base-version'])
        self['tagurl'] = '%s/tags/release-%s' % (self['svnurl'],
                                                 self['full-version'])
        self['temptagurl'] = self['tagurl'] + '-TEMP'

        for x in ['trunkurl', 'wipurl', 'tagurl', 'temptagurl']:
            print "%s: %s" % (x, self[x])


        commands = []
        if self['commands'] is not None:
            commands = self['commands'].split(',')
            # heh heh
            commands = [globals()[x] for x in commands]

        else:
            ## infer commands to run ##

            # This code expresses the high-level workflow of the
            # release procedure.

            if self['type'] == 'alpha':
                commands += [CopyTrunkToWIP]

            commands += [CheckoutWIP,
                         UpdateVersion,
                         ]

            ## if (self['type'] in ('rc', 'final')
            ##     and self['mergefiles'] is not None):
            ##     commands += [MergeFilesToWIP]

            commands += [TagTemp,
                         ExportTemp,
                         MakeDocs,
                         MakeBalls,
                         MakeDebs]

            if not self['notpermanent']:
                commands += [
                    FinalizeTag,
                    ReleaseBalls,
                    ReleaseDebs,
                    ReleaseSourceforge,
                    ]

                if self['type'] == 'final':
                    commands += [UpdateWebDocs]

                #yeah yeah, maybe next year
##                commands += [Announce,
##                             NotifyPackagers,
##                             ]

        self['commands'] = commands

        print "GOING TO DO", [x.__name__ for x in commands]


def runChdirSafe(f, *args, **kw):
    origdir = os.path.abspath('.')
    try:
        f(*args, **kw)
    finally:
        os.chdir(origdir)

class Transaction:
    """I am a dead-simple Transaction."""

    sensitiveUndo = 0

    def run(self, data):
        """
        Try to run this self.doIt; if it fails, call self.undoIt and
        return a Failure.
        """
        print "*", self.__class__.__name__

        try:
            runChdirSafe(self.doIt, data)
        except:
            f = failure.Failure()
            print "%s failed!" % self.__class__.__name__
            if self.sensitiveUndo:
                if raw_input("Are you sure you want to roll back "
                             "this transaction? ").lower().startswith('n'):
                    return f
            print "rolling back transaction."
            try:
                runChdirSafe(self.undoIt, data, f)
            except:
                print "Argh, the rollback failed."
                import traceback
                traceback.print_exc()
            return f

    def doIt(self, data):
        """Le's get it on!"""
        raise NotImplementedError

    def undoIt(self, data, fail):
        """Oops."""
        print "%s HAS NO ROLLBACK!" % self.__class__.__name__


#errors

class DirectoryExists(OSError):
    """Some directory exists when it shouldn't."""
    pass

class DirectoryDoesntExist(OSError):
    """Some directory doesn't exist when it should."""
    pass

class CommandFailed(OSError):
    pass


def main():
    
    try:
        opts = Options()
        opts.parseOptions()
    except usage.UsageError, ue:
        print "%s: %s (see --help)" % (sys.argv[0], ue)
        sys.exit(2)
    #sys.exit("BYE")

##    sys.path.insert(0, os.path.abspath('Twisted'))

##    if not os.path.exists('_twisted_release'):
##        os.mkdir('_twisted_release')
##    os.chdir('_twisted_release')

    last = None

    for command in opts['commands']:
        try:
            f = command().run(opts)
            if f is not None:
                raise f
        except:
            print ("ERROR: %s failed. last successful command was %s. "
                   "Traceback follows:" % (command.__name__, last))
            import traceback
            traceback.print_exc()
            break

        last = command


# utilities


def sh(command):#, sensitive=0):
    """
    I'll try to execute `command', and if `sensitive' is true, I'll
    ask before running it.  If the command returns something other
    than 0, I'll raise CommandFailed(command).
    """
    if debug:# or sensitive:
        if raw_input("%r ?? " % command).startswith('n'):
            return
    print command
    if os.system(command) != 0:
        raise CommandFailed(command)

        
def replaceInFile(filename, oldstr, newstr, escape=True):
    """
    I replace the text `oldstr' with `newstr' in `filename' using sed
    and mv.
    """
    sh('cp %s %s.bak' % (filename, filename))
    if escape:
        oldstr = re.escape(oldstr)
    sh("sed -e 's/%s/%s/' < %s > %s.new" % (oldstr,
                                            newstr, filename, filename))
    sh('cp %s.new %s' % (filename,  filename))


##
# The transactions.
##

class CopyTrunkToWIP(Transaction):
    def doIt(self, opts):
        try:
            sh('svn ls %s' % opts['wipurl'])
        except CommandFailed:
            pass
        else:
            sh('svn rm -m "Removing wip-%s in order to re-copy from trunk" %s' % (opts['base-version'], opts['wipurl']))

        sh('svn cp -m "Copying trunk to wip-%s branch." %s %s' % (opts['base-version'], opts['trunkurl'], opts['wipurl']))

    def undoIt(self, opts, fail):
        print "Sorry! Can't do anything about a failed cp."

checkoutdir = 'Twisted.WIP'

class CheckoutWIP(Transaction):
    def doIt(self, opts):

        if os.path.exists(checkoutdir):
            raise DirectoryExists("CheckoutWIP: %s (--root) already exists" % checkoutdir)

        sh('svn co %s %s' % (opts['wipurl'], checkoutdir))

    def undoIt(self, opts, fail):
        # we don't want to remove the directory if we didn't create it
        if fail.check(DirectoryExists):
            return 
        
        if os.path.exists(checkoutdir):
            sh('rm -rf %s' % (checkoutdir,))

    
class UpdateVersion(Transaction):
    #  * the SVN tree is modified. to back out we must copy all the
    #    .bak files created by replaceInFile back to their original
    #    location, and remove them.
    #  * the tree is committed. this is atomic, afaict.
    #    [this isn't, but pretend it is anyway -- Moshe]
    #    [maybe it is now that we use SVN? --radix]

    files = None
    
    def doIt(self, opts):
        oldver = 'SVN-trunk'
        newver = opts['full-version']

        r = checkoutdir
        self.files = ('README', 'twisted/copyright.py', 'admin/twisted.spec')
        for file in self.files:
            replaceInFile(os.path.join(r, file), oldver, newver)
            crapout = os.popen('cd %s && svn diff %s' % (r, file)).read()
            print "diff output:"
            print crapout
            if not crapout:
                raise Exception("No diff output!")
        sh('cd %s &&  svn commit -m "Setting version for %s" %s'
           % (r, newver, ' '.join(self.files)))#, sensitive=1)

    def undoIt(self, opts, fail):
        if self.files:
            for file in self.files:
                try:
                    sh('mv %s.bak %s' % (file, file))
                except:
                    print "WARNING: couldn't move %s.bak back to %s, chugging along" % (file, file)


##class MergeFilesToWIP(Transaction):
##    def doIt(self, opts):
##        pass


class TagTemp(Transaction):
    def doIt(self, opts):
        sh('cd %s &&  svn cp %s %s -m "Tagging preliminary %s"'
           % (checkoutdir, opts['wipurl'],
              opts['temptagurl'], opts['full-version']),
           )# sensitive=1)


class ExportTemp(Transaction):
    def doIt(self, opts):
        if os.path.exists('Twisted.exp'):
            raise DirectoryExists("ExportTemp: 'Twisted' already exists")
        sh('svn export %s Twisted.exp' % (opts['temptagurl'],))

    def undoIt(self, opts, fail):
        # we don't want to remove the directory if we didn't create it
        if fail.check(DirectoryExists):
            return
        sh('rm -rf Twisted.exp')

##class PrepareDist(Transaction):
##    def doIt(self, opts):
##        ver = opts['full-version']
##        tdir = "Twisted-%s" % ver

##        if os.path.exists(tdir):
##            raise DirectoryExists("PrepareDist: %s exists already." % tdir)

##        shutil.copytree('Twisted', tdir)

##    def undoIt(self, opts, fail):
##        #don't delete the directory if we didn't create it!
##        if not fail.check(DirectoryExists):
##            ver = opts['full-version']
##            tdir = "Twisted-%s" % ver

##            sh('rm -rf %s' % tdir)


class MakeDocs(Transaction):

    # documentation generation can take a looong time, so we don't
    # want to force redoing everything
    #sensitiveUndo = 1
    
    def doIt(self, opts):
        ver = opts['full-version']
        tdir = 'Twisted.exp'
        
        if not os.path.exists(tdir):
            raise DirectoryDoesntExist("GenerateDocs: %s doesn't exist!" % tdir)
        
        sh('cd %s &&  ./admin/epyrun -o doc/api' % (tdir))

        sh('cd %s && ./admin/process-docs %s' % (tdir, ver))

        #shwack the crap
        for ext in ['*.pyc', '.cvsignore']:
            sh('find %s -name "%s" | xargs rm -f' % (tdir, ext))


    def undoIt(self, opts, fail):

        if fail.check(DirectoryDoesntExist):
            #no state change here
            return

        tdir = "Twisted.exp"

        #first shwack the epydocs
        sh('rm -rf %s/doc/api/*' % tdir)

        #then swhack the results of generate-domdocs
        # This isn't really necessary, so screw it.
##        sh('rm -f %s/doc/howto/*.html' % tdir)
##        sh('rm -f %s/doc/specifications/*.html' % tdir)

        #then swhack the results of the latex stuff
        sh('cd %s/doc/howto && rm -f *.eps *.tex *.aux *.log book.*' % tdir)

class MakeBalls(Transaction):

    def doIt(self, opts):
        ver = opts['full-version']
        tdir = "Twisted-%s" % (ver,)

        if not os.path.exists('Twisted.exp'):
            raise DirectoryDoesntExist("MakeBalls: Twisted.exp doesn't exist"
                                       % tdir)

        if os.path.exists(tdir):
            raise DirectoryExists('MakeBalls: %s exists' % tdir)

        shutil.copytree('Twisted.exp', tdir)

        print "MakeBalls: Twisted_NoDocs."
        sh('''
        tar --exclude %(tdir)s/doc -cf - %(tdir)s \
            |gzip -9 > Twisted_NoDocs-%(ver)s.tar.gz && 
        tar --exclude %(tdir)s/doc -cjf Twisted_NoDocs-%(ver)s.tar.bz2 %(tdir)s
        ''' % locals())

        print "MakeBalls: Twisted"
        sh('''
        tar cf - %(tdir)s | gzip -9 > %(tdir)s.tar.gz&&
        tar cjf   %(tdir)s.tar.bz2 %(tdir)s
        ''' % locals())

        print "MakeBalls: TwistedDocs"
        docdir = "TwistedDocs-%s" % ver
        sh('''
        cd %(tdir)s && 
        mv doc %(docdir)s

        tar cf -  %(docdir)s | gzip -9 > %(docdir)s.tar.gz&& 
        mv %(docdir)s.tar.gz ../ && 

        tar cjf   %(docdir)s.tar.bz2 %(docdir)s && 
        mv %(docdir)s.tar.bz2 ../
        ''' % locals())


##    def undoIt(self, opts, fail):
##        ver = opts['full-version']
##        for ext in ['tar.gz', 'tar.bz2']:
##            for prefix in ['Twisted_NoDocs', 'TwistedDocs', 'Twisted']:
##                try:
##                    sh('rm -f %s-%s.%s' % (prefix, ver, ext))
##                except:
##                    pass

class MakeDebs(Transaction):

    #takes a while
    #sensitiveUndo = 1
    
    def doIt(self, opts):
        #rel = os.path.abspath('.')
        ver = opts['full-version']
        tgz = os.path.abspath('Twisted-%s.tar.gz' % (ver,))
        unique = 't-r-%s.%s' % (time.time(), os.getpid())
        debdir = 'debian-%s' % ver
        lastdeb = opts['lastdeb']

        os.mkdir('/sid-chroot/tmp/%s' % unique)
        sh('cd /sid-chroot/tmp/%s && tar xzf %s' % (unique, tgz))
        sh("ssh -p 9022 localhost "
             "'cd /tmp/%(unique)s && "
             "./Twisted-%(ver)s/admin/make-deb -a -o %(lastdeb)s'"%vars())

        if not os.path.isdir(debdir):
            os.mkdir(debdir)

        sys.stdout.write("Moving files to %s" % debdir)
        sys.stdout.flush()

        for file in glob.glob('/sid-chroot/tmp/%s/*' % unique):
            if not os.path.isfile(file):
                continue
            sh('cp %(file)s %(debdir)s' % vars())
            sys.stdout.write(".")
            sys.stdout.flush()
        sys.stdout.write("\n")

        os.chdir(debdir)
        sh('tar xzf %(tgz)s Twisted-%(ver)s/admin' % vars())
        sh('mv Twisted-%(ver)s/admin/override .' % vars())
        sh('./Twisted-%(ver)s/admin/createpackages override'% vars())
        sh('rm -rf woody')

        os.mkdir('woody')
        sh('cp *.orig.tar.gz *.diff.gz *.dsc woody/')
        os.chdir('woody')
        sh('dpkg-source -x *.dsc')
        twisted_dir = filter(os.path.isdir, glob.glob('twisted-*'))[0]
        os.chdir(twisted_dir)
        replaceInFile('debian/changelog', ver+'-1', ver+'-1woody')
        replaceInFile('debian/control', ', python2.3-dev', '')
        sh('rm -f debian/*.bak')
        sh('dpkg-buildpackage -rfakeroot -us -uc')
        os.chdir('..')
        sh('rm -rf %(twisted_dir)s' % vars())
        sh('../Twisted-%(ver)s/admin/createpackages ../override'% vars())

        sh('rm -rf ../Twisted-%(ver)s' % vars())
        sh('rm -rf /sid-chroot/tmp/%s' % unique)

##    def undoIt(self, opts, fail):
##        ver = opts['full-version']
##        sh("rm -rf debian-%(ver)s" % vars())


class FinalizeTag(Transaction):
    def doIt(self, opts):
        # atomic-assumption
        sh('svn mv -m "Finalizing release of %s" %s %s' % (opts['full-version'], opts['temptagurl'], opts['tagurl']))


class ReleaseDebs(Transaction):

    target = '/twisted/Debian'

    def doIt(self, opts):
        ver = opts['full-version']
        debdir = 'debian-%s' % ver
        target = self.target

        for file in ('Packages.gz', 'Sources.gz', 'override'):
            if os.path.isfile('%(target)s/%(file)s' % vars()):
                sh('rm -f %(target)s/%(file)s.old' % vars())
                sh('mv %(target)s/%(file)s %(target)s/%(file)s.old' % vars())
            if os.path.isfile('%(target)s/woody/%(file)s' % vars()):
                sh('rm -f %(target)s/woody/%(file)s.old' % vars())
                sh('mv %(target)s/woody/%(file)s %(target)s/woody/%(file)s.old' % vars())

        for file in os.listdir(debdir):
            if file == 'woody':
                continue
            sh("cp %(debdir)s/%(file)s %(target)s/" % vars())

        sh("cp %(debdir)s/woody/* %(target)s/woody/" % vars())

    def undoIt(self, opts, fail):
        ver = opts['full-version']
        target = self.target
        for dir in (target, target+'/woody'):
            for file in glob.glob('%(dir)s/*.old' % vars()):
                new = os.path.splitext(file)[0]
                sh('mv %(file)s %(new)s' % vars())
                sh('mv %(file)s %(new)s' % vars())
            sh('rm -f %(dir)s/*%(ver)s*' % vars())




class ReleaseBalls(Transaction):
    def doIt(self, opts):
        rel = opts['releasedir']
        ver = opts['full-version']
        tdir = "Twisted-%s" % ver

        if not os.path.exists(rel):
            print "Distribute: Creating", rel
            os.mkdir(rel)

        if not os.path.exists("%s/old" % rel):
            print "Distribute: creating %s/old" % rel
            os.mkdir("%s/old" % rel)

        sh('mv %(rel)s/*.tar.gz %(rel)s/*.tar.bz2 %(rel)s/old || true' % locals())

        sh('''
        cp Twisted_NoDocs-%(ver)s.tar.gz  %(rel)s &&
        cp Twisted_NoDocs-%(ver)s.tar.bz2 %(rel)s
        ''' % locals())


        sh('''
        cp TwistedDocs-%(ver)s.tar.gz  %(rel)s &&
        cp TwistedDocs-%(ver)s.tar.bz2 %(rel)s
        ''' % locals())

        sh('''
        cp %(tdir)s.tar.gz %(rel)s &&
        cp %(tdir)s.tar.bz2 %(rel)s
        ''' % locals())

    def undoIt(self, opts, fail):
        ver = opts['full-version']
        rel = opts['releasedir']

        for ext in ['zip', 'tar.gz', 'tar.bz2']:
            for prefix in ['Twisted_NoDocs', 'TwistedDocs', 'Twisted']:
                try:
                    sh('rm -f %s/%s-%s.%s' % (rel, prefix, ver, ext))
                except:
                    pass

class ReleaseSourceforge(Transaction):

    #takes a long time
    #sensitiveUndo = 1
    
    def doIt(self, opts):
        name = opts['sfname']
        rel = opts['releasedir']
        ver = opts['full-version']
        path = '/home/users/'+name[0]+'/'+name[:2]+'/'+name 
        sh("ssh %(name)s@shell.sf.net mkdir Twisted-%(ver)s || true" % vars())
        sh("scp -r %(rel)s/Twisted*%(ver)s* %(rel)s/debian-%(ver)s "
           "%(name)s@shell.sf.net:%(path)s/Twisted-%(ver)s/" % vars())
        sh("echo "
           "'"
           "umask 0002&&"
           "rm -f /home/groups/t/tw/twisted/htdocs/debian/woody/Packages.gz&&"
           "rm -f /home/groups/t/tw/twisted/htdocs/debian/woody/Sources.gz&&"
           "rm -f /home/groups/t/tw/twisted/htdocs/debian/woody/override&&"
           "rm -f /home/groups/t/tw/twisted/htdocs/debian/Packages.gz&&"
           "rm -f /home/groups/t/tw/twisted/htdocs/debian/Sources.gz&&"
           "rm -f /home/groups/t/tw/twisted/htdocs/debian/override&&"
           "mv Twisted-%(ver)s/debian-%(ver)s/woody/* "
              "/home/groups/t/tw/twisted/htdocs/debian/woody/&&"
           "rmdir Twisted-%(ver)s/debian-%(ver)s/woody&&"
           "mv Twisted-%(ver)s/debian-%(ver)s/* "
              "/home/groups/t/tw/twisted/htdocs/debian/&&"
           "rmdir Twisted-%(ver)s/debian-%(ver)s&&"
           "mv Twisted-%(ver)s/* /home/groups/t/tw/twisted/htdocs&&"
           "cd /home/groups/t/tw/twisted/htdocs&&"
           "tar xzf TwistedDocs-%(ver)s.tar.gz'"
           "|ssh %(name)s@shell.sf.net newgrp twisted" % vars())


class UpdateWebDocs(Transaction):
    origlinkdest = None
    def doIt(self, opts):
        os.chdir(opts['docdir'])

        tdocdir = os.path.join(opts['docdir'],
                               'TwistedDocs-%s' % opts['full-version'])

        tbz2 = '%s/TwistedDocs-%s.tar.bz2' % (opts['releasedir'],
                                              opts['full-version'])
        if os.path.exists(tdocdir):
            raise DirectoryExists('%s already exists!' % tdocdir)

        sh('tar xjf %s' % tbz2)

        # lore-ize all the docs

        os.chdir('%s/howto' % tdocdir)

        template = os.path.abspath('website-template.tpl')

        lorecmd = (("lore -p --config template=%s " % template)
                   + "--config baseurl="
                   "http://twistedmatrix.com/documents/current/api/%s.html")
        sh("%s --config ext= *.xhtml" % lorecmd)
        os.chdir('..')
        for dir in ['examples', 'fun', 'historic', 'legal',
                    'specifications', 'vision', 'man']:
            os.chdir(dir)
            sh('%s  --config ext= -l../howto/ *.xhtml')
            os.chdir('..')

        os.chdir('man')
        sh('lore -p --config ext=.xhtml -l../howto -iman -olore *.1')
        sh('lore -p --config template=$templ '
           '--config ext=-man -l ../howto *.xhtml')

        os.chdir('../..') # should be in docdir now

        # 'deploy' to web

        if os.path.exists('current'):
            if os.path.islink('current'):
                self.origlinkdest = os.path.realpath('current')
                os.unlink('current')
            else:
                raise Exception("'current' exists and is NOT a symlink. Won't remove.")

        os.symlink(tdocdir, 'current')
        # who cares where we're chdir'd to; Transaction'll set us back.

    def undoIt(self, opts, fail):
        if fail.check(DirectoryExists):
            return
        os.chdir(opts['docdir'])

        if self.origlinkdest is not None:
            if not os.path.exists('current'):
                os.symlink(self.origlinkdest, 'current')
            # I can't imagine 'else' of this ever having a useful back-out

        sh('rm -rf TwistedDocs-%s' % opts['full-version'])


class Announce(Transaction):
    pass


class NotifyPackagers(Transaction):
    pass


if __name__=='__main__':
    main()

