cloudmatrix/esky

View on GitHub
esky/bdist_esky/__init__.py

Summary

Maintainability
F
1 wk
Test Coverage
#  Copyright (c) 2009-2010, Cloud Matrix Pty. Ltd.
#  All rights reserved; available under the terms of the BSD License.
"""

  esky.bdist_esky:  distutils command to freeze apps in esky format

Importing this module makes "bdist_esky" available as a distutils command.
This command will freeze the given scripts and package them into a zipfile
named with the application name, version and platform.

The main interface is the 'Esky' class, which represents a frozen app.  An Esky
must be given the path to the top-level directory of the frozen app, and a
'VersionFinder' object that it will use to search for updates.
"""

from __future__ import with_statement


import os
import sys
import shutil
import tempfile
import hashlib
import inspect
import json
from glob import glob

import distutils.command
from distutils.core import Command
from distutils.util import convert_path

import esky.patch
from esky.util import get_platform, create_zipfile, \
                      split_app_version, join_app_version, ESKY_CONTROL_DIR, \
                      ESKY_APPDATA_DIR, really_rmtree

if sys.platform == "win32":
    from esky import winres
    from xml.dom import minidom

try:
    from esky.bdist_esky import pypyc
except ImportError, e:
    pypyc = None
    PYPYC_ERROR = e
    COMPILED_BOOTSTRAP_CACHE = None
else:
    COMPILED_BOOTSTRAP_CACHE = os.path.dirname(__file__)
    if not os.path.isdir(COMPILED_BOOTSTRAP_CACHE):
        COMPILED_BOOTSTRAP_CACHE = None



# setuptools likes to be imported before anything else that
#  might monkey-patch distutils.  We don't actually use it,
#  this is just to avoid errors with cx_Freeze.
try:
    import setuptools
except ImportError:
    pass


_FREEZERS = {}
try:
    from esky.bdist_esky import f_py2exe
    _FREEZERS["py2exe"] = f_py2exe
except ImportError:
    _FREEZERS["py2exe"] = None
try:
    from esky.bdist_esky import f_py2app
    _FREEZERS["py2app"] = f_py2app
except ImportError:
    _FREEZERS["py2app"] = None
try:
    from esky.bdist_esky import f_bbfreeze
    _FREEZERS["bbfreeze"] = f_bbfreeze
except ImportError:
    _FREEZERS["bbfreeze"] = None
try:
    from esky.bdist_esky import f_cxfreeze
    _FREEZERS["cxfreeze"] = f_cxfreeze
    _FREEZERS["cx_Freeze"] = f_cxfreeze
    _FREEZERS["cx_freeze"] = f_cxfreeze
except ImportError:
    _FREEZERS["cxfreeze"] = None
    _FREEZERS["cx_Freeze"] = None
    _FREEZERS["cx_freeze"] = None


class Executable(unicode):
    """Class to hold information about a specific executable.

    This class provides a uniform way to specify extra meta-data about
    a frozen executable.  By setting various keyword arguments, you can
    specify e.g. the icon, and whether it is a gui-only script.

    Some freezer modules require all items in the "scripts" argument to
    be strings naming real files.  This is therefore a subclass of unicode,
    and if it refers only to in-memory code then its string value will be
    the path to this very file.  I know it's ugly, but it works.
    """

    def __new__(cls,script,**kwds):
        if isinstance(script,basestring):
            return unicode.__new__(cls,script)
        else:
            return unicode.__new__(cls,__file__)

    def __init__(self,script,name=None,icon=None,gui_only=None,
                      include_in_bootstrap_env=True,**kwds):
        unicode.__init__(self)
        if isinstance(script,Executable):
            script = script.script
            if name is None:
                name = script.name
            if gui_only is None:
                gui_only = script.gui_only
        if not isinstance(script,basestring):
            if name is None:
                raise TypeError("Must specify name if script is not a file")
        self.script = script
        self.include_in_bootstrap_env = include_in_bootstrap_env
        self.icon = icon
        self._name = name
        self._gui_only = gui_only
        self._kwds = kwds

    @property
    def name(self):
        if self._name is not None:
            nm = self._name
        else:
            if not isinstance(self.script,basestring):
                raise TypeError("Must specify name if script is not a file")
            nm = os.path.basename(self.script)
            if nm.endswith(".py"):
                nm = nm[:-3]
            elif nm.endswith(".pyw"):
                nm = nm[:-4]
        if sys.platform == "win32" and not nm.endswith(".exe"):
            nm += ".exe"
        return nm

    @property
    def gui_only(self):
        if self._gui_only is None:
            if not isinstance(self.script,basestring):
                return False
            else:
                return self.script.endswith(".pyw")
        else:
            return self._gui_only



