<gh_stars>0
#!/usr/bin/env python
"""
(C) Copyright 2012-2013 Nuxeo SA (http://nuxeo.com/) and contributors.

All rights reserved. This program and the accompanying materials
are made available under the terms of the GNU Lesser General Public License
(LGPL) version 2.1 which accompanies this distribution, and is available at
http://www.gnu.org/licenses/lgpl-2.1.html

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.

Contributors:
    <NAME>

Utilities for Python scripts."""

import ConfigParser
import errno
import os
import platform
import re
import shlex
import shutil
import subprocess
import sys
import time
import urllib2
from zipfile import ZIP_DEFLATED, ZipFile
from distutils.version import LooseVersion
import warnings


REQUIRED_GIT_VERSION = "1.8.4"
SUPPORTED_GIT_ONLINE_URLS = "http://", "https://", "git://", "git@"
DEFAULT_MP_CONF_URL = "https://raw.github.com/nuxeo/integration-scripts/master/marketplace.ini"


class ExitException(Exception):
    def __init__(self, return_code, message=None):
        super(ExitException, self).__init__(message)
        self.return_code = return_code
        self.message = message

def deprecated(func):
    '''This is a decorator which can be used to mark functions
    as deprecated. It will result in a warning being emitted
    when the function is used.'''
    def new_func(*args, **kwargs):
        warnings.warn("Call to deprecated function {}.".format(func.__name__), category=DeprecationWarning)
        return func(*args, **kwargs)
    new_func.__name__ = func.__name__
    new_func.__doc__ = func.__doc__
    new_func.__dict__.update(func.__dict__)
    return new_func

