From 743e8f7985e404b31fba04247d3837ca689c56f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 03:56:09 +0000 Subject: [PATCH] feat(build): add --clean-cache option to case.build Reset only the CMake cache (CMakeCache.txt and the small bookkeeping files in CMakeFiles/) without touching compiled object files. This is useful when CMake needs to re-configure (e.g. after editing macros or toolchain settings) but a full --clean-all is too expensive. Adds: - --clean-cache flag in CIME/Tools/case.build (mutually exclusive with the existing --clean-* options) - build._clean_cache_impl and a clean_cache=True path through build.clean, logged under a distinct "build.clean_cache" phase - pytest unit tests covering the happy path, no-op when no cmake-bld dir exists, missing CMakeCache.txt, and dispatch routing in build.clean --- CIME/Tools/case.build | 24 ++++- CIME/build.py | 58 +++++++++++- CIME/tests/test_unit_clean_cache.py | 137 ++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 CIME/tests/test_unit_clean_cache.py diff --git a/CIME/Tools/case.build b/CIME/Tools/case.build index 131ca94564d..83da7f72beb 100755 --- a/CIME/Tools/case.build +++ b/CIME/Tools/case.build @@ -40,6 +40,10 @@ other options are specific to one mode or the other: ./case.build --clean ... or: ./case.build --clean-depends ... + + To reset only the CMake cache (forcing CMake to re-configure on the next + build) while preserving compiled object files: + ./case.build --clean-cache """ from standard_script_setup import * @@ -49,6 +53,7 @@ from CIME.case import Case from CIME.utils import find_system_test, get_model from CIME.test_status import * + ############################################################################### def parse_command_line(args, description): ############################################################################### @@ -154,6 +159,15 @@ def parse_command_line(args, description): "files in the source tree or in SourceMods.", ) + mutex_group.add_argument( + "--clean-cache", + action="store_true", + help="Remove the CMake cache (CMakeCache.txt) so CMake re-configures\n" + "on the next build. Compiled object files are preserved, so this is\n" + "much cheaper than --clean-all when you only need CMake to pick up\n" + "configuration changes.", + ) + args = CIME.utils.parse_args_and_handle_standard_logging_options(args, parser) clean_depends = ( @@ -177,6 +191,7 @@ def parse_command_line(args, description): args.clean_all, buildlist, clean_depends, + args.clean_cache, not args.skip_provenance_check, args.separate_builds, args.ninja, @@ -196,6 +211,7 @@ def _main_func(description): clean_all, buildlist, clean_depends, + clean_cache, save_build_provenance, separate_builds, ninja, @@ -207,12 +223,18 @@ def _main_func(description): with Case(caseroot, read_only=False, record=True) as case: testname = case.get_value("TESTCASE") - if cleanlist is not None or clean_all or clean_depends is not None: + if ( + cleanlist is not None + or clean_all + or clean_depends is not None + or clean_cache + ): build.clean( case, cleanlist=cleanlist, clean_all=clean_all, clean_depends=clean_depends, + clean_cache=clean_cache, ) elif testname is not None: logging.warning( diff --git a/CIME/build.py b/CIME/build.py index af1709bb59c..b765786b367 100644 --- a/CIME/build.py +++ b/CIME/build.py @@ -976,6 +976,53 @@ def _create_build_metadata_for_component(config_dir, libroot, bldroot, case): return "" if cmake_args is None else cmake_args +############################################################################### +def _clean_cache_impl(case): + ############################################################################### + """Remove the CMake cache so the next build re-configures CMake without + discarding compiled object files. + + Only ``CMakeCache.txt`` and the small CMake-generated bookkeeping files in + ``CMakeFiles`` (``cmake.check_cache``, ``CMakeCache*.txt``) are removed; + per-target object directories are left in place so incremental builds + continue to work. + """ + exeroot = os.path.abspath(case.get_value("EXEROOT")) + case.load_env() + bldroot = os.path.join(exeroot, "cmake-bld") + if not os.path.isdir(bldroot): + logging.info("No cmake build directory found at {}".format(bldroot)) + return + + cache_file = os.path.join(bldroot, "CMakeCache.txt") + if os.path.isfile(cache_file): + logging.info("removing {}".format(cache_file)) + os.remove(cache_file) + else: + logging.info("no CMakeCache.txt found at {}".format(cache_file)) + + # Also remove the small CMake bookkeeping files that reference the cache, + # but preserve per-target object directories under CMakeFiles/. + cmake_files_dir = os.path.join(bldroot, "CMakeFiles") + if os.path.isdir(cmake_files_dir): + stale = ["cmake.check_cache"] + stale += [ + os.path.basename(p) + for p in glob.glob(os.path.join(cmake_files_dir, "CMakeCache*.txt")) + ] + for name in stale: + path = os.path.join(cmake_files_dir, name) + if os.path.isfile(path): + logging.info("removing {}".format(path)) + os.remove(path) + + # unlink Locked files directory so env_build.xml can be modified + unlock_file("env_build.xml", case.get_value("CASEROOT")) + + case.set_value("BUILD_COMPLETE", "FALSE") + case.flush() + + ############################################################################### def _clean_impl(case, cleanlist, clean_all, clean_depends): ############################################################################### @@ -1342,12 +1389,17 @@ def case_build( ############################################################################### -def clean(case, cleanlist=None, clean_all=False, clean_depends=None): +def clean(case, cleanlist=None, clean_all=False, clean_depends=None, clean_cache=False): ############################################################################### - functor = lambda: _clean_impl(case, cleanlist, clean_all, clean_depends) + if clean_cache: + functor = lambda: _clean_cache_impl(case) + phase = "build.clean_cache" + else: + functor = lambda: _clean_impl(case, cleanlist, clean_all, clean_depends) + phase = "build.clean" return run_and_log_case_status( functor, - "build.clean", + phase, caseroot=case.get_value("CASEROOT"), gitinterface=case._gitinterface, ) diff --git a/CIME/tests/test_unit_clean_cache.py b/CIME/tests/test_unit_clean_cache.py new file mode 100644 index 00000000000..d02703505b6 --- /dev/null +++ b/CIME/tests/test_unit_clean_cache.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 + +"""Unit tests for ``build._clean_cache_impl``. + +These tests exercise the ``--clean-cache`` flag added to ``case.build``, +which removes ``CMakeCache.txt`` and the small CMake bookkeeping files in +``CMakeFiles/`` while preserving compiled object files so the next build can +incrementally re-configure CMake without rebuilding everything. +""" + +import os +from unittest import mock + +import pytest + +from CIME import build + + +def _make_case(exeroot, caseroot): + """Build a minimal ``Case`` mock that ``_clean_cache_impl`` understands.""" + case = mock.MagicMock() + case.get_value.side_effect = lambda key: { + "EXEROOT": str(exeroot), + "CASEROOT": str(caseroot), + }[key] + return case + + +@pytest.fixture +def cmake_bld(tmp_path): + """Create a fake ``cmake-bld`` tree with a populated CMakeFiles/ directory.""" + exeroot = tmp_path / "bld" + bldroot = exeroot / "cmake-bld" + cmake_files = bldroot / "CMakeFiles" + target_dir = cmake_files / "atm.dir" + target_dir.mkdir(parents=True) + + # The cache file itself. + (bldroot / "CMakeCache.txt").write_text("# fake cache\n") + + # CMake bookkeeping files that reference the cache. + (cmake_files / "cmake.check_cache").write_text("") + (cmake_files / "CMakeCacheCopy.txt").write_text("") + + # A precious object file that must survive a cache clean. + object_file = target_dir / "foo.f90.o" + object_file.write_bytes(b"\x7fELF") + + return exeroot, object_file + + +@mock.patch("CIME.build.unlock_file") +def test_clean_cache_removes_cache_and_bookkeeping(unlock_file, tmp_path, cmake_bld): + """Happy path: cache + check-cache files removed, objects preserved.""" + exeroot, object_file = cmake_bld + case = _make_case(exeroot, tmp_path) + + build._clean_cache_impl(case) + + bldroot = exeroot / "cmake-bld" + assert not (bldroot / "CMakeCache.txt").exists() + assert not (bldroot / "CMakeFiles" / "cmake.check_cache").exists() + assert not (bldroot / "CMakeFiles" / "CMakeCacheCopy.txt").exists() + + # Object files must be preserved. + assert object_file.exists() + # CMakeFiles/ itself must remain (per-target object directories live there). + assert (bldroot / "CMakeFiles" / "atm.dir").is_dir() + + case.set_value.assert_any_call("BUILD_COMPLETE", "FALSE") + case.flush.assert_called_once() + unlock_file.assert_called_once_with("env_build.xml", str(tmp_path)) + + +@mock.patch("CIME.build.unlock_file") +def test_clean_cache_no_build_dir_is_noop(unlock_file, tmp_path): + """If the cmake-bld directory does not exist, the call should be a no-op.""" + exeroot = tmp_path / "bld" # intentionally not created + case = _make_case(exeroot, tmp_path) + + build._clean_cache_impl(case) + + case.set_value.assert_not_called() + case.flush.assert_not_called() + unlock_file.assert_not_called() + + +@mock.patch("CIME.build.unlock_file") +def test_clean_cache_missing_cache_file_still_resets_state(unlock_file, tmp_path): + """A cmake-bld dir without CMakeCache.txt still resets BUILD_COMPLETE.""" + exeroot = tmp_path / "bld" + (exeroot / "cmake-bld").mkdir(parents=True) + case = _make_case(exeroot, tmp_path) + + build._clean_cache_impl(case) + + case.set_value.assert_any_call("BUILD_COMPLETE", "FALSE") + case.flush.assert_called_once() + unlock_file.assert_called_once_with("env_build.xml", str(tmp_path)) + + +def test_clean_dispatches_to_clean_cache_when_requested(tmp_path): + """``build.clean(..., clean_cache=True)`` must route to the cache impl.""" + exeroot = tmp_path / "bld" + (exeroot / "cmake-bld").mkdir(parents=True) + case = _make_case(exeroot, tmp_path) + case._gitinterface = None + + with mock.patch("CIME.build._clean_cache_impl") as cache_impl, mock.patch( + "CIME.build._clean_impl" + ) as full_impl, mock.patch("CIME.build.run_and_log_case_status") as runner: + runner.side_effect = lambda functor, *a, **kw: functor() + + build.clean(case, clean_cache=True) + + cache_impl.assert_called_once_with(case) + full_impl.assert_not_called() + # The phase name should be distinct from the regular clean phase so + # case status logs make the operation traceable. + assert runner.call_args.args[1] == "build.clean_cache" + + +def test_clean_dispatches_to_clean_impl_by_default(tmp_path): + """Without ``clean_cache``, the legacy clean path must still be used.""" + case = _make_case(tmp_path / "bld", tmp_path) + case._gitinterface = None + + with mock.patch("CIME.build._clean_cache_impl") as cache_impl, mock.patch( + "CIME.build._clean_impl" + ) as full_impl, mock.patch("CIME.build.run_and_log_case_status") as runner: + runner.side_effect = lambda functor, *a, **kw: functor() + + build.clean(case, clean_all=True) + + full_impl.assert_called_once_with(case, None, True, None) + cache_impl.assert_not_called() + assert runner.call_args.args[1] == "build.clean"