#!/usr/bin/env python

# u1trial: Test runner for Python unit tests needing DBus
#
# Author: Rodney Dawes <rodney.dawes@canonical.com>
#
# Copyright 2009-2012 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 3, as published
# by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranties of
# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
# PURPOSE.  See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# In addition, as a special exception, the copyright holders give
# permission to link the code of portions of this program with the
# OpenSSL library under certain conditions as described in each
# individual source file, and distribute linked combinations
# including the two.
# You must obey the GNU General Public License in all respects
# for all of the code used other than OpenSSL.  If you modify
# file(s) with this exception, you may extend this exception to your
# version of the file(s), but you are not obligated to do so.  If you
# do not wish to do so, delete this exception statement from your
# version.  If you delete this exception statement from all source
# files in the program, then also delete it here.
"""Test runner that uses a private dbus session and glib main loop."""

import coverage
import gc
import inspect
import os
import re
import sys
import unittest

from twisted.python.usage import UsageError
from twisted.scripts import trial
from twisted.trial.runner import TrialRunner

sys.path.insert(0, os.path.abspath("."))

from ubuntuone.devtools.testing.txcheck import TXCheckSuite


def _is_in_ignored_path(testcase, paths):
    """Return if the testcase is in one of the ignored paths."""
    for ignored_path in paths:
        if testcase.startswith(ignored_path):
            return True
    return False


class TestRunner(TrialRunner):
    """The test runner implementation."""

    def __init__(self, config=None):
        # set $HOME to the _trial_temp dir, to avoid breaking user files
        trial_temp_dir = os.environ.get('TRIAL_TEMP_DIR', os.getcwd())
        homedir = os.path.join(trial_temp_dir, config['temp-directory'])
        os.environ['HOME'] = homedir

        # setup $XDG_*_HOME variables and create the directories
        xdg_cache = os.path.join(homedir, 'xdg_cache')
        xdg_config = os.path.join(homedir, 'xdg_config')
        xdg_data = os.path.join(homedir, 'xdg_data')
        os.environ['XDG_CACHE_HOME'] = xdg_cache
        os.environ['XDG_CONFIG_HOME'] = xdg_config
        os.environ['XDG_DATA_HOME'] = xdg_data

        if not os.path.exists(xdg_cache):
            os.makedirs(xdg_cache)
        if not os.path.exists(xdg_config):
            os.makedirs(xdg_config)
        if not os.path.exists(xdg_data):
            os.makedirs(xdg_data)

        # setup the ROOTDIR env var
        os.environ['ROOTDIR'] = os.getcwd()

        # Need an attribute for tempdir so we can use it later
        self.tempdir = homedir
        working_dir = os.path.join(self.tempdir, 'trial')

        # Handle running trial in debug or dry-run mode
        mode = None
        if config['debug']:
            mode = TrialRunner.DEBUG
        if config['dry-run']:
            mode = TrialRunner.DRY_RUN

        # Hook up to the parent test runner
        super(TestRunner, self).__init__(
            reporterFactory=config['reporter'],
            mode=mode,
            profile=config['profile'],
            logfile=config['logfile'],
            tracebackFormat=config['tbformat'],
            realTimeErrors=config['rterrors'],
            uncleanWarnings=config['unclean-warnings'],
            workingDirectory=working_dir,
            forceGarbageCollection=config['force-gc'])

        self.required_services = []
        self.source_files = []

    def _load_unittest(self, relpath):
        """Load unit tests from a Python module with the given 'relpath'."""
        assert relpath.endswith(".py"), (
            "%s does not appear to be a Python module" % relpath)
        if not os.path.basename(relpath).startswith('test_'):
            return
        modpath = relpath.replace(os.path.sep, ".")[:-3]
        module = __import__(modpath, None, None, [""])

        # If the module specifies required_services, make sure we get them
        members = [x[1] for x in inspect.getmembers(module, inspect.isclass)]
        for member_type in members:
            if hasattr(member_type, 'required_services'):
                member = member_type()
                for service in member.required_services():
                    if service not in self.required_services:
                        self.required_services.append(service)
                del member
        gc.collect()

        # If the module has a 'suite' or 'test_suite' function, use that
        # to load the tests.
        if hasattr(module, "suite"):
            return module.suite()
        elif hasattr(module, "test_suite"):
            return module.test_suite()
        else:
            return unittest.defaultTestLoader.loadTestsFromModule(module)

    def _collect_tests(self, path, test_pattern, ignored_modules,
        ignored_paths):
        """Return the set of unittests."""
        suite = TXCheckSuite()
        if test_pattern:
            pattern = re.compile('.*%s.*' % test_pattern)
        else:
            pattern = None

        # Disable this lint warning as we need to access _tests in the
        # test suites, to collect the tests
        # pylint: disable=W0212
        if path:
            try:
                module_suite = self._load_unittest(path)
                if pattern:
                    for inner_suite in module_suite._tests:
                        for test in inner_suite._tests:
                            if pattern.match(test.id()):
                                suite.addTest(test)
                else:
                    suite.addTests(module_suite)
                return suite
            except AssertionError:
                pass
        else:
            print 'Path should be defined.'
            exit(1)

        # We don't use the dirs variable, so ignore the warning
        # pylint: disable=W0612
        for root, dirs, files in os.walk(path):
            for test in files:
                filepath = os.path.join(root, test)
                if test.endswith(".py") and test not in ignored_modules and \
                    not _is_in_ignored_path(filepath, ignored_paths):
                    self.source_files.append(filepath)
                    if test.startswith("test_"):
                        module_suite = self._load_unittest(filepath)
                        if pattern:
                            for inner_suite in module_suite._tests:
                                for test in inner_suite._tests:
                                    if pattern.match(test.id()):
                                        suite.addTest(test)
                        else:
                            suite.addTests(module_suite)
        return suite

    def get_suite(self, config):
        """Get the test suite to use."""
        suite = unittest.TestSuite()
        for path in config['tests']:
            suite.addTest(self._collect_tests(path, config['test'],
                                              config['ignore-modules'],
                                              config['ignore-paths']))
        if config['loop']:
            old_suite = suite
            suite = unittest.TestSuite()
            for _ in xrange(config['loop']):
                suite.addTest(old_suite)

        return suite

    # pylint: disable=C0103
    def _runWithoutDecoration(self, test):
        """run the tests."""
        result = None
        running_services = []

        try:
            # Start any required services
            for service in self.required_services:
                runner = service()
                runner.start_service(tempdir=self.tempdir)
                running_services.append(runner)

            result = super(TestRunner, self)._runWithoutDecoration(test)
        finally:
            # Stop all the running services
            for runner in running_services:
                runner.stop_service()

        return result


