Repository: scikit-build/scikit-build-core (python)

Issue:
PermissionError on Windows runner for CMake FetchContent dependency for Python 3.7
Hello! I'm running into an issue where, for a Windows CI runner with Python 3.7, the build fails due to a `PermissionError: [WinError 5] Access is denied`. It appears to be occurring due to a CMake `FetchContent` dependency from a git repository. It seems the build is not properly releasing the git related resources during the wheel build. The build works fine for both macOS and Linux. The build works fine for Windows with Python 3.8.

This issue was originally posted to https://github.com/pypa/cibuildwheel/issues/1523 before being moved here. There, @henryiii noted that the issue is related to the implementation of `TemporaryDirectory` and `shutil.rmtree` [in Python 3.7](https://github.com/python/cpython/blob/85c4608a3afa6753f04ed4ade1ec1a866aaacf95/Lib/tempfile.py#L796-L798) which was then fixed [in Python 3.8](https://github.com/python/cpython/blob/40ea37ddae465d7046e2a6b8d9fd80c27f8b2da6/Lib/tempfile.py#L794).

I setup a small example repository demonstrating this issue here: https://github.com/golmschenk/cibuildwheel_fetchcontent_permission_error_example.
The failing build using Python 3.7 can be seen here: https://github.com/golmschenk/cibuildwheel_fetchcontent_permission_error_example/actions/runs/5270068999/jobs/9529084594.
The successful build using Python 3.8 can be seen here:
https://github.com/golmschenk/cibuildwheel_fetchcontent_permission_error_example/actions/runs/5290893595/jobs/9575683760

The error appears as:
```
    *** Making wheel...
    Traceback (most recent call last):
      File "C:\Users\runneradmin\AppData\Local\Temp\cibw-run-4pku3vah\cp37-win32\build\venv\lib\site-packages\pip\_vendor\pyproject_hooks\_in_process\_in_process.py", line 353, in <module>
        main()
      File "C:\Users\runneradmin\AppData\Local\Temp\cibw-run-4pku3vah\cp37-win32\build\venv\lib\site-packages\pip\_vendor\pyproject_hooks\_in_process\_in_process.py", line 335, in main
        json_out['return_val'] = hook(**hook_input['kwargs'])
      File "C:\Users\runneradmin\AppData\Local\Temp\cibw-run-4pku3vah\cp37-win32\build\venv\lib\site-packages\pip\_vendor\pyproject_hooks\_in_process\_in_process.py", line 252, in build_wheel
        metadata_directory)
      File "C:\Users\RUNNER~1\AppData\Local\Temp\pip-build-env-ss4tjy2i\overlay\Lib\site-packages\scikit_build_core\build\__init__.py", line 36, in build_wheel
        editable=False,
      File "C:\Users\RUNNER~1\AppData\Local\Temp\pip-build-env-ss4tjy2i\overlay\Lib\site-packages\scikit_build_core\build\wheel.py", line 288, in _build_wheel_impl
        f"import _{normalized_name}_editable\n".encode(),
      File "C:\Users\runneradmin\AppData\Local\pypa\cibuildwheel\Cache\nuget-cpython\pythonx86.3.7.9\tools\lib\tempfile.py", line 807, in __exit__
        self.cleanup()
      File "C:\Users\runneradmin\AppData\Local\pypa\cibuildwheel\Cache\nuget-cpython\pythonx86.3.7.9\tools\lib\tempfile.py", line 811, in cleanup
        _shutil.rmtree(self.name)
      File "C:\Users\runneradmin\AppData\Local\pypa\cibuildwheel\Cache\nuget-cpython\pythonx86.3.7.9\tools\lib\shutil.py", line 516, in rmtree
        return _rmtree_unsafe(path, onerror)
      File "C:\Users\runneradmin\AppData\Local\pypa\cibuildwheel\Cache\nuget-cpython\pythonx86.3.7.9\tools\lib\shutil.py", line 395, in _rmtree_unsafe
        _rmtree_unsafe(fullname, onerror)
      File "C:\Users\runneradmin\AppData\Local\pypa\cibuildwheel\Cache\nuget-cpython\pythonx86.3.7.9\tools\lib\shutil.py", line 395, in _rmtree_unsafe
        _rmtree_unsafe(fullname, onerror)
      File "C:\Users\runneradmin\AppData\Local\pypa\cibuildwheel\Cache\nuget-cpython\pythonx86.3.7.9\tools\lib\shutil.py", line 395, in _rmtree_unsafe
        _rmtree_unsafe(fullname, onerror)
      [Previous line repeated 3 more times]
      File "C:\Users\runneradmin\AppData\Local\pypa\cibuildwheel\Cache\nuget-cpython\pythonx86.3.7.9\tools\lib\shutil.py", line 400, in _rmtree_unsafe
        onerror(os.unlink, fullname, sys.exc_info())
      File "C:\Users\runneradmin\AppData\Local\pypa\cibuildwheel\Cache\nuget-cpython\pythonx86.3.7.9\tools\lib\shutil.py", line 398, in _rmtree_unsafe
        os.unlink(fullname)
    PermissionError: [WinError 5] Access is denied: 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\tmpwvkc37iy\\build\\_deps\\fortran_stdlib-src\\.git\\objects\\pack\\pack-faae3e6a7f3ee7b73a650fc640815b9d8dbf92df.idx'
```

