diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index 69da40c95..574e5fcba 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -23,7 +23,7 @@ from cibuildwheel.platforms import ALL_PLATFORM_MODULES, get_build_identifiers, native_platform from cibuildwheel.selector import BuildSelector, EnableGroup, selector_matches from cibuildwheel.typing import PLATFORMS, PlatformName -from cibuildwheel.util.file import CIBW_CACHE_PATH +from cibuildwheel.util.file import CIBW_CACHE_PATH, ensure_cache_sentinel from cibuildwheel.util.helpers import strtobool from cibuildwheel.util.resources import read_all_configs @@ -179,6 +179,12 @@ def main_inner(global_options: GlobalOptions) -> None: help="Print the build identifiers matched by the current invocation and exit.", ) + parser.add_argument( + "--clean-cache", + action="store_true", + help="Clear the cibuildwheel cache and exit.", + ) + parser.add_argument( "--allow-empty", action="store_true", @@ -196,6 +202,48 @@ def main_inner(global_options: GlobalOptions) -> None: global_options.print_traceback_on_error = args.debug_traceback + if args.clean_cache: + if not CIBW_CACHE_PATH.exists(): + print(f"Cache directory does not exist: {CIBW_CACHE_PATH}") + sys.exit(0) + + sentinel_file = CIBW_CACHE_PATH / "CACHEDIR.TAG" + if not sentinel_file.exists(): + print( + f"Error: {CIBW_CACHE_PATH} does not appear to be a cibuildwheel cache directory.", + "Only directories with a CACHEDIR.TAG sentinel file can be cleaned.", + sep="\n", + file=sys.stderr, + ) + sys.exit(1) + + # Verify signature to ensure it's a proper cache dir + # See https://bford.info/cachedir/ for more + try: + sentinel_content = sentinel_file.read_text(encoding="utf-8") + except OSError as e: + print(f"Error reading cache directory tag: {e}", file=sys.stderr) + sys.exit(1) + + if not sentinel_content.startswith("Signature: 8a477f597d28d172789f06886806bc55"): + print( + f"Error: {sentinel_file} does not contain a valid cache directory signature.", + "For safety, only properly signed cache directories can be cleaned.", + sep="\n", + file=sys.stderr, + ) + sys.exit(1) + + print(f"Clearing cache directory: {CIBW_CACHE_PATH}") + try: + shutil.rmtree(CIBW_CACHE_PATH) + print("Cache cleared successfully.") + except OSError as e: + print(f"Error clearing cache: {e}", file=sys.stderr) + sys.exit(1) + + sys.exit(0) + args.package_dir = args.package_dir.resolve() # This are always relative to the base directory, even in SDist builds @@ -309,6 +357,7 @@ def build_in_directory(args: CommandLineArguments) -> None: # create the cache dir before it gets printed & builds performed CIBW_CACHE_PATH.mkdir(parents=True, exist_ok=True) + ensure_cache_sentinel(CIBW_CACHE_PATH) print_preamble(platform=platform, options=options, identifiers=identifiers) diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index fa10ff6f7..22dc48d1a 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -63,6 +63,7 @@ class CommandLineArguments: allow_empty: bool debug_traceback: bool enable: list[str] + clean_cache: bool @classmethod def defaults(cls) -> Self: @@ -77,6 +78,7 @@ def defaults(cls) -> Self: print_build_identifiers=False, debug_traceback=False, enable=[], + clean_cache=False, ) diff --git a/cibuildwheel/util/file.py b/cibuildwheel/util/file.py index c0fdc2c19..b93a5e50a 100644 --- a/cibuildwheel/util/file.py +++ b/cibuildwheel/util/file.py @@ -20,6 +20,26 @@ ).resolve() +def ensure_cache_sentinel(cache_path: Path) -> None: + """Create a sentinel file to mark a cibuildwheel cache directory. + + We help prevent accidental deletion of non-cache directories with this. + + Args: + cache_path: The cache directory path where the sentinel should be created + """ + if cache_path.exists(): + sentinel_file = cache_path / "CACHEDIR.TAG" + if not sentinel_file.exists(): + sentinel_file.write_text( + "Signature: 8a477f597d28d172789f06886806bc55\n" + "# This file is a cache directory tag created by cibuildwheel.\n" + "# For information about cache directory tags, see:\n" + "# https://www.brynosaurus.com/cachedir/", + encoding="utf-8", + ) + + def download(url: str, dest: Path) -> None: print(f"+ Download {url} to {dest}") dest_dir = dest.parent diff --git a/unit_test/main_commands_test.py b/unit_test/main_commands_test.py new file mode 100644 index 000000000..d3d09a7e5 --- /dev/null +++ b/unit_test/main_commands_test.py @@ -0,0 +1,127 @@ +import errno +import shutil +import sys + +import pytest + +import cibuildwheel.__main__ as main_module +from cibuildwheel.__main__ import main + + +def test_clean_cache_when_cache_exists(tmp_path, monkeypatch, capfd): + fake_cache_dir = (tmp_path / "cibw_cache").resolve() + monkeypatch.setattr(main_module, "CIBW_CACHE_PATH", fake_cache_dir) + + fake_cache_dir.mkdir(parents=True, exist_ok=True) + assert fake_cache_dir.exists() + + cibw_sentinel = fake_cache_dir / "CACHEDIR.TAG" + cibw_sentinel.write_text( + "Signature: 8a477f597d28d172789f06886806bc55\n" + "# This file is a cache directory tag created by cibuildwheel.\n" + "# For information about cache directory tags, see:\n" + "# https://www.brynosaurus.com/cachedir/", + encoding="utf-8", + ) + + dummy_file = fake_cache_dir / "dummy.txt" + dummy_file.write_text("hello") + + monkeypatch.setattr(sys, "argv", ["cibuildwheel", "--clean-cache"]) + + with pytest.raises(SystemExit) as e: + main() + + assert e.value.code == 0 + + out, err = capfd.readouterr() + assert f"Clearing cache directory: {fake_cache_dir}" in out + assert "Cache cleared successfully." in out + assert not fake_cache_dir.exists() + + +def test_clean_cache_when_cache_does_not_exist(tmp_path, monkeypatch, capfd): + fake_cache_dir = (tmp_path / "nonexistent_cache").resolve() + monkeypatch.setattr(main_module, "CIBW_CACHE_PATH", fake_cache_dir) + + monkeypatch.setattr(sys, "argv", ["cibuildwheel", "--clean-cache"]) + + with pytest.raises(SystemExit) as e: + main() + + assert e.value.code == 0 + + out, err = capfd.readouterr() + assert f"Cache directory does not exist: {fake_cache_dir}" in out + + +def test_clean_cache_with_error(tmp_path, monkeypatch, capfd): + fake_cache_dir = (tmp_path / "cibw_cache").resolve() + monkeypatch.setattr(main_module, "CIBW_CACHE_PATH", fake_cache_dir) + + fake_cache_dir.mkdir(parents=True, exist_ok=True) + assert fake_cache_dir.exists() + + cibw_sentinel = fake_cache_dir / "CACHEDIR.TAG" + cibw_sentinel.write_text( + "Signature: 8a477f597d28d172789f06886806bc55\n" + "# This file is a cache directory tag created by cibuildwheel.\n" + "# For information about cache directory tags, see:\n" + "# https://www.brynosaurus.com/cachedir/", + encoding="utf-8", + ) + + monkeypatch.setattr(sys, "argv", ["cibuildwheel", "--clean-cache"]) + + def fake_rmtree(path): # noqa: ARG001 + raise OSError(errno.EACCES, "Permission denied") + + monkeypatch.setattr(shutil, "rmtree", fake_rmtree) + + with pytest.raises(SystemExit) as e: + main() + + assert e.value.code == 1 + + out, err = capfd.readouterr() + assert f"Clearing cache directory: {fake_cache_dir}" in out + assert "Error clearing cache:" in err + + +def test_clean_cache_without_sentinel(tmp_path, monkeypatch, capfd): + fake_cache_dir = (tmp_path / "not_a_cache").resolve() + monkeypatch.setattr(main_module, "CIBW_CACHE_PATH", fake_cache_dir) + + fake_cache_dir.mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(sys, "argv", ["cibuildwheel", "--clean-cache"]) + + with pytest.raises(SystemExit) as e: + main() + + assert e.value.code == 1 + + out, err = capfd.readouterr() + assert "does not appear to be a cibuildwheel cache directory" in err + assert fake_cache_dir.exists() + + +def test_clean_cache_with_invalid_signature(tmp_path, monkeypatch, capfd): + fake_cache_dir = (tmp_path / "fake_cache").resolve() + monkeypatch.setattr(main_module, "CIBW_CACHE_PATH", fake_cache_dir) + + fake_cache_dir.mkdir(parents=True, exist_ok=True) + + cibw_sentinel = fake_cache_dir / "CACHEDIR.TAG" + cibw_sentinel.write_text("Invalid signature\n# This is not a real cache directory tag") + + monkeypatch.setattr(sys, "argv", ["cibuildwheel", "--clean-cache"]) + + with pytest.raises(SystemExit) as e: + main() + + assert e.value.code == 1 + + out, err = capfd.readouterr() + assert "does not contain a valid cache directory signature" in err + assert fake_cache_dir.exists()