File indexing completed on 2026-09-16 08:18:54
0001
0002
0003
0004
0005
0006
0007
0008
0009
0010
0011 import multiprocessing as mp
0012 import re
0013 import shutil
0014 import subprocess
0015 import tempfile
0016 from pathlib import Path
0017 import shlex
0018
0019 from typing import Annotated
0020
0021 import typer
0022 from rich.console import Console
0023
0024 app = typer.Typer(add_completion=False)
0025 console = Console()
0026
0027
0028 EXCLUDE_PATTERNS = [
0029 r"/boost/",
0030 r"json\.hpp",
0031 ]
0032
0033
0034 EXCLUDE_PATHS = [
0035 "Tests/",
0036 "Python/",
0037 "Examples/",
0038 "docs/",
0039 "dependencies/",
0040 "spack/",
0041 "thirdparty/",
0042
0043 "Detray/",
0044 "Traccc/",
0045 ]
0046
0047
0048 def _resolve_excludes(source_dir: Path) -> list[str]:
0049 """Return exclude patterns: EXCLUDE_PATTERNS as-is plus EXCLUDE_PATHS prefixed with source_dir."""
0050 source_prefix = re.escape(source_dir.as_posix()) + r"/"
0051 return EXCLUDE_PATTERNS + [source_prefix + p for p in EXCLUDE_PATHS]
0052
0053
0054 def locate_executable(name: str, hint: str) -> str:
0055 path = shutil.which(name)
0056 if not path:
0057 console.print(hint, style="red")
0058 raise typer.Exit(1)
0059 return path
0060
0061
0062 def gcovr_version(gcovr_exe: str) -> tuple[int, int] | None:
0063 version_text = subprocess.check_output([gcovr_exe, "--version"], text=True).strip()
0064 match = re.match(r"gcovr (\d+)\.(\d+)", version_text)
0065 if not match:
0066 console.print(
0067 f"Unexpected gcovr version output: {version_text}",
0068 style="yellow",
0069 )
0070 return None
0071 return (int(match.group(1)), int(match.group(2)))
0072
0073
0074 @app.command()
0075 def generate(
0076 build_dir: Annotated[Path, typer.Argument(help="CMake build directory")],
0077 gcov: Annotated[
0078 str | None,
0079 typer.Option(help="Path to gcov executable"),
0080 ] = None,
0081 jobs: Annotated[
0082 int, typer.Option("--jobs", "-j", help="Number of parallel jobs")
0083 ] = mp.cpu_count(),
0084 verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
0085 filter_xml: Annotated[
0086 bool,
0087 typer.Option(
0088 "--filter/--no-filter", help="Filter the coverage XML after generation"
0089 ),
0090 ] = False,
0091 ) -> None:
0092 """Generate SonarQube XML and optionally HTML coverage reports from a CMake build directory using gcovr."""
0093 build_dir = build_dir.resolve()
0094 if not build_dir.is_dir():
0095 console.print(f"Build directory not found: {build_dir}", style="red")
0096 raise typer.Exit(1)
0097 if not (build_dir / "CMakeCache.txt").exists():
0098 console.print(
0099 f"Build directory missing CMakeCache.txt: {build_dir}",
0100 style="red",
0101 )
0102 raise typer.Exit(1)
0103
0104 gcov_exe = gcov or locate_executable(
0105 "gcov",
0106 "gcov not installed. Install GCC coverage tooling or pass the path to gcov with --gcov.",
0107 )
0108 gcovr_exe = locate_executable(
0109 "gcovr",
0110 "gcovr not installed. Use 'uv run --script' or install gcovr.",
0111 )
0112
0113 version = gcovr_version(gcovr_exe)
0114 if version is not None:
0115 console.print(f"Found gcovr version {version[0]}.{version[1]}")
0116 if version < (5, 0):
0117 console.print(
0118 "Consider upgrading to a newer gcovr version.", style="yellow"
0119 )
0120 elif version == (5, 1):
0121 console.print(
0122 "Version 5.1 does not support parallel processing of gcov data.",
0123 style="red",
0124 )
0125 raise typer.Exit(1)
0126
0127 coverage_dir = build_dir / "coverage"
0128 coverage_dir.mkdir(exist_ok=True)
0129
0130 coverage_xml_path = coverage_dir / "cov.xml"
0131 raw_xml_path = coverage_dir / "cov_raw.xml" if filter_xml else coverage_xml_path
0132
0133 with tempfile.TemporaryDirectory() as gcov_obj_dir:
0134 base_args = _build_gcovr_common_args(
0135 build_dir, gcov_exe, gcovr_exe, jobs, verbose, gcov_obj_dir
0136 )
0137 gcovr_cmd = base_args + ["--sonarqube", str(raw_xml_path)]
0138 html_dir = coverage_dir / "html"
0139 html_dir.mkdir(exist_ok=True)
0140 html_path = html_dir / "index.html"
0141 gcovr_cmd += [
0142 "--html-nested",
0143 str(html_path),
0144 "--html-theme",
0145 "github.blue",
0146 ]
0147
0148 console.print(f"$ {shlex.join(gcovr_cmd)}")
0149 subprocess.run(gcovr_cmd, cwd=build_dir, check=True)
0150
0151 console.print(f"HTML coverage report written to {coverage_dir / 'html'}")
0152
0153 if filter_xml:
0154 source_dir = Path(__file__).resolve().parent.parent
0155 xml_excludes = _resolve_excludes(source_dir) + ["^" + re.escape(build_dir.name)]
0156 filter_coverage_xml(raw_xml_path, coverage_xml_path, xml_excludes)
0157 raw_xml_path.unlink()
0158 console.print(f"Removed raw coverage file {raw_xml_path}")
0159
0160
0161 def filter_coverage_xml(
0162 input_path: Path, output_path: Path, excludes: list[str]
0163 ) -> None:
0164 from lxml import etree
0165
0166 patterns = [re.compile(p) for p in excludes]
0167
0168 tree = etree.parse(input_path)
0169 root = tree.getroot()
0170
0171 removed = 0
0172 for file_elem in root.findall("file"):
0173 path = file_elem.get("path", "")
0174 if any(p.search(path) for p in patterns):
0175 root.remove(file_elem)
0176 removed += 1
0177
0178 remaining = len(root.findall("file"))
0179 console.print(f"Removed {removed} file entries, {remaining} remaining")
0180
0181 deduped = 0
0182 for file_elem in root.findall("file"):
0183 lines: dict[int, etree._Element] = {}
0184 duplicates: list[etree._Element] = []
0185 for line_elem in file_elem.findall("lineToCover"):
0186 line_num = int(line_elem.get("lineNumber"))
0187 if line_num not in lines:
0188 lines[line_num] = line_elem
0189 else:
0190 existing = lines[line_num]
0191 if line_elem.get("covered") == "true":
0192 existing.set("covered", "true")
0193 for attr in ("branchesToCover", "coveredBranches"):
0194 new_val = line_elem.get(attr)
0195 if new_val is not None:
0196 old_val = existing.get(attr)
0197 if old_val is None or int(new_val) > int(old_val):
0198 existing.set(attr, new_val)
0199 duplicates.append(line_elem)
0200 deduped += 1
0201 for dup in duplicates:
0202 file_elem.remove(dup)
0203
0204 if deduped:
0205 console.print(f"Deduplicated {deduped} lineToCover entries")
0206
0207 output_path.parent.mkdir(parents=True, exist_ok=True)
0208 tree.write(output_path, xml_declaration=True, encoding="utf-8")
0209 console.print(f"Filtered coverage written to {output_path}")
0210
0211
0212 def _build_gcovr_common_args(
0213 build_dir: Path,
0214 gcov_exe: str,
0215 gcovr_exe: str,
0216 jobs: int,
0217 verbose: bool,
0218 gcov_object_directory: str,
0219 ) -> list[str]:
0220 script_dir = Path(__file__).resolve().parent
0221 source_dir = script_dir.parent.resolve()
0222
0223 version = gcovr_version(gcovr_exe)
0224 extra_flags: list[str] = []
0225 if version is not None and version >= (6, 0):
0226 extra_flags.append("--exclude-noncode-lines")
0227 if verbose:
0228 extra_flags.append("--verbose")
0229
0230 excludes: list[str] = []
0231 for pattern in _resolve_excludes(source_dir):
0232 excludes.extend(["-e", pattern])
0233 excludes.extend(["-e", f"{build_dir.as_posix()}/"])
0234
0235 return (
0236 [gcovr_exe]
0237 + ["-r", str(source_dir)]
0238 + ["--gcov-executable", gcov_exe]
0239 + ["--gcov-object-directory", gcov_object_directory]
0240 + ["-j", str(jobs)]
0241 + ["--merge-mode-functions", "separate"]
0242 + ["--gcov-ignore-errors", "source_not_found"]
0243 + ["--gcov-ignore-parse-errors", "suspicious_hits.warn"]
0244 + excludes
0245 + extra_flags
0246 )
0247
0248
0249 if __name__ == "__main__":
0250 app()