Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
51 changes: 50 additions & 1 deletion cibuildwheel/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions cibuildwheel/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class CommandLineArguments:
allow_empty: bool
debug_traceback: bool
enable: list[str]
clean_cache: bool

@classmethod
def defaults(cls) -> Self:
Expand All @@ -77,6 +78,7 @@ def defaults(cls) -> Self:
print_build_identifiers=False,
debug_traceback=False,
enable=[],
clean_cache=False,
)


Expand Down
20 changes: 20 additions & 0 deletions cibuildwheel/util/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 127 additions & 0 deletions unit_test/main_commands_test.py
Original file line number Diff line number Diff line change
@@ -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()
Loading