# -*- coding: utf-8 -*-
# redist_wininst.py

# Copyright (c) 2012-2014, Christoph Gohlke
# Copyright (c) 2012-2014, The Regents of the University of California
# Produced at the Laboratory for Fluorescence Dynamics.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright
#   notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
#   notice, this list of conditions and the following disclaimer in the
#   documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of any
#   contributors may be used to endorse or promote products derived
#   from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

"""Repackage multiple Python packages into one bdist_wininst installer.

The `redist_wininst.py` script repackages all distutils compatible source
packages and bdist_wininst installers found in the current working directory
into one bdist_wininst installer.

:Author:
  `Christoph Gohlke <http://www.lfd.uci.edu/~gohlke/>`_

:Organization:
  Laboratory for Fluorescence Dynamics, University of California, Irvine

:Version: 2013.09.25

Requirements
------------
* `CPython 2.7 or 3.3 for Windows <http://www.python.org>`_
* `Setuptools 1.1 <http://pypi.python.org/pypi/setuptools>`_

Usage
-----
Run the redist_wininst.py script from within a directory containing the
source packages and/or binary installers to be repackaged:

    `python.exe redist_wininst.py`

Or, if the redist_wininst.py module is installed in Python's sys.path, run:

    `python.exe -m redist_wininst`

The name of the installer will be determined from the current directory name.

Alternatively, the `setup` function can be called with arguments:

    >>> from redist_wininst import setup
    >>> setup(name='MyDistribution', version='1.0', author='Me')

Post-install scripts need to be specified explicitely, e.g.:

    >>> setup(options={"bdist_wininst": {"install_script":
    ...                                  "ipython_win_post_install.py"}})

Notes
-----
This is a hack!

The API is not stable yet and is expected to change between revisions.

A working Python build environment (e.g. Visual Studio Pro or mingw32) is
required when building packages containing C extensions from source.

Source packages need to be (in extracted form) in a subdirectory of the
working directory and contain a `setup.py` script that can build a
bdist_wininst distribution.

Binary installers need to be in Python's bdist_wininst format, which are
executable ZIP files (*.exe). They also need to match the version and bitness
of the Python interpreter used to run redist_wininst.py. Therefore the
official PyQt, numpy, and scipy binary installers won't work.

If a package fails to build or extract, it will not be available in the final
installer. Check the console output for failures.

The final installer can be found in the `dist` directory.

Easy_install/setuptools console scripts will refer to the "repackaged" package
name, not the original package, as entry points.

Please observe licenses, redistribution rights, and export regulations of the
individual packages when distributing the installer.

Any previous versions of the packages contained in the installer should be
uninstalled from the target system before running the installer.

Repackaging large number of files or packages is not recommended as
bdist_wininst installers tend to become sluggish.

Python's bdist_wininst installers have many shortcomings. Consider other
packaging options based on MSI or NSIS. See for example Pythonxy
<http://code.google.com/p/pythonxy/>.

"""

from __future__ import division, with_statement, print_function

import sys
import os
import re
import shutil
import time
import subprocess
from datetime import date
from glob import glob
from zipfile import ZipFile
from distutils.sysconfig import get_python_lib

import setuptools

__all__ = ['setup']
__version__ = '2013.09.25'
__docformat__ = 'restructuredtext en'

if os.name != 'nt':
    raise ValueError('This module requires Windows')


def setup(**kwargs):
    """Create bdist_wininst installer from packages in current directory."""
    print(os.getcwd())
    if not 'bdist_wininst' in sys.argv and not 'bdist_msi' in sys.argv:
        sys.argv.append('bdist_wininst')

    if 'force_rebuild' in kwargs:
        force_rebuild = kwargs['force_rebuild']
        del kwargs['force_rebuild']
    else:
        force_rebuild = False

    if 'ext_modules' in kwargs:
        libdir = lib_dir(True)
    else:
        libdir = lib_dir(False)

    setup_builddir()
    srcpkg, binpkg = list_packages()
    srcpkg, failed = build_packages(srcpkg, force_rebuild=force_rebuild)
    unpack_installers(binpkg)
    finalize_builddir(libdir=libdir)
    packages = srcpkg + binpkg

    _name, _version = default_name_version()
    if not 'name' in kwargs or kwargs['name'] is None:
        kwargs['name'] = _name
    if not 'version' in kwargs or kwargs['version'] is None:
        kwargs['version'] = _version
    if not 'long_description' in kwargs:
        kwargs['long_description'] = description(packages=packages)
    if not 'py_modules' in kwargs:
        if os.path.exists('redist_wininst.py'):
            kwargs['py_modules'] = ['redist_wininst']
        else:
            kwargs['py_modules'] = ['']
    _data_files = kwargs.get('data_files', None)
    kwargs['data_files'] = data_files(_data_files)
    _entry_points = kwargs.get('entry_points', None)
    kwargs['entry_points'] = entry_points(_entry_points, libdir=libdir)
    _options = kwargs.get('options', None)
    kwargs['options'] = options(_options)
    _scripts = kwargs.get('scripts', None)
    kwargs['scripts'] = scripts(_scripts)

    setuptools.setup(**kwargs)

    if packages:
        print('\nPackaged:\n *', '\n * '.join(packages))
    if failed:
        print('\nFailed:\n *', '\n * '.join(failed))


