diff --git a/aoc-main/src/aoc_main/_cmake_presets.py b/aoc-main/src/aoc_main/_cmake_presets.py new file mode 100644 index 00000000..98d819be --- /dev/null +++ b/aoc-main/src/aoc_main/_cmake_presets.py @@ -0,0 +1,118 @@ +"""Minimal reader for CMake preset files. + +CMake's preset schema (``CMakePresets.json`` / ``CMakeUserPresets.json``) is +large; this module implements only the slice the C++ solver build needs: + +* looking up a preset by *type* and *name*, with user presets taking precedence + over project presets (mirroring CMake's own override order); +* finding the configure step of a workflow preset; +* resolving the ``binaryDir`` of a configure preset, walking ``inherits`` chains + when the field is not set on the preset directly. + +Features such as ``include`` and macro expansion (e.g. ``${sourceDir}``) are +intentionally unsupported. +""" + +import json +import pathlib +from functools import cached_property +from typing import Any, Literal + +from aoc_main import _logging + +_logger = _logging.logger + +PresetType = Literal["build", "configure", "workflow"] + + +class CMakePresetError(Exception): + """Raised when the preset files cannot satisfy a requested lookup.""" + + +class CMakePresets: + """Reads CMake preset files from a directory and answers queries about them. + + Pass the directory that contains ``CMakePresets.json`` (and optionally + ``CMakeUserPresets.json``). Files are read lazily and cached on first + access, so an instance reflects their contents at that point. A missing + file is treated as containing no presets. + """ + + def __init__(self, root_dir: pathlib.Path) -> None: + self._root_dir = root_dir + + @cached_property + def _user_presets(self) -> dict[str, Any]: + return self._load(self._root_dir / "CMakeUserPresets.json") + + @cached_property + def _project_presets(self) -> dict[str, Any]: + return self._load(self._root_dir / "CMakePresets.json") + + @staticmethod + def _load(preset_file: pathlib.Path) -> dict[str, Any]: + if not preset_file.exists(): + return {} + return json.loads(preset_file.read_text()) + + def _get_preset(self, preset_type: PresetType, name: str) -> dict[str, Any] | None: + for presets in (self._user_presets, self._project_presets): + preset = next( + ( + preset + for preset in presets.get(f"{preset_type}Presets", []) + if preset["name"] == name + ), + None, + ) + if preset is not None: + return preset + return None + + def workflow_configure_preset_name(self, workflow_name: str) -> str: + """Return the configure step's preset name for a workflow preset.""" + workflow_preset = self._get_preset("workflow", workflow_name) + if workflow_preset is None: + msg = f"Requested workflow preset not found: {workflow_name}" + raise CMakePresetError(msg) + + try: + return next( + step["name"] + for step in workflow_preset["steps"] + if step["type"] == "configure" + ) + except KeyError as e: + msg = f"Invalid CMake Preset json for workflow preset: {workflow_name}" + raise CMakePresetError(msg) from e + except StopIteration: + msg = f"No configure step found for workflow: {workflow_name}" + raise CMakePresetError(msg) from None + + def binary_dir(self, configure_preset_name: str) -> pathlib.Path: + """Resolve the ``binaryDir`` of a configure preset. + + If the named preset does not set ``binaryDir`` itself, its ``inherits`` + parents are searched breadth-first and the first one that sets the field + wins. + """ + pending = [configure_preset_name] + while pending: + name = pending.pop(0) + preset = self._get_preset("configure", name) + if preset is None: + msg = f"Configure preset not found: {name}" + raise CMakePresetError(msg) + + binary_dir = preset.get("binaryDir") + if binary_dir is not None: + _logger.debug("Found binaryDir from preset %s: %s", name, binary_dir) + return pathlib.Path(binary_dir) + + _logger.debug( + "binaryDir not set on preset %s; checking inherited presets", name + ) + pending.extend(preset.get("inherits", [])) + + msg = f"binaryDir not found for configure preset {configure_preset_name}" + raise CMakePresetError(msg) diff --git a/aoc-main/src/aoc_main/_solver_cpp.py b/aoc-main/src/aoc_main/_solver_cpp.py index 13e8eeb1..e91b00f6 100644 --- a/aoc-main/src/aoc_main/_solver_cpp.py +++ b/aoc-main/src/aoc_main/_solver_cpp.py @@ -1,14 +1,13 @@ import asyncio -import json import os -import pathlib from dataclasses import dataclass from functools import cached_property -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING -from aoc_main import _logging, _solvers, _types +from aoc_main import _cmake_presets, _logging, _solvers, _types if TYPE_CHECKING: + import pathlib from collections.abc import Iterable _logger = _logging.logger @@ -57,51 +56,31 @@ def __init__(self, target: str, returncode: int) -> None: class _CMakeConfigResolver: - @cached_property - def configure_preset_name(self) -> str: - return self._resolve_configure_preset() + """Selects which CMake presets to use, then defers parsing to ``CMakePresets``. + + The *selection* (which configure or workflow preset to use, read from + environment variables) lives here; the *parsing* of the preset files lives + in :mod:`aoc_main._cmake_presets`. + """ @cached_property def solver_root_dir(self) -> pathlib.Path: return _solvers.get_solver_root_dir(_solvers.Solver.Cpp) @cached_property - def binary_dir(self) -> pathlib.Path: - return self._resolve_binary_dir() + def _presets(self) -> _cmake_presets.CMakePresets: + return _cmake_presets.CMakePresets(self.solver_root_dir) @cached_property - def _cmake_user_presets(self) -> dict[str, Any]: - return self._load_cmake_preset_file( - self.solver_root_dir / "CMakeUserPresets.json" - ) + def configure_preset_name(self) -> str: + return self._resolve_configure_preset() @cached_property - def _cmake_presets(self) -> dict[str, Any]: - return self._load_cmake_preset_file(self.solver_root_dir / "CMakePresets.json") - - @staticmethod - def _load_cmake_preset_file(preset_file: pathlib.Path) -> dict[str, Any]: - if not preset_file.exists(): - return {} - return json.loads(preset_file.read_text()) - - def _get_preset( - self, - preset_type: Literal["build", "configure", "workflow"], - name: str, - ) -> Any | None: # noqa: ANN401 - for presets in (self._cmake_user_presets, self._cmake_presets): - preset = next( - ( - preset - for preset in presets.get(f"{preset_type}Presets", {}) - if preset["name"] == name - ), - None, - ) - if preset is not None: - return preset - return None + def binary_dir(self) -> pathlib.Path: + try: + return self._presets.binary_dir(self.configure_preset_name) + except _cmake_presets.CMakePresetError as e: + raise _CppSolverConfigureError(str(e)) from e def _resolve_configure_preset(self) -> str: configure_preset_name = os.environ.get(_ENV_PRESET_CONFIGURE) @@ -117,57 +96,16 @@ def _resolve_configure_preset(self) -> str: return _DEFAULT_CONFIGURE_PRESET _logger.debug("Using workflow preset: %s", workflow_preset_name) - workflow_preset = self._get_preset("workflow", workflow_preset_name) - if workflow_preset is None: - msg = f"Requested workflow preset not found: {workflow_preset_name}" - raise _CppSolverConfigureError(msg) - try: - configure_preset_name = next( - step["name"] - for step in workflow_preset["steps"] - if step["type"] == "configure" + configure_preset_name = self._presets.workflow_configure_preset_name( + workflow_preset_name ) - except KeyError as e: - msg = ( - f"Invalid CMake Preset json for workflow preset: {workflow_preset_name}" - ) - raise _CppSolverConfigureError(msg) from e - except StopIteration: - msg = f"No configure step found for workflow: {workflow_preset_name}" - raise _CppSolverConfigureError(msg) from None + except _cmake_presets.CMakePresetError as e: + raise _CppSolverConfigureError(str(e)) from e _logger.debug("Using configure preset: %s", configure_preset_name) - assert configure_preset_name is not None return configure_preset_name - def _resolve_binary_dir(self) -> pathlib.Path: - configure_preset_names = [self.configure_preset_name] - while configure_preset_names: - configure_preset_name = configure_preset_names.pop(0) - configure_preset = self._get_preset("configure", configure_preset_name) - if configure_preset is None: - msg = f"Configure preset not found: {configure_preset_name}" - raise _CppSolverConfigureError(msg) - - binary_dir = configure_preset.get("binaryDir") - if binary_dir is not None: - _logger.debug( - "Found binaryDir from preset %s: %s", - configure_preset_name, - binary_dir, - ) - return pathlib.Path(binary_dir) - - _logger.debug( - "Binary dir not found for preset %s. Checking inherited presets", - configure_preset_name, - ) - configure_preset_names.extend(configure_preset.get("inherits", [])) - - error = f"binaryDir not found for configure preset {self.configure_preset_name}" - raise _CppSolverConfigureError(error) - class SolverCpp: def __init__(self, solver_ids: Iterable[_solvers.SolverId]) -> None: diff --git a/aoc-main/tests/test_cmake_presets.py b/aoc-main/tests/test_cmake_presets.py new file mode 100644 index 00000000..8a9fc367 --- /dev/null +++ b/aoc-main/tests/test_cmake_presets.py @@ -0,0 +1,185 @@ +import json +import pathlib + +import pytest + +from aoc_main._cmake_presets import CMakePresetError, CMakePresets + + +def _write_presets( + root: pathlib.Path, + presets: dict[str, object], + *, + user: bool = False, +) -> None: + name = "CMakeUserPresets.json" if user else "CMakePresets.json" + (root / name).write_text(json.dumps(presets)) + + +def test_binary_dir_set_on_preset(tmp_path: pathlib.Path) -> None: + _write_presets( + tmp_path, + {"configurePresets": [{"name": "aoc", "binaryDir": "build/aoc"}]}, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/aoc") + + +def test_binary_dir_inherited_from_parent(tmp_path: pathlib.Path) -> None: + _write_presets( + tmp_path, + { + "configurePresets": [ + {"name": "aoc", "inherits": ["base"]}, + {"name": "base", "binaryDir": "build/base"}, + ] + }, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/base") + + +def test_binary_dir_on_preset_wins_over_inherited(tmp_path: pathlib.Path) -> None: + _write_presets( + tmp_path, + { + "configurePresets": [ + {"name": "aoc", "binaryDir": "build/aoc", "inherits": ["base"]}, + {"name": "base", "binaryDir": "build/base"}, + ] + }, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/aoc") + + +def test_binary_dir_inheritance_is_breadth_first(tmp_path: pathlib.Path) -> None: + # `aoc` inherits two parents; the first listed parent's value is preferred + # over a value reachable only through the second parent's own grandparent. + _write_presets( + tmp_path, + { + "configurePresets": [ + {"name": "aoc", "inherits": ["first", "second"]}, + {"name": "first", "binaryDir": "build/first"}, + {"name": "second", "inherits": ["grandparent"]}, + {"name": "grandparent", "binaryDir": "build/grandparent"}, + ] + }, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/first") + + +def test_presets_are_read_from_both_files(tmp_path: pathlib.Path) -> None: + # CMake requires preset names to be unique across the union of the project + # and user files, so both files are consulted but never collide by name. + _write_presets( + tmp_path, + {"configurePresets": [{"name": "project", "binaryDir": "build/project"}]}, + ) + _write_presets( + tmp_path, + {"configurePresets": [{"name": "user", "binaryDir": "build/user"}]}, + user=True, + ) + presets = CMakePresets(tmp_path) + assert presets.binary_dir("project") == pathlib.Path("build/project") + assert presets.binary_dir("user") == pathlib.Path("build/user") + + +def test_user_preset_can_inherit_from_project_preset(tmp_path: pathlib.Path) -> None: + _write_presets( + tmp_path, + {"configurePresets": [{"name": "base", "binaryDir": "build/base"}]}, + ) + _write_presets( + tmp_path, + {"configurePresets": [{"name": "aoc", "inherits": ["base"]}]}, + user=True, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/base") + + +def test_binary_dir_unknown_preset_raises(tmp_path: pathlib.Path) -> None: + _write_presets(tmp_path, {"configurePresets": []}) + with pytest.raises(CMakePresetError, match="Configure preset not found: missing"): + CMakePresets(tmp_path).binary_dir("missing") + + +def test_binary_dir_missing_through_inheritance_raises( + tmp_path: pathlib.Path, +) -> None: + _write_presets( + tmp_path, + { + "configurePresets": [ + {"name": "aoc", "inherits": ["base"]}, + {"name": "base"}, + ] + }, + ) + with pytest.raises(CMakePresetError, match="binaryDir not found"): + CMakePresets(tmp_path).binary_dir("aoc") + + +def test_missing_preset_files_are_treated_as_empty(tmp_path: pathlib.Path) -> None: + with pytest.raises(CMakePresetError, match="Configure preset not found: aoc"): + CMakePresets(tmp_path).binary_dir("aoc") + + +def test_workflow_configure_preset_name(tmp_path: pathlib.Path) -> None: + _write_presets( + tmp_path, + { + "workflowPresets": [ + { + "name": "ci", + "steps": [ + {"type": "configure", "name": "aoc"}, + {"type": "build", "name": "aoc"}, + ], + } + ] + }, + ) + assert CMakePresets(tmp_path).workflow_configure_preset_name("ci") == "aoc" + + +def test_workflow_lookup_uses_user_presets(tmp_path: pathlib.Path) -> None: + _write_presets( + tmp_path, + { + "workflowPresets": [ + {"name": "ci", "steps": [{"type": "configure", "name": "user-aoc"}]} + ] + }, + user=True, + ) + assert CMakePresets(tmp_path).workflow_configure_preset_name("ci") == "user-aoc" + + +def test_workflow_unknown_raises(tmp_path: pathlib.Path) -> None: + _write_presets(tmp_path, {"workflowPresets": []}) + with pytest.raises( + CMakePresetError, match="Requested workflow preset not found: ci" + ): + CMakePresets(tmp_path).workflow_configure_preset_name("ci") + + +def test_workflow_without_configure_step_raises(tmp_path: pathlib.Path) -> None: + _write_presets( + tmp_path, + { + "workflowPresets": [ + {"name": "ci", "steps": [{"type": "build", "name": "aoc"}]} + ] + }, + ) + with pytest.raises(CMakePresetError, match="No configure step found for workflow"): + CMakePresets(tmp_path).workflow_configure_preset_name("ci") + + +def test_workflow_with_malformed_steps_raises(tmp_path: pathlib.Path) -> None: + _write_presets( + tmp_path, + {"workflowPresets": [{"name": "ci", "steps": [{"type": "configure"}]}]}, + ) + with pytest.raises(CMakePresetError, match="Invalid CMake Preset json"): + CMakePresets(tmp_path).workflow_configure_preset_name("ci")