~vila/uci-engine/integration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
#!/usr/bin/env python
# -*- Mode: python -*-
# Ubuntu CI Engine
# Copyright 2014 Canonical Ltd.

# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero 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 Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""The CI Engine test runner."""

import logging
import os
import subunit
import sys


from ucitests import (
    filters,
    loaders,
    matchers,
    runners,
    results,
)


class SysPath(object):
    """Add paths in front of sys.path temporarily."""

    def __init__(self, paths):
        self.paths = paths
        self.orig = sys.path[:]

    def __enter__(self):
        sys.path = self.paths + sys.path

    def __exit__(self, *args):
        sys.path = self.orig


def load_component_tests(loader, component_base_dir):
    """Load tests for a given component.

    A component is organized inside a given base directory containing files
    *and* a python directory with the code and the tests. We care about that
    python directory only.
    """
    # We set the current directory to the component base one and restore it
    # after loading. This could produce duplicate tests if different components
    # define the same python modules but this would be a bug in itself so we
    # just ignore the potential issue.
    suite = loader.suiteClass()
    with SysPath([component_base_dir]):
        sub_loader = loader.SubLoader(root=component_base_dir)
        suite.addTests(sub_loader.loadTestsFromTree('.'))
    return suite


def load_regular_component_tests(include_regexps, exclude_regexps=None):
    """Load tests matching inclusive and exclusive regexps.

    :param include_regexps: A list of regexps describing the tests to include.

    :param exclude_regexps: A list of regexps describing the tests to exclude.

    :return: The test suite for all collected tests.
    """
    components = ['ci-utils',
                  'cli',
                  'branch-source-builder',
                  'image-builder',
                  'lander',
                  'test_runner',
                  ]
    loader = loaders.Loader()
    # setuptools tends to leave eggs all over the place
    loader.dir_matcher = matchers.NameMatcher(includes=[r'.*'],
                                              excludes=[r'\.egg$',
                                                        r'\.egg-info$',
                                                        ])
    suite = loader.suiteClass()
    for c in components:
        suite.addTests(load_component_tests(loader, c))
    suite = filters.include_regexps(include_regexps, suite)
    suite = filters.exclude_regexps(exclude_regexps, suite)
    return suite


def get_runner():
    """Return a test runner class tailored to django needs.


    The django design forces us to delay the imports so we can prepare the
    environment before django attempt to import modules and set some globals
    there.

    Since the imports needs to be delayed, that DjangoTestRunner class has to
    be defined inside a function so it can inherit from DjangoTestSuiteRunner.
    """
    from django.test import (
        simple,
        utils,
    )

    class DjangoTestRunner(simple.DjangoTestSuiteRunner):

        def __init__(self, component_base_dir, requires_south, out, result,
                     options,
                     **kwargs):
            super(DjangoTestRunner, self).__init__(**kwargs)
            self.component_base_dir = component_base_dir
            self.requires_south = requires_south
            self.out = out
            self.result = result
            self.options = options
            # Used to cache the test suite
            self._cached_test_suite = None

        def build_suite(self, *args, **kwargs):
            # We cache the test suite to ensure it's loaded only once. This is
            # used to workaround DjangoTestSuiteRunner closed design that
            # doesn't allow to hook that more precisely. Concretely,
            # DjangoTestSuiteRunner.run_tests() setup the databases even if
            # there are no tests to run, we take care of that in our own
            # run_tests() by not upcalling when the test suite is empty.
            if self._cached_test_suite is not None:
                return self._cached_test_suite
            loader = loaders.Loader()
            suite = load_component_tests(loader, self.component_base_dir)
            # Filtered as required by the user
            suite = filters.include_regexps(
                self.options.include_regexps, suite)
            suite = filters.exclude_regexps(
                self.options.exclude_regexps, suite)
            self._cached_test_suite = suite
            return suite

        def run_tests(self, test_labels, extra_tests=None, **kwargs):
            # We load the tests early to avoid the costly database setup when
            # it's not needed.
            suite = self.build_suite(test_labels, extra_tests, **kwargs)
            if self.options.list_only:
                ret = runners.list_tests(suite, self.out)
                return ret

            if suite.countTestCases() == 0:
                # No tests to run, we let the caller handle the case where no
                # tests are run at all which is an error. By loading the test
                # suite early we avoid calling
                # DjangoTestSuiteRunner.run_tests() which will setup the
                # databases even if the test suite is empty.
                return 0

            # South needs to patch the way the db is created/synced.
            if self.requires_south:
                from south.management import commands
                commands.patch_for_test_db_setup()

            # Otherwise let the base class handle the setup and the run
            return super(DjangoTestRunner, self).run_tests(
                test_labels, extra_tests=None, **kwargs)

        def run_suite(self, suite, **kwargs):
            suite.run(self.result)
            return self.result

    return DjangoTestRunner


class IsolatedImportAndLogging(object):
    """Protect sys.modules, sys.path and logging.root

    This allows running django tests which relies on a lot of global context by
    taking a snapshot of 'sys.path', 'sys.modules' and 'logging.root' when
    entering the context and restoring them afterwards. As such, it creates a
    context where django can freely setup globals in modules all over the place
    without interfering with the outside.

    Several django apps can therefore be tested during the same test run
    without stepping on each other toes.
    """

    def __init__(self):
        # Keep copies of the right objects to be able to restore them
        self.paths = list(sys.path)
        self.modules = sys.modules.copy()
        self.root = logging.root

    def __enter__(self):
        logging.root.handlers = []
        logging.root.setLevel(logging.WARNING)

    def __exit__(self, *args):
        # Restore the copied list
        sys.path = self.paths
        # Remove all added modules
        added = [m for m in sys.modules.keys() if m not in self.modules]
        if added:
            for m in added:
                del sys.modules[m]
        # Restore deleted or modified modules
        sys.modules.update(self.modules)
        logging.root = self.root


def run_django_tests(component_base_dir, component_name, requires_south,
                     out, result, options):
    """Run tests for a django app.

    :param component_base_dir: Where the component files are.

    :param component_name: The name of the component acting as a base for its
        python name space.

    :param requires_south: Whether the component uses the 'south' module and
        needs to be setup accordingly.

    :param out: The output stream to use.

    :param result: The test result object to use for the run.

    :param options: A dict with the options from the command line.

    :return: 0 on success, 1 on failure.
    """
    with IsolatedImportAndLogging():
        # We don't have to use setdefault here because we don't have (and don't
        # want !) to support overriding. We want to test with the default
        # values defined in the project settings.py
        settings_path = '{}.settings'.format(component_name)
        os.environ["DJANGO_SETTINGS_MODULE"] = settings_path
        kls = get_runner()
        test_runner = kls(component_base_dir, requires_south,
                          out, result, options)
        failures = test_runner.run_tests([])
        return failures


def run_regular_components(suite, out, result, options):
    """Run tests for all the regular components.

    :param suite: The test suite to run.

    :param out: The output stream to use.

    :param result: The test result object to use for the run.

    :param options: A dict with the options from the command line.

    :return: 0 on success, 1 on failure.
    """
    with IsolatedImportAndLogging():
        if options.list_only:
            # List the tests without running them
            ret = runners.list_tests(suite, out)
        else:
            suite.run(result)
            # TODO: This logic is worth putting into the test result class
            # itself or at least a helper should be provided by uci-tests
            # itself. -- vila 2014-02-08
            ret = int(not (result.wasSuccessful()
                           and result.testsRun > 0))
        return ret


def main(args, stdout, stderr):
    # Interpret user wishes
    parser = runners.RunTestsArgParser()
    options = parser.parse_args(args)
    if options.list_only:
        # We won't run tests, we won't need a test result
        result = None
    else:
        # Run the tests with the required output
        if options.format == 'text':
            result = results.TextResult(stdout, verbosity=2)
        else:
            result = subunit.TestProtocolClient(stdout)
        # We'll run tests, setup the result accordingly
        result.startTestRun()
    # Load the tests, keeping only the ones required by the user
    suite = load_regular_component_tests(options.include_regexps,
                                         options.exclude_regexps)
    # Regular components
    ret = run_regular_components(suite, stdout, result, options)
    # Django-based components
    with IsolatedImportAndLogging():
        ppa =  run_django_tests('ppa-assigner', 'ppa_assigner', True,
                                stdout, result, options)
        ret = ppa or ret
    with IsolatedImportAndLogging():
        ts = run_django_tests('ticket_system', 'ticket_system', True,
                              stdout, result, options)
        ret = ts or ret
    if not options.list_only:
        # We did run tests, stop the run
        result.stopTestRun()
    # Fails if at least one fail
    return int(ret)


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:], sys.stdout, sys.stderr))