# pylint: disable=R0902
class Repository(object):
    """Nuxeo repository manager.

    Provides recursive Git and Shell functions."""

    def __init__(self, basedir, alias, dirmapping=True, is_nuxeoecm=True):
        assert_git_config()
        (self.basedir, self.driveletter, self.oldbasedir) = long_path_workaround_init(basedir, dirmapping)
        self.mp_dir = os.path.join(self.basedir, "marketplace")
        if not os.path.isdir(self.mp_dir):
            os.mkdir(self.mp_dir)
        self.alias = alias
        # find the remote URL
        os.chdir(self.basedir)
        remote_lines = check_output("git remote -v").split("\n")
        for remote_line in remote_lines:
            remote_alias, remote_url, _ = remote_line.split()
            if alias == remote_alias:
                break

        self.is_online = remote_url.startswith(SUPPORTED_GIT_ONLINE_URLS)
        if self.is_online:
            self.url_pattern = re.sub("(.*)nuxeo", r"\1module", remote_url)
        else:
            self.url_pattern = remote_url + "/module"
        self.modules = []
        self.sub_modules = {}
        self.addons = []
        self.optional_addons = []
        self.is_nuxeoecm = is_nuxeoecm

    def cleanup(self):
        long_path_workaround_cleanup(self.driveletter, self.oldbasedir)

    # pylint: disable=C0103
    @deprecated
    def eval_modules(self):
        """Set the list of Nuxeo addons in 'self.modules'."""
        self.modules = self.retrieve_modules(self.basedir)

    @deprecated
    def eval_addons(self):
        """Set the list of Nuxeo addons in 'self.addons' and 'self.optional_addons'."""
        addons_dir = os.path.join(self.basedir, "addons")
        self.addons = self.retrieve_modules(addons_dir)
        self.optional_addons = self.retrieve_modules(addons_dir, "pom-optionals.xml")

    def execute_on_modules(self, function, with_optionals=False):
        """Executes the given function on each first and second level modules of Nuxeo repository.

        'function' the function to execute on module.
        'with_optionals' weither or not we execute function on optionals (modules with pom-optionals.xml file)
        """
        cwd = os.getcwd()
        os.chdir(self.basedir)
        if not self.modules and self.is_nuxeoecm:
            self.modules = self.retrieve_modules(self.basedir)
        # First level
        addons = ["addons", "addons-core"]
        for module in self.modules:
            function(module)
            # Second level - addons
            if module in addons:
                if not self.is_online:
                    self.url_pattern = self.url_pattern.replace("module", "%s/module" % module)
                os.chdir(module)
                if module not in self.sub_modules and self.is_nuxeoecm:
                    module_dir = os.path.join(self.basedir, module)
                    self.sub_modules[module] = self.retrieve_modules(module_dir)
                    # Handle optionals
                    if with_optionals:
                        self.sub_modules[module] = self.sub_modules[module] + self.retrieve_modules(module_dir,
                                                                                                    "pom-optionals.xml")
                for sub_module in self.sub_modules[module]:
                    function(sub_module)
                os.chdir(self.basedir)
                if not self.is_online:
                    self.url_pattern = self.url_pattern.replace("%s/module" % module, "module")
        os.chdir(cwd)

    @staticmethod
    def retrieve_modules(project_dir, pom_name = "pom.xml"):
        """Retrieve all modules of input Maven project and return it."""
        modules = []
        if os.path.exists(os.path.join(project_dir, pom_name)):
            log("Modules list calculated from the POM file %s/%s" % (project_dir, pom_name))
            cwd = os.getcwd()
            os.chdir(project_dir)
            f = open(pom_name, "r")
            pom_content = f.read()
            modules = re.findall("<module>(.*?)</module>", pom_content)
            f.close()
            os.chdir(cwd)
            modules = sorted(set(modules))
        return modules

    def git_pull(self, module, version, fallback_branch=None):
        """Git clone or fetch, then update.

        'module': the Git module to run on.
        'version': the version to checkout.
        'fallback_branch': the branch to fallback on when 'version' is not
        found locally or remotely."""
        repo_url = self.url_pattern.replace("module", module)
        cwd = os.getcwd()
        log("[%s]" % module)
        if os.path.isdir(module):
            os.chdir(module)
            system_with_retries("git fetch %s" % (self.alias))
        else:
            system_with_retries("git clone %s --origin %s" % (repo_url, self.alias))
            os.chdir(module)
        self.git_update(version, fallback_branch)
        os.chdir(cwd)

    def system_recurse(self, command, with_optionals=False):
        """Execute the given command on current and sub-modules.

        'command': the command to execute.
        If 'with_optionals', also recurse on "optional" addons."""
        cwd = os.getcwd()
        os.chdir(self.basedir)
        log("[.]")
        system(command)
        self.execute_on_modules(lambda module: self.system_module(command, module), with_optionals)
        os.chdir(cwd)

    @staticmethod
    def system_module(command, module):
        """Execute the given command on given module.

        'command': the command to execute.
        'module': the module into execute command."""
        cwd = os.getcwd()
        os.chdir(module)
        log("[%s]" % module)
        system(command)
        os.chdir(cwd)

    def git_recurse(self, command, with_optionals=False):
        """Execute the given Git command on current and sub-modules. It ignores non Git repositories.

        'command': the command to execute.
        If 'with_optionals', also recurse on "optional" addons."""
        command = "git " + command
        cwd = os.getcwd()
        os.chdir(self.basedir)
        log("[.]")
        system(command)
        self.execute_on_modules(lambda module: self.git_module(command, module), with_optionals)
        os.chdir(cwd)

    @staticmethod
    def git_module(command, module):
        """Execute the given Shell command on the given module. It ignores non Git repositories.

        'command': the command to execute.
        'module': the Git sub-directory where to execute the command."""
        cwd = os.getcwd()
        os.chdir(module)
        if os.path.isdir(".git"):
            log("[%s]" % module)
            system(command)
        os.chdir(cwd)

    def archive(self, archive, version=None, with_optionals=False):
        """Archive the sources of current and sub-repositories.

        'archive': full path of archive to generate.
        'version': version to archive, defaults to current version.
        If 'with_optionals', also recurse on "optional" addons."""
        if version is None:
            version = self.get_current_version()
        archive_dir = os.path.join(os.path.dirname(archive), "sources")
        cwd = os.getcwd()
        os.chdir(self.basedir)
        if os.path.isdir(archive_dir):
            shutil.rmtree(archive_dir)
        os.mkdir(archive_dir)
        log("[.]")
        p = system("git archive %s" % version, wait=False)
        # pylint: disable=E1103
        system("tar -C %s -xf -" % archive_dir, stdin=p.stdout)
        self.execute_on_modules(lambda module: self.archive_module(archive_dir, version, module), with_optionals)
        make_zip(archive, archive_dir)
        shutil.rmtree(archive_dir)
        os.chdir(cwd)

    @staticmethod
    def archive_module(archive_dir, version, module):
        """Archive the sources of a the given Git sub-directory.

        'archive_dir': full path of archive to generate.
        'version': version to archive, defaults to current version.
        'module': the Git sub-directory to archive."""
        cwd = os.getcwd()
        os.chdir(module)
        log("[%s]" % module)
        p = system("git archive --prefix=%s/ %s" % (module, version), wait=False)
        system("tar -C %s -xf -" % archive_dir, stdin=p.stdout)
        os.chdir(cwd)

    def git_update(self, version, fallback_branch=None):
        """Git update using checkout, stash (if needed) and rebase.

        'version': the version to checkout.
        'fallback_branch': the branch to fallback on when 'version' is not
        found locally or remotely."""
        is_tag = version in check_output("git tag --list %s" % version).split()
        is_local_branch = version in check_output("git branch --list %s" % version).split()
        is_remote_branch = "%s/%s" % (self.alias, version) in check_output(
                           "git branch -r --list %s/%s" % (self.alias, version)).split()
        if is_tag:
            system("git checkout %s -q" % version)
        elif is_local_branch:
            system("git checkout %s -q" % version)
            if is_remote_branch:
                system("git rebase -q --autostash %s/%s" % (self.alias, version))
        elif is_remote_branch:
            system("git checkout --track -b %s %s/%s -q" % (version, self.alias, version), fallback_branch is None)
        elif fallback_branch:
            log("Branch %s not found, fallback on %s" % (version, fallback_branch))
            self.git_update(fallback_branch)
        else:
            log("Branch %s not found" % version)
        log("")

    def get_mp_config(self, marketplace_conf, user_defaults = {}):
        """Return the Marketplace packages configuration."""
        defaults = {'other_versions': None, 'prepared': 'False', 'performed': 'False', 'branched': 'False',
                    "profiles": '', "auto_increment_policy": "auto_patch"}
        defaults.update(user_defaults)
        mp_config = ConfigParser.SafeConfigParser(defaults=defaults)
        if marketplace_conf is None:
            no_remote = True
        else:
            try:
                mp_config.readfp(urllib2.urlopen(marketplace_conf))
                log("Read configuration from: '%s'" % marketplace_conf)
                no_remote = False
            except urllib2.URLError:
                no_remote = True
            except ValueError:
                no_remote = True
        mp_config = self.save_mp_config(mp_config, True, no_remote)
        return mp_config

    def save_mp_config(self, mp_config, read_first=False,
                       fail_if_no_file=False):
        """Save the Marketplace packages configuration."""
        configfile_path = os.path.join(self.mp_dir, "release.ini")
        if read_first and os.path.isfile(configfile_path):
            log("Read configuration from: '%s'" % configfile_path)
            mp_config.read(configfile_path)
        if fail_if_no_file and not os.path.isfile(configfile_path):
            raise ExitException(1, "Missing configuration: '%s'" % configfile_path)
        mp_config.write(open(configfile_path, 'w'))
        log("Configuration saved: '%s'" % configfile_path)
        return mp_config

    def clone_mp(self, marketplace_conf, fallback_branch=None):
        """Clone or update Nuxeo Package repositories.

        Returns the Nuxeo Packages configuration."""
        if marketplace_conf == '':
            marketplace_conf = DEFAULT_MP_CONF_URL
        if not marketplace_conf:
            return
        os.chdir(self.mp_dir)
        user_defaults={}
        if self.is_nuxeoecm:
            user_defaults["nuxeo-branch"] = self.get_current_version()
        mp_config = self.get_mp_config(marketplace_conf, user_defaults)
        for marketplace in mp_config.sections():
            self.git_pull(marketplace, mp_config.get(marketplace, "branch"), fallback_branch=fallback_branch)
        return mp_config

    def clone(self, version=None, fallback_branch=None, with_optionals=False,
              marketplace_conf=None):
        """Clone or update whole Nuxeo repository.

        'version': the version to checkout; defaults to current version.
        'fallback_branch': the branch to fallback on when 'version' is not
        found locally or remotely.
        If 'with_optionals', also clone/update "optional" addons.
        'marketplace_conf': URL of configuration file listing the Marketplace
        repositories to clone or update."""
        cwd = os.getcwd()
        os.chdir(self.basedir)
        log("[.]")
        system_with_retries("git fetch %s" % (self.alias))
        if version is None:
            version = self.get_current_version()
        self.git_update(version, fallback_branch)
        if self.is_nuxeoecm:
            self.execute_on_modules(lambda module: self.clone_module(module, version, fallback_branch), with_optionals)
            self.clone_mp(marketplace_conf, fallback_branch)
        os.chdir(cwd)

    def clone_module(self, module, version, fallback_branch):
        # Ignore modules which are not Git sub-repositories
        if not os.path.isdir(module) or os.path.isdir(os.path.join(module, ".git")):
            self.git_pull(module, version, fallback_branch)

    @staticmethod
    def get_current_version():
        """Return branch or tag version of current Git workspace."""
        t = check_output("git describe --all").split("/")
        return t[-1]

    def mvn(self, commands, skip_tests=False, skip_ITs=False,
            profiles=None, dryrun=False):
        """Run Maven commands (install, package, deploy, ...) on the whole
        sources (including addons and all distributions) with the given
        parameters.

        'commands': the commands to run.
        'skip_tests': whether to skip or not the tests.
        'skip_ITs': whether to skip or not the Integration Tests.
        'profiles': comma-separated additional Maven profiles to use.
        If 'dryrun', then print command without executing them."""
        skip_tests_param = "-fae"
        if skip_tests:
            skip_tests_param += " -DskipTests=true"
        if skip_ITs:
            skip_tests_param += " -DskipITs=true"
        profiles_param = []
        if self.is_nuxeoecm:
            profiles_param += ["addons", "distrib"]
        if profiles:
            profiles_param += profiles.split(',')
        if profiles_param:
            profiles_param = " -P" + ','.join(profiles_param)
        else:
            profiles_param = ""
        system("mvn %s %s%s -Dnuxeo.tests.random.mode=BYPASS" % (
               commands, skip_tests_param, profiles_param), delay_stdout=False,
               run=not dryrun)


