From ff973537672193bc6a7363d38e850cbafc6f18f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Oct 2026 19:39:06 -0500 Subject: [PATCH] [espidf] Resolve the ESP-IDF tool paths once per build (#19996) --- esphome/espidf/framework.py | 33 +++++++++++++++++++++-- tests/unit_tests/test_espidf_framework.py | 24 +++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 8c377561ca..e9049815f6 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -2,6 +2,7 @@ from collections.abc import Callable from ctypes.util import find_library +from dataclasses import dataclass, field import json import logging import os @@ -18,7 +19,7 @@ from esphome.build_helpers.ccache import ( ) from esphome.build_helpers.pch import ccache_pch_env from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path -from esphome.core import Version +from esphome.core import CORE, Version from esphome.framework_helpers import ( PathType, create_venv, @@ -42,6 +43,21 @@ _LOGGER = logging.getLogger(__name__) _SCRIPTS_DIR = Path(__file__).parent +DOMAIN = "espidf_framework" + + +@dataclass +class _FrameworkCache: + tool_paths: dict[Path, tuple[list[str], dict[str, str]]] = field( + default_factory=dict + ) + + +def _cache() -> _FrameworkCache: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _FrameworkCache() + return CORE.data[DOMAIN] + ESPHOME_STAMP_FILE = ".esphome.stamp.json" @@ -362,7 +378,17 @@ def _get_idf_tool_paths( Raises: RuntimeError: If ESP-IDF tool paths cannot be determined + + The install check and the build environment both resolve the same + framework, so the result is cached per run and the helper script runs + once per build instead of once per caller. The script also reads + ``IDF_TOOLS_PATH``; every caller sets it from ``get_idf_tools_path()``, + so the key leaves it out. """ + cache = _cache().tool_paths + key = Path(idf_framework_root) + if (cached := cache.get(key)) is not None: + return cached success, stdout, stderr = _run_idf_tools_script( idf_framework_root, "get_idf_tool_paths.py", "ESP-IDF tool paths", env=env @@ -373,11 +399,13 @@ def _get_idf_tool_paths( # Extract json values try: data = json.loads(stdout) - return data["paths_to_export"], data["export_vars"] + result = (data["paths_to_export"], data["export_vars"]) except Exception as e: raise RuntimeError( f"Can't extract ESP-IDF tool paths of {idf_framework_root}" ) from e + cache[key] = result + return result def _get_python_version( @@ -927,6 +955,7 @@ def _check_esphome_idf_framework_install( # Validate via the managed tool-path resolution, not ``idf_tools.py check``: # ``check`` probes tools on the system PATH and aborts if any fail to run (e.g. a # broken Homebrew openocd), which forced a toolchain reinstall on every build. + # The resolved paths stay cached for get_framework_env. try: _get_idf_tool_paths(framework_path, env) install = False diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index c785d56fa4..13aa8f7091 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -1511,6 +1511,30 @@ def test_get_idf_tool_paths_raises_on_failure(tmp_path: Path) -> None: _get_idf_tool_paths(tmp_path) +def test_get_idf_tool_paths_runs_the_script_once_per_build(tmp_path: Path) -> None: + payload = json.dumps({"paths_to_export": ["/a"], "export_vars": {"X": "1"}}) + env = {"IDF_TOOLS_PATH": str(tmp_path / "tools")} + with patch( + "esphome.espidf.framework.run_command", return_value=(True, payload, "") + ) as run: + first = _get_idf_tool_paths(tmp_path, env) + second = _get_idf_tool_paths(tmp_path, env) + assert run.call_count == 1 + assert first == second == (["/a"], {"X": "1"}) + + +def test_get_idf_tool_paths_does_not_cache_a_failure(tmp_path: Path) -> None: + payload = json.dumps({"paths_to_export": ["/a"], "export_vars": {}}) + with patch( + "esphome.espidf.framework.run_command", + side_effect=[(False, "", "err"), (True, payload, "")], + ) as run: + with pytest.raises(RuntimeError, match="Can't get ESP-IDF tool paths"): + _get_idf_tool_paths(tmp_path) + assert _get_idf_tool_paths(tmp_path) == (["/a"], {}) + assert run.call_count == 2 + + def test_get_python_version_parses_stdout(tmp_path: Path) -> None: with patch( "esphome.espidf.framework.run_command", return_value=(True, "3.11.0\n", "")