#! /usr/bin/env python
#
# Main test driver.

import os
import os.path
import sys
import shutil
import fnmatch
import optparse
import re
import tempfile
import subprocess
import copy
import glob
import fnmatch
import ConfigParser

VERSION = "0.3" # Automatically filled in.

Name ="btest"
ConfigDefault = "btest.cfg"
Config = None

RE_INPUT = re.compile("%INPUT")
RE_DIR = re.compile("%DIR")
RE_ENV = re.compile("\$\{(\w+)\}")
RE_START_NEXT_TEST = re.compile("@TEST-START-NEXT")
RE_START_FILE = re.compile("@TEST-START-FILE +([^\n ]*)")
RE_END_FILE = re.compile("@TEST-END-FILE")

# Commands as tuple (tag, regexp, more-than-one-is-ok, optional, group-main, group-add)
RE_EXEC     = ("exec",     re.compile("@TEST-EXEC(-FAIL)?: *(.*)"), True, False, 2, 1)
RE_REQUIRES = ("requires", re.compile("@TEST-REQUIRES: *(.*)"), True, True, 1, -1)

Commands = (RE_EXEC, RE_REQUIRES)

def output(msg, nl=True, file=None):
    if not file:
        file = sys.stderr

    if nl:
        print >>file, msg
    else:
        print >>file, msg,

def error(msg):
    print >>sys.stderr, msg
    sys.exit(1)

def mkdir(dir):
    if not os.path.exists(dir):
        try:
            os.mkdir(dir)
        except OSError, e:
            error("cannot create directory %s: %s" % (dir, e))

    else:
        if not os.path.isdir(dir):
            error("path %s exists but is not a directory" % dir)

def getOption(key, default):
    try:
        return Config.get("btest", key)
    except ConfigParser.NoSectionError:
        return default

reBackticks = re.compile(r"`(([^`]|\`)*)`")

# We monkey-patch the OptionParser to expand backticks.
def cpExpandBackticks(self, section, option, rawval, vars):
    def _exec(m):
        cmd = m.group(1)
        if not cmd:
            return ""

        try:
            return subprocess.Popen(cmd.split(), stdout=subprocess.PIPE).communicate()[0].strip()
        except OSError, e:
            error("cannot execute '%s': %s" % (cmd, e))

    value = cpOriginalInterpolate(self, section, option, rawval, vars)
    value = reBackticks.sub(_exec, value)

    return value

cpOriginalInterpolate = ConfigParser.ConfigParser._interpolate
ConfigParser.ConfigParser._interpolate = cpExpandBackticks

class Test(object):
    def __init__(self, file):
        self.file = file
        self.dir = os.path.abspath(os.path.dirname(file))
        self.name = None
        self.number = 1
        self.group = None
        self.cmdlines = []
        self.tmpdir = None
        self.diag = None
        self.baseline = None
        self.files = []
        self.requires = []

        # Parse the test's content.
    def parse(self, content):
        cmds = {}
        for line in content:

            if line.find("@TEST-IGNORE") >= 0:
                # Ignore this file.
                return False

            for (tag, regexp, multiple, optional, group1, group2) in Commands:
                m = regexp.search(line)

                if m:
                    value = m.group(group1)

                    if group2 >= 0:
                        value = (value, m.group(group2))

                    if not multiple:
                        if tag in cmds:
                            error("%s: %d defined multiple times." % (test, tag))

                        cmds[tag] = value

                    else:
                        try:
                            cmds[tag] += [value]
                        except KeyError:
                            cmds[tag] = [value]

        # Make sure all non-optional commands are there.
        for (tag, regexp, multiple, optional, group1, group2) in Commands:
            if not optional and not tag in cmds:
                error("%s: mandatory %s command not found." % (self.file, tag))

        # self.name = cmds["name"]
        # self.group = cmds["group"]
        # self.diagin = cmds["diag-in"] if "diag-in" in cmds else None

        (name, ext) = os.path.splitext(self.file)

        self.name = name.replace("/", ".")
        while self.name.startswith("."):
            self.name = self.name[1:]

        self.group = os.path.dirname(self.file).replace("/", ".")
        while self.group.startswith("."):
            self.group = self.group[1:]

        self.content = content
        self.cmdlines = [(cmd.strip(), success!="-FAIL") for (cmd, success) in cmds["exec"]]

        if "requires" in cmds:
            self.requires = [cmd.strip() for cmd in cmds["requires"]]

        if Substitutions:
            for (key, val) in Substitutions.items():
                self.cmdlines = [(re.sub("\\b" + re.escape(key) + "\\b", val, cmd[0]), cmd[1]) for cmd in self.cmdlines]

        return True

    # Copies all control information over t a new Test but replaces test content
    # with a new one.
    def clone(self, content):
        clone = Test("")
        clone.file = self.file
        clone.number = self.number + 1
        clone.name = self.name
        clone.group = self.group