def log(message, out=sys.stdout):
    out.write(message + os.linesep)
    out.flush()


# Can't this method be replaced with system?
# pylint: disable=C0103
def check_output(cmd):
    """Return Shell command output."""
    args = shlex.split(cmd)
    try:
        p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                             shell=platform.system() == "Windows")
    # pylint: disable=C0103
    except OSError, e:
        log("$> " + cmd)
        if e.errno == errno.ENOENT:
            raise ExitException(1, "Command not found: '%s'" % args[0])
        else:
            # re-raise unexpected exception
            raise
    out, err = p.communicate()
    retcode = p.returncode
    if retcode != 0:
        if err is None or err == "":
            err = out
        raise ExitException(retcode, "Command '%s' returned non-zero exit code (%s)\n%s" % (cmd, retcode, err))
    return out.strip()


# pylint: disable=R0912,R0913
def system(cmd, failonerror=True, delay_stdout=True, logOutput=True, wait=True, run=True, stdin=subprocess.PIPE,
           stdout=subprocess.PIPE, stderr=subprocess.PIPE):
    """Shell execution.

    'cmd': the command to execute.
    If 'failonerror', command execution failure raises an ExitException.
    If 'delay_stdout', output is flushed at the end of command execution.
    If not 'run', the command is only printed.
    If not 'wait', the process is executed in background and returned as 'p'.
    'stdin', 'stdout', 'stderr' are only used if 'delay_stdout' is True.
    If not 'logOutput', output is only logged in case of exception. Only
    available if 'delay_stdout'"""
    log("$> " + cmd)
    if not run:
        return
    args = shlex.split(cmd)
    try:
        if delay_stdout:
            if logOutput:
                # Merge stderr with stdout
                stderr = subprocess.STDOUT
            p = subprocess.Popen(args, stdin=stdin, stdout=stdout, stderr=stderr, shell=platform.system() == "Windows")
            if wait:
                out, err = p.communicate()
                if logOutput:
                    sys.stdout.write(out)
                    sys.stdout.flush()
        else:
            logOutput = True
            p = subprocess.Popen(args, shell=platform.system() == "Windows")
            if wait:
                p.wait()
    except OSError, e:
        if e.errno == errno.ENOENT:
            raise ExitException(1, "Command not found: '%s'" % args[0])
        else:
            # re-raise unexpected exception
            raise
    if not wait:
        return p
    # pylint: disable=E1103
    retcode = p.returncode
    if retcode != 0:
        if failonerror:
            if logOutput:
                raise ExitException(
                    retcode, "Command '%s' returned non-zero exit code (%s)"
                    % (cmd, retcode))
            else:
                if err is None or err == "":
                    err = out
                raise ExitException(
                    retcode,
                    "Command '%s' returned non-zero exit code (%s)\n%s"
                    % (cmd, retcode, err))
    return retcode


