1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-26 15:12:21 +01:00

[core] Add a clean-platform option (#10831)

This commit is contained in:
Jonathan Swoboda
2025-09-23 12:41:25 -04:00
committed by GitHub
parent 3b20969171
commit 3cb2a4569c
5 changed files with 301 additions and 18 deletions

View File

@@ -731,6 +731,16 @@ def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None:
return clean_mqtt(config, args) return clean_mqtt(config, args)
def command_clean_platform(args: ArgsProtocol, config: ConfigType) -> int | None:
try:
writer.clean_platform()
except OSError as err:
_LOGGER.error("Error deleting platform files: %s", err)
return 1
_LOGGER.info("Done!")
return 0
def command_mqtt_fingerprint(args: ArgsProtocol, config: ConfigType) -> int | None: def command_mqtt_fingerprint(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import mqtt from esphome import mqtt
@@ -929,9 +939,10 @@ POST_CONFIG_ACTIONS = {
"upload": command_upload, "upload": command_upload,
"logs": command_logs, "logs": command_logs,
"run": command_run, "run": command_run,
"clean-mqtt": command_clean_mqtt,
"mqtt-fingerprint": command_mqtt_fingerprint,
"clean": command_clean, "clean": command_clean,
"clean-mqtt": command_clean_mqtt,
"clean-platform": command_clean_platform,
"mqtt-fingerprint": command_mqtt_fingerprint,
"idedata": command_idedata, "idedata": command_idedata,
"rename": command_rename, "rename": command_rename,
"discover": command_discover, "discover": command_discover,
@@ -940,6 +951,7 @@ POST_CONFIG_ACTIONS = {
SIMPLE_CONFIG_ACTIONS = [ SIMPLE_CONFIG_ACTIONS = [
"clean", "clean",
"clean-mqtt", "clean-mqtt",
"clean-platform",
"config", "config",
] ]
@@ -1144,6 +1156,13 @@ def parse_args(argv):
"configuration", help="Your YAML configuration file(s).", nargs="+" "configuration", help="Your YAML configuration file(s).", nargs="+"
) )
parser_clean = subparsers.add_parser(
"clean-platform", help="Delete all platform files."
)
parser_clean.add_argument(
"configuration", help="Your YAML configuration file(s).", nargs="+"
)
parser_dashboard = subparsers.add_parser( parser_dashboard = subparsers.add_parser(
"dashboard", help="Create a simple web server for a dashboard." "dashboard", help="Create a simple web server for a dashboard."
) )

View File

@@ -479,6 +479,12 @@ class EsphomeCleanMqttHandler(EsphomeCommandWebSocket):
return [*DASHBOARD_COMMAND, "clean-mqtt", config_file] return [*DASHBOARD_COMMAND, "clean-mqtt", config_file]
class EsphomeCleanPlatformHandler(EsphomeCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
config_file = settings.rel_path(json_message["configuration"])
return [*DASHBOARD_COMMAND, "clean-platform", config_file]
class EsphomeCleanHandler(EsphomeCommandWebSocket): class EsphomeCleanHandler(EsphomeCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]: async def build_command(self, json_message: dict[str, Any]) -> list[str]:
config_file = settings.rel_path(json_message["configuration"]) config_file = settings.rel_path(json_message["configuration"])
@@ -1313,6 +1319,7 @@ def make_app(debug=get_bool_env(ENV_DEV)) -> tornado.web.Application:
(f"{rel}compile", EsphomeCompileHandler), (f"{rel}compile", EsphomeCompileHandler),
(f"{rel}validate", EsphomeValidateHandler), (f"{rel}validate", EsphomeValidateHandler),
(f"{rel}clean-mqtt", EsphomeCleanMqttHandler), (f"{rel}clean-mqtt", EsphomeCleanMqttHandler),
(f"{rel}clean-platform", EsphomeCleanPlatformHandler),
(f"{rel}clean", EsphomeCleanHandler), (f"{rel}clean", EsphomeCleanHandler),
(f"{rel}vscode", EsphomeVscodeHandler), (f"{rel}vscode", EsphomeVscodeHandler),
(f"{rel}ace", EsphomeAceEditorHandler), (f"{rel}ace", EsphomeAceEditorHandler),

View File

@@ -323,19 +323,41 @@ def clean_build():
# Clean PlatformIO cache to resolve CMake compiler detection issues # Clean PlatformIO cache to resolve CMake compiler detection issues
# This helps when toolchain paths change or get corrupted # This helps when toolchain paths change or get corrupted
try: try:
from platformio.project.helpers import get_project_cache_dir from platformio.project.config import ProjectConfig
except ImportError: except ImportError:
# PlatformIO is not available, skip cache cleaning # PlatformIO is not available, skip cache cleaning
pass pass
else: else:
cache_dir = get_project_cache_dir() config = ProjectConfig.get_instance()
if cache_dir and cache_dir.strip(): cache_dir = Path(config.get("platformio", "cache_dir"))
cache_path = Path(cache_dir) if cache_dir.is_dir():
if cache_path.is_dir():
_LOGGER.info("Deleting PlatformIO cache %s", cache_dir) _LOGGER.info("Deleting PlatformIO cache %s", cache_dir)
shutil.rmtree(cache_dir) shutil.rmtree(cache_dir)
def clean_platform():
import shutil
# Clean entire build dir
if CORE.build_path.is_dir():
_LOGGER.info("Deleting %s", CORE.build_path)
shutil.rmtree(CORE.build_path)
# Clean PlatformIO project files
try:
from platformio.project.config import ProjectConfig
except ImportError:
# PlatformIO is not available, skip cleaning
pass
else:
config = ProjectConfig.get_instance()
for pio_dir in ["cache_dir", "packages_dir", "platforms_dir", "core_dir"]:
path = Path(config.get("platformio", pio_dir))
if path.is_dir():
_LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path)
shutil.rmtree(path)
GITIGNORE_CONTENT = """# Gitignore settings for ESPHome GITIGNORE_CONTENT = """# Gitignore settings for ESPHome
# This is an example and may include too much for your use-case. # This is an example and may include too much for your use-case.
# You can modify this file to suit your needs. # You can modify this file to suit your needs.

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from dataclasses import dataclass from dataclasses import dataclass
import logging
from pathlib import Path from pathlib import Path
import re import re
from typing import Any from typing import Any
@@ -16,6 +17,7 @@ from esphome import platformio_api
from esphome.__main__ import ( from esphome.__main__ import (
Purpose, Purpose,
choose_upload_log_host, choose_upload_log_host,
command_clean_platform,
command_rename, command_rename,
command_update_all, command_update_all,
command_wizard, command_wizard,
@@ -1853,3 +1855,101 @@ esp32:
# Should not have any Python error messages # Should not have any Python error messages
assert "TypeError" not in clean_output assert "TypeError" not in clean_output
assert "can only concatenate str" not in clean_output assert "can only concatenate str" not in clean_output
def test_command_clean_platform_success(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test command_clean_platform when writer.clean_platform() succeeds."""
args = MockArgs()
config = {}
# Set logger level to capture INFO messages
with (
caplog.at_level(logging.INFO),
patch("esphome.writer.clean_platform") as mock_clean_platform,
):
result = command_clean_platform(args, config)
assert result == 0
mock_clean_platform.assert_called_once()
# Check that success message was logged
assert "Done!" in caplog.text
def test_command_clean_platform_oserror(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test command_clean_platform when writer.clean_platform() raises OSError."""
args = MockArgs()
config = {}
# Create a mock OSError with a specific message
mock_error = OSError("Permission denied: cannot delete directory")
# Set logger level to capture ERROR and INFO messages
with (
caplog.at_level(logging.INFO),
patch(
"esphome.writer.clean_platform", side_effect=mock_error
) as mock_clean_platform,
):
result = command_clean_platform(args, config)
assert result == 1
mock_clean_platform.assert_called_once()
# Check that error message was logged
assert (
"Error deleting platform files: Permission denied: cannot delete directory"
in caplog.text
)
# Should not have success message
assert "Done!" not in caplog.text
def test_command_clean_platform_oserror_no_message(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test command_clean_platform when writer.clean_platform() raises OSError without message."""
args = MockArgs()
config = {}
# Create a mock OSError without a message
mock_error = OSError()
# Set logger level to capture ERROR and INFO messages
with (
caplog.at_level(logging.INFO),
patch(
"esphome.writer.clean_platform", side_effect=mock_error
) as mock_clean_platform,
):
result = command_clean_platform(args, config)
assert result == 1
mock_clean_platform.assert_called_once()
# Check that error message was logged (should show empty string for OSError without message)
assert "Error deleting platform files:" in caplog.text
# Should not have success message
assert "Done!" not in caplog.text
def test_command_clean_platform_args_and_config_ignored() -> None:
"""Test that command_clean_platform ignores args and config parameters."""
# Test with various args and config to ensure they don't affect the function
args1 = MockArgs(name="test1", file="test.bin")
config1 = {"wifi": {"ssid": "test"}}
args2 = MockArgs(name="test2", dashboard=True)
config2 = {"api": {}, "ota": {}}
with patch("esphome.writer.clean_platform") as mock_clean_platform:
result1 = command_clean_platform(args1, config1)
result2 = command_clean_platform(args2, config2)
assert result1 == 0
assert result2 == 0
assert mock_clean_platform.call_count == 2

View File

@@ -362,11 +362,17 @@ def test_clean_build(
assert dependencies_lock.exists() assert dependencies_lock.exists()
assert platformio_cache_dir.exists() assert platformio_cache_dir.exists()
# Mock PlatformIO's get_project_cache_dir # Mock PlatformIO's ProjectConfig cache_dir
with patch( with patch(
"platformio.project.helpers.get_project_cache_dir" "platformio.project.config.ProjectConfig.get_instance"
) as mock_get_cache_dir: ) as mock_get_instance:
mock_get_cache_dir.return_value = str(platformio_cache_dir) mock_config = MagicMock()
mock_get_instance.return_value = mock_config
mock_config.get.side_effect = (
lambda section, option: str(platformio_cache_dir)
if (section, option) == ("platformio", "cache_dir")
else ""
)
# Call the function # Call the function
with caplog.at_level("INFO"): with caplog.at_level("INFO"):
@@ -486,7 +492,7 @@ def test_clean_build_platformio_not_available(
# Mock import error for platformio # Mock import error for platformio
with ( with (
patch.dict("sys.modules", {"platformio.project.helpers": None}), patch.dict("sys.modules", {"platformio.project.config": None}),
caplog.at_level("INFO"), caplog.at_level("INFO"),
): ):
# Call the function # Call the function
@@ -520,11 +526,17 @@ def test_clean_build_empty_cache_dir(
# Verify pioenvs exists before # Verify pioenvs exists before
assert pioenvs_dir.exists() assert pioenvs_dir.exists()
# Mock PlatformIO's get_project_cache_dir to return whitespace # Mock PlatformIO's ProjectConfig cache_dir to return whitespace
with patch( with patch(
"platformio.project.helpers.get_project_cache_dir" "platformio.project.config.ProjectConfig.get_instance"
) as mock_get_cache_dir: ) as mock_get_instance:
mock_get_cache_dir.return_value = " " # Whitespace only mock_config = MagicMock()
mock_get_instance.return_value = mock_config
mock_config.get.side_effect = (
lambda section, option: " " # Whitespace only
if (section, option) == ("platformio", "cache_dir")
else ""
)
# Call the function # Call the function
with caplog.at_level("INFO"): with caplog.at_level("INFO"):
@@ -723,3 +735,126 @@ def test_write_cpp_with_duplicate_markers(
# Call should raise an error # Call should raise an error
with pytest.raises(EsphomeError, match="Found multiple auto generate code begins"): with pytest.raises(EsphomeError, match="Found multiple auto generate code begins"):
write_cpp("// New code") write_cpp("// New code")
@patch("esphome.writer.CORE")
def test_clean_platform(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_platform removes build and PlatformIO dirs."""
# Create build directory
build_dir = tmp_path / "build"
build_dir.mkdir()
(build_dir / "dummy.txt").write_text("x")
# Create PlatformIO directories
pio_cache = tmp_path / "pio_cache"
pio_packages = tmp_path / "pio_packages"
pio_platforms = tmp_path / "pio_platforms"
pio_core = tmp_path / "pio_core"
for d in (pio_cache, pio_packages, pio_platforms, pio_core):
d.mkdir()
(d / "keep").write_text("x")
# Setup CORE
mock_core.build_path = build_dir
# Mock ProjectConfig
with patch(
"platformio.project.config.ProjectConfig.get_instance"
) as mock_get_instance:
mock_config = MagicMock()
mock_get_instance.return_value = mock_config
def cfg_get(section: str, option: str) -> str:
mapping = {
("platformio", "cache_dir"): str(pio_cache),
("platformio", "packages_dir"): str(pio_packages),
("platformio", "platforms_dir"): str(pio_platforms),
("platformio", "core_dir"): str(pio_core),
}
return mapping.get((section, option), "")
mock_config.get.side_effect = cfg_get
# Call
from esphome.writer import clean_platform
with caplog.at_level("INFO"):
clean_platform()
# Verify deletions
assert not build_dir.exists()
assert not pio_cache.exists()
assert not pio_packages.exists()
assert not pio_platforms.exists()
assert not pio_core.exists()
# Verify logging mentions each
assert "Deleting" in caplog.text
assert str(build_dir) in caplog.text
assert "PlatformIO cache" in caplog.text
assert "PlatformIO packages" in caplog.text
assert "PlatformIO platforms" in caplog.text
assert "PlatformIO core" in caplog.text
@patch("esphome.writer.CORE")
def test_clean_platform_platformio_not_available(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_platform when PlatformIO is not available."""
# Build dir
build_dir = tmp_path / "build"
build_dir.mkdir()
mock_core.build_path = build_dir
# PlatformIO dirs that should remain untouched
pio_cache = tmp_path / "pio_cache"
pio_cache.mkdir()
from esphome.writer import clean_platform
with (
patch.dict("sys.modules", {"platformio.project.config": None}),
caplog.at_level("INFO"),
):
clean_platform()
# Build dir removed, PlatformIO dirs remain
assert not build_dir.exists()
assert pio_cache.exists()
# No PlatformIO-specific logs
assert "PlatformIO" not in caplog.text
@patch("esphome.writer.CORE")
def test_clean_platform_partial_exists(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test clean_platform when only build dir exists."""
build_dir = tmp_path / "build"
build_dir.mkdir()
mock_core.build_path = build_dir
with patch(
"platformio.project.config.ProjectConfig.get_instance"
) as mock_get_instance:
mock_config = MagicMock()
mock_get_instance.return_value = mock_config
# Return non-existent dirs
mock_config.get.side_effect = lambda *_args, **_kw: str(
tmp_path / "does_not_exist"
)
from esphome.writer import clean_platform
clean_platform()
assert not build_dir.exists()