PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
Loading...
Searching...
No Matches
c_coverage_gate.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Generate gcov line-coverage summary for src/*.c and enforce a threshold."""
3
4from __future__ import annotations
5
6import argparse
7import re
8import subprocess
9import sys
10from pathlib import Path
11
12
13LINE_RE = re.compile(r"^\s*([^:]+):\s*([0-9]+):(.*)$")
14
15
16def parse_args() -> argparse.Namespace:
17 """!
18 @brief Parse args.
19 @return Value returned by `parse_args()`.
20 """
21 parser = argparse.ArgumentParser(
22 description=__doc__,
23 formatter_class=argparse.RawDescriptionHelpFormatter,
24 epilog=(
25 "Examples:\n"
26 " python3 scripts/c_coverage_gate.py\n"
27 " python3 scripts/c_coverage_gate.py --min-line 60\n"
28 " python3 scripts/c_coverage_gate.py --src-dir src --obj-dir obj --output-dir coverage/c\n"
29 ),
30 )
31 parser.add_argument(
32 "--src-dir",
33 default="src",
34 help="Repository-relative C source directory scanned for *.c files (default: src).",
35 )
36 parser.add_argument(
37 "--obj-dir",
38 default="obj",
39 help="Repository-relative directory containing coverage objects (*.gcda/*.gcno) (default: obj).",
40 )
41 parser.add_argument(
42 "--output-dir",
43 default="coverage/c",
44 help="Repository-relative output directory for gcov files and summary.txt (default: coverage/c).",
45 )
46 parser.add_argument(
47 "--min-line",
48 type=float,
49 default=55.0,
50 help="Minimum required weighted line coverage percent (default: 55.0).",
51 )
52 return parser.parse_args()
53
54
55def run_gcov(src_files: list[Path], obj_dir: Path, repo_root: Path, output_dir: Path) -> None:
56 """!
57 @brief Run gcov.
58 @param[in] src_files Argument passed to `run_gcov()`.
59 @param[in] obj_dir Argument passed to `run_gcov()`.
60 @param[in] repo_root Argument passed to `run_gcov()`.
61 @param[in] output_dir Argument passed to `run_gcov()`.
62 """
63 for src in src_files:
64 cmd = ["gcov", "-o", str(obj_dir), str(src)]
65 proc = subprocess.run(cmd, cwd=str(repo_root), text=True, capture_output=True, check=False)
66 if proc.returncode != 0:
67 raise RuntimeError(
68 f"gcov failed for {src}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
69 )
70 for gcov_file in repo_root.glob("*.gcov"):
71 gcov_file.replace(output_dir / gcov_file.name)
72
73
74def parse_gcov_file(gcov_path: Path, repo_root: Path) -> tuple[Path | None, int, int]:
75 """!
76 @brief Parse gcov file.
77 @param[in] gcov_path Argument passed to `parse_gcov_file()`.
78 @param[in] repo_root Argument passed to `parse_gcov_file()`.
79 @return Value returned by `parse_gcov_file()`.
80 """
81 source_path = None
82 covered = 0
83 total = 0
84
85 for raw_line in gcov_path.read_text(encoding="utf-8", errors="replace").splitlines():
86 if "Source:" in raw_line and ":Source:" in raw_line:
87 source_path = raw_line.split("Source:", 1)[1].strip()
88 continue
89
90 match = LINE_RE.match(raw_line)
91 if not match:
92 continue
93 count_token = match.group(1).strip()
94 if count_token == "-":
95 continue
96
97 if count_token.startswith("#####") or count_token.startswith("====="):
98 total += 1
99 continue
100
101 numeric = "".join(ch for ch in count_token if ch.isdigit())
102 if not numeric:
103 continue
104
105 total += 1
106 if int(numeric) > 0:
107 covered += 1
108
109 if not source_path:
110 return None, covered, total
111
112 source = Path(source_path)
113 if not source.is_absolute():
114 source = (repo_root / source).resolve()
115 else:
116 source = source.resolve()
117 return source, covered, total
118
119
120def main() -> int:
121 """!
122 @brief Entry point for this script.
123 @return Value returned by `main()`.
124 """
125 args = parse_args()
126 repo_root = Path(__file__).resolve().parents[1]
127 src_dir = (repo_root / args.src_dir).resolve()
128 obj_dir = (repo_root / args.obj_dir).resolve()
129 output_dir = (repo_root / args.output_dir).resolve()
130 output_dir.mkdir(parents=True, exist_ok=True)
131
132 if not src_dir.is_dir():
133 raise SystemExit(f"[coverage-c] src directory missing: {src_dir}")
134 if not obj_dir.is_dir():
135 raise SystemExit(f"[coverage-c] obj directory missing: {obj_dir}")
136
137 for old in output_dir.glob("*.gcov"):
138 old.unlink()
139
140 src_files = sorted(src_dir.glob("*.c"))
141 if not src_files:
142 raise SystemExit(f"[coverage-c] no source files found in {src_dir}")
143
144 run_gcov(src_files, obj_dir, repo_root, output_dir)
145
146 by_source: dict[Path, tuple[int, int]] = {}
147 for gcov_path in sorted(output_dir.glob("*.gcov")):
148 source, covered, total = parse_gcov_file(gcov_path, repo_root)
149 if source is None:
150 continue
151 if source.parent != src_dir:
152 continue
153 prev_cov, prev_total = by_source.get(source, (0, 0))
154 by_source[source] = (prev_cov + covered, prev_total + total)
155
156 print("[coverage-c] per-file line coverage")
157 print("[coverage-c] -----------------------------------------------")
158
159 total_cov = 0
160 total_exec = 0
161 missing = []
162 for src in src_files:
163 covered, executable = by_source.get(src, (0, 0))
164 if executable == 0:
165 missing.append(src)
166 percent = 0.0
167 else:
168 percent = (100.0 * covered) / executable
169 total_cov += covered
170 total_exec += executable
171 rel = src.relative_to(repo_root)
172 print(f"[coverage-c] {rel}: {covered}/{executable} ({percent:.2f}%)")
173
174 overall = 0.0 if total_exec == 0 else (100.0 * total_cov) / total_exec
175 print("[coverage-c] -----------------------------------------------")
176 print(f"[coverage-c] weighted total: {total_cov}/{total_exec} ({overall:.2f}%)")
177 print(f"[coverage-c] minimum required: {args.min_line:.2f}%")
178
179 summary_path = output_dir / "summary.txt"
180 summary_path.write_text(
181 "\n".join(
182 [
183 f"weighted_total={overall:.4f}",
184 f"covered_lines={total_cov}",
185 f"executable_lines={total_exec}",
186 f"minimum_required={args.min_line:.4f}",
187 ]
188 )
189 + "\n",
190 encoding="utf-8",
191 )
192
193 if missing:
194 print("[coverage-c] WARNING: missing or zero-coverage gcov data for:", file=sys.stderr)
195 for src in missing:
196 print(f"[coverage-c] - {src.relative_to(repo_root)}", file=sys.stderr)
197
198 if overall < args.min_line:
199 print(
200 f"[coverage-c] FAIL: coverage {overall:.2f}% is below required {args.min_line:.2f}%.",
201 file=sys.stderr,
202 )
203 return 2
204
205 return 0
206
207
208if __name__ == "__main__":
209 raise SystemExit(main())
argparse.Namespace parse_args()
Parse args.
None run_gcov(list[Path] src_files, Path obj_dir, Path repo_root, Path output_dir)
Run gcov.
int main()
Entry point for this script.
tuple[Path|None, int, int] parse_gcov_file(Path gcov_path, Path repo_root)
Parse gcov file.