def system_with_retries(cmd, failonerror=True):
    """Shell execution with ten retries in case of failures.

    'cmd': the command to execute.
    If 'failonerror', latest command execution failure raises an ExitException.
    """
    retries = 0
    while True:
        retries += 1
        retcode = system(cmd, failonerror=False)
        if retcode == 0:
            return 0
        elif retries > 10:
            return system(cmd, failonerror=failonerror)
        else:
            log("Error executing %s - retrying in 10 seconds..." % cmd, sys.stderr)
            time.sleep(10)


def long_path_workaround_init(basedir, dirmapping=True):
    """Windows only. Try to map the 'basedir' to an unused drive letter
    to shorten path names."""
    newdir = basedir
    driveletter = None
    if platform.system() == "Windows" and dirmapping:
        for letter in "GHIJKLMNOPQRSTUVWXYZ":
            if not os.path.isdir("%s:\\" % (letter,)):
                system("SUBST %s: \"%s\"" % (letter, basedir))
                time.sleep(10)
                driveletter = letter
                newdir = driveletter + ":\\"
                break
    return newdir, driveletter, basedir


def long_path_workaround_cleanup(driveletter, basedir):
    """Windows only. Cleanup the directory mapping if any."""
    if driveletter is not None:
        os.chdir(basedir)
        system("SUBST %s: /D" % (driveletter,), failonerror=False)


