mirror of
https://github.com/esphome/esphome.git
synced 2026-02-11 10:12:38 +00:00
Compare commits
6 Commits
copilot/fi
...
fix-clean-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e529545118 | ||
|
|
8a0b412e1f | ||
|
|
a157b4431e | ||
|
|
588cea939f | ||
|
|
66c6ce607a | ||
|
|
a7a5a0b9a2 |
@@ -37,6 +37,7 @@ from esphome.const import (
|
||||
__version__,
|
||||
)
|
||||
from esphome.core import CORE, HexInt, TimePeriod
|
||||
from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import copy_file_if_changed, write_file_if_changed
|
||||
from esphome.types import ConfigType
|
||||
@@ -262,15 +263,32 @@ def add_idf_component(
|
||||
"deprecated and will be removed in ESPHome 2026.1. If you are seeing this, report "
|
||||
"an issue to the external_component author and ask them to update it."
|
||||
)
|
||||
components_registry = CORE.data[KEY_ESP32][KEY_COMPONENTS]
|
||||
if components:
|
||||
for comp in components:
|
||||
CORE.data[KEY_ESP32][KEY_COMPONENTS][comp] = {
|
||||
existing = components_registry.get(comp)
|
||||
if existing and existing.get(KEY_REF) != ref:
|
||||
_LOGGER.warning(
|
||||
"IDF component %s version conflict %s replaced by %s",
|
||||
comp,
|
||||
existing.get(KEY_REF),
|
||||
ref,
|
||||
)
|
||||
components_registry[comp] = {
|
||||
KEY_REPO: repo,
|
||||
KEY_REF: ref,
|
||||
KEY_PATH: f"{path}/{comp}" if path else comp,
|
||||
}
|
||||
else:
|
||||
CORE.data[KEY_ESP32][KEY_COMPONENTS][name] = {
|
||||
existing = components_registry.get(name)
|
||||
if existing and existing.get(KEY_REF) != ref:
|
||||
_LOGGER.warning(
|
||||
"IDF component %s version conflict %s replaced by %s",
|
||||
name,
|
||||
existing.get(KEY_REF),
|
||||
ref,
|
||||
)
|
||||
components_registry[name] = {
|
||||
KEY_REPO: repo,
|
||||
KEY_REF: ref,
|
||||
KEY_PATH: path,
|
||||
@@ -592,6 +610,14 @@ def require_vfs_dir() -> None:
|
||||
CORE.data[KEY_VFS_DIR_REQUIRED] = True
|
||||
|
||||
|
||||
def _parse_idf_component(value: str) -> ConfigType:
|
||||
"""Parse IDF component shorthand syntax like 'owner/component^version'"""
|
||||
if "^" not in value:
|
||||
raise cv.Invalid(f"Invalid IDF component shorthand '{value}'")
|
||||
name, ref = value.split("^", 1)
|
||||
return {CONF_NAME: name, CONF_REF: ref}
|
||||
|
||||
|
||||
def _validate_idf_component(config: ConfigType) -> ConfigType:
|
||||
"""Validate IDF component config and warn about deprecated options."""
|
||||
if CONF_REFRESH in config:
|
||||
@@ -659,14 +685,19 @@ FRAMEWORK_SCHEMA = cv.Schema(
|
||||
),
|
||||
cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list(
|
||||
cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_NAME): cv.string_strict,
|
||||
cv.Optional(CONF_SOURCE): cv.git_ref,
|
||||
cv.Optional(CONF_REF): cv.string,
|
||||
cv.Optional(CONF_PATH): cv.string,
|
||||
cv.Optional(CONF_REFRESH): cv.All(cv.string, cv.source_refresh),
|
||||
}
|
||||
cv.Any(
|
||||
cv.All(cv.string_strict, _parse_idf_component),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_NAME): cv.string_strict,
|
||||
cv.Optional(CONF_SOURCE): cv.git_ref,
|
||||
cv.Optional(CONF_REF): cv.string,
|
||||
cv.Optional(CONF_PATH): cv.string,
|
||||
cv.Optional(CONF_REFRESH): cv.All(
|
||||
cv.string, cv.source_refresh
|
||||
),
|
||||
}
|
||||
),
|
||||
),
|
||||
_validate_idf_component,
|
||||
)
|
||||
@@ -851,6 +882,18 @@ def _configure_lwip_max_sockets(conf: dict) -> None:
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _add_yaml_idf_components(components: list[ConfigType]):
|
||||
"""Add IDF components from YAML config with final priority to override code-added components."""
|
||||
for component in components:
|
||||
add_idf_component(
|
||||
name=component[CONF_NAME],
|
||||
repo=component.get(CONF_SOURCE),
|
||||
ref=component.get(CONF_REF),
|
||||
path=component.get(CONF_PATH),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
|
||||
@@ -1097,13 +1140,10 @@ async def to_code(config):
|
||||
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
|
||||
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
|
||||
|
||||
for component in conf[CONF_COMPONENTS]:
|
||||
add_idf_component(
|
||||
name=component[CONF_NAME],
|
||||
repo=component.get(CONF_SOURCE),
|
||||
ref=component.get(CONF_REF),
|
||||
path=component.get(CONF_PATH),
|
||||
)
|
||||
# Components from YAML are added in a separate coroutine with FINAL priority
|
||||
# Schedule it to run after all other components
|
||||
if conf[CONF_COMPONENTS]:
|
||||
CORE.add_job(_add_yaml_idf_components, conf[CONF_COMPONENTS])
|
||||
|
||||
|
||||
APP_PARTITION_SIZES = {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from collections.abc import Callable
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
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
|
||||
@@ -301,9 +305,24 @@ def clean_cmake_cache():
|
||||
pioenvs_cmake_path.unlink()
|
||||
|
||||
|
||||
def clean_build(clear_pio_cache: bool = True):
|
||||
import shutil
|
||||
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 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):
|
||||
# Allow skipping cache cleaning for integration tests
|
||||
if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"):
|
||||
_LOGGER.warning("Skipping build cleaning (ESPHOME_SKIP_CLEAN_BUILD set)")
|
||||
@@ -312,11 +331,11 @@ def clean_build(clear_pio_cache: bool = True):
|
||||
pioenvs = CORE.relative_pioenvs_path()
|
||||
if pioenvs.is_dir():
|
||||
_LOGGER.info("Deleting %s", pioenvs)
|
||||
shutil.rmtree(pioenvs)
|
||||
shutil.rmtree(pioenvs, onerror=_rmtree_error_handler)
|
||||
piolibdeps = CORE.relative_piolibdeps_path()
|
||||
if piolibdeps.is_dir():
|
||||
_LOGGER.info("Deleting %s", piolibdeps)
|
||||
shutil.rmtree(piolibdeps)
|
||||
shutil.rmtree(piolibdeps, onerror=_rmtree_error_handler)
|
||||
dependencies_lock = CORE.relative_build_path("dependencies.lock")
|
||||
if dependencies_lock.is_file():
|
||||
_LOGGER.info("Deleting %s", dependencies_lock)
|
||||
@@ -337,12 +356,10 @@ def clean_build(clear_pio_cache: bool = True):
|
||||
cache_dir = Path(config.get("platformio", "cache_dir"))
|
||||
if cache_dir.is_dir():
|
||||
_LOGGER.info("Deleting PlatformIO cache %s", cache_dir)
|
||||
shutil.rmtree(cache_dir)
|
||||
shutil.rmtree(cache_dir, onerror=_rmtree_error_handler)
|
||||
|
||||
|
||||
def clean_all(configuration: list[str]):
|
||||
import shutil
|
||||
|
||||
data_dirs = []
|
||||
for config in configuration:
|
||||
item = Path(config)
|
||||
@@ -364,7 +381,7 @@ def clean_all(configuration: list[str]):
|
||||
if item.is_file() and not item.name.endswith(".json"):
|
||||
item.unlink()
|
||||
elif item.is_dir() and item.name != "storage":
|
||||
shutil.rmtree(item)
|
||||
shutil.rmtree(item, onerror=_rmtree_error_handler)
|
||||
|
||||
# Clean PlatformIO project files
|
||||
try:
|
||||
@@ -378,7 +395,7 @@ def clean_all(configuration: list[str]):
|
||||
path = Path(config.get("platformio", pio_dir))
|
||||
if path.is_dir():
|
||||
_LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path)
|
||||
shutil.rmtree(path)
|
||||
shutil.rmtree(path, onerror=_rmtree_error_handler)
|
||||
|
||||
|
||||
GITIGNORE_CONTENT = """# Gitignore settings for ESPHome
|
||||
|
||||
@@ -4,6 +4,10 @@ esp32:
|
||||
cpu_frequency: 400MHz
|
||||
framework:
|
||||
type: esp-idf
|
||||
components:
|
||||
- espressif/mdns^1.8.2
|
||||
- name: espressif/esp_hosted
|
||||
ref: 2.6.6
|
||||
advanced:
|
||||
enable_idf_experimental_features: yes
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Test writer module functionality."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -1062,3 +1064,109 @@ def test_clean_all_preserves_json_files(
|
||||
# Verify logging mentions cleaning
|
||||
assert "Cleaning" in caplog.text
|
||||
assert str(build_dir) in caplog.text
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_build_handles_readonly_files(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test clean_build handles read-only files (e.g., git pack files on Windows)."""
|
||||
# Create directory structure with read-only files
|
||||
pioenvs_dir = tmp_path / ".pioenvs"
|
||||
pioenvs_dir.mkdir()
|
||||
git_dir = pioenvs_dir / ".git" / "objects" / "pack"
|
||||
git_dir.mkdir(parents=True)
|
||||
|
||||
# Create a read-only file (simulating git pack files on Windows)
|
||||
readonly_file = git_dir / "pack-abc123.pack"
|
||||
readonly_file.write_text("pack data")
|
||||
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
|
||||
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
|
||||
|
||||
# Verify file is read-only
|
||||
assert not os.access(readonly_file, os.W_OK)
|
||||
|
||||
# Call the function - should not crash
|
||||
with caplog.at_level("INFO"):
|
||||
clean_build()
|
||||
|
||||
# Verify directory was removed despite read-only files
|
||||
assert not pioenvs_dir.exists()
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_all_handles_readonly_files(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test clean_all handles read-only files."""
|
||||
from esphome.writer import clean_all
|
||||
|
||||
# Create config directory
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
|
||||
build_dir = config_dir / ".esphome"
|
||||
build_dir.mkdir()
|
||||
|
||||
# Create a subdirectory with read-only files
|
||||
subdir = build_dir / "subdir"
|
||||
subdir.mkdir()
|
||||
readonly_file = subdir / "readonly.txt"
|
||||
readonly_file.write_text("content")
|
||||
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
|
||||
|
||||
# Verify file is read-only
|
||||
assert not os.access(readonly_file, os.W_OK)
|
||||
|
||||
# Call the function - should not crash
|
||||
with caplog.at_level("INFO"):
|
||||
clean_all([str(config_dir)])
|
||||
|
||||
# Verify directory was removed despite read-only files
|
||||
assert not subdir.exists()
|
||||
assert build_dir.exists() # .esphome dir itself is preserved
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_build_reraises_for_other_errors(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test clean_build re-raises errors that are not read-only permission issues."""
|
||||
# Create directory structure with a read-only subdirectory
|
||||
# This prevents file deletion and triggers the error handler
|
||||
pioenvs_dir = tmp_path / ".pioenvs"
|
||||
pioenvs_dir.mkdir()
|
||||
subdir = pioenvs_dir / "subdir"
|
||||
subdir.mkdir()
|
||||
test_file = subdir / "test.txt"
|
||||
test_file.write_text("content")
|
||||
|
||||
# Make subdir read-only so files inside can't be deleted
|
||||
os.chmod(subdir, stat.S_IRUSR | stat.S_IXUSR)
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
|
||||
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
|
||||
|
||||
try:
|
||||
# Mock os.access in writer module to return True (writable)
|
||||
# This simulates a case where the error is NOT due to read-only permissions
|
||||
# so the error handler should re-raise instead of trying to fix permissions
|
||||
with (
|
||||
patch("esphome.writer.os.access", return_value=True),
|
||||
pytest.raises(PermissionError),
|
||||
):
|
||||
clean_build()
|
||||
finally:
|
||||
# Cleanup - restore write permission so tmp_path cleanup works
|
||||
os.chmod(subdir, stat.S_IRWXU)
|
||||
|
||||
Reference in New Issue
Block a user