Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-09-16 08:18:54

0001 #!/usr/bin/env python
0002 # /// script
0003 # requires-python = ">=3.14"
0004 # dependencies = [
0005 #     "gcovr==8.6",
0006 #     "lxml",
0007 #     "rich",
0008 #     "typer",
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 # Regex patterns for excluding files from coverage (matched against file paths as-is)
0028 EXCLUDE_PATTERNS = [
0029     r"/boost/",
0030     r"json\.hpp",
0031 ]
0032 
0033 # Paths relative to source directory (resolved to absolute when source_dir is known)
0034 EXCLUDE_PATHS = [
0035     "Tests/",
0036     "Python/",
0037     "Examples/",
0038     "docs/",
0039     "dependencies/",
0040     "spack/",
0041     "thirdparty/",
0042     # Excluded for the time being
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()