def assert_git_config():
    """Check Git configuration."""
    t = check_output("git --version").split()[-1]
    if LooseVersion(t) < LooseVersion(REQUIRED_GIT_VERSION):
        raise ExitException(1, "Requires Git version %s+ (detected %s)" % (REQUIRED_GIT_VERSION, t))
    try:
        t = check_output("git config --get-all color.branch")
    except ExitException, e:
        # Error code 1 is fine (default value)
        if e.return_code > 1:
            log("[WARN] %s" % e.message, sys.stderr)
    try:
        t += check_output("git config --get-all color.status")
    except ExitException, e:
        # Error code 1 is fine (default value)
        if e.return_code > 1:
            log("[WARN] %s" % e.message, sys.stderr)
    if "always" in t:
        raise ExitException(1, "The git color mode must not be always, try:" +
                            "\n git config --global color.branch auto" +
                            "\n git config --global color.status auto")


def make_zip(archive, rootdir=None, basedir=None, mode="w"):
    """Create a zip file from all the files under 'rootdir'/'basedir'.

    If 'rootdir' is not specified, it uses the current directory.
    If 'basedir' is not specified, it uses the current directory constant '.'.
    The 'mode' must be 'w' (write) or 'a' (append)."""
    cwd = os.getcwd()
    if rootdir is not None:
        os.chdir(rootdir)
    try:
        if basedir is None:
            basedir = os.curdir
        log("Creating %s with %s ..." % (archive, basedir))
        zipfile = ZipFile(archive, mode, compression=ZIP_DEFLATED)
        for dirpath, _, filenames in os.walk(basedir):
            for name in filenames:
                path = os.path.normpath(os.path.join(dirpath, name))
                if os.path.isfile(path):
                    zipfile.write(path, path)
                    log("Adding %s" % path)
        zipfile.close()
    finally:
        if rootdir is not None:
            os.chdir(cwd)


def extract_zip(archive, outdir=None):
    """Extract a zip file.

    Extracts all the files to the 'outdir' directory (defaults to current dir)
    """
    zipfile = ZipFile(archive, "r")
    if outdir is None:
        outdir = os.getcwd()
    zipfile.extractall(outdir)
    zipfile.close()
