fix: remove potential partial cache-population in case of error (#2892)

fix: remove potential partial extraction in case of error

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Matthieu Darbois
2026-06-05 08:10:48 -04:00
committed by GitHub
co-authored by Copilot
parent cdb170b8c6
commit 6cd2d197c1
9 changed files with 159 additions and 49 deletions
+9 -2
View File
@@ -30,7 +30,13 @@ from cibuildwheel.frontend import (
from cibuildwheel.logger import log from cibuildwheel.logger import log
from cibuildwheel.util import resources from cibuildwheel.util import resources
from cibuildwheel.util.cmd import call, shell from cibuildwheel.util.cmd import call, shell
from cibuildwheel.util.file import CIBW_CACHE_PATH, copy_test_sources, download, move_file from cibuildwheel.util.file import (
CIBW_CACHE_PATH,
copy_test_sources,
download,
move_file,
remove_on_error,
)
from cibuildwheel.util.helpers import prepare_command from cibuildwheel.util.helpers import prepare_command
from cibuildwheel.util.packaging import find_compatible_wheel from cibuildwheel.util.packaging import find_compatible_wheel
from cibuildwheel.util.python_build_standalone import create_python_build_standalone_environment from cibuildwheel.util.python_build_standalone import create_python_build_standalone_environment
@@ -182,7 +188,8 @@ def setup_target_python(config: PythonConfiguration, build_path: Path) -> Path:
python_tgz = CIBW_CACHE_PATH / config.url.rpartition("/")[-1] python_tgz = CIBW_CACHE_PATH / config.url.rpartition("/")[-1]
with FileLock(f"{python_tgz}.lock"): with FileLock(f"{python_tgz}.lock"):
if not python_tgz.exists(): if not python_tgz.exists():
download(config.url, python_tgz, sha256=config.sha256) with remove_on_error(python_tgz):
download(config.url, python_tgz, sha256=config.sha256)
python_dir = build_path / "python" python_dir = build_path / "python"
python_dir.mkdir() python_dir.mkdir()
+10 -3
View File
@@ -25,7 +25,13 @@ from cibuildwheel.logger import log
from cibuildwheel.platforms.macos import install_cpython as install_build_cpython from cibuildwheel.platforms.macos import install_cpython as install_build_cpython
from cibuildwheel.util import resources from cibuildwheel.util import resources
from cibuildwheel.util.cmd import call, shell, split_command from cibuildwheel.util.cmd import call, shell, split_command
from cibuildwheel.util.file import CIBW_CACHE_PATH, copy_test_sources, download, move_file from cibuildwheel.util.file import (
CIBW_CACHE_PATH,
copy_test_sources,
download,
move_file,
remove_on_error,
)
from cibuildwheel.util.helpers import prepare_command, unwrap_preserving_paragraphs from cibuildwheel.util.helpers import prepare_command, unwrap_preserving_paragraphs
from cibuildwheel.util.packaging import find_compatible_wheel from cibuildwheel.util.packaging import find_compatible_wheel
from cibuildwheel.venv import constraint_flags, virtualenv from cibuildwheel.venv import constraint_flags, virtualenv
@@ -140,8 +146,9 @@ def install_target_cpython(tmp: Path, config: PythonConfiguration, free_threadin
if not installation_path.exists(): if not installation_path.exists():
downloaded_tar_gz = tmp / ios_python_tar_gz downloaded_tar_gz = tmp / ios_python_tar_gz
download(config.url, downloaded_tar_gz, sha256=config.sha256) download(config.url, downloaded_tar_gz, sha256=config.sha256)
installation_path.mkdir(parents=True, exist_ok=True) with remove_on_error(installation_path):
call("tar", "-C", installation_path, "-xf", downloaded_tar_gz) installation_path.mkdir(parents=True)
call("tar", "-C", installation_path, "-xf", downloaded_tar_gz)
downloaded_tar_gz.unlink() downloaded_tar_gz.unlink()
return installation_path return installation_path
+20 -5
View File
@@ -27,7 +27,13 @@ from cibuildwheel.frontend import (
from cibuildwheel.logger import log from cibuildwheel.logger import log
from cibuildwheel.util import resources from cibuildwheel.util import resources
from cibuildwheel.util.cmd import call, shell from cibuildwheel.util.cmd import call, shell
from cibuildwheel.util.file import CIBW_CACHE_PATH, copy_test_sources, download, move_file from cibuildwheel.util.file import (
CIBW_CACHE_PATH,
copy_test_sources,
download,
move_file,
remove_on_error,
)
from cibuildwheel.util.helpers import prepare_command, unwrap from cibuildwheel.util.helpers import prepare_command, unwrap
from cibuildwheel.util.packaging import find_compatible_wheel, get_pip_version from cibuildwheel.util.packaging import find_compatible_wheel, get_pip_version
from cibuildwheel.venv import constraint_flags, find_uv, target_marker_env, virtualenv from cibuildwheel.venv import constraint_flags, find_uv, target_marker_env, virtualenv
@@ -196,7 +202,8 @@ def install_pypy(tmp: Path, url: str, sha256: str) -> Path:
downloaded_tar_bz2 = tmp / pypy_tar_bz2 downloaded_tar_bz2 = tmp / pypy_tar_bz2
download(url, downloaded_tar_bz2, sha256=sha256) download(url, downloaded_tar_bz2, sha256=sha256)
installation_path.parent.mkdir(parents=True, exist_ok=True) installation_path.parent.mkdir(parents=True, exist_ok=True)
call("tar", "-C", installation_path.parent, "-xf", downloaded_tar_bz2) with remove_on_error(installation_path):
call("tar", "-C", installation_path.parent, "-xf", downloaded_tar_bz2)
downloaded_tar_bz2.unlink() downloaded_tar_bz2.unlink()
return installation_path / "bin" / "pypy3" return installation_path / "bin" / "pypy3"
@@ -210,9 +217,17 @@ def install_graalpy(tmp: Path, url: str, sha256: str) -> Path:
if not installation_path.exists(): if not installation_path.exists():
downloaded_archive = tmp / graalpy_archive downloaded_archive = tmp / graalpy_archive
download(url, downloaded_archive, sha256=sha256) download(url, downloaded_archive, sha256=sha256)
installation_path.mkdir(parents=True) with remove_on_error(installation_path):
# GraalPy top-folder name is inconsistent with archive name installation_path.mkdir(parents=True)
call("tar", "-C", installation_path, "--strip-components=1", "-xzf", downloaded_archive) # GraalPy top-folder name is inconsistent with archive name
call(
"tar",
"-C",
installation_path,
"--strip-components=1",
"-xzf",
downloaded_archive,
)
downloaded_archive.unlink() downloaded_archive.unlink()
return installation_path / "bin" / "graalpy" return installation_path / "bin" / "graalpy"
+31 -27
View File
@@ -29,6 +29,7 @@ from cibuildwheel.util.file import (
extract_tar, extract_tar,
extract_zip, extract_zip,
move_file, move_file,
remove_on_error,
) )
from cibuildwheel.util.helpers import prepare_command, unwrap, unwrap_preserving_paragraphs from cibuildwheel.util.helpers import prepare_command, unwrap, unwrap_preserving_paragraphs
from cibuildwheel.util.packaging import find_compatible_wheel, get_pip_version from cibuildwheel.util.packaging import find_compatible_wheel, get_pip_version
@@ -92,10 +93,11 @@ def ensure_node(major_version: str) -> Path:
with TemporaryDirectory() as tmp_path: with TemporaryDirectory() as tmp_path:
archive = Path(tmp_path) / f"{name}.{ext}" archive = Path(tmp_path) / f"{name}.{ext}"
download(url, archive) download(url, archive)
if ext == "zip": with remove_on_error(path):
extract_zip(archive, path.parent) if ext == "zip":
else: extract_zip(archive, path.parent)
extract_tar(archive, path.parent) else:
extract_tar(archive, path.parent)
assert path.exists() assert path.exists()
if not IS_WIN: if not IS_WIN:
return path / "bin" return path / "bin"
@@ -110,18 +112,19 @@ def install_emscripten(env: dict[str, str], version: str, xbuildenv_cache_path:
with FileLock(CIBW_CACHE_PATH / "emscripten.lock"): with FileLock(CIBW_CACHE_PATH / "emscripten.lock"):
if emscripten_dir.exists(): if emscripten_dir.exists():
return emscripten_dir return emscripten_dir
call( with remove_on_error(emscripten_dir):
"pyodide", call(
"xbuildenv", "pyodide",
"install-emscripten", "xbuildenv",
"--force", "install-emscripten",
"--version", "--force",
version, "--version",
"--path", version,
str(xbuildenv_cache_path), "--path",
env=env, str(xbuildenv_cache_path),
cwd=CIBW_CACHE_PATH, env=env,
) cwd=CIBW_CACHE_PATH,
)
assert emscripten_dir.exists() assert emscripten_dir.exists()
return emscripten_dir return emscripten_dir
@@ -208,17 +211,18 @@ def install_xbuildenv(env: dict[str, str], xbuildenv_cache_path: Path, pyodide_v
env.pop("PYODIDE_ROOT", None) env.pop("PYODIDE_ROOT", None)
# Install the xbuildenv # Install the xbuildenv
call( with remove_on_error(xbuildenv_cache_path / pyodide_version):
"pyodide", call(
"xbuildenv", "pyodide",
"install", "xbuildenv",
"--path", "install",
str(xbuildenv_cache_path), "--path",
pyodide_version, str(xbuildenv_cache_path),
env=env, pyodide_version,
cwd=CIBW_CACHE_PATH, env=env,
) cwd=CIBW_CACHE_PATH,
assert pyodide_root.exists() )
assert pyodide_root.exists()
return str(pyodide_root) return str(pyodide_root)
+11 -6
View File
@@ -30,6 +30,7 @@ from cibuildwheel.util.file import (
download, download,
extract_zip, extract_zip,
move_file, move_file,
remove_on_error,
) )
from cibuildwheel.util.helpers import prepare_command, unwrap from cibuildwheel.util.helpers import prepare_command, unwrap
from cibuildwheel.util.packaging import find_compatible_wheel, get_pip_version from cibuildwheel.util.packaging import find_compatible_wheel, get_pip_version
@@ -112,7 +113,8 @@ def _ensure_nuget() -> Path:
nuget = CIBW_CACHE_PATH / "nuget.exe" nuget = CIBW_CACHE_PATH / "nuget.exe"
with FileLock(str(nuget) + ".lock"): with FileLock(str(nuget) + ".lock"):
if not nuget.exists(): if not nuget.exists():
download("https://dist.nuget.org/win-x86-commandline/latest/nuget.exe", nuget) with remove_on_error(nuget):
download("https://dist.nuget.org/win-x86-commandline/latest/nuget.exe", nuget)
return nuget return nuget
@@ -128,7 +130,8 @@ def install_cpython(configuration: PythonConfiguration, arch: str | None = None)
with FileLock(str(base_output_dir) + f"-{version}{free_threaded_str}-{arch}.lock"): with FileLock(str(base_output_dir) + f"-{version}{free_threaded_str}-{arch}.lock"):
if not installation_path.exists(): if not installation_path.exists():
nuget = _ensure_nuget() nuget = _ensure_nuget()
call(nuget, "install", *nuget_args) with remove_on_error(installation_path.parent):
call(nuget, "install", *nuget_args)
return installation_path / "python.exe" return installation_path / "python.exe"
@@ -144,8 +147,9 @@ def install_pypy(tmp: Path, arch: str, url: str, sha256: str) -> Path:
if not installation_path.exists(): if not installation_path.exists():
pypy_zip = tmp / zip_filename pypy_zip = tmp / zip_filename
download(url, pypy_zip, sha256=sha256) download(url, pypy_zip, sha256=sha256)
# Extract to the parent directory because the zip file still contains a directory with remove_on_error(installation_path):
extract_zip(pypy_zip, installation_path.parent) # Extract to the parent directory because the zip file still contains a directory
extract_zip(pypy_zip, installation_path.parent)
return installation_path / "python.exe" return installation_path / "python.exe"
@@ -158,8 +162,9 @@ def install_graalpy(tmp: Path, url: str, sha256: str) -> Path:
if not installation_path.exists(): if not installation_path.exists():
graalpy_zip = tmp / zip_filename graalpy_zip = tmp / zip_filename
download(url, graalpy_zip, sha256=sha256) download(url, graalpy_zip, sha256=sha256)
# Extract to the parent directory because the zip file still contains a directory with remove_on_error(installation_path):
extract_zip(graalpy_zip, installation_path.parent) # Extract to the parent directory because the zip file still contains a directory
extract_zip(graalpy_zip, installation_path.parent)
return installation_path / "bin" / "graalpy.exe" return installation_path / "bin" / "graalpy.exe"
+18 -1
View File
@@ -7,6 +7,7 @@ import ssl
import tarfile import tarfile
import time import time
import urllib.request import urllib.request
from contextlib import contextmanager
from pathlib import Path, PurePath from pathlib import Path, PurePath
from typing import Final from typing import Final
from zipfile import ZipFile from zipfile import ZipFile
@@ -18,7 +19,7 @@ from cibuildwheel.errors import FatalError
TYPE_CHECKING = False TYPE_CHECKING = False
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable, Generator
DEFAULT_CIBW_CACHE_PATH: Final[Path] = user_cache_path(appname="cibuildwheel", appauthor="pypa") DEFAULT_CIBW_CACHE_PATH: Final[Path] = user_cache_path(appname="cibuildwheel", appauthor="pypa")
CIBW_CACHE_PATH: Final[Path] = Path( CIBW_CACHE_PATH: Final[Path] = Path(
@@ -26,6 +27,22 @@ CIBW_CACHE_PATH: Final[Path] = Path(
).resolve() ).resolve()
@contextmanager
def remove_on_error(path: Path) -> Generator[None, None, None]:
try:
yield
except BaseException as original_exception:
try:
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
elif path.exists() or path.is_symlink():
path.unlink()
except BaseException as cleanup_exception:
msg = f"Failed to remove {path}. Please remove it manually."
raise BaseExceptionGroup(msg, [original_exception, cleanup_exception]) from None
raise
def ensure_cache_sentinel(cache_path: Path) -> None: def ensure_cache_sentinel(cache_path: Path) -> None:
"""Create a sentinel file to mark a cibuildwheel cache directory. """Create a sentinel file to mark a cibuildwheel cache directory.
+3 -2
View File
@@ -10,7 +10,7 @@ import typing
from filelock import FileLock from filelock import FileLock
from cibuildwheel.util.file import download, extract_tar from cibuildwheel.util.file import download, extract_tar, remove_on_error
from cibuildwheel.util.resources import PYTHON_BUILD_STANDALONE_RELEASES from cibuildwheel.util.resources import PYTHON_BUILD_STANDALONE_RELEASES
TYPE_CHECKING = False TYPE_CHECKING = False
@@ -157,7 +157,8 @@ def _download_or_get_from_cache(
return asset_cache_path return asset_cache_path
print(f"Downloading python_build_standalone: {asset_url} to {asset_cache_path}") print(f"Downloading python_build_standalone: {asset_url} to {asset_cache_path}")
download(asset_url, asset_cache_path, sha256=sha256 or None) with remove_on_error(asset_cache_path):
download(asset_url, asset_cache_path, sha256=sha256 or None)
return asset_cache_path return asset_cache_path
+3 -2
View File
@@ -16,7 +16,7 @@ from packaging.version import Version
from cibuildwheel.util import resources from cibuildwheel.util import resources
from cibuildwheel.util.cmd import call from cibuildwheel.util.cmd import call
from cibuildwheel.util.file import CIBW_CACHE_PATH, download from cibuildwheel.util.file import CIBW_CACHE_PATH, download, remove_on_error
TYPE_CHECKING = False TYPE_CHECKING = False
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -57,7 +57,8 @@ def _ensure_virtualenv(version: str) -> tuple[Path, Version]:
path = CIBW_CACHE_PATH / f"virtualenv-{version}.pyz" path = CIBW_CACHE_PATH / f"virtualenv-{version}.pyz"
with FileLock(str(path) + ".lock"): with FileLock(str(path) + ".lock"):
if not path.exists(): if not path.exists():
download(url, path, sha256=sha256) with remove_on_error(path):
download(url, path, sha256=sha256)
return (path, Version(version)) return (path, Version(version))
+54 -1
View File
@@ -1,3 +1,4 @@
import re
import textwrap import textwrap
from pathlib import Path, PurePath from pathlib import Path, PurePath
from unittest.mock import Mock, call from unittest.mock import Mock, call
@@ -6,7 +7,7 @@ import pytest
from cibuildwheel import errors from cibuildwheel import errors
from cibuildwheel.ci import fix_ansi_codes_for_github_actions from cibuildwheel.ci import fix_ansi_codes_for_github_actions
from cibuildwheel.util.file import copy_test_sources from cibuildwheel.util.file import copy_test_sources, remove_on_error
from cibuildwheel.util.helpers import ( from cibuildwheel.util.helpers import (
FlexibleVersion, FlexibleVersion,
format_safe, format_safe,
@@ -395,6 +396,58 @@ def test_copy_test_sources_alternate_copy_into(sample_project: Path) -> None:
) )
def test_remove_on_error_keeps_paths(tmp_path: Path) -> None:
file_path = tmp_path / "file.txt"
file_path.write_text("data")
dir_path = tmp_path / "dir"
dir_path.mkdir()
for path in (file_path, dir_path):
with remove_on_error(path):
pass
assert file_path.exists()
assert dir_path.exists()
def test_remove_on_error_file_or_tree(tmp_path: Path) -> None:
file_path = tmp_path / "file.txt"
file_path.write_text("data")
dir_path = tmp_path / "dir"
nested_file_path = dir_path / "nested.txt"
nested_file_path.parent.mkdir()
nested_file_path.write_text("data")
for path in (file_path, dir_path):
with pytest.raises(RuntimeError), remove_on_error(path):
raise RuntimeError
assert not file_path.exists()
assert not dir_path.exists()
def test_remove_on_error_cleanup_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
path = tmp_path / "cleanup-failure"
path.mkdir()
original_exception = RuntimeError("original error")
cleanup_exception = PermissionError("cleanup error")
def _raise_cleanup_error(_: Path) -> None:
raise cleanup_exception
monkeypatch.setattr("cibuildwheel.util.file.shutil.rmtree", _raise_cleanup_error)
with (
pytest.raises(
BaseExceptionGroup,
match=re.escape(f"Failed to remove {path}. Please remove it manually."),
) as caught_exception,
remove_on_error(path),
):
raise original_exception
assert caught_exception.value.exceptions == (original_exception, cleanup_exception)
def test_unwrap() -> None: def test_unwrap() -> None:
assert ( assert (
unwrap(""" unwrap("""