class bdist_esky(Command):
    """Create a frozen application in 'esky' format.

    This distutils command can be used to freeze an application in the
    format expected by esky.  It interprets the following standard 
    distutils options:

       scripts:  list of scripts to freeze as executables;
                 to make a gui-only script, name it 'script.pyw'

       data_files:  copied into the frozen app directory

       package_data:  copied into library.zip alongside the module code

    To further customize the behaviour of the bdist_esky command, you can
    specify the following custom options:

        includes:  a list of modules to explicitly include in the freeze

        excludes:  a list of modules to explicitly exclude from the freeze

        freezer_module:  name of freezer module to use; currently py2exe,
                         py2app,  bbfreeze and cx-freeze are supported.

        freezer_options: dict of options to pass through to the underlying
                         freezer module.

        bootstrap_module:  a custom module to use for esky bootstrapping;
                           the default calls esky.bootstrap.bootstrap()

        bootstrap_code:  a custom code string to use for esky bootstrapping;
                         this precludes the use of the bootstrap_module option.
                         If a non-string object is given, its source is taken
                         using inspect.getsource().

        compile_bootstrap_exes:  whether to compile the bootstrapping code to a
                                 stand-alone exe; this requires PyPy installed
                                 and the bootstrap code to be valid RPython.
                                 When false, the bootstrap env will use a
                                 trimmed-down copy of the freezer module exe.

        dont_run_startup_hooks:  don't force all executables to call
                                 esky.run_startup_hooks() on startup.

        bundle_msvcrt:  whether to bundle the MSVCRT DLLs, manifest files etc
                        as a private assembly.  The default is False; only
                        those with a valid license to redistriute these files
                        should enable it.



        pre_freeze_callback:  function to call just before starting to freeze
                              the application; this is a good opportunity to
                              customize the bdist_esky instance.

        pre_zip_callback:  function to call just before starting to zip up
                           the frozen application; this is a good opportunity
                           to e.g. sign the resulting executables.
    
    """

    description = "create a frozen app in 'esky' format"

    user_options = [
        ('dist-dir=', 'd',
         "directory to put final built distributions in"),
        ('freezer-module=', None,
         "module to use for freezing the application"),
        ('freezer-options=', None,
         "options to pass to the underlying freezer module"),
        ('bootstrap-module=', None,
         "module to use for bootstrapping the application"),
        ('bootstrap-code=', None,
         "code to use for bootstrapping the application"),
        ('compile-bootstrap-exes=', None,
         "whether to compile the bootstrapping exes with pypy"),
        ('bundle-msvcrt=', None,
         "whether to bundle MSVCRT as private assembly"),
        ('includes=', None,
         "list of modules to specifically include"),
        ('excludes=', None,
         "list of modules to specifically exclude"),
        ('dont-run-startup-hooks=', None,
         "don't force execution of esky.run_startup_hooks()"),
        ('pre-freeze-callback=', None,
         "function to call just before starting to freeze the app"),
        ('pre-zip-callback=', None,
         "function to call just before starting to zip up the app"),
        ('enable-appdata-dir=', None,
         "enable new 'appdata' directory layout (will go away after the 0.9.X series)"),
        ('detached-bootstrap-library=', None,
         "By default Esky appends the library.zip to the bootstrap executable when using CX_Freeze, this will tell esky to not do that, but create a separate library.zip instead"),
        ('compress=', 'c',
         "Compression options of the Esky, use lower case for compressed or upper case for uncompressed, currently only support zip files"),
    ]

    boolean_options = ["bundle-msvcrt","dont-run-startup-hooks","compile-bootstrap-exes","enable-appdata-dir"]

    def initialize_options(self):
        self.dist_dir = None
        self.includes = []
        self.excludes = []
        self.freezer_module = None
        self.freezer_options = {}
        self.bundle_msvcrt = False
        self.dont_run_startup_hooks = False
        self.bootstrap_module = None
        self.bootstrap_code = None
        self.compile_bootstrap_exes = False
        self._compiled_exes = {}
        self.pre_freeze_callback = None
        self.pre_zip_callback = None
        self.enable_appdata_dir = False
        self.detached_bootstrap_library = False
        self.compress = 'zip'

    def finalize_options(self):
        assert self.compress in (False, None, 'false', 'none', 'zip', 'ZIP'), 'Bad options passed to compress'

        self.set_undefined_options('bdist',('dist_dir', 'dist_dir'))
        if self.compile_bootstrap_exes and pypyc is None:
            raise PYPYC_ERROR
        if self.freezer_module is None:
            for freezer_module in ("py2exe","py2app","bbfreeze","cxfreeze"):
                self.freezer_module = _FREEZERS[freezer_module]
                if self.freezer_module is not None:
                    break
            else:
                err = "no supported freezer modules found"
                err += " (try installing bbfreeze)"
                raise RuntimeError(err)
        else:
            try:
                freezer = _FREEZERS[self.freezer_module]
            except KeyError:
                err = "freezer module not supported: '%s'"
                err = err % (self.freezer_module,)
                raise RuntimeError(err)
            else:
                if freezer is None:
                    err = "freezer module not found: '%s'"
                    err = err % (self.freezer_module,)
                    raise RuntimeError(err)
            self.freezer_module = freezer
        if isinstance(self.pre_freeze_callback,basestring):
            self.pre_freeze_callback = self._name2func(self.pre_freeze_callback)
        if isinstance(self.pre_zip_callback,basestring):
            self.pre_zip_callback = self._name2func(self.pre_zip_callback)

    def _name2func(self,name):
        """Convert a dotted name into a function reference."""
        if "." not in name:
            return globals()[name]
        modname,funcname = name.rsplit(".",1)
        mod = __import__(modname,fromlist=[funcname])
        return getattr(mod,funcname)

    def run(self):
        self.tempdir = tempfile.mkdtemp()
        try:
            self._run()
        finally:
            really_rmtree(self.tempdir)

    def _run(self):
        self._run_initialise_dirs()
        if self.pre_freeze_callback is not None:
            self.pre_freeze_callback(self)
        self._run_freeze_scripts()
        if self.pre_zip_callback is not None:
            self.pre_zip_callback(self)
        self._generate_filelist_manifest()
        self._run_create_zipfile()

    def _run_initialise_dirs(self):
        """Create the dirs into which to freeze the app."""
        fullname = self.distribution.get_fullname()
        platform = get_platform()
        self.bootstrap_dir = os.path.join(self.dist_dir,
                                          "%s.%s"%(fullname,platform,))
        if self.enable_appdata_dir:
            self.freeze_dir = os.path.join(self.bootstrap_dir,ESKY_APPDATA_DIR,
                                           "%s.%s"%(fullname,platform,))
        else:
            self.freeze_dir = os.path.join(self.bootstrap_dir,
                                           "%s.%s"%(fullname,platform,))
        if os.path.exists(self.bootstrap_dir):
            really_rmtree(self.bootstrap_dir)
        os.makedirs(self.freeze_dir)

    def _run_freeze_scripts(self):
        """Call the selected freezer module to freeze the scripts."""
        fullname = self.distribution.get_fullname()
        platform = get_platform()
        self.freezer_module.freeze(self)
        if platform != "win32":
            lockfile = os.path.join(self.freeze_dir,ESKY_CONTROL_DIR,"lockfile.txt")
            with open(lockfile,"w") as lf:
                lf.write("this file is used by esky to lock the version dir\n")


    def _generate_filelist_manifest(self):
        """Create a list of all the files in application"""
        filelist_file = os.path.join(self.freeze_dir,ESKY_CONTROL_DIR, esky.patch.ESKY_FILELIST)
        esky_files  = []
        for root, dirs, files in os.walk(self.bootstrap_dir):
            for f in files:
                esky_files.append(os.path.join(os.path.relpath(root, self.bootstrap_dir), f))
        with open(filelist_file, 'w') as f:
            f.write(json.dumps(esky_files))


    def _run_create_zipfile(self):
        """Zip up the final distribution."""
        if self.compress:
            fullname = self.distribution.get_fullname()
            platform = get_platform()
            zfname = os.path.join(self.dist_dir,"%s.%s.zip"%(fullname,platform,))
            if hasattr(self.freezer_module,"zipit"):
                self.freezer_module.zipit(self,self.bootstrap_dir,zfname)
            else:
                if self.compress == 'zip':
                    print "zipping up the esky with compression"
                    create_zipfile(self.bootstrap_dir,zfname,compress=True)
                    really_rmtree(self.bootstrap_dir)
                elif self.compress == 'ZIP':
                    print "zipping up the esky without compression"
                    create_zipfile(self.bootstrap_dir,zfname,compress=False)
                    really_rmtree(self.bootstrap_dir)
                else:
                    print("To zip the esky use compress or c set to ZIP or zip")

    def _obj2code(self,obj):
        """Convert an object to some python source code.

        Iterables are flattened, None is elided, strings are included verbatim,
        open files are read and anything else is passed to inspect.getsource().
        """
        if obj is None:
            return ""
        if isinstance(obj,basestring):
            return obj
        if hasattr(obj,"read"):
            return obj.read()
        try:
            return "\n\n\n".join(self._obj2code(i) for i in obj)
        except TypeError:
            return inspect.getsource(obj)

    def get_bootstrap_code(self):
        """Get any extra code to be executed by the bootstrapping exe.

        This method interprets the bootstrap-code and bootstrap-module settings
        to construct any extra bootstrapping code that must be executed by
        the frozen bootstrap executable.  It is returned as a string.
        """
        bscode = self.bootstrap_code
        if bscode is None:
            if self.bootstrap_module is not None:
                bscode = __import__(self.bootstrap_module)
                for submod in self.bootstrap_module.split(".")[1:]:
                    bscode = getattr(bscode,submod)
        bscode = self._obj2code(bscode)
        return bscode

    def get_executables(self,normalise=True):
        """Yield a normalised Executable instance for each script to be frozen.

        If "normalise" is True (the default) then the user-provided scripts
        will be rewritten to decode any non-filename items specified as part
        of the script, and to include the esky startup code.  If the freezer
        has a better way of doing these things, it should pass normalise=False.
        """
        if normalise:
            if not os.path.exists(os.path.join(self.tempdir,"scripts")):
                os.mkdir(os.path.join(self.tempdir,"scripts"))
        if self.distribution.has_scripts():
            for s in self.distribution.scripts:
                if isinstance(s,Executable):
                    exe = s
                else:
                    exe = Executable(s)
                if normalise:
                    #  Give the normalised script file a name matching that
                    #  specified, since some freezers only take the filename.
                    name = exe.name
                    if sys.platform == "win32" and name.endswith(".exe"):
                        name = name[:-4]
                    if exe.endswith(".pyw"):
                        ext = ".pyw"
                    else:
                        ext = ".py"
                    script = os.path.join(self.tempdir,"scripts",name+ext)
                    #  Get the code for the target script.
                    #  If it's a single string then interpret it as a filename,
                    #  otherwise feed it into the _obj2code logic.
                    if isinstance(exe.script,basestring):
                        with open(exe.script,"rt") as f:
                            code = f.read()
                    else:
                        code = self._obj2code(exe.script)
                    #  Check that the code actually compiles - sometimes it
                    #  can be hard to get a good message out of the freezer.
                    compile(code,"","exec")
                    #  Augment the given code with special esky-related logic.
                    with open(script,"wt") as fOut:
                        lines = (ln+"\n" for ln in code.split("\n"))
                        #  Keep any leading comments and __future__ imports
                        #  at the start of the file.
                        for ln in lines:
                            if ln.strip():
                                if not ln.strip().startswith("#"):
                                    if "__future__" not in ln:
                                        break
                            fOut.write(ln)
                        #  Run the startup hooks before any actual code.
                        if not self.dont_run_startup_hooks:
                            fOut.write("import esky\n")
                            fOut.write("esky.run_startup_hooks()\n")
                            fOut.write("\n")
                        #  Then just include the rest of the script code.
                        fOut.write(ln)
                        for ln in lines:
                            fOut.write(ln)
                    new_exe = Executable(script)
                    new_exe.__dict__.update(exe.__dict__)
                    new_exe.script = script
                    exe = new_exe
                yield exe

    def get_data_files(self):
        """Yield (source,destination) tuples for data files.

        This method generates the names of all data file to be included in
        the frozen app.  They should be placed directly into the freeze
        directory as raw files.
        """
        fdir = self.freeze_dir
        if sys.platform == "win32" and self.bundle_msvcrt:
            for (src,dst) in self.get_msvcrt_private_assembly_files():
                yield (src,dst)
        if self.distribution.data_files:
            for datafile in self.distribution.data_files:
                #  Plain strings get placed in the root dist directory.
                if isinstance(datafile,basestring):
                    datafile = ("",[datafile])
                (dst,sources) = datafile
                if os.path.isabs(dst):
                    err = "cant freeze absolute data_file paths (%s)"
                    err = err % (dst,)
                    raise ValueError(err)
                dst = convert_path(dst)
                for src in sources:
                    src = convert_path(src)
                    yield (src,os.path.join(dst,os.path.basename(src)))
 
    def get_package_data(self):
        """Yield (source,destination) tuples for package data files.

        This method generates the names of all package data files to be
        included in the frozen app.  They should be placed in the library.zip
        or equivalent, alongside the python files for that package.
        """
        if self.distribution.package_data:
            for pkg,data in self.distribution.package_data.iteritems():
                pkg_dir = self.get_package_dir(pkg)
                pkg_path = pkg.replace(".","/")
                if isinstance(data,basestring):
                    data = [data]
                for dpattern in data:
                    dfiles = glob(os.path.join(pkg_dir,convert_path(dpattern)))
                    for nm in dfiles:
                        arcnm = pkg_path + nm[len(pkg_dir):]
                        yield (nm,arcnm)

    def get_package_dir(self,pkg):
        """Return directory where the given package is located.

        This was largely swiped from distutils, with some cleanups.
        """
        inpath = pkg.split(".")
        outpath = []
        if not self.distribution.package_dir:
            outpath = inpath
        else:
            while inpath:
                try:
                    dir = self.distribution.package_dir[".".join(inpath)]
                except KeyError:
                    outpath.insert(0, inpath[-1])
                    del inpath[-1]
                else:
                    outpath.insert(0, dir)
                    break
            else:
                try:
                    dir = self.package_dir[""]
                except KeyError:
                    pass
                else:
                    outpath.insert(0, dir)
        if outpath:
            return os.path.join(*outpath)
        else:
            return ""

    @staticmethod
    def get_msvcrt_private_assembly_files():
        """Get (source,destination) tuples for the MSVCRT DLLs, manifest etc.

        This method generates data_files tuples for the MSVCRT DLLs, manifest
        and associated paraphernalia.  Including these files is required for
        newer Python versions if you want to run on machines that don't have
        the latest C runtime installed *and* you don't want to run the special
        "vcredist_x86.exe" program during your installation process.

        Bundling is only performed on win32 paltforms, and only if you enable
        it explicitly.  Before doing so, carefully check whether you have a
        license to distribute these files.
        """
        cls = bdist_esky
        msvcrt_info = cls._get_msvcrt_info()
        if msvcrt_info is not None:
            msvcrt_name = msvcrt_info[0]
            #  Find installed manifest file with matching info
            for candidate in cls._find_msvcrt_manifest_files(msvcrt_name):
                manifest_file, msvcrt_dir = candidate
                try:
                    with open(manifest_file,"rb") as mf:
                        manifest_data = mf.read()
                        for info in msvcrt_info:
                            if info.encode() not in manifest_data:
                                break
                        else:
                            break
                except EnvironmentError:
                    pass
            else:
                err = "manifest for %s not found" % (msvcrt_info,)
                raise RuntimeError(err)
            #  Copy the manifest and matching directory into the freeze dir.
            manifest_name = msvcrt_name + ".manifest"
            yield (manifest_file,os.path.join(msvcrt_name,manifest_name))
            for fnm in os.listdir(msvcrt_dir):
                yield (os.path.join(msvcrt_dir,fnm),
                       os.path.join(msvcrt_name,fnm))

    @staticmethod
    def _get_msvcrt_info():
        """Get info about the MSVCRT in use by this python executable.

        This parses the name, version and public key token out of the exe
        manifest and returns them as a tuple.
        """
        try:
            manifest_str = winres.get_app_manifest()
        except EnvironmentError:
            return None
        manifest = minidom.parseString(manifest_str)
        for assembly in manifest.getElementsByTagName("assemblyIdentity"):
            name = assembly.attributes["name"].value
            if name.startswith("Microsoft") and name.endswith("CRT"):
                version = assembly.attributes["version"].value 
                pubkey = assembly.attributes["publicKeyToken"].value 
                return (name,version,pubkey)
        return None
        
    
    @staticmethod
    def _find_msvcrt_manifest_files(name):
        """Search the system for candidate MSVCRT manifest files.

        This method yields (manifest_file,msvcrt_dir) tuples giving a candidate
        manifest file for the given assembly name, and the directory in which
        the actual assembly data files are found.
        """
        cls = bdist_esky
        #  Search for redist files in a Visual Studio install
        progfiles = os.path.expandvars("%PROGRAMFILES%")
        for dnm in os.listdir(progfiles):
            if dnm.lower().startswith("microsoft visual studio"):
                dpath = os.path.join(progfiles,dnm,"VC","redist")
                for (subdir,_,filenames) in os.walk(dpath):
                    for fnm in filenames:
                        if name.lower() in fnm.lower():
                            if fnm.lower().endswith(".manifest"):
                                mf = os.path.join(subdir,fnm)
                                md = cls._find_msvcrt_dir_for_manifest(name,mf)
                                if md is not None:
                                    yield (mf,md)
        #  Search for manifests installed in the WinSxS directory
        winsxs_m = os.path.expandvars("%WINDIR%\\WinSxS\\Manifests")
        for fnm in os.listdir(winsxs_m):
            if name.lower() in fnm.lower():
                if fnm.lower().endswith(".manifest"):
                    mf = os.path.join(winsxs_m,fnm)
                    md = cls._find_msvcrt_dir_for_manifest(name,mf)
                    if md is not None:
                        yield (mf,md)
        winsxs = os.path.expandvars("%WINDIR%\\WinSxS")
        for fnm in os.listdir(winsxs):
            if name.lower() in fnm.lower():
                if fnm.lower().endswith(".manifest"):
                    mf = os.path.join(winsxs,fnm)
                    md = cls._find_msvcrt_dir_for_manifest(name,mf)
                    if md is not None:
                        yield (mf,md)

    @staticmethod
    def _find_msvcrt_dir_for_manifest(msvcrt_name,manifest_file):
        """Find the directory containing data files for the given manifest.

        This searches a few common locations for the data files that go with
        the given manifest file.  If a suitable directory is found then it is
        returned, otherwise None is returned.
        """
        #  The manifest file might be next to the dir, inside the dir, or
        #  in a subdir named "Manifests".  Walk around till we find it.
        msvcrt_dir = ".".join(manifest_file.split(".")[:-1])
        if os.path.isdir(msvcrt_dir):
            return msvcrt_dir
        msvcrt_basename = os.path.basename(msvcrt_dir)
        msvcrt_parent = os.path.dirname(os.path.dirname(msvcrt_dir))
        msvcrt_dir = os.path.join(msvcrt_parent,msvcrt_basename)
        if os.path.isdir(msvcrt_dir):
            return msvcrt_dir
        msvcrt_dir = os.path.join(msvcrt_parent,msvcrt_name)
        if os.path.isdir(msvcrt_dir):
            return msvcrt_dir
        return None


    def compile_to_bootstrap_exe(self,exe,source,relpath=None):
        """Compile the given sourcecode into a bootstrapping exe.

        This method compiles the given sourcecode into a stand-alone exe using
        PyPy, then stores that in the bootstrap env under the name of the given
        Executable object.  If the source has been previously compiled then a
        cached version of the exe may be used.
        """
        if not relpath:
            relpath = exe.name
        source = "__rpython__ = True\n" + source
        cdir = os.path.join(self.tempdir,"compile")
        if not os.path.exists(cdir):
            os.mkdir(cdir)
        source_hash = hashlib.md5(source).hexdigest()
        outname = "bootstrap_%s.%s" % (source_hash,get_platform())
        if exe.gui_only:
            outname += ".gui"
        if sys.platform == "win32":
            outname += ".exe"
        #  First try to use a precompiled version.
        if COMPILED_BOOTSTRAP_CACHE is not None:
            outfile = os.path.join(COMPILED_BOOTSTRAP_CACHE,outname)
            if os.path.exists(outfile):
                return self.copy_to_bootstrap_env(outfile,relpath)
        #  Otherwise we have to compile it anew.
        try:
            outfile = self._compiled_exes[(source_hash,exe.gui_only)]
        except KeyError:
            infile = os.path.join(cdir,"bootstrap.py")
            outfile = os.path.join(cdir,outname)
            with open(infile,"wt") as f:
                f.write(source)
            opts = dict(gui_only=exe.gui_only)
            pypyc.compile_rpython(infile,outfile,**opts)
            self._compiled_exes[(source_hash,exe.gui_only)] = outfile
        #  Try to save the compiled exe for future use.
        if COMPILED_BOOTSTRAP_CACHE is not None:
            cachedfile = os.path.join(COMPILED_BOOTSTRAP_CACHE,outname)
            try:
                shutil.copy2(outfile,cachedfile)
            except EnvironmentError:
                pass
        return self.copy_to_bootstrap_env(outfile,relpath)

    def copy_to_bootstrap_env(self,src,dst=None):
        """Copy the named file into the bootstrap environment.

        The filename is also added to the bootstrap manifest.
        """
        if dst is None:
            dst = src
        srcpath = os.path.join(self.freeze_dir,src)
        dstpath = os.path.join(self.bootstrap_dir,dst)
        if os.path.isdir(srcpath):
            self.copy_tree(srcpath,dstpath)
        else:
            if not os.path.isdir(os.path.dirname(dstpath)):
               self.mkpath(os.path.dirname(dstpath))
            self.copy_file(srcpath,dstpath)
        self.add_to_bootstrap_manifest(dstpath)
        return dstpath

    def add_to_bootstrap_manifest(self,dstpath):
        if not os.path.isdir(os.path.join(self.freeze_dir,ESKY_CONTROL_DIR)):
            os.mkdir(os.path.join(self.freeze_dir,ESKY_CONTROL_DIR))
        f_manifest = os.path.join(self.freeze_dir,ESKY_CONTROL_DIR,"bootstrap-manifest.txt")
        with open(f_manifest,"at") as f_manifest:
            f_manifest.seek(0,os.SEEK_END)
            if os.path.isdir(dstpath):
                for (dirnm,_,filenms) in os.walk(dstpath):
                    for fnm in filenms:
                        fpath = os.path.join(dirnm,fnm)
                        dpath = fpath[len(self.bootstrap_dir)+1:]
                        if os.sep != "/":
                            dpath = dpath.replace(os.sep,"/")
                        f_manifest.write(dpath)
                        f_manifest.write("\n")
            else:
                dst = dstpath[len(self.bootstrap_dir)+1:]
                if os.sep != "/":
                    dst = dst.replace(os.sep,"/")
                f_manifest.write(dst)
                f_manifest.write("\n")


