#!/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

def _install_reactor():
    """Install the correct reactor according to the platform."""
    # the select reactor seems to be the most stable one in darwin
    # and win32, we will just install a reactor for windows and use
    # the default one for the other platforms.
    platform = sys.platform
    if platform not in ['win32', 'darwin']:
        # install the glib2reactor before any import of the reactor to avoid
        # using the default SelectReactor and be able to run the dbus tests
        from twisted.internet import glib2reactor
        glib2reactor.install()
        
class TestRunner(TrialRunner):
    """The test runner implementation."""
    
    def __init__(self, force_gc=False):
        # install the correct reactor accrding to the platform, do
        # this to ensure that we can instantiate the best reactor per
        # platform
        _install_reactor()
        from twisted.trial.reporter import TreeReporter

        # setup a custom XDG_CACHE_HOME and create the logs directory
        xdg_cache = os.path.join(os.getcwd(), "_trial_temp", "xdg_cache")
        os.environ["XDG_CACHE_HOME"] = xdg_cache
        # setup the ROOTDIR env var
        os.environ['ROOTDIR'] = os.getcwd()
        if not os.path.exists(xdg_cache):
            os.makedirs(xdg_cache)

        self.tempdir = os.path.join(os.getcwd(), "_trial_temp")
        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, path, options=None):
        """run the tests."""
        success = 0
        running_services = []
        if options.coverage:
            coverage.erase()
            coverage.start()

        try:
            suite = 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.")
    (options, args) = parser.parse_args()
    if args:
        testpath = args[0]
        if not os.path.exists(testpath):
            print "the path to test does not exists!"
            sys.exit(1)
    else:
        parser.print_help()
        sys.exit(2)
    TestRunner(force_gc=options.force_gc).run(testpath, options)

if __name__ == '__main__':
    main()
