Coverage for /builds/svesch/cikit/src/cikit/macros/cmake.py: 100%

206 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-09 14:17 +0000

1# SPDX-License-Identifier: GPL-3.0-or-later 

2"""CMake and CTest macro helpers for cikit. 

3 

4This module configures and builds CMake projects, extracts build metrics, 

5stores configure and build logs, executes CTest suites, and generates build 

6reports. CTest results are stored in the same run data shape used by the 

7pytest macro. 

8 

9Author: 

10 Sven Schlender 

11Date: 

12 2026-09-13 

13""" 

14 

15import re 

16import shlex 

17import shutil 

18import time 

19from pathlib import Path 

20from tempfile import TemporaryDirectory 

21 

22from cikit.artifacts import Artifact, ArtifactType 

23from cikit.command import Command, run_command 

24from cikit.errors import CiToolsError 

25from cikit.json_utils import load_json_file 

26 

27from .reports import write_build_report, process_test_result 

28 

29 

30CMAKE_COMPILE_COMMANDS = "compile_commands.json" 

31 

32CMAKE_CONFIGURE_LOG = "cmake-configure.log" 

33CMAKE_BUILD_LOG = "cmake-build.log" 

34CMAKE_FILE_API_QUERY_DIR = Path(".cmake/api/v1/query/client-cikit") 

35CMAKE_TOOLCHAINS_QUERY = "toolchains-v1" 

36CMAKE_VERSION_PATTERN = re.compile(r"^cmake version\s+(?P<version>\S+)", re.MULTILINE) 

37 

38OPTIMIZATION_PATTERNS = ( 

39 re.compile(r"^-O(?:0|1|2|3|g|s|z|fast)$"), 

40 re.compile(r"^/O(?:d|1|2|x)$", re.IGNORECASE), 

41) 

42 

43LTO_PATTERNS = ( 

44 re.compile(r"^-flto(?:=(.+))?$"), 

45 re.compile(r"^/GL$", re.IGNORECASE), 

46) 

47 

48BUILD_OBJECT_PATTERN = re.compile(r"\bBuilding\s+.+?\s+object\b") 

49MEMORY_PATTERN = re.compile( 

50 r"^\s*(?P<region>[^:]+):\s+" 

51 r"(?P<used>[\d.]+)\s*(?P<used_unit>[KMGT]?B)\s+" 

52 r"(?P<total>[\d.]+)\s*(?P<total_unit>[KMGT]?B)\s+" 

53 r"(?P<percent>[\d.]+)%\s*$" 

54) 

55MEMORY_FACTORS = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4} 

56WARNING_PATTERN = re.compile(r"\bwarning:\s", re.IGNORECASE) 

57ERROR_PATTERN = re.compile(r"\berror:\s", re.IGNORECASE) 

58 

59 

60def _prepare_file_api_query(build_dir: Path): 

61 """Prepare the CMake File API query for toolchain metadata. 

62 

63 The query requests normalized compiler information for all languages 

64 enabled by the CMake project. CMake generates the corresponding reply 

65 during configuration. 

66 

67 Args: 

68 build_dir: CMake build directory. 

69 """ 

70 query_dir = build_dir / CMAKE_FILE_API_QUERY_DIR 

71 query_dir.mkdir(parents=True, exist_ok=True) 

72 (query_dir / CMAKE_TOOLCHAINS_QUERY).touch() 

73 

74 

75def _load_toolchains_reply(build_dir: Path) -> dict: 

76 """Load the CMake File API toolchains reply. 

77 

78 The newest reply index generated by CMake is resolved and the toolchains 

79 reply belonging to the cikit client query is loaded from it. 

80 

81 Args: 

82 build_dir: Configured CMake build directory. 

83 

84 Returns: 

85 Parsed toolchains reply. 

86 

87 Raises: 

88 CiToolsError: If the File API reply is missing or invalid. 

89 """ 

90 reply_dir = build_dir / ".cmake" / "api" / "v1" / "reply" 

91 index_files = sorted(reply_dir.glob("index-*.json")) 

92 

93 if not index_files: 

94 raise CiToolsError("CMake File API reply index not found") 

95 

96 index = load_json_file(index_files[-1], description="CMake File API index") 

97 

98 try: 

99 reply = index["reply"]["client-cikit"]["toolchains-v1"] 

100 reply_file = reply_dir / reply["jsonFile"] 

101 except (KeyError, TypeError) as exp: 

102 raise CiToolsError("CMake File API toolchains reply not found") from exp 

103 

104 toolchains = load_json_file(reply_file, description="CMake File API toolchains reply") 

105 

106 if not toolchains: 

107 raise CiToolsError("CMake File API toolchains reply not found") 

108 

109 return toolchains 

110 

111 

112def _get_toolchain(build_dir: Path, language: str) -> dict: 

113 """Return CMake toolchain metadata for one language. 

114 

115 Args: 

116 build_dir: Configured CMake build directory. 

117 language: CMake language name, for example ``C`` or ``CXX``. 

118 

119 Returns: 

120 Toolchain metadata for the requested language. 

121 

122 Raises: 

123 CiToolsError: If the toolchain metadata cannot be loaded or the 

124 requested language is unavailable. 

125 """ 

126 data = _load_toolchains_reply(build_dir) 

127 

128 for toolchain in data.get("toolchains", []): 

129 if toolchain.get("language") == language: 

130 return toolchain 

131 

132 raise CiToolsError(f"CMake toolchain for language '{language}' not found") 

133 

134 

135def _get_compile_options(arguments: list[str]) -> tuple[str, str]: 

136 """Extract relevant options from compiler arguments. 

137 

138 Args: 

139 arguments: Compiler command arguments. 

140 

141 Returns: 

142 Optimization and LTO settings. 

143 """ 

144 optimization = "" 

145 lto = "" 

146 

147 for argument in arguments: 

148 if any(pattern.match(argument) for pattern in OPTIMIZATION_PATTERNS): 

149 optimization = argument.lstrip("-/") 

150 

151 if argument == "-fno-lto": 

152 lto = "disabled" 

153 continue 

154 

155 for pattern in LTO_PATTERNS: 

156 match = pattern.match(argument) 

157 if match: 

158 lto = match.group(1) if match.lastindex else "enabled" 

159 lto = lto or "enabled" 

160 break 

161 

162 return optimization, lto 

163 

164 

165def _extract_diagnostics(output: str) -> dict: 

166 """Extract warning and error counts from Ninja build output. 

167 

168 The output is scanned for GCC- and Clang-style ``warning:`` and 

169 ``error:`` diagnostics. Ninja status messages are not counted as 

170 compiler diagnostics. 

171 

172 Args: 

173 output: Captured Ninja build output. 

174 

175 Returns: 

176 Dictionary containing warning and error counts. 

177 """ 

178 warnings = 0 

179 errors = 0 

180 

181 for line in output.splitlines(): 

182 if WARNING_PATTERN.search(line): 

183 warnings += 1 

184 if ERROR_PATTERN.search(line): 

185 errors += 1 

186 

187 return {"warnings": warnings, "errors": errors} 

188 

189 

190def _normalize_memory_region(region: str) -> str: 

191 """Normalize a linker memory region for cikit data keys. 

192 

193 Region names are converted to lowercase and non-alphanumeric separators 

194 are replaced with underscores to produce stable jobdata keys. 

195 

196 Args: 

197 region: Memory region reported by the linker. 

198 

199 Returns: 

200 Normalized region name. 

201 """ 

202 region = region.strip().lower() 

203 region = re.sub(r"[^a-z0-9]+", "_", region) 

204 return region.strip("_") 

205 

206 

207def _memory_size_bytes(value: str, unit: str) -> int: 

208 """Convert a linker memory size to bytes. 

209 

210 Args: 

211 value: Numeric size value. 

212 unit: Size unit reported by the linker. 

213 

214 Returns: 

215 Size in bytes. 

216 """ 

217 return round(float(value) * MEMORY_FACTORS[unit]) 

218 

219 

220def _extract_compile_options(build_dir: Path) -> dict: 

221 """Extract compiler options from compile_commands.json. 

222 

223 Optimization and LTO metadata are only returned when the compilation database 

224 is available and the corresponding compiler options can be recognized. 

225 

226 Args: 

227 build_dir: Configured CMake build directory. 

228 

229 Returns: 

230 Dictionary containing recognized compiler options. 

231 """ 

232 compile_commands_file = build_dir / CMAKE_COMPILE_COMMANDS 

233 

234 if not compile_commands_file.is_file(): 

235 return {} 

236 

237 data = load_json_file(compile_commands_file, description="CMake compile commands") 

238 

239 optimization_levels = set() 

240 lto_settings = set() 

241 

242 for entry in data: 

243 arguments = entry.get("arguments") 

244 

245 if arguments is None: 

246 arguments = shlex.split(entry.get("command", "")) 

247 

248 optimization, lto = _get_compile_options(arguments) 

249 

250 if optimization: 

251 optimization_levels.add(optimization) 

252 

253 if lto: 

254 lto_settings.add(lto) 

255 

256 metrics = {} 

257 

258 if len(optimization_levels) == 1: 

259 metrics["optimization"] = optimization_levels.pop() 

260 elif optimization_levels: 

261 metrics["optimization"] = "mixed" 

262 

263 if len(lto_settings) == 1: 

264 metrics["lto"] = lto_settings.pop() 

265 elif lto_settings: 

266 metrics["lto"] = "mixed" 

267 

268 return metrics 

269 

270 

271def _extract_build_files(output: str) -> dict: 

272 """Extract the number of compiled files from CMake build output. 

273 

274 CMake output is scanned for ``Building <...> object`` lines. If no 

275 matching compile lines are found, no metric is returned. 

276 

277 Args: 

278 output: Captured CMake build output. 

279 

280 Returns: 

281 Dictionary containing ``build_files`` if compile lines were found. 

282 """ 

283 build_files = sum(1 for line in output.splitlines() if BUILD_OBJECT_PATTERN.search(line)) 

284 

285 if not build_files: 

286 return {} 

287 

288 return {"build_files": build_files} 

289 

290 

291def _extract_memory_usage(output: str) -> dict: 

292 """Extract linker memory usage from CMake build output. 

293 

294 GNU linker ``--print-memory-usage`` output is converted into flat cikit 

295 jobdata keys. Only recognized memory regions are returned. 

296 

297 Args: 

298 output: Captured CMake build output. 

299 

300 Returns: 

301 Dictionary containing memory usage metrics for recognized regions. 

302 """ 

303 data = {} 

304 

305 for line in output.splitlines(): 

306 match = MEMORY_PATTERN.match(line) 

307 if not match: 

308 continue 

309 

310 region = _normalize_memory_region(match.group("region")) 

311 prefix = f"memory_{region}" 

312 

313 data[f"{prefix}_used"] = _memory_size_bytes(match.group("used"), match.group("used_unit")) 

314 data[f"{prefix}_total"] = _memory_size_bytes(match.group("total"), 

315 match.group("total_unit")) 

316 data[f"{prefix}_percent"] = float(match.group("percent")) 

317 

318 return data 

319 

320 

321def _extract_build_metrics(output: str, build_dir: Path) -> dict: 

322 """Extract supported metrics from a CMake build. 

323 

324 Args: 

325 output: Captured CMake build output. 

326 build_dir: Configured CMake build directory. 

327 

328 Returns: 

329 Dictionary containing all recognized build metrics. 

330 """ 

331 return { 

332 **_extract_build_files(output), 

333 **_extract_memory_usage(output), 

334 **_extract_diagnostics(output), 

335 **_extract_compile_options(build_dir), 

336 } 

337 

338def _create_parallel_args(jobs: int = 0) -> list[str]: 

339 """Create command line arguments for parallel execution. 

340 

341 Args: 

342 jobs: Number of parallel jobs. Use 0 for tool default, 1 for serial 

343 execution, and values greater than 1 for an explicit job count. 

344 

345 Returns: 

346 Command line arguments for the selected parallel mode. 

347 """ 

348 if jobs == 0: 

