|
1 | 1 | #!/usr/bin/env python |
2 | | -import sys |
3 | | -import os |
4 | | -import subprocess |
5 | | -import argparse |
| 2 | +# /// script |
| 3 | +# requires-python = ">=3.14" |
| 4 | +# dependencies = [ |
| 5 | +# "gcovr==8.6", |
| 6 | +# "lxml", |
| 7 | +# "rich", |
| 8 | +# "typer", |
| 9 | +# ] |
| 10 | +# /// |
6 | 11 | import multiprocessing as mp |
7 | 12 | import re |
| 13 | +import shutil |
| 14 | +import subprocess |
| 15 | +import tempfile |
| 16 | +from pathlib import Path |
| 17 | +import shlex |
| 18 | + |
| 19 | +from typing import Annotated |
| 20 | + |
| 21 | +import typer |
| 22 | +from rich.console import Console |
| 23 | + |
| 24 | +app = typer.Typer(add_completion=False) |
| 25 | +console = Console() |
| 26 | + |
| 27 | +# Regex patterns for excluding files from coverage (matched against file paths as-is) |
| 28 | +EXCLUDE_PATTERNS = [ |
| 29 | + r"/boost/", |
| 30 | + r"json\.hpp", |
| 31 | +] |
| 32 | + |
| 33 | +# Paths relative to source directory (resolved to absolute when source_dir is known) |
| 34 | +EXCLUDE_PATHS = [ |
| 35 | + "Tests/", |
| 36 | + "Python/", |
| 37 | + "dependencies/", |
| 38 | + "spack/", |
| 39 | + "thirdparty/", |
| 40 | +] |
| 41 | + |
| 42 | + |
| 43 | +def _resolve_excludes(source_dir: Path) -> list[str]: |
| 44 | + """Return exclude patterns: EXCLUDE_PATTERNS as-is plus EXCLUDE_PATHS prefixed with source_dir.""" |
| 45 | + source_prefix = re.escape(source_dir.as_posix()) + r"/" |
| 46 | + return EXCLUDE_PATTERNS + [source_prefix + p for p in EXCLUDE_PATHS] |
| 47 | + |
| 48 | + |
| 49 | +def locate_executable(name: str, hint: str) -> str: |
| 50 | + path = shutil.which(name) |
| 51 | + if not path: |
| 52 | + console.print(hint, style="red") |
| 53 | + raise typer.Exit(1) |
| 54 | + return path |
| 55 | + |
| 56 | + |
| 57 | +def gcovr_version(gcovr_exe: str) -> tuple[int, int] | None: |
| 58 | + version_text = subprocess.check_output([gcovr_exe, "--version"], text=True).strip() |
| 59 | + match = re.match(r"gcovr (\d+)\.(\d+)", version_text) |
| 60 | + if not match: |
| 61 | + console.print( |
| 62 | + f"Unexpected gcovr version output: {version_text}", |
| 63 | + style="yellow", |
| 64 | + ) |
| 65 | + return None |
| 66 | + return (int(match.group(1)), int(match.group(2))) |
| 67 | + |
| 68 | + |
| 69 | +@app.command() |
| 70 | +def generate( |
| 71 | + build_dir: Annotated[Path, typer.Argument(help="CMake build directory")], |
| 72 | + gcov: Annotated[ |
| 73 | + str | None, |
| 74 | + typer.Option(help="Path to gcov executable"), |
| 75 | + ] = None, |
| 76 | + jobs: Annotated[ |
| 77 | + int, typer.Option("--jobs", "-j", help="Number of parallel jobs") |
| 78 | + ] = mp.cpu_count(), |
| 79 | + verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False, |
| 80 | + filter_xml: Annotated[ |
| 81 | + bool, |
| 82 | + typer.Option( |
| 83 | + "--filter/--no-filter", help="Filter the coverage XML after generation" |
| 84 | + ), |
| 85 | + ] = False, |
| 86 | +) -> None: |
| 87 | + """Generate SonarQube XML and optionally HTML coverage reports from a CMake build directory using gcovr.""" |
| 88 | + build_dir = build_dir.resolve() |
| 89 | + if not build_dir.is_dir(): |
| 90 | + console.print(f"Build directory not found: {build_dir}", style="red") |
| 91 | + raise typer.Exit(1) |
| 92 | + if not (build_dir / "CMakeCache.txt").exists(): |
| 93 | + console.print( |
| 94 | + f"Build directory missing CMakeCache.txt: {build_dir}", |
| 95 | + style="red", |
| 96 | + ) |
| 97 | + raise typer.Exit(1) |
| 98 | + |
| 99 | + gcov_exe = gcov or locate_executable( |
| 100 | + "gcov", |
| 101 | + "gcov not installed. Install GCC coverage tooling or pass the path to gcov with --gcov.", |
| 102 | + ) |
| 103 | + gcovr_exe = locate_executable( |
| 104 | + "gcovr", |
| 105 | + "gcovr not installed. Use 'uv run --script' or install gcovr.", |
| 106 | + ) |
8 | 107 |
|
9 | | - |
10 | | -if not os.path.exists("CMakeCache.txt"): |
11 | | - print("Not in CMake build dir. Not executing") |
12 | | - sys.exit(1) |
| 108 | + version = gcovr_version(gcovr_exe) |
| 109 | + if version is not None: |
| 110 | + console.print(f"Found gcovr version {version[0]}.{version[1]}") |
| 111 | + if version < (5, 0): |
| 112 | + console.print( |
| 113 | + "Consider upgrading to a newer gcovr version.", style="yellow" |
| 114 | + ) |
| 115 | + elif version == (5, 1): |
| 116 | + console.print( |
| 117 | + "Version 5.1 does not support parallel processing of gcov data.", |
| 118 | + style="red", |
| 119 | + ) |
| 120 | + raise typer.Exit(1) |
| 121 | + |
| 122 | + coverage_dir = build_dir / "coverage" |
| 123 | + coverage_dir.mkdir(exist_ok=True) |
| 124 | + |
| 125 | + coverage_xml_path = coverage_dir / "cov.xml" |
| 126 | + raw_xml_path = coverage_dir / "cov_raw.xml" if filter_xml else coverage_xml_path |
| 127 | + |
| 128 | + with tempfile.TemporaryDirectory() as gcov_obj_dir: |
| 129 | + base_args = _build_gcovr_common_args( |
| 130 | + build_dir, gcov_exe, gcovr_exe, jobs, verbose, gcov_obj_dir |
| 131 | + ) |
| 132 | + gcovr_cmd = base_args + ["--sonarqube", str(raw_xml_path)] |
| 133 | + html_dir = coverage_dir / "html" |
| 134 | + html_dir.mkdir(exist_ok=True) |
| 135 | + html_path = html_dir / "index.html" |
| 136 | + gcovr_cmd += [ |
| 137 | + "--html-nested", |
| 138 | + str(html_path), |
| 139 | + "--html-theme", |
| 140 | + "github.blue", |
| 141 | + ] |
| 142 | + |
| 143 | + console.print(f"$ {shlex.join(gcovr_cmd)}") |
| 144 | + subprocess.run(gcovr_cmd, cwd=build_dir, check=True) |
| 145 | + |
| 146 | + console.print(f"HTML coverage report written to {coverage_dir / 'html'}") |
| 147 | + |
| 148 | + if filter_xml: |
| 149 | + source_dir = Path(__file__).resolve().parent.parent |
| 150 | + xml_excludes = _resolve_excludes(source_dir) + ["^" + re.escape(build_dir.name)] |
| 151 | + filter_coverage_xml(raw_xml_path, coverage_xml_path, xml_excludes) |
| 152 | + raw_xml_path.unlink() |
| 153 | + console.print(f"Removed raw coverage file {raw_xml_path}") |
| 154 | + |
| 155 | + |
| 156 | +def filter_coverage_xml( |
| 157 | + input_path: Path, output_path: Path, excludes: list[str] |
| 158 | +) -> None: |
| 159 | + from lxml import etree |
| 160 | + |
| 161 | + patterns = [re.compile(p) for p in excludes] |
| 162 | + |
| 163 | + tree = etree.parse(input_path) |
| 164 | + root = tree.getroot() |
| 165 | + |
| 166 | + removed = 0 |
| 167 | + for file_elem in root.findall("file"): |
| 168 | + path = file_elem.get("path", "") |
| 169 | + if any(p.search(path) for p in patterns): |
| 170 | + root.remove(file_elem) |
| 171 | + removed += 1 |
| 172 | + |
| 173 | + remaining = len(root.findall("file")) |
| 174 | + console.print(f"Removed {removed} file entries, {remaining} remaining") |
| 175 | + |
| 176 | + deduped = 0 |
| 177 | + for file_elem in root.findall("file"): |
| 178 | + lines: dict[int, etree._Element] = {} |
| 179 | + duplicates: list[etree._Element] = [] |
| 180 | + for line_elem in file_elem.findall("lineToCover"): |
| 181 | + line_num = int(line_elem.get("lineNumber")) |
| 182 | + if line_num not in lines: |
| 183 | + lines[line_num] = line_elem |
| 184 | + else: |
| 185 | + existing = lines[line_num] |
| 186 | + if line_elem.get("covered") == "true": |
| 187 | + existing.set("covered", "true") |
| 188 | + for attr in ("branchesToCover", "coveredBranches"): |
| 189 | + new_val = line_elem.get(attr) |
| 190 | + if new_val is not None: |
| 191 | + old_val = existing.get(attr) |
| 192 | + if old_val is None or int(new_val) > int(old_val): |
| 193 | + existing.set(attr, new_val) |
| 194 | + duplicates.append(line_elem) |
| 195 | + deduped += 1 |
| 196 | + for dup in duplicates: |
| 197 | + file_elem.remove(dup) |
| 198 | + |
| 199 | + if deduped: |
| 200 | + console.print(f"Deduplicated {deduped} lineToCover entries") |
| 201 | + |
| 202 | + output_path.parent.mkdir(parents=True, exist_ok=True) |
| 203 | + tree.write(output_path, xml_declaration=True, encoding="utf-8") |
| 204 | + console.print(f"Filtered coverage written to {output_path}") |
| 205 | + |
| 206 | + |
| 207 | +def _build_gcovr_common_args( |
| 208 | + build_dir: Path, |
| 209 | + gcov_exe: str, |
| 210 | + gcovr_exe: str, |
| 211 | + jobs: int, |
| 212 | + verbose: bool, |
| 213 | + gcov_object_directory: str, |
| 214 | +) -> list[str]: |
| 215 | + script_dir = Path(__file__).resolve().parent |
| 216 | + source_dir = script_dir.parent.resolve() |
| 217 | + |
| 218 | + version = gcovr_version(gcovr_exe) |
| 219 | + extra_flags: list[str] = [] |
| 220 | + if version is not None and version >= (6, 0): |
| 221 | + extra_flags.append("--exclude-noncode-lines") |
| 222 | + if verbose: |
| 223 | + extra_flags.append("--verbose") |
| 224 | + |
| 225 | + excludes: list[str] = [] |
| 226 | + for pattern in _resolve_excludes(source_dir): |
| 227 | + excludes.extend(["-e", pattern]) |
| 228 | + excludes.extend(["-e", f"{build_dir.as_posix()}/"]) |
| 229 | + |
| 230 | + return ( |
| 231 | + [gcovr_exe] |
| 232 | + + ["-r", str(source_dir)] |
| 233 | + + ["--gcov-executable", gcov_exe] |
| 234 | + + ["--gcov-object-directory", gcov_object_directory] |
| 235 | + + ["-j", str(jobs)] |
| 236 | + + ["--merge-mode-functions", "separate"] |
| 237 | + + ["--gcov-ignore-errors", "source_not_found"] |
| 238 | + + ["--gcov-ignore-parse-errors", "suspicious_hits.warn"] |
| 239 | + + excludes |
| 240 | + + extra_flags |
| 241 | + ) |
13 | 242 |
|
14 | 243 |
|
15 | | -def check_output(*args, **kwargs): |
16 | | - p = subprocess.Popen( |
17 | | - *args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kwargs |
18 | | - ) |
19 | | - p.wait() |
20 | | - stdout, stderr = p.communicate() |
21 | | - stdout = stdout.decode("utf-8") |
22 | | - return (p.returncode, stdout.strip()) |
23 | | - |
24 | | - |
25 | | -# call helper function |
26 | | -def call(cmd): |
27 | | - print(" ".join(cmd)) |
28 | | - try: |
29 | | - subprocess.check_call(cmd) |
30 | | - except subprocess.CalledProcessError as e: |
31 | | - print("Failed, output: ", e.output) |
32 | | - raise e |
33 | | - |
34 | | - |
35 | | -p = argparse.ArgumentParser() |
36 | | -p.add_argument("--gcov", default=check_output(["which", "gcov"])[1]) |
37 | | -args = p.parse_args() |
38 | | - |
39 | | -ret, gcovr_exe = check_output(["which", "gcovr"]) |
40 | | -assert ret == 0, "gcovr not installed. Use 'pip install gcovr'." |
41 | | - |
42 | | -ret, gcovr_version_text = check_output(["gcovr", "--version"]) |
43 | | -gcovr_version = tuple( |
44 | | - map(int, re.match(r"gcovr (\d+\.\d+)", gcovr_version_text).group(1).split(".")) |
45 | | -) |
46 | | - |
47 | | -extra_flags = [] |
48 | | - |
49 | | -print(f"Found gcovr version {gcovr_version[0]}.{gcovr_version[1]}") |
50 | | -if gcovr_version < (5,): |
51 | | - print("Consider upgrading to a newer gcovr version.") |
52 | | -elif gcovr_version == (5, 1): |
53 | | - assert False and "Version 5.1 does not support parallel processing of gcov data" |
54 | | -elif gcovr_version >= (6,): |
55 | | - extra_flags += ["--exclude-noncode-lines"] |
56 | | - |
57 | | -gcovr = [gcovr_exe] |
58 | | - |
59 | | -script_dir = os.path.dirname(__file__) |
60 | | -source_dir = os.path.abspath(os.path.join(script_dir, "..")) |
61 | | -coverage_dir = os.path.abspath("coverage") |
62 | | - |
63 | | -if not os.path.exists(coverage_dir): |
64 | | - os.makedirs(coverage_dir) |
65 | | - |
66 | | -excludes = ["-e", "../Tests/", "-e", r".*json\.hpp", "-e", "../Python/"] |
67 | | - |
68 | | -# create the html report |
69 | | -call( |
70 | | - gcovr |
71 | | - + ["-r", source_dir] |
72 | | - + ["--gcov-executable", args.gcov] |
73 | | - + ["-j", str(mp.cpu_count())] |
74 | | - + ["--merge-mode-functions", "separate"] |
75 | | - + excludes |
76 | | - + extra_flags |
77 | | - + ["--sonarqube", "coverage/cov.xml"] |
78 | | -) |
79 | | - |
80 | | -call( |
81 | | - gcovr |
82 | | - + ["-r", source_dir] |
83 | | - + ["-j", str(mp.cpu_count())] |
84 | | - + ["--gcov-executable", args.gcov] |
85 | | - + ["--merge-mode-functions", "separate"] |
86 | | - + excludes |
87 | | - + extra_flags |
88 | | -) |
| 244 | +if __name__ == "__main__": |
| 245 | + app() |
0 commit comments