1#############################################################################
2##
3## Copyright (C) 2018 The Qt Company Ltd.
4## Contact: https://www.qt.io/licensing/
5##
6## This file is part of the Qt for Python project.
7##
8## $QT_BEGIN_LICENSE:LGPL$
9## Commercial License Usage
10## Licensees holding valid commercial Qt licenses may use this file in
11## accordance with the commercial license agreement provided with the
12## Software or, alternatively, in accordance with the terms contained in
13## a written agreement between you and The Qt Company. For licensing terms
14## and conditions see https://www.qt.io/terms-conditions. For further
15## information use the contact form at https://www.qt.io/contact-us.
16##
17## GNU Lesser General Public License Usage
18## Alternatively, this file may be used under the terms of the GNU Lesser
19## General Public License version 3 as published by the Free Software
20## Foundation and appearing in the file LICENSE.LGPL3 included in the
21## packaging of this file. Please review the following information to
22## ensure the GNU Lesser General Public License version 3 requirements
23## will be met: https://www.gnu.org/licenses/lgpl-3.0.html.
24##
25## GNU General Public License Usage
26## Alternatively, this file may be used under the terms of the GNU
27## General Public License version 2.0 or (at your option) the GNU General
28## Public license version 3 or any later version approved by the KDE Free
29## Qt Foundation. The licenses are as published by the Free Software
30## Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3
31## included in the packaging of this file. Please review the following
32## information to ensure the GNU General Public License requirements will
33## be met: https://www.gnu.org/licenses/gpl-2.0.html and
34## https://www.gnu.org/licenses/gpl-3.0.html.
35##
36## $QT_END_LICENSE$
37##
38#############################################################################
39
40from __future__ import print_function
41
42import os
43import sys
44import re
45import subprocess
46import inspect
47
48from collections import namedtuple
49from textwrap import dedent
50
51from .buildlog import builds
52from .helper import decorate, PY3, TimeoutExpired
53
54# Get the dir path to the utils module
55try:
56    this_file = __file__
57except NameError:
58    this_file = sys.argv[0]
59this_file = os.path.abspath(this_file)
60this_dir = os.path.dirname(this_file)
61build_scripts_dir = os.path.abspath(os.path.join(this_dir, '../build_scripts'))
62
63sys.path.append(build_scripts_dir)
64from utils import detect_clang
65
66class TestRunner(object):
67    def __init__(self, log_entry, project, index):
68        self.log_entry = log_entry
69        built_path = log_entry.build_dir
70        self.test_dir = os.path.join(built_path, project)
71        log_dir = log_entry.log_dir
72        if index is not None:
73            self.logfile = os.path.join(log_dir, project + ".{}.log".format(index))
74        else:
75            self.logfile = os.path.join(log_dir, project + ".log")
76        os.environ['CTEST_OUTPUT_ON_FAILURE'] = '1'
77        self._setup_clang()
78        self._setup()
79
80    def _setup_clang(self):
81        if sys.platform != "win32":
82            return
83        clang_dir = detect_clang()
84        if clang_dir[0]:
85            clang_bin_dir = os.path.join(clang_dir[0], 'bin')
86            path = os.environ.get('PATH')
87            if not clang_bin_dir in path:
88                os.environ['PATH'] = clang_bin_dir + os.pathsep + path
89                print("Adding %s as detected by %s to PATH" % (clang_bin_dir, clang_dir[1]))
90
91    def _find_ctest_in_file(self, file_name):
92        """
93        Helper for _find_ctest() that finds the ctest binary in a build
94        system file (ninja, Makefile).
95        """
96        look_for = "--force-new-ctest-process"
97        line = None
98        with open(file_name) as makefile:
99            for line in makefile:
100                if look_for in line:
101                    break
102            else:
103                # We have probably forgotten to build the tests.
104                # Give a nice error message with a shortened but exact path.
105                rel_path = os.path.relpath(file_name)
106                msg = dedent("""\n
107                    {line}
108                    **  ctest is not in '{}'.
109                    *   Did you forget to build the tests with '--build-tests' in setup.py?
110                    """).format(rel_path, line=79 * "*")
111                raise RuntimeError(msg)
112        # the ctest program is on the left to look_for
113        assert line, "Did not find {}".format(look_for)
114        ctest = re.search(r'(\S+|"([^"]+)")\s+' + look_for, line).groups()
115        return ctest[1] or ctest[0]
116
117    def _find_ctest(self):
118        """
119        Find ctest in a build system file (ninja, Makefile)
120
121        We no longer use make, but the ctest command directly.
122        It is convenient to look for the ctest program using the Makefile.
123        This serves us two purposes:
124
125          - there is no dependency of the PATH variable,
126          - each project is checked whether ctest was configured.
127        """
128        candidate_files = ["Makefile", "build.ninja"]
129        for candidate in candidate_files:
130            path = os.path.join(self.test_dir, candidate)
131            if os.path.exists(path):
132                return self._find_ctest_in_file(path)
133        raise RuntimeError('Cannot find any of the build system files {}.'.format(
134                           ', '.join(candidate_files)))
135
136    def _setup(self):
137        self.ctestCommand = self._find_ctest()
138
139    def _run(self, cmd_tuple, label, timeout):
140        """
141        Perform a test run in a given build
142
143        The build can be stopped by a keyboard interrupt for testing
144        this script. Also, a timeout can be used.
145
146        After the change to directly using ctest, we no longer use
147        "--force-new-ctest-process". Until now this has no drawbacks
148        but was a little faster.
149        """
150
151        self.cmd = cmd_tuple
152        # We no longer use the shell option. It introduces wrong handling
153        # of certain characters which are not yet correctly escaped:
154        # Especially the "^" caret char is treated as an escape, and pipe symbols
155        # without a caret are interpreted as such which leads to weirdness.
156        # Since we have all commands with explicit paths and don't use shell
157        # commands, this should work fine.
158        print(dedent("""\
159            running {cmd}
160                 in {test_dir}
161            """).format(**self.__dict__))
162        ctest_process = subprocess.Popen(self.cmd,
163                                         cwd=self.test_dir,
164                                         stdout=subprocess.PIPE,
165                                         stderr=subprocess.STDOUT)
166        def py_tee(input, output, label):
167            '''
168            A simple (incomplete) tee command in Python
169
170            This script simply logs everything from input to output
171            while the output gets some decoration. The specific reason
172            to have this script at all is:
173
174            - it is necessary to have some decoration as prefix, since
175              we run commands several times
176
177            - collecting all output and then decorating is not nice if
178              you have to wait for a long time
179
180            The special escape is for the case of an embedded file in
181            the output.
182            '''
183            def xprint(*args, **kw):
184                print(*args, file=output, **kw)
185
186            # 'for line in input:' would read into too large chunks
187            labelled = True
188            # make sure that this text is not found in a traceback of the runner!
189            text_a = "BEGIN" "_FILE"
190            text_z = "END" "_FILE"
191            while True:
192                line = input.readline()
193                if not line:
194                    break
195                if line.startswith(text_a):
196                    labelled = False
197                txt = line.rstrip()
198                xprint(label, txt) if label and labelled else xprint(txt)
199                if line.startswith(text_z):
200                    labelled = True
201
202        tee_src = dedent("""\
203            from __future__ import print_function
204            import sys
205            {}
206            py_tee(sys.stdin, sys.stdout, '{label}')
207            """).format(dedent(inspect.getsource(py_tee)), label=label)
208        tee_cmd = (sys.executable, "-E", "-u", "-c", tee_src)
209        tee_process = subprocess.Popen(tee_cmd,
210                                       cwd=self.test_dir,
211                                       stdin=ctest_process.stdout)
212        try:
213            comm = tee_process.communicate
214            output = (comm(timeout=timeout) if PY3 else comm())[0]
215        except (TimeoutExpired, KeyboardInterrupt):
216            print()
217            print("aborted, partial result")
218            ctest_process.kill()
219            outs, errs = ctest_process.communicate()
220            # ctest lists to a temp file. Move it to the log
221            tmp_name = self.logfile + ".tmp"
222            if os.path.exists(tmp_name):
223                if os.path.exists(self.logfile):
224                    os.unlink(self.logfile)
225                os.rename(tmp_name, self.logfile)
226            self.partial = True
227        else:
228            self.partial = False
229        finally:
230            print("End of the test run")
231            print()
232        tee_process.wait()
233
234    def run(self, label, rerun, timeout):
235        cmd = self.ctestCommand, "--output-log", self.logfile
236        if rerun is not None:
237            # cmd += ("--rerun-failed",)
238            # For some reason, this worked never in the script file.
239            # We pass instead the test names as a regex:
240            words = "^(" + "|".join(rerun) + ")$"
241            cmd += ("--tests-regex", words)
242        self._run(cmd, label, timeout)
243# eof
244