#       clone.diagin = self.diagin
        clone.cmdlines = self.cmdlines

        clone.content = content

        return clone

    def run(self, filter=None):
        if self.number > 1:
            self.name = "%s-%d" % (self.name, self.number)

        if not filter:
            name = self.name
        else:
            name = "%s (%s)" % (self.name, filter)

        if Options.verbose or not Options.brief:
            output("%s ..." % name, nl=Options.verbose)

        self.tmpdir = os.path.abspath(os.path.join(TmpDir, self.name))
        self.diag = os.path.join(self.tmpdir, ".diag")
        self.baseline = os.path.abspath(os.path.join(BaselineDir, self.name))
        self.diagmsgs = []

        self.rmTmp()
        mkdir(self.baseline)
        mkdir(self.tmpdir)
        os.chdir(self.tmpdir)

        for (fname, lines) in self.files:
            subdir = os.path.dirname(fname)
            if subdir != "":
                mkdir(subdir)
            try:
                ffile = open(fname, "w")
            except IOError, e:
                error("cannot write test's additional file '%s'" % fname)

            for line in lines:
                print >>ffile, line,

            ffile.close()

        self.localfile = os.path.join(self.tmpdir, os.path.basename(self.file))

        content = open(self.localfile, "w")
        for line in self.content:
            print >>content, line,
        content.close()

        self.log = open(os.path.join(self.tmpdir, ".log"), "w")
        self.stdout = open(os.path.join(self.tmpdir, ".stdout"), "w")
        self.stderr = open(os.path.join(self.tmpdir, ".stderr"), "w")

        for cmd in self.requires:

            (success, rc) = self.execute((cmd, True), apply_filter=(filter != None))

            if not success:
                global Skipped
                Skipped += 1

                if Options.verbose:
                    output("... %s not available, skipped" % name)

                else:
                    if not Options.brief:
                        output("not available, skipped")

                if Options.diagfile:
                    print >>Options.diagfile, "%s ... not available, skipped" % name

                self.finish()
                return

        failures = 0

        for cmd in self.cmdlines:

            (success, rc) = self.execute(cmd, apply_filter=(filter != None))

            if not success:
                failures += 1

                if failures == 1:

                    global Failed
                    Failed += 1

                    if Options.verbose:
                        output("... %s failed" % name)

                    else:
                        if Options.brief:
                            output("%s ..." % name, nl=False)

                        output(" failed")

                    if Options.diagfile:
                        print >>Options.diagfile, "%s ... failed" % name

                if Options.diag or Options.diagall:
                    self.showDiag()

                if Options.diagfile:
                    self.showDiag(Options.diagfile)

                if rc == 200:
                    # Abort all tests.
                    sys.exit(1)

                if rc != 100:
                    break

        if failures == 0:
            if Options.verbose:
                    output("... %s ok" % name)
            else:
                if not Options.brief:
                    output("ok")

            if Options.diagall:
                self.showDiag()

            if not Options.tmps:
                self.rmTmp()

        self.finish()

    def finish(self):
        try:
            # Try removing the baseline directory. If it works, it's empty, i.e., no baseline was created.
            os.rmdir(self.baseline)
        except OSError, e:
            pass

        self.log.close()
        self.stdout.close()
        self.stderr.close()

    def execute(self, cmd, apply_filter=False):
        (cmdline, expect_success) = cmd

        # See if we need to apply a filter.
        filter_cmd = None

        if apply_filter:
            try:
                (path, executable) = os.path.split(cmdline.split()[0])
                filter_cmd = Filters[executable]
            except LookupError:
                pass

        if not filter_cmd or not expect_success: # Do not apply filter if we expect failure.
            localfile = self.localfile
        else:
            # This is not quite correct as it does not necessarily need to be
            # the %INPUT file which we are filtering ...
            filtered = os.path.join(self.tmpdir, "filtered-%s" % os.path.basename(self.localfile))
            (success, rc) = self.execute(("%s %s %s" % (filter_cmd, self.localfile, filtered), True), apply_filter=False)
            if not success:
                return (False, rc)

            (success, rc) = self.execute(("mv %s %s" % (filtered, self.localfile), True), apply_filter=False)
            if not success:
                return (False, rc)

            localfile = self.localfile

        if Options.verbose:
            output("  > %s" % cmdline)

        # Replace special names.
        cmdline = RE_INPUT.sub(localfile, cmdline)
        cmdline = RE_DIR.sub(self.dir, cmdline)

        # Replace environment variables.
        def replace_with_env(m):
            try:
                return os.environ[m.group(1)]
            except KeyError:
                return ""

        cmdline = RE_ENV.sub(replace_with_env, cmdline)

        print >>self.log, cmdline, "(expect %s)" % (("failure", "success")[expect_success])

        env = self.prepareEnv()

        retcode = subprocess.call(cmdline, shell=True, env=env, stderr=self.stderr, stdout=self.stdout)

        if retcode != 0:
            if expect_success:
                self.diagmsgs += ["'%s' failed unexpectedly (exit code %s)" % (cmdline, retcode)]
                return (False, retcode)

            else:
                return (True, retcode)

        if not expect_success:
            self.diagmsgs += ["'%s' succeeded unexpectedly (exit code 0)" % cmdline]
            return (False, 0)

        return (True, 0)

    def rmTmp(self):
        os.chdir(TmpDir)

        try:
            if os.path.isfile(self.tmpdir):
                os.remove(self.tmpdir)

            if os.path.isdir(self.tmpdir):
                subprocess.call("rm -rf %s 2>/dev/null" % self.tmpdir, shell=True)

        except OSError, e:
            error("cannot remove tmp directory %s: %s" % (self.tmpdir, e))

    # Prepares the environment for the child processes.
    def prepareEnv(self):
        env = copy.deepcopy(os.environ)

        env["TEST_BASELINE"] = self.baseline
        env["TEST_DIAGNOSTICS"] = self.diag
        env["TEST_MODE"] = Options.mode.upper()
        env["TEST_NAME"] = self.name

        return env

    def addFiles(self, files):
        # files is a list of tuple (fname, lines).
        self.files = files

    def showDiag(self, file=None):
        for line in self.diagmsgs:
            output("  % " + line, file=file)

        for f in (self.diag, ".stderr"):
            if not f:
                continue

            if os.path.isfile(f):
                output("  % cat " + os.path.basename(f), file=file)
                for line in open(f):
                    output("  " + line.strip(), file=file)
                output("", file=file)

        if Options.wait and not file:
            output("<Enter> ...")
            try:
                sys.stdin.readline()
            except KeyboardInterrupt:
                sys.exit(1)

