From 571a12ffe5dbfd8967802b9352bf3cc3b02e07fa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 31 May 2026 16:29:16 -0400 Subject: [PATCH] [core] Clean build when the toolchain changes (#16744) --- esphome/writer.py | 16 ++++++++++++---- tests/unit_tests/test_writer.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index ad3877465d..192c9d68e8 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -90,10 +90,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: """Return True when the build tree must be wiped before reuse. Predicate is True when *old* is missing (first build), - ``src_version`` differs, ``build_path`` differs, or a previously - loaded integration was removed in *new*. Adding integrations or - changing unrelated fields (friendly name, esphome version, etc.) - does not trigger a clean. + ``src_version`` differs, ``build_path`` differs, the build + ``toolchain`` differs (e.g. switching between the PlatformIO and + native ESP-IDF toolchains, which produce incompatible build trees), + or a previously loaded integration was removed in *new*. Adding + integrations or changing unrelated fields (friendly name, esphome + version, etc.) does not trigger a clean. Used by esphome-device-builder (esphome/device-builder) to gate its remote-build artifact materialiser so a local → remote → local @@ -109,6 +111,8 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool: return True if old.build_path != new.build_path: return True + if old.toolchain != new.toolchain: + return True # Check if any components have been removed return bool(old.loaded_integrations - new.loaded_integrations) @@ -505,6 +509,10 @@ def clean_build(clear_pio_cache: bool = True): if dependencies_lock.is_file(): _LOGGER.info("Deleting %s", dependencies_lock) dependencies_lock.unlink() + idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") + if idedata_cache.is_file(): + _LOGGER.info("Deleting %s", idedata_cache) + idedata_cache.unlink() # Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir # and the Component Manager's fetched managed components live under # the project's build path, not under .pioenvs / .piolibdeps. diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 91b4bd8e87..be37dd5d58 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -75,6 +75,7 @@ def create_storage() -> Callable[..., StorageJSON]: no_mdns=kwargs.get("no_mdns", False), framework=kwargs.get("framework", "arduino"), core_platform=kwargs.get("core_platform", "esp32"), + toolchain=kwargs.get("toolchain", "platformio"), ) return _create @@ -106,6 +107,20 @@ def test_storage_should_clean_when_build_path_changes( assert storage_should_clean(old, new) is True +def test_storage_should_clean_when_toolchain_changes( + create_storage: Callable[..., StorageJSON], +) -> None: + """Test that clean is triggered when the build toolchain changes. + + Switching between the PlatformIO and native ESP-IDF toolchains produces + incompatible build trees (and toolchain-specific idedata), so the build + must be wiped. + """ + old = create_storage(loaded_integrations=["api", "wifi"], toolchain="platformio") + new = create_storage(loaded_integrations=["api", "wifi"], toolchain="esp-idf") + assert storage_should_clean(old, new) is True + + def test_storage_should_clean_when_component_removed( create_storage: Callable[..., StorageJSON], ) -> None: @@ -443,6 +458,11 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") + # idedata cache lives under the data dir, not the build path. + idedata_cache = tmp_path / "idedata" / "test.json" + idedata_cache.parent.mkdir() + idedata_cache.write_text("{}") + # Native ESP-IDF toolchain artifacts. idf_build_dir = tmp_path / "build" idf_build_dir.mkdir() @@ -463,11 +483,14 @@ def test_clean_build( mock_core.relative_pioenvs_path.return_value = pioenvs_dir mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.name = "test" + mock_core.relative_internal_path.side_effect = tmp_path.joinpath # Verify all exist before assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() + assert idedata_cache.exists() assert idf_build_dir.exists() assert managed_components_dir.exists() assert platformio_cache_dir.exists() @@ -492,6 +515,7 @@ def test_clean_build( assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() + assert not idedata_cache.exists() assert not idf_build_dir.exists() assert not managed_components_dir.exists() assert not platformio_cache_dir.exists() @@ -501,6 +525,7 @@ def test_clean_build( assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text + assert str(idedata_cache) in caplog.text assert str(idf_build_dir) in caplog.text assert str(managed_components_dir) in caplog.text assert "PlatformIO cache" in caplog.text