mirror of
https://github.com/esphome/esphome.git
synced 2026-10-10 21:39:06 +00:00
[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>
This commit is contained in:
co-authored by
pre-commit-ci-lite[bot]
parent
0b78800e3d
commit
19b434f99c
+38
-33
@@ -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/
|
||||
|
||||
+109
-20
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user