1# Copyright 2022 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14 15# Inspired by 16# https://fuchsia.googlesource.com/infra/recipes/+/336933647862a1a9718b4ca18f0a67e89c2419f8/recipe_modules/ninja/resources/ninja_wrapper.py 17"""Extracts a concise error from a ninja log.""" 18 19import argparse 20import logging 21from pathlib import Path 22import re 23from typing import IO 24import sys 25 26_LOG: logging.Logger = logging.getLogger(__name__) 27 28# Assume any of these lines could be prefixed with ANSI color codes. 29_COLOR_CODES_PREFIX = r'^(?:\x1b)?(?:\[\d+m\s*)?' 30 31_GOOGLETEST_FAILED, _GOOGLETEST_RUN, _GOOGLETEST_OK, _GOOGLETEST_DISABLED = ( 32 '[ FAILED ]', 33 '[ RUN ]', 34 '[ OK ]', 35 '[ DISABLED ]', 36) 37 38 39def _remove_passing_tests(failure_lines: list[str]) -> list[str]: 40 test_lines: list[str] = [] 41 result = [] 42 for line in failure_lines: 43 if test_lines: 44 if _GOOGLETEST_OK in line: 45 test_lines = [] 46 elif _GOOGLETEST_FAILED in line: 47 result.extend(test_lines) 48 test_lines = [] 49 result.append(line) 50 else: 51 test_lines.append(line) 52 elif _GOOGLETEST_RUN in line: 53 test_lines.append(line) 54 elif _GOOGLETEST_DISABLED in line: 55 pass 56 else: 57 result.append(line) 58 result.extend(test_lines) 59 return result 60 61 62def _parse_ninja(ins: IO) -> str: 63 failure_lines: list[str] = [] 64 last_line: str = '' 65 66 for line in ins: 67 _LOG.debug('processing %r', line) 68 # Trailing whitespace isn't significant, as it doesn't affect the 69 # way the line shows up in the logs. However, leading whitespace may 70 # be significant, especially for compiler error messages. 71 line = line.rstrip() 72 73 if failure_lines: 74 _LOG.debug('inside failure block') 75 76 if re.match(_COLOR_CODES_PREFIX + r'\[\d+/\d+\] (\S+)', line): 77 _LOG.debug('next rule started, ending failure block') 78 break 79 80 if re.match(_COLOR_CODES_PREFIX + r'ninja: build stopped:.*', line): 81 _LOG.debug('ninja build stopped, ending failure block') 82 break 83 failure_lines.append(line) 84 85 else: 86 if re.match(_COLOR_CODES_PREFIX + r'FAILED: (.*)$', line): 87 _LOG.debug('starting failure block') 88 failure_lines.extend([last_line, line]) 89 elif 'FAILED' in line: 90 _LOG.debug('not a match') 91 last_line = line 92 93 failure_lines = _remove_passing_tests(failure_lines) 94 95 # Remove "Requirement already satisfied:" lines since many of those might 96 # be printed during Python installation, and they usually have no relevance 97 # to the actual error. 98 failure_lines = [ 99 x 100 for x in failure_lines 101 if not x.lstrip().startswith('Requirement already satisfied:') 102 ] 103 104 # Trim paths so the output is less likely to wrap. Specifically, if the 105 # path has six or more segments trim it down to three. 106 path_re = re.compile( 107 r'(?P<prefix>\b|\s)' 108 r'[-\w._]+/[-\w._]+(?:/[-\w._]+)+' 109 r'(?P<core>/[-\w._]+/[-\w._]+/[-\w._]+)' 110 r'(?P<suffix>\b|\s)' 111 ) 112 113 def replace(m: re.Match): 114 return ''.join( 115 (m.group('prefix'), '[...]', m.group('core'), m.group('suffix')) 116 ) 117 118 failure_lines = [path_re.sub(replace, x) for x in failure_lines] 119 120 result: str = '\n'.join(failure_lines) 121 return re.sub(r'\n+', '\n', result) 122 123 124def parse_ninja_stdout(ninja_stdout: Path) -> str: 125 """Extract an error summary from ninja output.""" 126 with ninja_stdout.open() as ins: 127 return _parse_ninja(ins) 128 129 130def main(argv=None): 131 parser = argparse.ArgumentParser() 132 parser.add_argument('input', type=argparse.FileType('r')) 133 parser.add_argument('output', type=argparse.FileType('w')) 134 args = parser.parse_args(argv) 135 136 for line in _parse_ninja(args.input): 137 args.output.write(line) 138 139 140if __name__ == '__main__': 141 sys.exit(main()) 142