class Options(trial.Options):
    """Class for options handling."""

    optFlags = [["coverage", "c"],
                ["gui", None],
                ["help-reactors", None],
                ]

    optParameters = [["test", "t", None],
                     ["loop", None, 1],
                     ["ignore-modules", "i", ""],
                     ["ignore-paths", "p", ""],
                     ["reactor", "r", "glib"],
                     ]

    def __init__(self):
        self['tests'] = set()
        super(Options, self).__init__()
        self['rterrors'] = True

    def opt_coverage(self):
        """Generate a coverage report for the run tests"""
        self['coverage'] = True

    def opt_gui(self):
        """Use the GUI mode of some reactors"""
        self['gui'] = True

    def opt_help_reactors(self):
        """Help on available reactors for use with tests"""
        synopsis = ('')
        print synopsis
        print 'Need to get list of reactors and print them here.'
        print
        sys.exit(0)

    def opt_test(self, option):
        """Run specific tests, e.g: className.methodName"""
        self['test'] = option

    def opt_loop(self, option):
        """Loop tests the specified number of times."""
        try:
            self['loop'] = long(option)
        except ValueError:
            raise UsageError('A positive integer value must be specified.')

    def opt_ignore_modules(self, option):
        """Comma-separate list of test modules to ignore,
           e.g: test_gtk.py, test_account.py
           """
        self['ignore-modules'] = map(str.strip, option.split(','))

    def opt_ignore_paths(self, option):
        """Comma-separated list of relative paths to ignore,
           e.g: tests/platform/windows, tests/platform/macosx
           """
        self['ignore-paths'] = map(str.strip, option.split(','))

    def opt_reactor(self, option):
        """Which reactor to use (see --help-reactors for a list
           of possibilities)
           """
        self['reactor'] = option
    opt_r = opt_reactor


def main():
    """Do the deed."""
    if len(sys.argv) == 1:
        sys.argv.append('--help')

    config = Options()
    config.parseOptions()

    try:
        reactor_name = 'ubuntuone.devtools.reactors.%s' % config['reactor']
        reactor = __import__(reactor_name, None, None, [''])
    except ImportError:
        print 'The specified reactor is not supported.'
        sys.exit(1)
    else:
        try:
            reactor.install(options=config)
        except ImportError:
            print('The Python package providing the requested reactor is not '
                  'installed. You can find it here: %s' % reactor.REACTOR_URL)
            raise

    trial_runner = TestRunner(config=config)
    suite = trial_runner.get_suite(config)

    if config['coverage']:
        coverage.erase()
        coverage.start()

    if config['until-failure']:
        result = trial_runner.runUntilFailure(suite)
    else:
        result = trial_runner.run(suite)

    if config['coverage']:
        coverage.stop()
        coverage.report(trial_runner.source_files, ignore_errors=True,
                        show_missing=False)

    sys.exit(not result.wasSuccessful())


if __name__ == '__main__':
    main()
