From 19b434f99c6db5123263ff07f53fa507b025ff30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 12:36:57 -0500 Subject: [PATCH] [espidf] Cache the discovered component list to skip the discovery configure (#18752) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/build_gen/espidf.py | 71 ++++--- esphome/espidf/toolchain.py | 129 ++++++++++-- tests/unit_tests/build_gen/test_espidf.py | 91 +++++++- tests/unit_tests/test_espidf_toolchain.py | 242 +++++++++++++++++++++- 4 files changed, 473 insertions(+), 60 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 5d4e6b8401..2ef89cf595 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -1,6 +1,7 @@ """ESP-IDF direct build generator for ESPHome.""" import json +import logging from pathlib import Path from esphome.components.esp32 import ( @@ -11,6 +12,7 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.core import CORE +from esphome.espidf import variant_to_idf_target from esphome.framework_helpers import ( get_project_compile_flags, get_project_cxx_compile_flags, @@ -18,6 +20,8 @@ from esphome.framework_helpers import ( ) from esphome.helpers import mkdir_p, write_file_if_changed +_LOGGER = logging.getLogger(__name__) + # Replaces the IDF default C++ standard (-std=gnu++2b appended to # CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via # cg.set_cpp_standard(). Emitted between include(project.cmake) and project(), @@ -31,11 +35,12 @@ idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")"" def get_available_components() -> list[str] | None: - """Get list of built-in ESP-IDF components from project_description.json. + """List the built-in ESP-IDF components from ``project_description.json``. - Excludes ``src``, IDF-managed components (``managed_components/``), and - converted PIO libs (``pio_components/``). Returns ``None`` if the build - dir or ``project_description.json`` isn't ready yet. + Only components below its ``idf_path/components`` count, which leaves out + ``src``, IDF-managed components, converted PIO libs and project local + ones such as the Arduino ``component_stubs``. Returns ``None`` if the + build dir or ``project_description.json`` isn't ready yet. """ if CORE.build_path is None: return None @@ -46,30 +51,24 @@ def get_available_components() -> list[str] | None: try: with project_desc.open(encoding="utf-8") as f: data = json.load(f) - - component_info = data.get("build_component_info", {}) - - result = [] - for name, info in component_info.items(): - # Exclude our own src component - if name == "src": - continue - - # Exclude IDF-managed and converted-PIO components (external). - comp_dir = info.get("dir", "") - if "managed_components" in comp_dir or "pio_components" in comp_dir: - continue - - result.append(name) - - return result - except (json.JSONDecodeError, OSError): + root = (Path(data["idf_path"]) / "components").resolve() + result = [ + name + for name, info in data.get("build_component_info", {}).items() + if (comp_dir := info.get("dir")) + and Path(comp_dir).resolve().is_relative_to(root) + ] + except (json.JSONDecodeError, KeyError, OSError) as err: + _LOGGER.debug("Could not read %s: %s", project_desc, err) return None + if not result: + _LOGGER.warning("No ESP-IDF components found under %s", root) + return result def has_discovered_components() -> bool: - """Check if we have discovered components from a previous configure.""" - return get_available_components() is not None + """Check if a previous configure discovered any built-in components.""" + return bool(get_available_components()) def _cmake_quote(value: str) -> str: @@ -79,15 +78,17 @@ def _cmake_quote(value: str) -> str: return f'"{escaped}"' -def get_project_cmakelists(minimal: bool = False) -> str: +def get_project_cmakelists( + minimal: bool = False, builtin_components: list[str] | None = None +) -> str: """Generate the top-level CMakeLists.txt for ESP-IDF project. When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS`` since ``project_description.json`` may be stale on the first write. + ``builtin_components`` supplies the discovered list (from the cache) + instead of reading it from ``project_description.json``. """ - # Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3) - variant = get_esp32_variant() - idf_target = variant.lower().replace("-", "") + idf_target = variant_to_idf_target(get_esp32_variant()) # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get @@ -162,9 +163,11 @@ def get_project_cmakelists(minimal: bool = False) -> str: else "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)" for name in sorted( - set(get_available_components() or []).difference( - CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";") - ) + set( + builtin_components + if builtin_components is not None + else get_available_components() or [] + ).difference(CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";")) ) ) ) @@ -279,7 +282,9 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC """ -def write_project(minimal: bool = False) -> None: +def write_project( + minimal: bool = False, builtin_components: list[str] | None = None +) -> None: """Write ESP-IDF project files.""" mkdir_p(CORE.build_path) mkdir_p(CORE.relative_src_path()) @@ -287,7 +292,7 @@ def write_project(minimal: bool = False) -> None: # Write top-level CMakeLists.txt write_file_if_changed( CORE.relative_build_path("CMakeLists.txt"), - get_project_cmakelists(minimal=minimal), + get_project_cmakelists(minimal=minimal, builtin_components=builtin_components), ) # Write component CMakeLists.txt in src/ diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index baf316a4ee..986f9dfb8b 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -1,6 +1,7 @@ """ESP-IDF direct build API for ESPHome.""" from dataclasses import dataclass, field +import hashlib import json import logging import os @@ -23,7 +24,7 @@ from esphome.core import CORE, EsphomeError from esphome.espidf import variant_to_idf_target from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary -from esphome.helpers import add_git_ceiling_directory +from esphome.helpers import add_git_ceiling_directory, write_file _LOGGER = logging.getLogger(__name__) @@ -256,6 +257,106 @@ def run_reconfigure() -> int: return run_idf_py(*_get_sdkconfig_args(), "reconfigure") +def _builtin_component_cache_path() -> Path | None: + """Cache file for this build's built-in component list. + + The file lives inside the extracted framework directory so it is + discarded together with that exact checkout (re-extract, source + override, clean-all); the target and the EXCLUDE_COMPONENTS set name it. + The sdkconfig is not part of the key: IDF components register regardless + of CONFIG_* options and only gate their sources on them. A checkout + supplied through IDF_PATH is not managed by ESPHome and is never cached. + """ + if "IDF_PATH" in os.environ: + return None + target = variant_to_idf_target(CORE.data[KEY_ESP32][KEY_VARIANT]) + excluded = CORE.cmake_args.get("EXCLUDE_COMPONENTS", "") + excluded_key = hashlib.sha256(excluded.encode()).hexdigest()[:12] + return ( + _get_idf_path() / ".esphome_component_lists" / f"{target}-{excluded_key}.json" + ) + + +def load_cached_builtin_components() -> list[str] | None: + """Return the cached built-in component list for this build, if valid. + + Every name must still exist under ``$IDF_PATH/components`` so a stale + entry is treated as a miss instead of failing the configure. + """ + if (path := _builtin_component_cache_path()) is None: + return None + try: + components = json.loads(path.read_text(encoding="utf-8")) + present = { + entry.name + for entry in (path.parents[1] / "components").iterdir() + if entry.is_dir() + } + except (OSError, ValueError): + return None + if ( + isinstance(components, list) + and all(isinstance(c, str) for c in components) + and present.issuperset(components) + ): + return components + return None + + +def save_cached_builtin_components(components: list[str]) -> None: + """Store a built-in component list that just configured successfully.""" + if not components or (path := _builtin_component_cache_path()) is None: + return + try: + write_file(path, json.dumps(components, separators=(",", ":"))) + except EsphomeError as err: + _LOGGER.warning("Could not write component list cache %s: %s", path, err) + + +def _write_project_and_reconfigure(builtin_components: list[str] | None) -> int: + """Write the full CMakeLists.txt and run the configure for it.""" + from esphome.build_gen.espidf import write_project + + _LOGGER.info("Writing CMakeLists.txt with the built-in component list...") + write_project(minimal=False, builtin_components=builtin_components) + # Explicit reconfigure: ninja only re-runs cmake when CMakeLists.txt + # is strictly newer than build.ninja, which fails on coarse-mtime + # filesystems (#18682). Also keeps idf.py from regenerating memory.ld + # in testing mode. + return run_reconfigure() + + +def _configure_project() -> int: + """Configure the project, discovering the built-in components if needed. + + A cached component list skips the discovery configure. If the configure + with a cached list fails the entry is dropped and discovery runs once; a + list is only cached after it configured successfully. + """ + from esphome.build_gen.espidf import get_available_components, write_project + + if (cached := load_cached_builtin_components()) is not None: + _LOGGER.info("Using cached ESP-IDF component list") + if _write_project_and_reconfigure(cached) == 0: + return 0 + _LOGGER.warning("Cached component list failed; rediscovering") + _builtin_component_cache_path().unlink(missing_ok=True) + _LOGGER.info("Discovering available ESP-IDF components...") + write_project(minimal=True) + if (rc := run_reconfigure()) != 0: + _LOGGER.error("Component discovery failed") + return rc + discovered = get_available_components() + if not discovered: + _LOGGER.error("Component discovery found no built-in ESP-IDF components") + return 1 + if (rc := _write_project_and_reconfigure(discovered)) != 0: + _LOGGER.error("Reconfigure with discovered components failed") + return rc + save_cached_builtin_components(discovered) + return 0 + + def has_outdated_files(): """Check if the build configuration is stale. @@ -382,29 +483,17 @@ def run_compile(config, verbose: bool) -> int: """Compile the ESP-IDF project. Uses two-phase configure to auto-discover available components: - 1. If no previous build, configure with minimal REQUIRES to discover components + 1. If no previous build, configure with minimal REQUIRES to discover + components (skipped when a cached list for this IDF/target/exclusion + set exists) 2. Regenerate CMakeLists.txt with discovered components 3. Run full build """ - from esphome.build_gen.espidf import write_project - # Check if we need to do discovery phase - if need_reconfigure(): - _LOGGER.info("Discovering available ESP-IDF components...") - write_project(minimal=True) - rc = run_reconfigure() - if rc != 0: - _LOGGER.error("Component discovery failed") - return rc - _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") - write_project(minimal=False) - # Explicit reconfigure: ninja only re-runs cmake when CMakeLists.txt - # is strictly newer than build.ninja, which fails on coarse-mtime - # filesystems (#18682). Also keeps idf.py from regenerating memory.ld - # in testing mode. - rc = run_reconfigure() - if rc != 0: - _LOGGER.error("Reconfigure with discovered components failed") + if not need_reconfigure(): + _LOGGER.info("Build configuration is up to date") + else: + if (rc := _configure_project()) != 0: return rc # cmake does not rewrite CMakeCache.txt when only properties change, # so restamp it or every build repeats discovery. Only after success, diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 29010bcf0e..079f10ddb9 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging from pathlib import Path from unittest.mock import patch @@ -35,22 +36,25 @@ def _reset_core(tmp_path: Path) -> None: } -def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None: +def _write_project_description( + tmp_path: Path, components: dict[str, str], idf_path: str = "/idf" +) -> None: """Stub a project_description.json with the given component_name -> dir map.""" build_dir = tmp_path / "build" build_dir.mkdir(exist_ok=True) (build_dir / "project_description.json").write_text( json.dumps( { + "idf_path": idf_path, "build_component_info": { name: {"dir": dir_} for name, dir_ in components.items() - } + }, } ) ) -def _render(minimal: bool = False) -> str: +def _render(minimal: bool = False, builtin_components: list[str] | None = None) -> str: """Render the top-level CMakeLists with the standard variant/name patches.""" with ( patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), @@ -58,7 +62,9 @@ def _render(minimal: bool = False) -> str: ): from esphome.build_gen.espidf import get_project_cmakelists - return get_project_cmakelists(minimal=minimal) + return get_project_cmakelists( + minimal=minimal, builtin_components=builtin_components + ) def test_get_available_components_returns_none_without_build_path() -> None: @@ -77,8 +83,11 @@ def test_get_available_components_returns_none_without_project_description( assert get_available_components() is None -def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) -> None: - """Built-ins are returned; src/, managed_components/, pio_components/ skipped.""" +def test_get_available_components_keeps_only_idf_tree_components( + tmp_path: Path, +) -> None: + """Only components under idf_path/components are built-ins: src, managed, + converted PIO libs and Arduino component_stubs are all left out.""" _write_project_description( tmp_path, { @@ -86,6 +95,7 @@ def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) -> "esp_lcd": "/idf/components/esp_lcd", "espressif__arduino-esp32": f"{tmp_path}/managed_components/arduino", "JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC", + "cbor": f"{tmp_path}/component_stubs/cbor", "freertos": "/idf/components/freertos", }, ) @@ -94,6 +104,75 @@ def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) -> assert sorted(get_available_components()) == ["esp_lcd", "freertos"] +def test_codegen_and_configure_writes_render_the_same_cmakelists( + tmp_path: Path, +) -> None: + """write_project() at codegen time (no list) and the configure-time write + (discovered list) must agree, or ninja re-runs cmake on every build.""" + _write_project_description( + tmp_path, + { + "lwip": "/idf/components/lwip", + "cbor": f"{tmp_path}/component_stubs/cbor", + }, + ) + from esphome.build_gen.espidf import get_available_components + + assert _render() == _render(builtin_components=get_available_components()) + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS cbor" not in _render() + + +def test_get_available_components_warns_when_nothing_is_under_idf_path( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + _write_project_description(tmp_path, {"cbor": f"{tmp_path}/component_stubs/cbor"}) + from esphome.build_gen.espidf import ( + get_available_components, + has_discovered_components, + ) + + assert get_available_components() == [] + assert "No ESP-IDF components found under" in caplog.text + # An empty discovery must not count as configured, or it would be latched in. + assert not has_discovered_components() + + +def test_get_available_components_ignores_corrupt_or_unexpected_file( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + build_dir = tmp_path / "build" + build_dir.mkdir() + from esphome.build_gen.espidf import ( + get_available_components, + has_discovered_components, + ) + + (build_dir / "project_description.json").write_text("{not json") + assert get_available_components() is None + assert not has_discovered_components() + (build_dir / "project_description.json").write_text('{"build_component_info": {}}') + with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.espidf"): + assert get_available_components() is None + assert "Could not read" in caplog.text + + +def test_has_discovered_components_after_configure(tmp_path: Path) -> None: + _write_project_description(tmp_path, {"lwip": "/idf/components/lwip"}) + from esphome.build_gen.espidf import has_discovered_components + + assert has_discovered_components() + + +def test_get_project_cmakelists_uses_supplied_builtin_components() -> None: + """A cached list replaces project_description.json and is still filtered + by EXCLUDE_COMPONENTS.""" + with patch.dict(CORE.cmake_args, {"EXCLUDE_COMPONENTS": "fatfs;unity"}): + content = _render(builtin_components=["lwip", "fatfs", "esp_timer"]) + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_timer APPEND" in content + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS lwip APPEND" in content + assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS fatfs APPEND" not in content + + def test_get_project_cmakelists_minimal_omits_builtin_components_property( tmp_path: Path, ) -> None: diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index c54daec6a4..9deb27d83c 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -2,6 +2,8 @@ # pylint: disable=protected-access +from collections.abc import Iterator +from contextlib import contextmanager import json import os from pathlib import Path @@ -309,6 +311,11 @@ def test_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> No with ( patch.object(toolchain, "need_reconfigure", return_value=True), + patch.object(toolchain, "load_cached_builtin_components", return_value=None), + patch.object(toolchain, "save_cached_builtin_components"), + patch( + "esphome.build_gen.espidf.get_available_components", return_value=["lwip"] + ), patch("esphome.build_gen.espidf.write_project"), patch.object(toolchain, "run_reconfigure", return_value=0), patch.object(toolchain, "run_idf_py", return_value=0), @@ -329,6 +336,11 @@ def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None: with ( patch.object(toolchain, "need_reconfigure", return_value=True), + patch.object(toolchain, "load_cached_builtin_components", return_value=None), + patch.object(toolchain, "save_cached_builtin_components"), + patch( + "esphome.build_gen.espidf.get_available_components", return_value=["lwip"] + ), patch("esphome.build_gen.espidf.write_project"), patch.object(toolchain, "run_reconfigure", return_value=0), patch.object(toolchain, "run_idf_py", return_value=0), @@ -354,7 +366,7 @@ def test_run_compile_reconfigures_after_full_write_outside_testing_mode( calls: list[tuple] = [] reconfigures = 0 - def record_write(minimal: bool = False) -> None: + def record_write(minimal: bool = False, builtin_components=None) -> None: calls.append(("write_project", minimal)) def record_reconfigure() -> int: @@ -365,6 +377,11 @@ def test_run_compile_reconfigures_after_full_write_outside_testing_mode( with ( patch.object(toolchain, "need_reconfigure", return_value=True), + patch.object(toolchain, "load_cached_builtin_components", return_value=None), + patch.object(toolchain, "save_cached_builtin_components"), + patch( + "esphome.build_gen.espidf.get_available_components", return_value=["lwip"] + ), patch("esphome.build_gen.espidf.write_project", side_effect=record_write), patch.object(toolchain, "run_reconfigure", side_effect=record_reconfigure), patch.object(toolchain, "run_idf_py", return_value=0) as mock_build, @@ -383,6 +400,229 @@ def test_run_compile_reconfigures_after_full_write_outside_testing_mode( assert cmakecache.stat().st_mtime == old +def _record_compile_calls( + cached: list[str] | None, + saved: list[str] | None = None, + reconfigure_rcs: tuple[int, ...] = (), + cache_file: Path | None = None, +) -> tuple[int, list[tuple]]: + """Run run_compile with a stubbed cache and return (rc, call log). + + ``reconfigure_rcs`` overrides the exit codes of the first reconfigures; + later ones succeed. + """ + calls: list[tuple] = [] + rcs = iter(reconfigure_rcs) + + def record_reconfigure() -> int: + calls.append(("run_reconfigure",)) + return next(rcs, 0) + + def record_write(minimal: bool = False, builtin_components=None) -> None: + calls.append(("write_project", minimal, builtin_components)) + + def record_save(components: list[str]) -> None: + calls.append(("save", components)) + + with ( + patch.object(toolchain, "need_reconfigure", return_value=True), + patch.object(toolchain, "load_cached_builtin_components", return_value=cached), + patch.object( + toolchain, "save_cached_builtin_components", side_effect=record_save + ), + patch("esphome.build_gen.espidf.get_available_components", return_value=saved), + patch("esphome.build_gen.espidf.write_project", side_effect=record_write), + patch.object(toolchain, "run_reconfigure", side_effect=record_reconfigure), + patch.object( + toolchain, "_builtin_component_cache_path", return_value=cache_file + ), + patch.object( + toolchain, + "run_idf_py", + side_effect=lambda *a, **kw: calls.append(("build",)) or 0, + ), + patch.object(toolchain, "print_summary"), + ): + rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) + return rc, calls + + +def test_run_compile_poisoned_cache_is_dropped_and_rediscovered( + setup_core: Path, tmp_path: Path +) -> None: + """A cached list that fails the configure is deleted and discovery runs + once more instead of every later build failing the same way.""" + _setup_build(setup_core) + cache_file = tmp_path / "esp32-abc.json" + cache_file.write_text("[]") + rc, calls = _record_compile_calls( + ["stale"], saved=["lwip"], reconfigure_rcs=(1,), cache_file=cache_file + ) + assert rc == 0 + assert not cache_file.exists() + assert calls == [ + ("write_project", False, ["stale"]), + ("run_reconfigure",), + ("write_project", True, None), + ("run_reconfigure",), + ("write_project", False, ["lwip"]), + ("run_reconfigure",), + ("save", ["lwip"]), + ("build",), + ] + + +def test_run_compile_cache_miss_discovers_and_saves(setup_core: Path) -> None: + """Without a cached list the discovery configure runs, the discovered list + feeds the full write and is cached only after that configure succeeds.""" + _setup_build(setup_core) + rc, calls = _record_compile_calls(None, saved=["lwip"]) + assert rc == 0 + assert calls == [ + ("write_project", True, None), + ("run_reconfigure",), + ("write_project", False, ["lwip"]), + ("run_reconfigure",), + ("save", ["lwip"]), + ("build",), + ] + + +def test_run_compile_discovery_failure_stops_before_full_write( + setup_core: Path, +) -> None: + """A failed discovery configure returns its exit code and never writes + the full CMakeLists, a cache entry or a build.""" + _setup_build(setup_core) + rc, calls = _record_compile_calls(None, reconfigure_rcs=(2,)) + assert rc == 2 + assert calls == [("write_project", True, None), ("run_reconfigure",)] + + +@pytest.mark.parametrize("discovered", [None, []], ids=["no_manifest", "empty"]) +def test_run_compile_fails_when_discovery_finds_nothing( + setup_core: Path, + caplog: pytest.LogCaptureFixture, + discovered: list[str] | None, +) -> None: + _setup_build(setup_core) + rc, calls = _record_compile_calls(None, saved=discovered) + assert rc == 1 + assert calls == [("write_project", True, None), ("run_reconfigure",)] + assert "found no built-in ESP-IDF components" in caplog.text + + +def test_run_compile_does_not_cache_a_list_that_failed_to_configure( + setup_core: Path, +) -> None: + _setup_build(setup_core) + rc, calls = _record_compile_calls(None, saved=["lwip"], reconfigure_rcs=(0, 3)) + assert rc == 3 + assert ("save", ["lwip"]) not in calls + assert ("build",) not in calls + + +def test_run_compile_cache_hit_skips_discovery(setup_core: Path) -> None: + """A cached list goes straight to the full write; the explicit reconfigure + after it (#18730) still runs.""" + _setup_build(setup_core) + rc, calls = _record_compile_calls(["esp_timer", "lwip"]) + assert rc == 0 + assert calls == [ + ("write_project", False, ["esp_timer", "lwip"]), + ("run_reconfigure",), + ("build",), + ] + + +@contextmanager +def _cache_env(tmp_path: Path, excluded: str) -> Iterator[Path]: + """Patch everything the cache key derives from onto a temp IDF tree and + yield that tree's path.""" + idf_path = tmp_path / "idf" + (idf_path / "components").mkdir(parents=True, exist_ok=True) + with ( + patch.object(toolchain, "_get_idf_path", return_value=idf_path), + patch.dict(CORE.data, {KEY_ESP32: {KEY_VARIANT: "ESP32"}}), + patch.dict(CORE.cmake_args, {"EXCLUDE_COMPONENTS": excluded}), + ): + yield idf_path + + +def test_component_cache_round_trip(setup_core: Path, tmp_path: Path) -> None: + """A saved list is read back until it is dropped.""" + _setup_build(setup_core) + with _cache_env(tmp_path, "fatfs") as idf_path: + for name in ("lwip", "esp_timer"): + (idf_path / "components" / name).mkdir() + assert toolchain.load_cached_builtin_components() is None + toolchain.save_cached_builtin_components(["esp_timer", "lwip"]) + assert toolchain.load_cached_builtin_components() == ["esp_timer", "lwip"] + toolchain._builtin_component_cache_path().unlink() + assert toolchain.load_cached_builtin_components() is None + + +def test_component_cache_misses_on_key_change_or_missing_component( + setup_core: Path, tmp_path: Path +) -> None: + """A different exclusion set uses another entry, an entry naming a + component that no longer exists is ignored, and a custom IDF_PATH is + never cached.""" + _setup_build(setup_core) + with _cache_env(tmp_path, "fatfs") as idf_path: + (idf_path / "components" / "lwip").mkdir() + toolchain.save_cached_builtin_components(["lwip"]) + path = toolchain._builtin_component_cache_path() + assert path.parent == idf_path / ".esphome_component_lists" + assert path.name.startswith("esp32-") + assert toolchain.load_cached_builtin_components() == ["lwip"] + with patch.dict(os.environ, {"IDF_PATH": str(idf_path)}): + assert toolchain.load_cached_builtin_components() is None + with _cache_env(tmp_path, "fatfs;unity"): + assert toolchain.load_cached_builtin_components() is None + with _cache_env(tmp_path, "fatfs") as idf_path: + path.write_text(json.dumps(["lwip", "gone"])) + assert toolchain.load_cached_builtin_components() is None + # A plain file with the right name is not a component directory. + (idf_path / "components" / "gone").write_text("not a directory") + assert toolchain.load_cached_builtin_components() is None + + +def test_component_cache_save_skips_empty_list_or_custom_idf_path( + setup_core: Path, tmp_path: Path +) -> None: + _setup_build(setup_core) + with _cache_env(tmp_path, "") as idf_path: + toolchain.save_cached_builtin_components([]) + with patch.dict(os.environ, {"IDF_PATH": str(idf_path)}): + toolchain.save_cached_builtin_components(["lwip"]) + assert not (idf_path / ".esphome_component_lists").exists() + + +def test_component_cache_write_failure_is_logged( + setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + _setup_build(setup_core) + with ( + _cache_env(tmp_path, ""), + patch.object(toolchain, "write_file", side_effect=EsphomeError("disk full")), + ): + toolchain.save_cached_builtin_components(["lwip"]) + assert toolchain.load_cached_builtin_components() is None + assert "Could not write component list cache" in caplog.text + + +def test_component_cache_ignores_corrupt_file(setup_core: Path, tmp_path: Path) -> None: + _setup_build(setup_core) + with _cache_env(tmp_path, ""): + path = toolchain._builtin_component_cache_path() + path.parent.mkdir(parents=True) + path.write_text("{not json") + assert toolchain.load_cached_builtin_components() is None + path.write_text(json.dumps({"components": ["lwip"]})) + assert toolchain.load_cached_builtin_components() is None + + def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None: """compile_process_limit is forwarded to run_idf_py as the job limit.""" _setup_build(setup_core)