def default_name_version(path=None):
    """Extract name and version from path string."""
    if path is None:
        path = os.getcwd()
    match_obj = re.search(r'.*(?:^|\\)([\w\d]{2,32})-?([\d\.]*)', path,
                          re.IGNORECASE| re.DOTALL)
    name = match_obj.group(1)
    if not name:
        name = ''
    version = match_obj.group(2)
    if not version:
        version = version_from_date()
    return name, version


def version_from_date():
    """Return version number string from today's date."""
    today = date.today()
    return '.'.join((today.strftime('%y'),
                     today.strftime('%m').lstrip('0'),
                     today.strftime('%d').lstrip('0')))


def data_files(data_files=None, path=r'build\DATA'):
    """Return list of data files in path."""
    if data_files is None:
        data_files = []
    for root, dirs, files in os.walk(path, topdown=False):
        if files:
            files = [root+'\\'+f for f in files]
            root = root[11:]
            data_files.append((root, files))
    return data_files


def platform_name():
    """Return name of Python platform."""
    if tuple.__itemsize__ == 8:
        return 'win-amd64-py%i.%i' % sys.version_info[:2]
    else:
        return 'win32-py%i.%i' % sys.version_info[:2]


def lib_dir(has_extensions):
    """Return name of build/lib directory."""
    if not has_extensions:
        return 'lib'
    elif tuple.__itemsize__ == 8:
        return 'lib.win-amd64-%i.%i' % sys.version_info[:2]
    else:
        return 'lib.win32-%i.%i' % sys.version_info[:2]


def list_packages(path=None):
    """Return lists of package source directories and installers in path."""
    if path is None:
        path = ''
    srcpkg = list(os.path.dirname(f) for f in glob(path + "*\\setup.py"))
    srcpkg = list(sorted(srcpkg, key=lambda x: x.lower()))
    binpkg = list(glob(path + '*.' + platform_name() + '*'))
    binpkg = list(sorted(binpkg, key=lambda x: x.lower()))
    return srcpkg, binpkg


def setup_builddir(path='build'):
    """Clean build directory; create PLATLIB and PURELIB directories."""
    if not path or len(path) < 4:
        raise ValueError()
    print("Cleaning '%s' directory" % path)
    if os.path.exists(path):
        shutil.rmtree(path)
        time.sleep(1)
    os.makedirs(path)
    platlib = os.path.join(path, 'PLATLIB')
    purelib = os.path.join(path, 'PURELIB')
    if not os.path.exists(platlib):
        os.makedirs(platlib)
    if not os.path.exists(purelib):
        os.makedirs(purelib)


def finalize_builddir(path='build', libdir='lib'):
    """Copy contents from PLATLIB to PURELIB; rename PURELIB to lib."""
    platlib = os.path.join(path, 'PLATLIB')
    purelib = os.path.join(path, 'PURELIB')
    copydir(platlib, purelib)
    time.sleep(1)
    os.rename(purelib, os.path.join(path, libdir))
    time.sleep(1)
    if os.path.exists(platlib):
        shutil.rmtree(platlib)
    if os.path.exists(purelib):
        shutil.rmtree(purelib)
    time.sleep(1)


def copydir(source, destination):
    """Copy all files from source to destination directory."""
    cmd = r'xcopy.exe /E /Y /H /Q %s\* %s' % (source, destination)
    print('Running', cmd)
    os.system(cmd)


def unpack_installers(file_list, path='build'):
    """Unzip the content of bdist_wininst installers to path."""
    for f in file_list:
        print('Extracting', f)
        try:
            zf = ZipFile(f)
            zf.extractall(path)
            zf.close()
        except Exception:
            print('Failed!')


def copy_exe_from_build(path):
    """Try copy installer from build directory to dist, e.g. numpy on py3k."""
    if sys.version_info[0] < 3:
        return
    try:
        os.system(r'xcopy.exe /Y /H /Q %s\build\py3k\dist\*.exe %s\dist' % (
            path, path))
    except Exception:
        try:
            os.system(
                r'xcopy.exe /Y /H /Q %s\build\py%i.%i\dist\*.exe %s\dist\\' % (
                    path, sys.version_info[0], sys.version_info[1], path))
        except Exception:
            pass


def build_packages(pkgdir_list, path='build', force_rebuild=False):
    """Build bdist_wininst installers from source; then extract to path.

    Return lists of successfully built and failed packages.

    """
    success = []
    failed = []
    for pkg in pkgdir_list:
        print('Building', pkg)
        distdir = os.path.join(pkg, 'dist')
        exename = '%s\\*.%s.exe' % (distdir, platform_name())
        exeists = glob(exename)
        if force_rebuild or not exeists:
            builddir = os.path.join(pkg, 'build')
            if os.path.exists(builddir):
                shutil.rmtree(builddir)
                time.sleep(1)
            for f in exeists:
                os.remove(f)
            err = subprocess.call(
                [sys.executable, 'setup.py', 'bdist_wininst',
                 '--target-version=%i.%i' % sys.version_info[:2]],
                cwd=pkg)
            if err:
                print('Failed to build!')
                failed.append(pkg)
                continue
            copy_exe_from_build(pkg)
            try:
                shutil.rmtree(builddir)
            except Exception:
                pass
        try:
            exefile = glob(exename)[0]
            zf = ZipFile(exefile)
            zf.extractall(path)
            zf.close()
        except Exception:
            print('Failed to extract', exename)
            failed.append(pkg)
            continue
        success.append(pkg)
    return success, failed


def list_package_names(packages=None):
    """Return list of sorted package names."""
    if packages is None:
        srcpkg, binpkg = list_packages()
        packages = srcpkg + binpkg
    packages = [pkg.split('.win')[0] for pkg in
                sorted(packages, key=lambda x: x.lower())]
    return packages


def description(description=None, packages=None):
    """Return long_description including names of packages."""
    packages = list_package_names(packages)
    if description is None:
        description = ('The following packages are included '
                       'in this installer:\n')
    description = [description]
    description.append('\n    ')
    l = 0
    for p in packages:
        lp = len(p)
        if l + lp < 66:
            description.extend((p, ', '))
            l += lp + 2
        else:
            description.extend(('\n    ', p, ', '))
            l = lp + 2
    description = "".join(description)
    if description.endswith(', '):
        description = description[:-2]
    return description


def entry_points(entry_points=None, path='build', libdir='lib'):
    """Return script entry points from all packages."""
    if entry_points is None:
        entry_points = {}
    for f in glob('%s/%s/*/entry_points.txt' % (path, libdir)):
        for line in open(f):
            line = line.strip()
            if line.startswith('['):
                key = line[1:-1]
                if not key in entry_points:
                    entry_points[key] = []
            elif '=' in line:
                line = line.strip()  # .lower()
                if not line in entry_points[key]:
                    entry_points[key].append(line)
    return entry_points


def options(options=None):
    """Return setup options for building bdist_wininst installers."""
    if options is None:
        options = {}
    if not 'bdist_wininst' in options:
        opt = {'target_version': '%i.%i' % sys.version_info[:2],
               'user_access_control': 'auto'}
        if os.path.exists('clean_egg-info.py'):
            opt['install_script'] = 'clean_egg-info.py'
        options['bdist_wininst'] = opt
    if not 'bdist_msi' in options:
        opt = {'target_version': '%i.%i' % sys.version_info[:2]}
        if os.path.exists('clean_egg-info.py'):
            opt['install_script'] = 'clean_egg-info.py'
        options['bdist_msi'] = opt
    return options


def scripts(scripts=None, path='build'):
    """Return list of scripts from all packages."""
    if scripts is None:
        scripts = []
    if os.path.exists('clean_egg-info.py'):
        scripts.append('clean_egg-info.py')
    for f in glob(path + '/scripts/*'):
        if not (f.endswith('-script.py') or f.endswith('.exe')):
            scripts.append(f)
    return scripts


def clean_egg_info(pattern):
    """Remove outdated egg-info directories and files from site-packages.

    >>> from redist_wininst import clean_egg_info
    >>> clean_egg_info('.egg-info')
    >>> clean_egg_info('-nspkg.pth')

    """
    sitepackages = get_python_lib()
    pyver = 'py%i.%i' % sys.version_info[:2]
    packages = {}

    for egginfo in glob(sitepackages + ('/*%s' % pattern)):
        package = egginfo.rsplit('\\')[-1]
        try:
            name, version = package.split('-')[:2]
        except Exception:
            print(package)
            continue
        name = name.lower()
        if not name in packages:
            packages[name] = [version]
        else:
            packages[name].append(version)

    for name, versions in list(packages.items()):
        if len(versions) > 1:
            pass
        else:
            del packages[name]

    if not packages:
        print("No '%s' to remove" % pattern)

    for name, versions in list(packages.items()):
        bydate = []
        for v in versions:
            try:
                egginfo = '%s\\%s-%s-%s%s' % (sitepackages, name, v, pyver,
                                              pattern)
                stats = os.stat(egginfo)
                fdate = time.localtime(stats[8])
                bydate.append((fdate, egginfo))
            except Exception:
                print("Not found: %s" % egginfo)
        bydate.sort()
        for d, f in bydate[:-1]:
            print('Removing', f, end=' ')
            try:
                os.remove(f)
            except Exception:
                print('directory', end=' ')
                try:
                    shutil.rmtree(f)
                except Exception:
                    print('failed', end=' ')
            print()


if __name__ == "__main__":
    setup()