diff --git a/esphome/writer.py b/esphome/writer.py index bf683e6cd0..721db07f96 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import importlib import logging import os @@ -5,6 +6,7 @@ from pathlib import Path import re import shutil import stat +from types import TracebackType from esphome import loader from esphome.config import iter_component_configs, iter_components @@ -303,18 +305,21 @@ def clean_cmake_cache(): pioenvs_cmake_path.unlink() -def _rmtree_error_handler(func, path, exc_info): +def _rmtree_error_handler( + func: Callable[[str], object], + path: str, + exc_info: tuple[type[BaseException], BaseException, TracebackType | None], +) -> None: """Error handler for shutil.rmtree to handle read-only files on Windows. On Windows, git pack files and other files may be marked read-only, causing shutil.rmtree to fail with "Access is denied". This handler removes the read-only flag and retries the deletion. """ - if not os.access(path, os.W_OK): - os.chmod(path, stat.S_IWUSR | stat.S_IRUSR) - func(path) - else: + if os.access(path, os.W_OK): raise exc_info[1].with_traceback(exc_info[2]) + os.chmod(path, stat.S_IWUSR | stat.S_IRUSR) + func(path) def clean_build(clear_pio_cache: bool = True): diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index a5faf559d8..6ca049f62d 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -15,6 +15,7 @@ from esphome.writer import ( CPP_INCLUDE_BEGIN, CPP_INCLUDE_END, GITIGNORE_CONTENT, + _rmtree_error_handler, clean_build, clean_cmake_cache, storage_should_clean, @@ -1137,3 +1138,20 @@ def test_clean_all_handles_readonly_files( # Verify directory was removed despite read-only files assert not subdir.exists() assert build_dir.exists() # .esphome dir itself is preserved + + +def test_rmtree_error_handler_reraises_for_writable_files() -> None: + """Test _rmtree_error_handler re-raises exception for writable files.""" + # Create a mock exception + original_error = PermissionError("Some other permission error") + exc_info = (type(original_error), original_error, original_error.__traceback__) + + # Patch os.access to return True (file is writable) + with ( + patch("esphome.writer.os.access", return_value=True), + pytest.raises(PermissionError) as exc_info_caught, + ): + _rmtree_error_handler(lambda p: None, "/some/path", exc_info) + + # Verify the original exception was re-raised + assert exc_info_caught.value is original_error