349 return ["--parallel"] 

350 

351 if jobs > 1: 

352 return ["--parallel", str(jobs)] 

353 

354 return [] 

355 

356 

357def get_build_dir(env, name: str = "rel") -> Path: 

358 """Return the CMake build directory below the project build directory. 

359 

360 Args: 

361 env: CI environment. 

362 name: Build directory name below ``PROJECT_ROOT/build``. 

363 

364 Returns: 

365 CMake build directory. 

366 """ 

367 return env["PROJECT_ROOT"] / "build" / name 

368 

369 

370def gather_cmake_meta(env): 

371 """Gather metadata about the installed CMake executable. 

372 

373 The CMake executable is queried directly so the stored version represents 

374 the tool actually used by the build commands. 

375 

376 Args: 

377 env: Resolved CI run environment. 

378 

379 Raises: 

380 CiToolsError: If CMake cannot be started, version detection fails, or 

381 the returned version output cannot be parsed. 

382 """ 

383 cmd = Command("cmake", ["--version"]) 

384 result = run_command(env.argv(cmd), cwd=env["PROJECT_ROOT"], live_output=False, 

385 check_exit_code=False) 

386 

387 result.check_returncode() 

388 

389 match = CMAKE_VERSION_PATTERN.search(result.stdout) 

390 if not match: 

391 raise CiToolsError("Could not determine CMake version") 

392 

393 env.data_update({"cmake_version": match.group("version")}) 

394 

395 

396def cmake_configure(env, source_dir: Path, build_dir: Path, *, defines: list[str] = None, # pylint: disable=too-many-arguments 

397 generator: str = None, clean: bool = False, compile_commands: bool = False): 

398 """Configure a CMake project. 

399 

400 Args: 

401 env: Resolved CI run environment. 

402 source_dir: CMake source directory. 

403 build_dir: CMake build directory. 

404 defines: Additional CMake definitions, for example ``-DFOO=ON``. 

405 generator: Optional CMake generator, for example ``Ninja``. 

406 clean: Remove the build directory before configuring. 

407 compile_commands: Request generation and registration of ``compile_commands.json``. 

408 The file may not be available with all CMake generators. 

409 

410 Raises: 

411 CiToolsError: If CMake cannot be started or configuration fails. 

412 """ 

413 if clean: 

414 shutil.rmtree(build_dir, ignore_errors=True) 

415 

416 _prepare_file_api_query(build_dir) 

417 

418 args = [] 

419 if generator: 

420 args.extend(["-G", generator]) 

421 

422 args.extend(["-S", str(source_dir), "-B", str(build_dir), *(defines or [])]) 

423 

424 if compile_commands: 

425 args.append("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON") 

426 

427 cmd = Command("cmake", common_args=args) 

428 

429 start_time = time.monotonic() 

430 result = run_command(env.argv(cmd), live_output=True, cwd=env["PROJECT_ROOT"], 

431 check_exit_code=False) 

432 configure_time = round(time.monotonic() - start_time, 1) 

433 

434 with TemporaryDirectory() as temp_dir: 

435 log_file = Path(temp_dir) / CMAKE_CONFIGURE_LOG 

436 result.write_report(log_file) 

437 env.copy_artifact(log_file, ArtifactType.LOG) 

438 

439 env.data_update({"configure_time": configure_time}) 

440 

441 result.check_returncode() 

442 

443 if compile_commands: 

444 compile_commands_file = build_dir / CMAKE_COMPILE_COMMANDS 

445 

446 if compile_commands_file.is_file(): 

447 env.copy_artifact(compile_commands_file, ArtifactType.METADATA) 

448 

449 

450def gather_compiler_meta(env, build_dir: Path, language: str): 

451 """Gather compiler metadata from a configured CMake project. 

452 

453 CMake File API toolchain metadata is used to determine the compiler selected 

454 for the requested language. This requires CMake 3.20 or newer. 

455 

456 Detected compiler metadata is stored directly in the run data. Missing or 

457 invalid metadata is reported as a ``CiToolsError``. 

458 

459 Args: 

460 env: Resolved CI run environment. 

461 build_dir: Configured CMake build directory. 

462 language: CMake language name, for example ``C`` or ``CXX``. 

463 

464 Raises: 

465 CiToolsError: If the CMake File API data is missing, invalid, or does 

466 not contain compiler metadata for the requested language. 

467 """ 

