#!/usr/bin/env python

# u1trial: Test runner for Python unit tests needing DBus
#
# Author: Rodney Dawes <rodney.dawes@canonical.com>
#
# Copyright 2009-2010 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/>.

"""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.trial.runner import TrialRunner


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


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, force_gc=False):
        from twisted.trial.reporter import TreeReporter

        # 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, '_trial_temp')
        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()

        self.tempdir = homedir
        working_dir = os.path.join(self.tempdir, 'tmp')
        super(TestRunner, self).__init__(reporterFactory=TreeReporter,
                                         realTimeErrors=True,
                                         workingDirectory=working_dir,
                                         forceGarbageCollection=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 = unittest.TestSuite()
        if test_pattern:
            pattern = re.compile('.*%s.*' % test_pattern)
        else:
            pattern = None
            
        # get the ignored modules/tests
        if ignored_modules:
            ignored_modules = map(str.strip, ignored_modules.split(','))
        else:
            ignored_modules = []

        # get the ignored paths
        if ignored_paths:
            ignored_paths = map(str.strip, ignored_paths.split(','))
        else:
            ignored_paths = []

        # 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

    # pylint: disable=E0202
    def run(self, args, options=None):
        """run the tests."""
        success = 0
        running_services = []
        if options.coverage:
            coverage.erase()
            coverage.start()

        try:
            suite = unittest.TestSuite()
            for path in args:
                print "Adding path"
                suite.addTest(self._collect_tests(path, options.test,
                                                  options.ignored_modules,
                                                  options.ignored_paths))
            if options.loops:
                old_suite = suite
                suite = unittest.TestSuite()
                for _ in xrange(options.loops):
                    suite.addTest(old_suite)

            # 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).run(suite)
            success = result.wasSuccessful()
        finally:
            # Stop all the running services
            for runner in running_services:
                runner.stop_service()

        if options.coverage:
            coverage.stop()
            coverage.report(self.source_files, ignore_errors=True,
                            show_missing=False)

        if not success:
            sys.exit(1)
        else:
            sys.exit(0)


def main():
    """Do the deed."""
    from optparse import OptionParser
    usage = '%prog [options] path'
    parser = OptionParser(usage=usage)
    parser.add_option("-t", "--test", dest="test",
                  help = "run specific tests, e.g: className.methodName")
    parser.add_option("-l", "--loop", dest="loops", type="int", default=1,
                      help = "loop selected tests LOOPS number of times",
                      metavar="LOOPS")
    parser.add_option("-c", "--coverage", action="store_true", dest="coverage",
                      help="print a coverage report when finished")
    parser.add_option("-i", "--ignored-modules", dest="ignored_modules",
                      default=None, help="comma-separated test moodules "
                      + "to ignore, e.g: test_gtk.py, test_account.py")
    parser.add_option("-p", "--ignore-paths", dest="ignored_paths",
                      default=None, help="comma-separated relative "
                      + "paths to ignore. "
                      + "e.g: tests/platform/windows, tests/platform/macosx")
    parser.add_option("--force-gc", action="store_true", dest="force_gc", 
                      default=False, help="Run gc.collect() before and after "
                      "each test case.")
    parser.add_option("--reactor", type="string", dest="reactor",
                      default='glib',
                      help="Run the tests using the specified reactor.",
                      metavar="REACTOR")
    parser.add_option("--gui", action="store_true", dest="use_gui",
                      help="Use the GUI mode of some reactors.")
    (options, args) = parser.parse_args()
    if not args:
        parser.print_help()
        sys.exit(2)

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

    TestRunner(force_gc=options.force_gc).run(args, options)


if __name__ == '__main__':
    main()
