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
« 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.
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.
9Author:
10 Sven Schlender
11Date:
12 2026-09-13
13"""
15import re
16import shlex
17import shutil
18import time
19from pathlib import Path
20from tempfile import TemporaryDirectory
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
27from .reports import write_build_report, process_test_result
30CMAKE_COMPILE_COMMANDS = "compile_commands.json"
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)
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)
43LTO_PATTERNS = (
44 re.compile(r"^-flto(?:=(.+))?$"),
45 re.compile(r"^/GL$", re.IGNORECASE),
46)
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)
60def _prepare_file_api_query(build_dir: Path):
61 """Prepare the CMake File API query for toolchain metadata.
63 The query requests normalized compiler information for all languages
64 enabled by the CMake project. CMake generates the corresponding reply
65 during configuration.
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()
75def _load_toolchains_reply(build_dir: Path) -> dict:
76 """Load the CMake File API toolchains reply.
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.
81 Args:
82 build_dir: Configured CMake build directory.
84 Returns:
85 Parsed toolchains reply.
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"))
93 if not index_files:
94 raise CiToolsError("CMake File API reply index not found")
96 index = load_json_file(index_files[-1], description="CMake File API index")
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
104 toolchains = load_json_file(reply_file, description="CMake File API toolchains reply")
106 if not toolchains:
107 raise CiToolsError("CMake File API toolchains reply not found")
109 return toolchains
112def _get_toolchain(build_dir: Path, language: str) -> dict:
113 """Return CMake toolchain metadata for one language.
115 Args:
116 build_dir: Configured CMake build directory.
117 language: CMake language name, for example ``C`` or ``CXX``.
119 Returns:
120 Toolchain metadata for the requested language.
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)
128 for toolchain in data.get("toolchains", []):
129 if toolchain.get("language") == language:
130 return toolchain
132 raise CiToolsError(f"CMake toolchain for language '{language}' not found")
135def _get_compile_options(arguments: list[str]) -> tuple[str, str]:
136 """Extract relevant options from compiler arguments.
138 Args:
139 arguments: Compiler command arguments.
141 Returns:
142 Optimization and LTO settings.
143 """
144 optimization = ""
145 lto = ""
147 for argument in arguments:
148 if any(pattern.match(argument) for pattern in OPTIMIZATION_PATTERNS):
149 optimization = argument.lstrip("-/")
151 if argument == "-fno-lto":
152 lto = "disabled"
153 continue
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
162 return optimization, lto
165def _extract_diagnostics(output: str) -> dict:
166 """Extract warning and error counts from Ninja build output.
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.
172 Args:
173 output: Captured Ninja build output.
175 Returns:
176 Dictionary containing warning and error counts.
177 """
178 warnings = 0
179 errors = 0
181 for line in output.splitlines():
182 if WARNING_PATTERN.search(line):
183 warnings += 1
184 if ERROR_PATTERN.search(line):
185 errors += 1
187 return {"warnings": warnings, "errors": errors}
190def _normalize_memory_region(region: str) -> str:
191 """Normalize a linker memory region for cikit data keys.
193 Region names are converted to lowercase and non-alphanumeric separators
194 are replaced with underscores to produce stable jobdata keys.
196 Args:
197 region: Memory region reported by the linker.
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("_")
207def _memory_size_bytes(value: str, unit: str) -> int:
208 """Convert a linker memory size to bytes.
210 Args:
211 value: Numeric size value.
212 unit: Size unit reported by the linker.
214 Returns:
215 Size in bytes.
216 """
217 return round(float(value) * MEMORY_FACTORS[unit])
220def _extract_compile_options(build_dir: Path) -> dict:
221 """Extract compiler options from compile_commands.json.
223 Optimization and LTO metadata are only returned when the compilation database
224 is available and the corresponding compiler options can be recognized.
226 Args:
227 build_dir: Configured CMake build directory.
229 Returns:
230 Dictionary containing recognized compiler options.
231 """
232 compile_commands_file = build_dir / CMAKE_COMPILE_COMMANDS
234 if not compile_commands_file.is_file():
235 return {}
237 data = load_json_file(compile_commands_file, description="CMake compile commands")
239 optimization_levels = set()
240 lto_settings = set()
242 for entry in data:
243 arguments = entry.get("arguments")
245 if arguments is None:
246 arguments = shlex.split(entry.get("command", ""))
248 optimization, lto = _get_compile_options(arguments)
250 if optimization:
251 optimization_levels.add(optimization)
253 if lto:
254 lto_settings.add(lto)
256 metrics = {}
258 if len(optimization_levels) == 1:
259 metrics["optimization"] = optimization_levels.pop()
260 elif optimization_levels:
261 metrics["optimization"] = "mixed"
263 if len(lto_settings) == 1:
264 metrics["lto"] = lto_settings.pop()
265 elif lto_settings:
266 metrics["lto"] = "mixed"
268 return metrics
271def _extract_build_files(output: str) -> dict:
272 """Extract the number of compiled files from CMake build output.
274 CMake output is scanned for ``Building <...> object`` lines. If no
275 matching compile lines are found, no metric is returned.
277 Args:
278 output: Captured CMake build output.
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))
285 if not build_files:
286 return {}
288 return {"build_files": build_files}
291def _extract_memory_usage(output: str) -> dict:
292 """Extract linker memory usage from CMake build output.
294 GNU linker ``--print-memory-usage`` output is converted into flat cikit
295 jobdata keys. Only recognized memory regions are returned.
297 Args:
298 output: Captured CMake build output.
300 Returns:
301 Dictionary containing memory usage metrics for recognized regions.
302 """
303 data = {}
305 for line in output.splitlines():
306 match = MEMORY_PATTERN.match(line)
307 if not match:
308 continue
310 region = _normalize_memory_region(match.group("region"))
311 prefix = f"memory_{region}"
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"))
318 return data
321def _extract_build_metrics(output: str, build_dir: Path) -> dict:
322 """Extract supported metrics from a CMake build.
324 Args:
325 output: Captured CMake build output.
326 build_dir: Configured CMake build directory.
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 }
338def _create_parallel_args(jobs: int = 0) -> list[str]:
339 """Create command line arguments for parallel execution.
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.
345 Returns:
346 Command line arguments for the selected parallel mode.
347 """
348 if jobs == 0:
349 return ["--parallel"]
351 if jobs > 1:
352 return ["--parallel", str(jobs)]
354 return []
357def get_build_dir(env, name: str = "rel") -> Path:
358 """Return the CMake build directory below the project build directory.
360 Args:
361 env: CI environment.
362 name: Build directory name below ``PROJECT_ROOT/build``.
364 Returns:
365 CMake build directory.
366 """
367 return env["PROJECT_ROOT"] / "build" / name
370def gather_cmake_meta(env):
371 """Gather metadata about the installed CMake executable.
373 The CMake executable is queried directly so the stored version represents
374 the tool actually used by the build commands.
376 Args:
377 env: Resolved CI run environment.
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)
387 result.check_returncode()
389 match = CMAKE_VERSION_PATTERN.search(result.stdout)
390 if not match:
391 raise CiToolsError("Could not determine CMake version")
393 env.data_update({"cmake_version": match.group("version")})
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.
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.
410 Raises:
411 CiToolsError: If CMake cannot be started or configuration fails.
412 """
413 if clean:
414 shutil.rmtree(build_dir, ignore_errors=True)
416 _prepare_file_api_query(build_dir)
418 args = []
419 if generator:
420 args.extend(["-G", generator])
422 args.extend(["-S", str(source_dir), "-B", str(build_dir), *(defines or [])])
424 if compile_commands:
425 args.append("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON")
427 cmd = Command("cmake", common_args=args)
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)
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)
439 env.data_update({"configure_time": configure_time})
441 result.check_returncode()
443 if compile_commands:
444 compile_commands_file = build_dir / CMAKE_COMPILE_COMMANDS
446 if compile_commands_file.is_file():
447 env.copy_artifact(compile_commands_file, ArtifactType.METADATA)
450def gather_compiler_meta(env, build_dir: Path, language: str):
451 """Gather compiler metadata from a configured CMake project.
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.
456 Detected compiler metadata is stored directly in the run data. Missing or
457 invalid metadata is reported as a ``CiToolsError``.
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``.
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)
470 compiler = toolchain.get("compiler", {})
471 compiler_path = compiler.get("path")
472 compiler_version = compiler.get("version")
474 if not compiler_path:
475 raise CiToolsError(f"CMake compiler path for language '{language}' not found")
477 if not compiler_version:
478 raise CiToolsError(f"CMake compiler version for language '{language}' not found")
480 compiler_meta = {"compiler": Path(compiler_path).name.removesuffix(".exe"),
481 "compiler_version": compiler_version}
483 compiler_type = compiler.get("id")
484 if compiler_type:
485 compiler_meta["compiler_type"] = compiler_type
487 env.data_update(compiler_meta)
490def cmake_build(env, build_dir: Path, jobs: int = 0):
491 """Build a configured CMake project and store extracted build metrics.
493 Args:
494 env: Resolved CI run environment.
495 build_dir: CMake build directory.
496 jobs: Number of parallel build jobs.
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)
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)
509 metrics = _extract_build_metrics(result.stdout, build_dir)
510 metrics["build_time"] = build_time
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)
516 env.data_update(metrics)
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)
523 result.check_returncode()
526def ctest(env, build_dir: Path, test_alias: str = "ctest", jobs: int = 0):
527 """Execute CTest and store pytest-compatible test data.
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.
535 Raises:
536 CiRecoverableError: If CTest reports failing tests.
537 """
538 report_basename = f"{test_alias}-report.xml"
540 with TemporaryDirectory() as temp_dir:
541 report_file = Path(temp_dir) / report_basename
543 args = ["--test-dir", str(build_dir), "--output-on-failure", "--output-junit",
544 str(report_file), *_create_parallel_args(jobs)]
546 cmd = Command("ctest", common_args=args)
547 proc_result = run_command(env.argv(cmd), live_output=True, check_exit_code=False)
549 process_test_result(env, test_alias, report_file, proc_result.returncode)
552def write_rst(env, name: str = None, report_title: str = None) -> Artifact:
553 """Create an RST report for a CMake build.
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.
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")