class bdist_esky_patch(Command):
    """Create a patch for a frozen application in 'esky' format.

    This distutils command can be used to create a patch file between two
    versions of an application frozen with esky.  Such a patch can be used
    for differential updates between application versions.
    """

    user_options = [
                    ('dist-dir=', 'd',
                     "directory to put final built distributions in"),
                    ('from-version=', None,
                     "version against which to produce patch"),
                   ]

    def initialize_options(self):
        self.dist_dir = None
        self.from_version = None

    def finalize_options(self):
        self.set_undefined_options('bdist',('dist_dir', 'dist_dir'))

    def run(self):
        fullname = self.distribution.get_fullname()
        platform = get_platform()
        vdir = "%s.%s" % (fullname,platform,)
        appname = split_app_version(vdir)[0]
        #  Ensure we have current version's esky, as target for patch.
        target_esky = os.path.join(self.dist_dir,vdir+".zip")
        if not os.path.exists(target_esky):
            self.run_command("bdist_esky")
        #  Generate list of source eskys to patch against.
        if self.from_version:
            source_vdir = join_app_version(appname,self.from_version,platform)
            source_eskys = [os.path.join(self.dist_dir,source_vdir+".zip")]
        else:
            source_eskys = []
            for nm in os.listdir(self.dist_dir):
                if target_esky.endswith(nm):
                    continue
                if nm.startswith(appname+"-") and nm.endswith(platform+".zip"):
                    source_eskys.append(os.path.join(self.dist_dir,nm))
        #  Write each patch, transparently unzipping the esky
        for source_esky in source_eskys:
            target_vdir = os.path.basename(source_esky)[:-4]
            target_version = split_app_version(target_vdir)[1]
            patchfile = vdir+".from-%s.patch" % (target_version,)
            patchfile = os.path.join(self.dist_dir,patchfile)
            print "patching", target_esky, "against", source_esky, "=>", patchfile
            if not self.dry_run:
                try:
                    esky.patch.main(["-Z","diff",source_esky,target_esky,patchfile])
                except:
                    import traceback
                    traceback.print_exc()
                    raise


#  Monkey-patch distutils to include our commands by default.
distutils.command.__all__.append("bdist_esky")
distutils.command.__all__.append("bdist_esky_patch")
sys.modules["distutils.command.bdist_esky"] = sys.modules["esky.bdist_esky"]
sys.modules["distutils.command.bdist_esky_patch"] = sys.modules["esky.bdist_esky"]