# Walk the given directory and return all test files.
def findTests(paths):
    tests = []

    ignore_files = getOption("IgnoreFiles", "").split()
    ignore_dirs = getOption("IgnoreDirs", "").split()

    for path in paths:
        if os.path.isfile(path):
            tests += readTestFile(path)

        elif os.path.isdir(path):
            for (dirpath, dirnames, filenames) in os.walk(path):
                for file in filenames:
                    for glob in ignore_files:
                        if fnmatch.fnmatch(file, glob):
                            break
                    else:
                        tests += readTestFile(os.path.join(dirpath, file))

                # Don't recurse into these.
                for skip in ignore_dirs:
                    if skip in dirnames:
                        dirnames.remove(skip)

        else:
            # See if we have a test named like this in our configured set.
            for t in Config.configured_tests:
                if t and path == t.name:
                    tests += [t]
                    break

            else:
                error("cannot read %s" % path)

    return tests

# Read the given test file and instantiate one or more tests from it.
def readTestFile(filename):

    def newTest(content, previous):
        if not previous:
            t = Test(filename)
            if t.parse(content):
                return t
            else:
                return None
        else:
            return previous.clone(content)

    try:
        input = open(filename)
    except IOError, e:
        error("cannot read test file: %s" % e)

    tests = []
    files = []

    content = []
    previous = None
    file = (None, [])

    state = "test"

    for line in input:

        if state == "test":
            m = RE_START_FILE.search(line)
            if m:
                state = "file"
                file = (m.group(1), [])
                continue

            m = RE_END_FILE.search(line)
            if m:
                error("unexpected @test-end-file")

            m = RE_START_NEXT_TEST.search(line)
            if not m:
                content += [line]
                continue

            t = newTest(content, previous)
            if not t:
                return []

            tests += [t]

            previous = t
            content = []

        elif state == "file":
            m = RE_END_FILE.search(line)
            if m:
                state = "test"
                files += [file]
                file = (None, [])
                continue

            file = (file[0], file[1] + [line])

        else:
            error("internal: unknown state %s" % state)

    if state == "file":
        files += [file]

    tests += [newTest(content, previous)]

    input.close()

    for t in tests:
        if t:
            t.addFiles(files)

    return tests

