From 3cb2a4569c82efda8255777d381386f7aa344fb0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:41:25 -0400 Subject: [PATCH] [core] Add a clean-platform option (#10831) --- esphome/__main__.py | 23 ++++- esphome/dashboard/web_server.py | 7 ++ esphome/writer.py | 36 ++++++-- tests/unit_tests/test_main.py | 100 +++++++++++++++++++++ tests/unit_tests/test_writer.py | 153 ++++++++++++++++++++++++++++++-- 5 files changed, 301 insertions(+), 18 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index aa237c83a7..27aced5f33 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -731,6 +731,16 @@ def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None: 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: from esphome import mqtt @@ -929,9 +939,10 @@ POST_CONFIG_ACTIONS = { "upload": command_upload, "logs": command_logs, "run": command_run, - "clean-mqtt": command_clean_mqtt, - "mqtt-fingerprint": command_mqtt_fingerprint, "clean": command_clean, + "clean-mqtt": command_clean_mqtt, + "clean-platform": command_clean_platform, + "mqtt-fingerprint": command_mqtt_fingerprint, "idedata": command_idedata, "rename": command_rename, "discover": command_discover, @@ -940,6 +951,7 @@ POST_CONFIG_ACTIONS = { SIMPLE_CONFIG_ACTIONS = [ "clean", "clean-mqtt", + "clean-platform", "config", ] @@ -1144,6 +1156,13 @@ def parse_args(argv): "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( "dashboard", help="Create a simple web server for a dashboard." ) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 7b6e6b4507..a4c24369a3 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -479,6 +479,12 @@ class EsphomeCleanMqttHandler(EsphomeCommandWebSocket): 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): async def build_command(self, json_message: dict[str, Any]) -> list[str]: 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}validate", EsphomeValidateHandler), (f"{rel}clean-mqtt", EsphomeCleanMqttHandler), + (f"{rel}clean-platform", EsphomeCleanPlatformHandler), (f"{rel}clean", EsphomeCleanHandler), (f"{rel}vscode", EsphomeVscodeHandler), (f"{rel}ace", EsphomeAceEditorHandler), diff --git a/esphome/writer.py b/esphome/writer.py index 6d34d8f751..718041876a 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -323,17 +323,39 @@ def clean_build(): # Clean PlatformIO cache to resolve CMake compiler detection issues # This helps when toolchain paths change or get corrupted try: - from platformio.project.helpers import get_project_cache_dir + from platformio.project.config import ProjectConfig except ImportError: # PlatformIO is not available, skip cache cleaning pass else: - cache_dir = get_project_cache_dir() - if cache_dir and cache_dir.strip(): - cache_path = Path(cache_dir) - if cache_path.is_dir(): - _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) - shutil.rmtree(cache_dir) + config = ProjectConfig.get_instance() + 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) + + +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 diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bb047d063c..8799ac56ff 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass +import logging from pathlib import Path import re from typing import Any @@ -16,6 +17,7 @@ from esphome import platformio_api from esphome.__main__ import ( Purpose, choose_upload_log_host, + command_clean_platform, command_rename, command_update_all, command_wizard, @@ -1853,3 +1855,101 @@ esp32: # Should not have any Python error messages assert "TypeError" 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 diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index ba309f2406..dc5fbf8db5 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -362,11 +362,17 @@ def test_clean_build( assert dependencies_lock.exists() assert platformio_cache_dir.exists() - # Mock PlatformIO's get_project_cache_dir + # Mock PlatformIO's ProjectConfig cache_dir with patch( - "platformio.project.helpers.get_project_cache_dir" - ) as mock_get_cache_dir: - mock_get_cache_dir.return_value = str(platformio_cache_dir) + "platformio.project.config.ProjectConfig.get_instance" + ) as mock_get_instance: + 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 with caplog.at_level("INFO"): @@ -486,7 +492,7 @@ def test_clean_build_platformio_not_available( # Mock import error for platformio with ( - patch.dict("sys.modules", {"platformio.project.helpers": None}), + patch.dict("sys.modules", {"platformio.project.config": None}), caplog.at_level("INFO"), ): # Call the function @@ -520,11 +526,17 @@ def test_clean_build_empty_cache_dir( # Verify pioenvs exists before 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( - "platformio.project.helpers.get_project_cache_dir" - ) as mock_get_cache_dir: - mock_get_cache_dir.return_value = " " # Whitespace only + "platformio.project.config.ProjectConfig.get_instance" + ) as mock_get_instance: + 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 with caplog.at_level("INFO"): @@ -723,3 +735,126 @@ def test_write_cpp_with_duplicate_markers( # Call should raise an error with pytest.raises(EsphomeError, match="Found multiple auto generate code begins"): 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()