468 toolchain = _get_toolchain(build_dir, language) 

469 

470 compiler = toolchain.get("compiler", {}) 

471 compiler_path = compiler.get("path") 

472 compiler_version = compiler.get("version") 

473 

474 if not compiler_path: 

475 raise CiToolsError(f"CMake compiler path for language '{language}' not found") 

476 

477 if not compiler_version: 

478 raise CiToolsError(f"CMake compiler version for language '{language}' not found") 

479 

480 compiler_meta = {"compiler": Path(compiler_path).name.removesuffix(".exe"), 

481 "compiler_version": compiler_version} 

482 

483 compiler_type = compiler.get("id") 

484 if compiler_type: 

485 compiler_meta["compiler_type"] = compiler_type 

486 

487 env.data_update(compiler_meta) 

488 

489 

490def cmake_build(env, build_dir: Path, jobs: int = 0): 

491 """Build a configured CMake project and store extracted build metrics. 

492 

493 Args: 

494 env: Resolved CI run environment. 

495 build_dir: CMake build directory. 

496 jobs: Number of parallel build jobs. 

497 

498 Raises: 

499 CiToolsError: If CMake cannot be started or the build fails. 

500 """ 

501 args = ["--build", str(build_dir), *_create_parallel_args(jobs)] 

502 cmd = Command("cmake", common_args=args) 

503 

504 start_time = time.monotonic() 

505 result = run_command(env.argv(cmd), live_output=True, cwd=env["PROJECT_ROOT"], 

506 check_exit_code=False) 

507 build_time = round(time.monotonic() - start_time, 1) 

508 

509 metrics = _extract_build_metrics(result.stdout, build_dir) 

510 metrics["build_time"] = build_time 

511 

512 configure_time = env.data.get("configure_time") 

513 if configure_time is not None: 

514 metrics["total_build_time"] = round(configure_time + build_time, 1) 

515 

516 env.data_update(metrics) 

517 

518 with TemporaryDirectory() as temp_dir: 

519 log_file = Path(temp_dir) / CMAKE_BUILD_LOG 

520 result.write_report(log_file) 

521 env.copy_artifact(log_file, ArtifactType.LOG) 

522 

523 result.check_returncode() 

524 

525 

526def ctest(env, build_dir: Path, test_alias: str = "ctest", jobs: int = 0): 

527 """Execute CTest and store pytest-compatible test data. 

528 

529 Args: 

530 env: CI run environment. 

531 build_dir: CMake build directory. 

532 test_alias: Test report alias used for badge and RST names. 

533 jobs: Number of parallel test jobs. 

534 

535 Raises: 

536 CiRecoverableError: If CTest reports failing tests. 

537 """ 

538 report_basename = f"{test_alias}-report.xml" 

539 

540 with TemporaryDirectory() as temp_dir: 

541 report_file = Path(temp_dir) / report_basename 

542 

543 args = ["--test-dir", str(build_dir), "--output-on-failure", "--output-junit", 

544 str(report_file), *_create_parallel_args(jobs)] 

545 

546 cmd = Command("ctest", common_args=args) 

547 proc_result = run_command(env.argv(cmd), live_output=True, check_exit_code=False) 

548 

549 process_test_result(env, test_alias, report_file, proc_result.returncode) 

550 

551 

552def write_rst(env, name: str = None, report_title: str = None) -> Artifact: 

553 """Create an RST report for a CMake build. 

554 

555 Args: 

556 env: Resolved CI run environment. 

557 name: Output file basename without extension. If omitted, the run name 

558 is used. 

559 report_title: Report title. If omitted, ``CMake Build Report`` is used. 

560 

561 Returns: 

562 Created and registered RST artifact. 

563 """ 

564 return write_build_report(env, name=name, report_title=report_title or "CMake Build Report")