Revision control

++ # Copyright (C) 2019 The Qt Company Ltd.
++ # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
++
++ import re
++ import subprocess
++ import sys
++ import logging
++ import gzip
++ from typing import Set, List
++
++ usage = """
++ Usage: parse_build_log.py [log_file]
++
++ Parses the output of COIN test runs and prints short summaries of compile
++ errors and test fails for usage as gerrit comment. Takes the file name
++ (either text or compressed .gz file).
++ """
++
++ # Match the log prefix "agent:2019/06/04 12:32:54 agent.go:262:"
++ # and alternatively prefix with column(?): "agent:2019/06/04 12:32:54 agent.go:262: 53: "
++ prefix_re = re.compile(r'^agent:[\d :/]+\w+\.go:\d+: (\d+: )?')
++
++ # Match QTestlib output
++ start_test_re = re.compile(r'^\*{9} Start testing of \w+ \*{9}$')
++ end_test_re = re.compile(r'Totals: \d+ passed, (\d+) failed, \d+ skipped, \d+ blacklisted, \d+ms')
++ end_test_crash_re = re.compile(r'\d+/\d+\sTest\s#\d+:.*\*\*\*Failed.*')
++ end_test_timeout_re = re.compile(r'Test #\d+: .+ \.*\*{3}Timeout \d+\.\d+ sec')
++
++ # Match make or cmake errors
++ make_error_re = re.compile(r'make\[.*Error \d+$')
++ cmake_error_re = re.compile(r'CMake Error')
++
++ # Failed to install tests archive
++ nosource_re = re.compile(r"No sources for \"https?://(\d+\.\d+\.\d+\.\d+):(\d+)") # failed to install tests archive
++
++
++ def read_file(file_name):
++ """
++ Read a text file into a list of of chopped lines.
++ """
++ opener = gzip.open if file_name.endswith(".gz") else open
++ with opener(file_name, mode="rt") as f:
++ return [prefix_re.sub('', l.rstrip()) for l in f.readlines()]
++
++
++ def is_compile_error(line):
++ """
++ Return whether a line is an error from one of the common compilers
++ (g++, MSVC, Python)
++ """
++ if any(e in line for e in (": error: ", ": error C", "SyntaxError:", "NameError:", "fatal error")):
++ # Ignore error messages in debug output
++ # and also ignore the final ERROR building message, as that one would only print sccache
++ # output
++ if not ("QDEBUG" in line or "QWARN" in line):
++ logging.debug(f"===> Found error in line \n{line}\n")
++ return True
++ has_error = make_error_re.match(line) or cmake_error_re.search(line)
++ if has_error:
++ logging.debug(f"===> Found error in line \n{line}\n")
++ return has_error
++
++
++ def print_failed_test(lines, start, end, already_known_errors):
++ """
++ For a failed test, print 3 lines following the FAIL!/XPASS and
++ header/footer.
++ """
++ last_fail = -50
++ # Print 3 lines after a failure
++ header = '\n{}: {}'.format(start, lines[start])
++ test_result = []
++ for i in range(start + 1, end):
++ line = lines[i]
++ if 'FAIL!' in line or 'XPASS' in line or '***Failed' in line:
++ # the format is FAIL! class::method(n) <info>
++ # with n being the time that the test has been repeated. To deduplicate, we need to
++ # remove n. n should be < 9, so %d is sufficient to match
++ # The first test might not have a number at all, but then the regex will also just
++ # ignore it
++ adjusted_line = re.sub(r'\(\d\)', '', line)
++ if not adjusted_line in already_known_errors:
++ already_known_errors.add(adjusted_line)
++ last_fail = i
++ if i - last_fail < 4:
++ test_result.append(line)
++ if test_result:
++ print(header)
++ print("\n".join(test_result))
++ print('{}\n'.format(lines[end]))
++
++ def is_fatal_timeout(line: str) -> bool:
++ if "Killed process: No output received (timeout" in line:
++ return True
++ if end_test_timeout_re.match(line):
++ return True
++
++ def print_line_with_context(start_line_number: int, context_before: int, lines: List[str]):
++ start = max(0, start_line_number - context_before)
++ sys.stdout.write('\n{}: '.format(start))
++ for e in range(start, start_line_number + 1):
++ print(lines[e])
++
++ def parse(lines):
++ """
++ Parse the output and print compile/test errors.
++ """
++ test_start_line = -1
++ within_configure_tests = False
++ already_known_errors: Set[str] = set()
++ # used to skip CMake output which contains information about failed configure tests
++ within_cmake_output: bool = False
++ # used to skip sccache output
++ for i, line in enumerate(lines):
++ if within_cmake_output:
++ if "======== End CMake output ======" in line:
++ within_cmake_output = False
++ elif within_configure_tests:
++ if line == 'Done running configuration tests.':
++ within_configure_tests = False
++ elif test_start_line >= 0:
++ end_match = end_test_re.match(line)
++ if end_match:
++ fails = int(end_match.group(1))
++ if fails:
++ print_failed_test(lines, test_start_line, i, already_known_errors)
++ test_start_line = -1
++ elif end_test_crash_re.match(line):
++ logging.debug(f"===> test crashed {line} {test_start_line} {i}")
++ print_failed_test(lines, test_start_line, i, already_known_errors)
++ test_start_line = -1
++ elif is_fatal_timeout(line):
++ logging.debug("===> Matched fatal timeout")
++ print("While running tests, a timeout occurred: ")
++ print_line_with_context(i, 10, lines)
++ # Do not report errors within configuration tests
++ elif line == 'Running configuration tests...':
++ within_configure_tests = True
++ elif "======== CMake output ======" in line:
++ within_cmake_output = True
++ elif start_test_re.match(line):
++ logging.debug("===> Matched test")
++ test_start_line = i
++ elif is_compile_error(line):
++ logging.debug("===> Matched compile error")
++ print_line_with_context(i, 10, lines)
++ elif nosource_re.match(line):
++ print_line_with_context(i, 10, lines)
++
++ if __name__ == '__main__':
++ if sys.version_info[0] != 3:
++ print("This script requires Python 3")
++ sys.exit(-2)
++ if len(sys.argv) < 2:
++ print(usage)
++ sys.exit(-1)
++ file_name = sys.argv[1]
++ try:
++ should_log = sys.argv[2] == "-debug"
++ if should_log:
++ logging.basicConfig(level=logging.DEBUG)
++ except IndexError:
++ pass
++
++ lines = read_file(file_name)
++ parse(lines)