### Main

optparser = optparse.OptionParser(usage="%prog [options] <directorys>", version=VERSION)
optparser.add_option("-U", "--update-baseline", action="store_const", dest="mode", const="UPDATE",
                     help="create a new baseline from the tests' output")
optparser.add_option("-u", "--update-interactive", action="store_const", dest="mode", const="UPDATE_INTERACTIVE",
                     help="interactively asks whether to update baseline for a failed test")
optparser.add_option("-d", "--diagnostics", action="store_true", dest="diag", default=False,
                     help="show diagnostic output for failed tests")
optparser.add_option("-D", "--diagnostics-all", action="store_true", dest="diagall", default=False,
                     help="show diagnostic output for ALL tests")
optparser.add_option("-f", "--file-diagnostics", action="store", type="string", dest="diagfile", default="",
                     help="write diagnostic output for failed tests into file; if files exists, output is appended")
optparser.add_option("-v", "--verbose", action="store_true", dest="verbose", default=False,
                     help="show commands as they are executed")
optparser.add_option("-w", "--wait", action="store_true", dest="wait", default=False,
                     help="wait for <enter> after each failed (with -d) or all (with -D) tests")
optparser.add_option("-b", "--brief", action="store_true", dest="brief", default=False,
                     help="outputs only failed tests")
optparser.add_option("-c", "--config", action="store", type="string", dest="config", default=ConfigDefault,
                     help="configuration file")
optparser.add_option("-F", "--filter", action="store", type="string", dest="filter", default=None,
                     help="active filter in given config file section")
optparser.add_option("-t", "--tmp-keep", action="store_true", dest="tmps", default=False,
                     help="do not delete tmp files created for running tests")
optparser.add_option("-S", "--subst", action="store", type="string", dest="subst", default=None,
                     help="active substitution in given config file section")

optparser.set_defaults(mode="TEST")
(Options, args) = optparser.parse_args()

(basedir, fname) = os.path.split(Options.config)

if not basedir:
    basedir = "."

os.chdir(basedir)

if not os.path.exists(Options.config):
    error("configuration file '%s' not found" % Options.config)

defaults=os.environ
defaults["testbase"] = os.path.abspath(basedir)
defaults["default_path"] = os.environ["PATH"]
Config = ConfigParser.ConfigParser(defaults)
Config.read(Options.config)

if Options.diagfile:
    try:
        Options.diagfile = open(Options.diagfile, "a")
    except IOError, e:
        print >>sys.stderr, "cannot open %s: %s" (Options.diagfile, e)

if Config.has_section("environment"):
    for (name, value) in Config.items("environment"):
        os.environ[name.upper()] = value

Filters = {}
if Options.filter:
    sec = "filters-%s" % Options.filter
    if not Config.has_section(sec):
        error("configuration file has no section '%s'" % sec)

    for (name, value) in Config.items(sec):
        Filters[name] = value

Substitutions = {}
if Options.subst:
    sec = "subst-%s" % Options.subst
    if not Config.has_section(sec):
        error("configuration file has no section '%s'" % sec)

    for (name, value) in Config.items(sec):
        Substitutions[name] = value

# Add the directory where this executable is located to PATH.
addpath = os.path.abspath(os.path.dirname(sys.argv[0]))
oldpath = os.environ["PATH"]
if oldpath:
    os.environ["PATH"] = "%s:%s" % (addpath, oldpath)
else:
    os.environ["PATH"] = addpath

Config.configured_tests = []
testdirs = getOption("TestDirs", None)
if testdirs:
    Config.configured_tests = findTests(testdirs.split())

if args:
    tests = findTests(args)
else:
    tests = Config.configured_tests

if not tests:
    output("no tests given")
    sys.exit(0)

TmpDir = os.path.abspath(getOption("TmpDir", os.path.join(defaults["testbase"], ".tmp")))
BaselineDir = os.path.abspath(getOption("BaselineDir", os.path.join(defaults["testbase"], "Baseline")))
mkdir(BaselineDir)
mkdir(TmpDir)

Failed = 0
Skipped = 0

for test in tests:

    if not test:
        continue

    if Filters:
        test2 = copy.deepcopy(test)
        test.run()
        test2.run(filter=Options.filter)
    else:
        test.run()

if Failed > 0:
    if Skipped > 0:
        skipped = (", %d skipped" % Skipped)
    else:
        skipped = ""

    output("%d test%s failed%s" % (Failed, ("","s")[Failed > 1], skipped))
    sys.exit(1)

else:
    output("all tests successful")
    sys.exit(0)

