Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion CIME/Tools/case.build
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand All @@ -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):
###############################################################################
Expand Down Expand Up @@ -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 = (
Expand All @@ -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,
Expand All @@ -196,6 +211,7 @@ def _main_func(description):
clean_all,
buildlist,
clean_depends,
clean_cache,
save_build_provenance,
separate_builds,
ninja,
Expand All @@ -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(
Expand Down
58 changes: 55 additions & 3 deletions CIME/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
###############################################################################
Expand Down Expand Up @@ -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,
)
137 changes: 137 additions & 0 deletions CIME/tests/test_unit_clean_cache.py
Original file line number Diff line number Diff line change
@@ -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"
Loading