| """ |
| Parses compiler output from Clang or GCC and checks that warnings |
| exist only in files that are expected to have warnings. |
| """ |
| |
| import argparse |
| import re |
| import sys |
| from collections import defaultdict |
| from pathlib import Path |
| from typing import NamedTuple, TypedDict |
| |
| |
| class IgnoreRule(NamedTuple): |
| file_path: str |
| count: int # type: ignore[assignment] |
| ignore_all: bool = False |
| is_directory: bool = False |
| |
| |
| class CompileWarning(TypedDict): |
| file: str |
| line: str |
| column: str |
| message: str |
| option: str |
| |
| |
| def parse_warning_ignore_file(file_path: str) -> set[IgnoreRule]: |
| """ |
| Parses the warning ignore file and returns a set of IgnoreRules |
| """ |
| files_with_expected_warnings: set[IgnoreRule] = set() |
| with Path(file_path).open(encoding="UTF-8") as ignore_rules_file: |
| files_with_expected_warnings = set() |
| for i, line in enumerate(ignore_rules_file): |
| line = line.strip() |
| if line and not line.startswith("#"): |
| line_parts = line.split() |
| if len(line_parts) >= 2: |
| file_name = line_parts[0] |
| count = line_parts[1] |
| ignore_all = count == "*" |
| is_directory = file_name.endswith("/") |
| |
| # Directories must have a wildcard count |
| if is_directory and count != "*": |
| print( |
| f"Error parsing ignore file: {file_path} " |
| f"at line: {i}" |
| ) |
| print( |
| f"Directory {file_name} must have count set to *" |
| ) |
| sys.exit(1) |
| if ignore_all: |
| count = "0" |
| |
| files_with_expected_warnings.add( |
| IgnoreRule( |
| file_name, int(count), ignore_all, is_directory |
| ) |
| ) |
| |
| return files_with_expected_warnings |
| |
| |
| def extract_warnings_from_compiler_output( |
| compiler_output: str, |
| compiler_output_type: str, |
| path_prefix: str = "", |
| ) -> list[CompileWarning]: |
| """ |
| Extracts warnings from the compiler output based on compiler |
| output type. Removes path prefix from file paths if provided. |
| Compatible with GCC and Clang compiler output. |
| """ |
| # Choose pattern and compile regex for particular compiler output |
| if compiler_output_type == "gcc": |
| regex_pattern = ( |
| r"(?P<file>.*):(?P<line>\d+):(?P<column>\d+): warning: " |
| r"(?P<message>.*?)(?: (?P<option>\[-[^\]]+\]))?$" |
| ) |
| elif compiler_output_type == "clang": |
| regex_pattern = ( |
| r"(?P<file>.*):(?P<line>\d+):(?P<column>\d+): warning: " |
| r"(?P<message>.*) (?P<option>\[-[^\]]+\])$" |
| ) |
| else: |
| raise RuntimeError( |
| f"Unsupported compiler output type: {compiler_output_type}", |
| ) |
| compiled_regex = re.compile(regex_pattern) |
| compiler_warnings: list[CompileWarning] = [] |
| for i, line in enumerate(compiler_output.splitlines(), start=1): |
| if match := compiled_regex.match(line): |
| try: |
| compiler_warnings.append({ |
| "file": match.group("file").removeprefix(path_prefix), |
| "line": match.group("line"), |
| "column": match.group("column"), |
| "message": match.group("message"), |
| "option": match.group("option").lstrip("[").rstrip("]"), |
| }) |
| except AttributeError: |
| print( |
| f"Error parsing compiler output. " |
| f"Unable to extract warning on line {i}:\n{line}" |
| ) |
| sys.exit(1) |
| |
| return compiler_warnings |
| |
| |
| def get_warnings_by_file( |
| warnings: list[CompileWarning], |
| ) -> dict[str, list[CompileWarning]]: |
| """ |
| Returns a dictionary where the key is the file and the data is the |
| warnings in that file. Does not include duplicate warnings for a |
| file from list of provided warnings. |
| """ |
| warnings_by_file = defaultdict(list) |
| warnings_added = set() |
| for warning in warnings: |
| warning_key = ( |
| f"{warning['file']}-{warning['line']}-" |
| f"{warning['column']}-{warning['option']}" |
| ) |
| if warning_key not in warnings_added: |
| warnings_added.add(warning_key) |
| warnings_by_file[warning["file"]].append(warning) |
| |
| return warnings_by_file |
| |
| |
| def is_file_ignored( |
| file_path: str, ignore_rules: set[IgnoreRule] |
| ) -> IgnoreRule | None: |
| """Return the IgnoreRule object for the file path. |
| |
| Return ``None`` if there is no related rule for that path. |
| """ |
| for rule in ignore_rules: |
| if rule.is_directory: |
| if file_path.startswith(rule.file_path): |
| return rule |
| elif file_path == rule.file_path: |
| return rule |
| return None |
| |
| |
| def get_unexpected_warnings( |
| ignore_rules: set[IgnoreRule], |
| files_with_warnings: dict[str, list[CompileWarning]], |
| ) -> int: |
| """ |
| Returns failure status if warnings discovered in list of warnings |
| are associated with a file that is not found in the list of files |
| with expected warnings |
| """ |
| unexpected_warnings = {} |
| for file in files_with_warnings.keys(): |
| rule = is_file_ignored(file, ignore_rules) |
| |
| if rule: |
| if rule.ignore_all: |
| continue |
| |
| if len(files_with_warnings[file]) > rule.count: |
| unexpected_warnings[file] = ( |
| files_with_warnings[file], |
| rule.count, |
| ) |
| continue |
| elif rule is None: |
| # If the file is not in the ignore list, then it is unexpected |
| unexpected_warnings[file] = (files_with_warnings[file], 0) |
| |
| if unexpected_warnings: |
| print("Unexpected warnings:") |
| for file in unexpected_warnings: |
| print( |
| f"{file} expected {unexpected_warnings[file][1]} warnings," |
| f" found {len(unexpected_warnings[file][0])}" |
| ) |
| for warning in unexpected_warnings[file][0]: |
| print(warning) |
| |
| return 1 |
| |
| return 0 |
| |
| |
| def get_unexpected_improvements( |
| ignore_rules: set[IgnoreRule], |
| files_with_warnings: dict[str, list[CompileWarning]], |
| ) -> int: |
| """ |
| Returns failure status if the number of warnings for a file is greater |
| than the expected number of warnings for that file based on the ignore |
| rules |
| """ |
| unexpected_improvements = [] |
| for rule in ignore_rules: |
| if ( |
| not rule.ignore_all |
| and rule.file_path not in files_with_warnings.keys() |
| ): |
| if rule.file_path not in files_with_warnings.keys(): |
| unexpected_improvements.append((rule.file_path, rule.count, 0)) |
| elif len(files_with_warnings[rule.file_path]) < rule.count: |
| unexpected_improvements.append(( |
| rule.file_path, |
| rule.count, |
| len(files_with_warnings[rule.file_path]), |
| )) |
| |
| if unexpected_improvements: |
| print("Unexpected improvements:") |
| for file in unexpected_improvements: |
| print(f"{file[0]} expected {file[1]} warnings, found {file[2]}") |
| return 1 |
| |
| return 0 |
| |
| |
| def main(argv: list[str] | None = None) -> int: |
| parser = argparse.ArgumentParser() |
| parser.add_argument( |
| "-c", |
| "--compiler-output-file-path", |
| type=str, |
| required=True, |
| help="Path to the compiler output file", |
| ) |
| parser.add_argument( |
| "-i", |
| "--warning-ignore-file-path", |
| type=str, |
| help="Path to the warning ignore file", |
| ) |
| parser.add_argument( |
| "-x", |
| "--fail-on-regression", |
| action="store_true", |
| default=False, |
| help="Flag to fail if new warnings are found", |
| ) |
| parser.add_argument( |
| "-X", |
| "--fail-on-improvement", |
| action="store_true", |
| default=False, |
| help="Flag to fail if files that were expected " |
| "to have warnings have no warnings", |
| ) |
| parser.add_argument( |
| "-t", |
| "--compiler-output-type", |
| type=str, |
| required=True, |
| choices=["gcc", "clang"], |
| help="Type of compiler output file (GCC or Clang)", |
| ) |
| parser.add_argument( |
| "-p", |
| "--path-prefix", |
| type=str, |
| help="Path prefix to remove from the start of file paths" |
| " in compiler output", |
| ) |
| |
| args = parser.parse_args(argv) |
| |
| exit_code = 0 |
| |
| # Check that the compiler output file is a valid path |
| if not Path(args.compiler_output_file_path).is_file(): |
| print( |
| f"Compiler output file does not exist:" |
| f" {args.compiler_output_file_path}" |
| ) |
| return 1 |
| |
| # Check that a warning ignore file was specified and if so is a valid path |
| if not args.warning_ignore_file_path: |
| print( |
| "Warning ignore file not specified." |
| " Continuing without it (no warnings ignored)." |
| ) |
| ignore_rules = set() |
| else: |
| if not Path(args.warning_ignore_file_path).is_file(): |
| print( |
| f"Warning ignore file does not exist:" |
| f" {args.warning_ignore_file_path}" |
| ) |
| return 1 |
| ignore_rules = parse_warning_ignore_file(args.warning_ignore_file_path) |
| |
| with Path(args.compiler_output_file_path).open(encoding="UTF-8") as f: |
| compiler_output_file_contents = f.read() |
| |
| warnings = extract_warnings_from_compiler_output( |
| compiler_output_file_contents, |
| args.compiler_output_type, |
| args.path_prefix, |
| ) |
| |
| files_with_warnings = get_warnings_by_file(warnings) |
| |
| status = get_unexpected_warnings(ignore_rules, files_with_warnings) |
| if args.fail_on_regression: |
| exit_code |= status |
| |
| status = get_unexpected_improvements(ignore_rules, files_with_warnings) |
| if args.fail_on_improvement: |
| exit_code |= status |
| |
| print( |
| "For information about this tool and its configuration" |
| " visit https://devguide.python.org/development-tools/warnings/" |
| ) |
| |
| return exit_code |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |