Skip to content

Commit c7df1d1

Browse files
ci: Update coverage scripts (acts-project#5038)
1 parent 2e904ee commit c7df1d1

File tree

2 files changed

+259
-87
lines changed

2 files changed

+259
-87
lines changed

.github/workflows/analysis.yml

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,30 @@ jobs:
8787
&& find build -name *.o -delete
8888
&& du -sh build
8989
- name: Coverage
90-
run: >
91-
CI/dependencies/run.sh .env pip3 install gcovr==7.2
92-
&& cd build
93-
&& ../CI/dependencies/run.sh ../.env /usr/bin/python3 ../CI/test_coverage.py
90+
run: |
91+
uv run --no-project CI/test_coverage.py build --filter
9492
9593
- uses: actions/upload-artifact@v6
94+
if: always()
9695
with:
9796
name: coverage-build
98-
path: build
97+
path: |
98+
build/compile_commands.json
99+
build/coverage/cov.xml
100+
101+
- uses: actions/upload-artifact@v6
102+
if: always()
103+
id: artifact-upload-step
104+
with:
105+
name: coverage-html
106+
path: build/coverage/html
107+
108+
- name: Coverage display
109+
run: |
110+
base_link='https://acts-herald.app.cern.ch/view/${{ github.repository }}/${{ steps.artifact-upload-step.outputs.artifact-id }}'
111+
link="$base_link/index.html"
112+
echo "🛡️ Code coverage available at: $link"
113+
echo "**🛡️ Code coverage available [here]($link)**" >> $GITHUB_STEP_SUMMARY
99114
100115
clang_tidy:
101116
runs-on: ubuntu-latest

CI/test_coverage.py

Lines changed: 239 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,245 @@
11
#!/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+
# ///
611
import multiprocessing as mp
712
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+
)
8107

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+
)
13242

14243

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

Comments
 (0)