Thank you for your time!


Pull request:
fix: workaround for Windows Python 3.7 TemporaryDirectory bug
Fix #384.


Patch:
diff --git a/src/scikit_build_core/_shutil.py b/src/scikit_build_core/_shutil.py
index 04c08c8..ba600af 100644
--- a/src/scikit_build_core/_shutil.py
+++ b/src/scikit_build_core/_shutil.py
@@ -1,9 +1,12 @@
 from __future__ import annotations
 
+import contextlib
 import dataclasses
 import os
+import stat
 import subprocess
-from collections.abc import Iterable
+import sys
+from collections.abc import Generator, Iterable
 from typing import ClassVar
 
 from ._logging import logger
@@ -78,3 +81,26 @@ class Run:
         if k in self._prev_env and k not in self.env:
             return "-"
         return " "
+
+
+def _fix_all_permissions(directory: str) -> None:
+    """
+    Makes sure the write permission is set. Only run this on Windows.
+    """
+    with os.scandir(directory) as it:
+        for entry in it:
+            if entry.is_dir():
+                _fix_all_permissions(entry.path)
+                continue
+            mode = stat.S_IMODE(entry.stat().st_mode)
+            if not mode & stat.S_IWRITE:
+                os.chmod(entry.path, mode | stat.S_IWRITE)  # noqa: PTH101
+
+
+@contextlib.contextmanager
+def fix_win_37_all_permissions(tmpdir: str) -> Generator[None, None, None]:
+    try:
+        yield
+    finally:
+        if sys.version_info < (3, 8) and sys.platform.startswith("win"):
+            _fix_all_permissions(tmpdir)
diff --git a/src/scikit_build_core/_version.pyi b/src/scikit_build_core/_version.pyi
index c34d624..23e6eab 100644
--- a/src/scikit_build_core/_version.pyi
+++ b/src/scikit_build_core/_version.pyi
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
-__version__: str
 version: str
-__version_tuple__: tuple[int, int, int, str, str] | tuple[int, int, int]
 version_tuple: tuple[int, int, int, str, str] | tuple[int, int, int]
+
+# Note that newer versions of setuptools_scm also add __version__, but we are
+# not forcing new versions of setuptools_scm, so only these imports are allowed.
diff --git a/src/scikit_build_core/build/_wheelfile.py b/src/scikit_build_core/build/_wheelfile.py
index 877ea96..f2aef57 100644
--- a/src/scikit_build_core/build/_wheelfile.py
+++ b/src/scikit_build_core/build/_wheelfile.py
@@ -21,8 +21,8 @@ from packaging.tags import Tag
 from packaging.utils import BuildTag
 from pyproject_metadata import StandardMetadata
 
+from .. import __version__
 from .._compat.typing import Self
-from .._version import __version__
 
 EMAIL_POLICY = EmailPolicy(max_line_length=0, mangle_from_=False, utf8=True)
 
diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py
index 3dc914e..d0bd6f8 100644
--- a/src/scikit_build_core/build/wheel.py
+++ b/src/scikit_build_core/build/wheel.py
@@ -14,6 +14,7 @@ from packaging.version import Version
 from .. import __version__
 from .._compat import tomllib
 from .._logging import logger, rich_print
+from .._shutil import fix_win_37_all_permissions
 from ..builder.builder import Builder, archs_to_tags, get_archs
 from ..builder.wheel_tag import WheelTag
 from ..cmake import CMake, CMaker
@@ -102,7 +103,7 @@ def _build_wheel_impl(
         f"[red]({action})[/red]",
     )
 
-    with tempfile.TemporaryDirectory() as tmpdir:
+    with tempfile.TemporaryDirectory() as tmpdir, fix_win_37_all_permissions(tmpdir):
         build_tmp_folder = Path(tmpdir)
         wheel_dir = build_tmp_folder / "wheel"
 
diff --git a/src/scikit_build_core/cmake.py b/src/scikit_build_core/cmake.py
index 883b418..fed2ba9 100644
--- a/src/scikit_build_core/cmake.py
+++ b/src/scikit_build_core/cmake.py
@@ -15,10 +15,10 @@ from typing import Generator
 
 from packaging.version import Version
 
+from . import __version__
 from ._compat.typing import Self
 from ._logging import logger
 from ._shutil import Run
-from ._version import __version__
 from .errors import CMakeConfigError, CMakeNotFoundError, FailedLiveProcessError
 from .program_search import best_program, get_cmake_programs
 


Test patch:
diff --git a/tests/test_shutil.py b/tests/test_shutil.py
new file mode 100644
index 0000000..1cfd2c3
--- /dev/null
+++ b/tests/test_shutil.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import shutil
+import stat
+import sys
+import tempfile
+from pathlib import Path
+
+import pytest
+
+from scikit_build_core._shutil import _fix_all_permissions, fix_win_37_all_permissions
+
+
+def _make_dir_with_ro(tmp_path: Path) -> Path:
+    base = tmp_path / "fix_all_perm"
+    base.mkdir()
+    base.joinpath("normal_file.txt").touch()
+    ro = base / "read_only.txt"
+    ro.touch()
+    ro.chmod(stat.S_IREAD)
+    nested = base / "nested"
+    nested.mkdir()
+    ro2 = nested / "read_only_2.txt"
+    ro2.touch()
+    ro2.chmod(stat.S_IREAD)
+
+    # Validity check
+    assert not stat.S_IMODE(ro2.stat().st_mode) & stat.S_IWRITE
+
+    return base
+
+
+@pytest.fixture()
+def make_dir_with_ro(tmp_path: Path) -> Path:
+    return _make_dir_with_ro(tmp_path)
+
+
+def test_broken_all_permissions(make_dir_with_ro: Path) -> None:
+    if sys.platform.startswith("win"):
+        with pytest.raises(PermissionError):
+            shutil.rmtree(make_dir_with_ro)
+    else:
+        shutil.rmtree(make_dir_with_ro)
+
+
+def test_fix_all_permissions(make_dir_with_ro: Path) -> None:
+    _fix_all_permissions(str(make_dir_with_ro))
+    shutil.rmtree(make_dir_with_ro)
+
+
+def test_tmpdir():
+    with tempfile.TemporaryDirectory() as tmp, fix_win_37_all_permissions(tmp):
+        _make_dir_with_ro(Path(tmp))