[espidf] Install only the toolchains for the variants being built (#17688)

This commit is contained in:
J. Nick Koston
2026-07-27 08:39:03 -10:00
committed by GitHub
parent 345bd11a2c
commit c0aa121c3d
7 changed files with 476 additions and 33 deletions
+269 -6
View File
@@ -19,7 +19,10 @@ from unittest.mock import patch
import pytest
from esphome.espidf.framework import (
ESPHOME_STAMP_FILE,
STAMP_SCHEMA_VERSION,
_ccache_env,
_check_esphome_idf_framework_install,
_check_stamp,
_check_windows_path_length,
_clone_idf_with_submodules,
@@ -32,6 +35,8 @@ from esphome.espidf.framework import (
_patch_tools_json_demote_unused_tools,
_patch_tools_json_for_linux_arm64,
_prefetch_idf_tool_archives,
_read_stamp,
_stamp_covers,
_windows_long_paths_enabled,
_write_idf_version_txt,
_write_stamp,
@@ -385,6 +390,7 @@ def espidf_mocks(setup_core: Path):
patch("esphome.espidf.framework._prefetch_idf_tool_archives"),
patch("esphome.espidf.framework._write_stamp"),
patch("esphome.espidf.framework._check_stamp", return_value=True),
patch("esphome.espidf.framework._stamp_covers", return_value=True),
patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION),
patch("esphome.espidf.framework._get_python_version", return_value="3.11.0"),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
@@ -514,13 +520,17 @@ def _mark_installed() -> None:
def test_check_esp_idf_install_stamp_mismatch_reinstalls(
espidf_mocks: SimpleNamespace,
) -> None:
"""A stamp mismatch reinstalls tools (marker present, so no re-extract)."""
"""A stamp mismatch reinstalls tools (marker present, so no re-extract).
The python env is left alone: it depends on the framework version and
features, not on which toolchains are installed.
"""
_mark_installed()
with patch("esphome.espidf.framework._check_stamp", return_value=False):
with patch("esphome.espidf.framework._stamp_covers", return_value=False):
check_esp_idf_install(_IDF_VERSION)
espidf_mocks.extract.assert_not_called() # marker present -> no re-extract
espidf_mocks.venv.assert_called_once() # tools reinstall -> venv rebuilt
espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept
def test_check_esp_idf_install_check_command_failure_reinstalls(
@@ -533,7 +543,7 @@ def test_check_esp_idf_install_check_command_failure_reinstalls(
check_esp_idf_install(_IDF_VERSION, features=["fb"])
espidf_mocks.extract.assert_not_called()
espidf_mocks.venv.assert_called_once()
espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept
def test_check_esp_idf_install_unknown_python_version_reinstalls(
@@ -553,8 +563,8 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv(
) -> None:
"""Framework stamp matches but the python-env stamp does not -> venv rebuilt."""
# _check_stamp passes for the framework (no python_version key) and fails
# for the python env (carries python_version), so only the venv rebuilds.
# _check_stamp only guards the python env now (the framework uses
# _stamp_covers, patched True by the fixture); failing it rebuilds the venv.
def stamp_ok(_stamp_file, info: dict) -> bool:
return "python_version" not in info
@@ -566,6 +576,146 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv(
espidf_mocks.venv.assert_called_once()
def _requested_stamp(targets: list[str], tools: list[str] | None = None) -> dict:
return {
"schema_version": STAMP_SCHEMA_VERSION,
"targets": targets,
"tools": tools or ["required"],
}
@pytest.mark.parametrize(
("stored", "targets", "expected"),
[
# a stored "all" covers any target
(_requested_stamp(["all"]), ["esp32"], True),
# exact match and superset both cover
(_requested_stamp(["esp32"]), ["esp32"], True),
(_requested_stamp(["esp32", "esp32c3"]), ["esp32"], True),
# a new target is not covered
(_requested_stamp(["esp32"]), ["esp32c3"], False),
# tools and schema_version must match exactly
(_requested_stamp(["all"], tools=["cmake", "required"]), ["esp32"], False),
(_requested_stamp(["all"]) | {"schema_version": "no"}, ["esp32"], False),
# an unknown extra field participates in invalidation by default
(_requested_stamp(["all"]) | {"module_version": 1}, ["esp32"], False),
# missing/corrupt stamps never cover
(None, ["esp32"], False),
(
{"schema_version": STAMP_SCHEMA_VERSION, "tools": ["required"]},
["esp32"],
False,
),
],
)
def test_stamp_covers(stored: dict | None, targets: list[str], expected: bool) -> None:
assert _stamp_covers(stored, _requested_stamp(targets)) is expected
@contextmanager
def _framework_install_patches():
"""Patches for calling _check_esphome_idf_framework_install directly with
real stamp files (unlike espidf_mocks, which stubs the stamp layer)."""
with (
patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok,
patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.espidf.framework.rmdir"),
):
yield run_ok
def _extracted_framework_with_stamp(stamp: dict) -> Path:
framework_path = _get_framework_path(_IDF_VERSION)
framework_path.mkdir(parents=True, exist_ok=True)
(framework_path / ".esphome_extracted").touch()
_write_stamp(framework_path / ESPHOME_STAMP_FILE, stamp)
return framework_path
def test_framework_install_target_subset_skips_install() -> None:
"""A stamp holding a superset of the requested targets skips the installer."""
framework_path = _extracted_framework_with_stamp(_requested_stamp(["all"]))
with _framework_install_patches() as run_ok:
_, fresh_extract = _check_esphome_idf_framework_install(
_IDF_VERSION, ["esp32"], ["required"]
)
run_ok.assert_not_called()
assert fresh_extract is False
# the stamp is untouched
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["all"]
def test_framework_install_new_target_installs_and_merges_stamp() -> None:
"""A new target runs the installer for just that target and the stamp
records the union of everything installed so far."""
framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"]))
with _framework_install_patches() as run_ok:
_, fresh_extract = _check_esphome_idf_framework_install(
_IDF_VERSION, ["esp32c3"], ["required"]
)
assert fresh_extract is False
assert "--targets=esp32c3" in run_ok.call_args[0][0]
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["esp32", "esp32c3"]
def test_check_esp_idf_install_env_targets_override_wins(
espidf_mocks: SimpleNamespace,
) -> None:
"""An explicitly set ESPHOME_IDF_DEFAULT_TARGETS overrides per-variant targets."""
with patch("esphome.espidf.framework._IDF_DEFAULT_TARGETS_EXPLICIT", True):
check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"])
install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0]
assert "--targets=all" in install_cmd
def test_check_esp_idf_install_uses_requested_targets(
espidf_mocks: SimpleNamespace,
) -> None:
"""Without the env override, the caller's per-variant targets are installed."""
check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"])
install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0]
assert "--targets=esp32" in install_cmd
def test_framework_install_all_request_collapses_merged_stamp_to_all() -> None:
"""Requesting "all" over a per-variant stamp merges and collapses to
["all"], not ["all", "esp32"], so the stamp shape stays canonical."""
framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"]))
with _framework_install_patches() as run_ok:
_check_esphome_idf_framework_install(_IDF_VERSION, ["all"], ["required"])
run_ok.assert_called_once()
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["all"]
def test_framework_install_tools_change_resets_stamp_targets() -> None:
"""A reinstall triggered by a tools change must not carry the old stamp's
targets forward: the installer only ran for this build's targets, so a
merged stamp would let other variants skip the reinstall they need."""
framework_path = _extracted_framework_with_stamp(
_requested_stamp(["all"], tools=["cmake", "required"])
)
with _framework_install_patches() as run_ok:
_check_esphome_idf_framework_install(_IDF_VERSION, ["esp32"], ["required"])
run_ok.assert_called_once()
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["esp32"]
assert stamp["tools"] == ["required"]
@pytest.mark.parametrize(
("lib", "expect_hint"),
[
@@ -1014,6 +1164,66 @@ def test_demote_unused_tools_patches_install_type(tmp_path: Path) -> None:
}
def test_demote_unused_tools_drops_xtensa_from_riscv_targets(tmp_path: Path) -> None:
"""riscv32-esp-elf loses the xtensa chips (ULP-RISC-V only, which ESPHome
never builds) but keeps its RISC-V targets; other tools are untouched."""
tools_json = _write_tools_json(
tmp_path,
{
"tools": [
{
"name": "riscv32-esp-elf",
"install": "always",
"supported_targets": ["esp32s2", "esp32s3", "esp32c3", "esp32p4"],
},
{
"name": "xtensa-esp-elf",
"install": "always",
"supported_targets": ["esp32", "esp32s2", "esp32s3"],
},
]
},
)
_patch_tools_json_demote_unused_tools(tmp_path)
data = json.loads(tools_json.read_text(encoding="utf-8"))
riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf")
xtensa = next(t for t in data["tools"] if t["name"] == "xtensa-esp-elf")
assert riscv["supported_targets"] == ["esp32c3", "esp32p4"]
assert riscv["install"] == "always"
assert xtensa["supported_targets"] == ["esp32", "esp32s2", "esp32s3"]
def test_demote_unused_tools_bad_supported_targets_type_still_demotes(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A non-list supported_targets on riscv32-esp-elf must not abort the
other demotions; the targets patch is best-effort and logs the skip so a
silently resumed riscv download is diagnosable."""
tools_json = _write_tools_json(
tmp_path,
{
"tools": [
{
"name": "riscv32-esp-elf",
"install": "always",
"supported_targets": None,
},
{"name": "openocd-esp32", "install": "always"},
]
},
)
with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"):
_patch_tools_json_demote_unused_tools(tmp_path)
data = json.loads(tools_json.read_text(encoding="utf-8"))
openocd = next(t for t in data["tools"] if t["name"] == "openocd-esp32")
riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf")
assert openocd["install"] == "on_request"
assert riscv["supported_targets"] is None
assert "Unexpected supported_targets" in caplog.text
def test_patch_tools_json_unexpected_structure_warns_and_skips(
tmp_path: Path,
) -> None:
@@ -1036,6 +1246,11 @@ def test_demote_unused_tools_already_patched_is_noop(tmp_path: Path) -> None:
{"name": "xtensa-esp-elf-gdb", "install": "on_request"},
{"name": "riscv32-esp-elf-gdb", "install": "on_request"},
{"name": "esp32ulp-elf", "install": "on_request"},
{
"name": "riscv32-esp-elf",
"install": "always",
"supported_targets": ["esp32c3", "esp32p4"],
},
]
},
)
@@ -1270,6 +1485,54 @@ def test_check_stamp_corrupt_file(tmp_path: Path) -> None:
assert _check_stamp(f, {"a": "1"}) is False
def test_read_stamp_corrupt_file_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# A corrupt stamp forces a full reinstall on every build, so it warns
# where the normal missing-file case stays silent.
f = tmp_path / "s.json"
f.write_text("{ not json", encoding="utf-8")
with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"):
assert _read_stamp(f) is None
assert "Ignoring corrupt stamp file" in caplog.text
def test_read_stamp_unreadable_file_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# An I/O fault (permissions, disk error) is distinguished from a simply
# missing stamp with a warning before falling back to reinstall.
f = tmp_path / "s.json"
f.write_text(json.dumps({"a": "1"}), encoding="utf-8")
with (
patch.object(Path, "open", side_effect=PermissionError("denied")),
caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"),
):
assert _read_stamp(f) is None
assert "Could not read stamp file" in caplog.text
def test_read_stamp_non_dict_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Well-formed JSON that is not an object is a fault, not a first install;
# it must leave a trace before forcing reinstalls.
f = tmp_path / "s.json"
f.write_text("null", encoding="utf-8")
with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"):
assert _read_stamp(f) is None
assert "unexpected type NoneType" in caplog.text
def test_read_stamp_missing_file_is_silent(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Missing stamps are the normal first-install case and must not log.
with caplog.at_level(logging.DEBUG, logger="esphome.espidf.framework"):
assert _read_stamp(tmp_path / "nope.json") is None
assert "stamp file" not in caplog.text
def test_write_idf_version_txt_writes_when_missing(tmp_path: Path) -> None:
_write_idf_version_txt(tmp_path, "5.1.2")
assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "v5.1.2\n"
+24 -2
View File
@@ -10,6 +10,7 @@ from unittest.mock import patch
import pytest
from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
@@ -55,7 +56,7 @@ def test_get_esphome_esp_idf_paths_forwards_source_override():
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", source_url=url)
mock_install.assert_called_once_with("5.5.4", targets=None, source_url=url)
def test_get_esphome_esp_idf_paths_no_override():
@@ -66,7 +67,28 @@ def test_get_esphome_esp_idf_paths_no_override():
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", source_url=None)
mock_install.assert_called_once_with("5.5.4", targets=None, source_url=None)
def test_get_configured_targets_from_variant(monkeypatch: pytest.MonkeyPatch):
"""The configured variant restricts the toolchain install to its target."""
monkeypatch.delenv("CI", raising=False)
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
assert toolchain._get_configured_targets() == ["esp32s3"]
def test_get_configured_targets_without_variant(monkeypatch: pytest.MonkeyPatch):
"""No stored variant (e.g. tooling outside a build) keeps the default."""
monkeypatch.delenv("CI", raising=False)
CORE.data.pop(KEY_ESP32, None)
assert toolchain._get_configured_targets() is None
def test_get_configured_targets_ci_installs_all(monkeypatch: pytest.MonkeyPatch):
"""CI installs every target so the shared cache covers all variants."""
monkeypatch.setenv("CI", "true")
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
assert toolchain._get_configured_targets() is None
def _setup_build(setup_core: Path) -> tuple[Path, Path]: