Merge branch 'dev' into web-server-offline-hint

This commit is contained in:
J. Nick Koston
2026-10-06 21:09:07 -10:00
683 changed files with 26217 additions and 3301 deletions
+55 -1
View File
@@ -21,12 +21,18 @@ from esphome.arduino8266.framework import InstalledPaths, toolchain_tool
from esphome.build_gen import arduino8266
from esphome.build_gen.arduino8266 import (
_defines_flags,
_elf2bin_flash_size,
_flag_defines,
_flash_size_str,
_resolve_build_config,
get_flash_ld_path,
)
from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD
from esphome.components.esp8266.boards import (
BOARDS,
ESP8266_BOARD_BUILD,
KEY_FLASH_SIZE,
board_ld_script,
)
from esphome.components.esp8266.build_surgery import RATETABLE_RULE
from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_SCANF_FLOAT
import esphome.config_validation as cv
@@ -747,6 +753,54 @@ def test_flash_size_str() -> None:
assert _flash_size_str(512 * 1024) == "512K"
def test_elf2bin_flash_size() -> None:
"""The image-header size follows the ldscript filename like PlatformIO,
falling back to board_upload.maximum_size and then the board table."""
assert _elf2bin_flash_size("esp8285", "eagle.flash.2m.ld") == "2M"
assert _elf2bin_flash_size("esp01", "eagle.flash.512k.ld") == "512K"
assert _elf2bin_flash_size("nodemcuv2", "eagle.flash.4m1m.ld") == "4M"
# The testing-mode prefix still matches (search, not match)
assert _elf2bin_flash_size("esp8285", "testing_eagle.flash.2m.ld") == "2M"
# Custom ldscript name: board_upload.maximum_size wins over the board
CORE.platformio_options["board_upload.maximum_size"] = "2097152"
assert _elf2bin_flash_size("esp8285", "custom.ld") == "2M"
del CORE.platformio_options["board_upload.maximum_size"]
assert _elf2bin_flash_size("esp8285", "custom.ld") == "1M"
@pytest.mark.parametrize("bad", ["2MB", "3145728", "-1"])
def test_elf2bin_flash_size_rejects_bad_maximum_size(bad: str) -> None:
"""A non-numeric or unsupported board_upload.maximum_size fails by name
instead of a ValueError or a late elf2bin choices error."""
CORE.platformio_options["board_upload.maximum_size"] = bad
with pytest.raises(EsphomeError, match="board_upload.maximum_size"):
_elf2bin_flash_size("esp8285", "custom.ld")
def test_elf2bin_flash_size_default_matches_board_table() -> None:
"""Without an ldscript override, every board's own ldscript parses to
the board-table size, so the emitted --flash_size is unchanged."""
for board, entry in BOARDS.items():
assert _elf2bin_flash_size(board, board_ld_script(entry)) == _flash_size_str(
entry[KEY_FLASH_SIZE]
), board
def test_write_project_flash_size_follows_ldscript_override(
tmp_path: Path,
) -> None:
"""An ldscript overriding the board's flash size drives the image header
too (the Athom shape: esp8285 with eagle.flash.2m.ld). A 1M header over
a 2M layout clamps the chip below the OTA scratch area and bricks OTA."""
paths = _make_framework(tmp_path)
(paths.framework / "variants" / "esp8285").mkdir()
CORE.data[KEY_ESP8266][KEY_BOARD] = "esp8285"
CORE.platformio_options["board_build.ldscript"] = "eagle.flash.2m.ld"
content = _write_ninja(paths)
assert "--flash_size 2M" in content
assert "eagle.flash.2m.ld" in content
def test_write_project_testing_mode(tmp_path: Path) -> None:
paths = _make_framework(tmp_path)
CORE.testing_mode = True
+143
View File
@@ -163,6 +163,53 @@ def test_has_discovered_components_after_configure(tmp_path: Path) -> None:
assert has_discovered_components()
@pytest.mark.parametrize("minimal", [False, True])
def test_get_project_cmakelists_emits_ldgen_override(
minimal: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Both renders override the ldgen dep walker to drop the app archive,
after include(project.cmake) which defines the original."""
monkeypatch.delenv("ESPHOME_LDGEN_FULL_DEPS", raising=False)
monkeypatch.delenv("ESPHOME_LDGEN_STRICT", raising=False)
content = _render(minimal=minimal)
assert "REMOVE_ITEM ${out_list_var} idf::src __idf_src" in content
# Quoted so spaced elements survive and an empty list stays defined
assert 'set(${out_list_var} "${${out_list_var}}" PARENT_SCOPE)' in content
assert 'message(WARNING "ESPHome ldgen app archive exclusion' in content
assert 'message(STATUS "ESPHome ldgen override target not found' in content
assert 'message(WARNING "ESPHome ldgen override never filtered' in content
assert content.index("tools/cmake/project.cmake") < content.index(
"function(__ldgen_get_lib_deps_of_target"
)
# The never-filtered check must run after project() has walked the deps
assert content.index("project(test)") < content.index("esphome_ldgen_armed GLOBAL")
def test_get_project_cmakelists_ldgen_strict_fails_closed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""ESPHOME_LDGEN_STRICT turns both degradation paths into hard errors so
CI fails right away when an IDF bump breaks the override."""
monkeypatch.delenv("ESPHOME_LDGEN_FULL_DEPS", raising=False)
monkeypatch.setenv("ESPHOME_LDGEN_STRICT", "1")
content = _render()
assert 'message(FATAL_ERROR "ESPHome ldgen app archive exclusion' in content
assert 'message(FATAL_ERROR "ESPHome ldgen override target not found' in content
assert 'message(FATAL_ERROR "ESPHome ldgen override never filtered' in content
assert "@SEVERITY@" not in content
assert "@MISSING@" not in content
def test_get_project_cmakelists_ldgen_full_deps_escape_hatch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""ESPHOME_LDGEN_FULL_DEPS restores stock ldgen behavior."""
monkeypatch.setenv("ESPHOME_LDGEN_FULL_DEPS", "true")
content = _render()
assert "__ldgen_get_lib_deps_of_target" not in content
assert "esphome_ldgen_armed" not in content
def test_get_project_cmakelists_size_command_uses_json2() -> None:
"""The POST_BUILD size command uses the cheap json2 format, with --ng
only on the 1.x tool bundled with IDF < 6."""
@@ -175,6 +222,30 @@ def test_get_project_cmakelists_size_command_uses_json2() -> None:
assert "--format=json2" in content
def test_get_project_cmakelists_drops_empty_lwip_sources() -> None:
"""The filter comes after project(), where the lwip target exists."""
from esphome.build_gen.espidf import (
LWIP_EMPTY_SOURCES,
LWIP_FULL_SOURCES_ENV,
lwip_empty_source_gate,
)
content = _render()
filter_at = content.index(
"set_property(TARGET ${esphome_lwip_lib} PROPERTY SOURCES"
)
assert filter_at > content.index("project(")
assert f"NOT DEFINED ENV{{{LWIP_FULL_SOURCES_ENV}}}" in content
for entry in LWIP_EMPTY_SOURCES:
assert lwip_empty_source_gate(*entry) in content
def test_get_project_cmakelists_declares_map_as_link_byproduct() -> None:
"""The link declares the map so size can build in the same ninja run."""
content = _render()
assert "BYPRODUCTS ${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}.map" in content
def test_get_project_cmakelists_uses_supplied_builtin_components() -> None:
"""A cached list replaces project_description.json and is still filtered
by EXCLUDE_COMPONENTS."""
@@ -623,3 +694,75 @@ def test_pch_checksum_disabled_writes_nothing(
_make_pch_project(tmp_path)
write_pch_checksum()
assert not CORE.relative_build_path(_PCH_SUM_PATH).exists()
# The macro body as shipped in tools/cmake/build.cmake; byte identical in
# IDF 5.5.5 and 6.1.0, so one fixture covers both supported versions.
IDF_BUILD_CMAKE = """\
some_other_cmake()
macro(__build_process_project_includes)
# Include the sdkconfig cmake file, since the following operations require
# knowledge of config values.
idf_build_get_property(sdkconfig_cmake SDKCONFIG_CMAKE)
include(${sdkconfig_cmake})
# Make each build property available as a read-only variable
idf_build_get_property(build_properties __BUILD_PROPERTIES)
foreach(build_property ${build_properties})
idf_build_get_property(val ${build_property})
set(${build_property} "${val}")
endforeach()
idf_build_get_property(build_component_targets __BUILD_COMPONENT_TARGETS)
# Include each component's project_include.cmake
foreach(component_target ${build_component_targets})
__component_get_property(dir ${component_target} COMPONENT_DIR)
__component_get_property(_name ${component_target} COMPONENT_NAME)
set(COMPONENT_NAME ${_name})
set(COMPONENT_DIR ${dir})
set(COMPONENT_PATH ${dir}) # this is deprecated, users are encouraged to use COMPONENT_DIR;
# retained for compatibility
if(EXISTS ${COMPONENT_DIR}/project_include.cmake)
include(${COMPONENT_DIR}/project_include.cmake)
endif()
endforeach()
endmacro()
"""
def _write_idf_build_cmake(tmp_path: Path, text: str = IDF_BUILD_CMAKE) -> Path:
idf = tmp_path / "idf"
(idf / "tools" / "cmake").mkdir(parents=True)
(idf / "tools" / "cmake" / "build.cmake").write_text(text)
return idf
def test_normalized_macro_strips_comments_and_whitespace() -> None:
from esphome.build_gen.espidf import _normalized_macro
text = "macro(__build_process_project_includes)\n a( b ) # tail\n\n # only\n c(d)\nendmacro()"
assert _normalized_macro(text) == ["a( b )", "c(d)"]
def test_normalized_macro_none_without_macro() -> None:
from esphome.build_gen.espidf import _normalized_macro
assert _normalized_macro("nothing here") is None
def test_idf_macro_matches_the_shipped_body(tmp_path: Path) -> None:
"""The override's embedded copy must equal what build.cmake ships."""
from esphome.build_gen.espidf import idf_macro_matches
assert idf_macro_matches(_write_idf_build_cmake(tmp_path)) is True
def test_idf_macro_mismatch_detected(tmp_path: Path) -> None:
from esphome.build_gen.espidf import idf_macro_matches
changed = IDF_BUILD_CMAKE.replace(
"include(${sdkconfig_cmake})", "include(${sdkconfig_cmake} NEW_ARG)"
)
assert idf_macro_matches(_write_idf_build_cmake(tmp_path, changed)) is False
@@ -0,0 +1,118 @@
"""Validation tests for the ESPectre SDK adapter."""
import pytest
from esphome.components import espectre
from esphome.components.wifi import POWER_SAVE_OFF_REASONS_KEY
import esphome.config_validation as cv
from esphome.const import (
KEY_CORE,
KEY_ESP32,
KEY_FRAMEWORK_VERSION,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
KEY_VARIANT,
)
from esphome.core import CORE
from esphome.types import ConfigType
@pytest.fixture(autouse=True)
def esp32_core() -> None:
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
KEY_TARGET_FRAMEWORK: "esp-idf",
KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 3),
}
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"}
@pytest.mark.parametrize("variant", ["ESP32C2", "ESP32H2", "ESP32P4"])
def test_unsupported_targets(variant: str) -> None:
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
with pytest.raises(cv.Invalid, match="only available"):
espectre.CONFIG_SCHEMA({})
def test_rejects_idf_below_minimum() -> None:
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(5, 5, 2)
with pytest.raises(cv.Invalid, match="5.5.3"):
espectre.CONFIG_SCHEMA({})
@pytest.mark.parametrize("variant", ["ESP32", "ESP32S3"])
def test_arduino_framework_version(variant: str) -> None:
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino"
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(3, 3, 6)
with pytest.raises(cv.Invalid, match="3.3.7"):
espectre.CONFIG_SCHEMA({})
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(3, 3, 7)
espectre.CONFIG_SCHEMA({})
@pytest.mark.parametrize(
("config", "message"),
[
(
{"traffic_generator_mode": "wifi_raw", "csi_capture_profile": "ht_vht"},
"ht_vht CSI capture profile",
),
(
{
"traffic_generator_mode": "wifi_raw",
"traffic_generator_target_ip": "192.168.1.1",
},
"does not use",
),
(
{"csi_traffic_multicast_group": "239.255.0.1"},
"requires traffic_generator_mode: external",
),
],
)
def test_incompatible_traffic_options(config: ConfigType, message: str) -> None:
with pytest.raises(cv.Invalid, match=message):
espectre.CONFIG_SCHEMA(config)
def test_c6_raw_traffic() -> None:
CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6"
with pytest.raises(cv.Invalid, match="not supported on ESP32-C6"):
espectre.CONFIG_SCHEMA({"traffic_generator_mode": "wifi_raw"})
@pytest.mark.parametrize(
"address",
[
"0.0.0.0",
"127.0.0.1",
"224.0.0.1",
"255.255.255.255",
],
)
def test_invalid_traffic_targets(address: str) -> None:
with pytest.raises(cv.Invalid, match="unicast"):
espectre.CONFIG_SCHEMA({"traffic_generator_target_ip": address})
def test_multicast_group_must_be_multicast() -> None:
with pytest.raises(cv.Invalid, match="multicast address"):
espectre.CONFIG_SCHEMA(
{
"traffic_generator_mode": "external",
"csi_traffic_multicast_group": "192.168.1.1",
}
)
def test_empty_multicast_group_skips_joining() -> None:
config = espectre.CONFIG_SCHEMA(
{"traffic_generator_mode": "external", "csi_traffic_multicast_group": " "}
)
assert config["csi_traffic_multicast_group"] == ""
def test_keeps_wifi_power_save_off() -> None:
espectre.final_validate({})
assert any("ESPectre" in reason for reason in CORE.data[POWER_SAVE_OFF_REASONS_KEY])
@@ -0,0 +1,163 @@
"""Tests for the noise-c/libsodium library wiring in the noise component.
Drives the real to_code() so every branch of the managed-versus-converted
decision runs end to end.
"""
from __future__ import annotations
import asyncio
import re
import pytest
import esphome.codegen as cg
from esphome.components import esp32, noise
import esphome.config_validation as cv
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Framework,
Platform,
Toolchain,
)
from esphome.core import CORE
DEFAULT_IDF_VERSION = cv.Version(5, 5, 4)
def _setup_core(
platform: Platform,
framework: Framework,
toolchain: Toolchain,
idf_version: cv.Version = DEFAULT_IDF_VERSION,
) -> None:
CORE.reset()
CORE.toolchain = toolchain
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: str(platform),
KEY_TARGET_FRAMEWORK: str(framework),
}
if platform == Platform.ESP32:
CORE.data[esp32.KEY_ESP32] = {
esp32.KEY_VARIANT: "ESP32",
esp32.KEY_IDF_VERSION: idf_version,
}
def _record_calls(
monkeypatch: pytest.MonkeyPatch,
) -> tuple[list[dict], list[tuple]]:
"""Capture both wiring paths so each test can assert one ran and one did not."""
idf_calls: list[dict] = []
lib_calls: list[tuple] = []
monkeypatch.setattr(
esp32, "add_idf_component", lambda **kwargs: idf_calls.append(kwargs)
)
monkeypatch.setattr(
cg,
"add_library",
lambda name, version, repository=None: lib_calls.append((name, version)),
)
return idf_calls, lib_calls
@pytest.mark.parametrize("toolchain", [Toolchain.ESP_IDF, Toolchain.PLATFORMIO])
def test_to_code_esp32_idf_uses_managed_idf_components(
toolchain: Toolchain,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""ESP32 + ESP-IDF declares both as managed IDF components on either toolchain."""
_setup_core(Platform.ESP32, Framework.ESP_IDF, toolchain)
idf_calls, lib_calls = _record_calls(monkeypatch)
asyncio.run(noise.to_code({}))
assert idf_calls == [
{"name": "esphome/noise-c", "ref": noise.NOISE_C_VERSION},
{"name": "esphome/libsodium", "ref": noise.LIBSODIUM_VERSION},
]
assert lib_calls == []
def test_to_code_esp32_arduino_below_idf6_uses_add_library(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Arduino below IDF 6.0 bundles espressif/libsodium, so both stay PlatformIO
libraries."""
_setup_core(
Platform.ESP32, Framework.ARDUINO, Toolchain.ESP_IDF, cv.Version(5, 5, 4)
)
idf_calls, lib_calls = _record_calls(monkeypatch)
asyncio.run(noise.to_code({}))
assert lib_calls == [
("esphome/noise-c", noise.NOISE_C_VERSION),
("esphome/libsodium", noise.LIBSODIUM_VERSION),
]
assert idf_calls == []
@pytest.mark.parametrize("idf_version", [cv.Version(6, 0, 0), cv.Version(6, 1, 0)])
def test_to_code_esp32_arduino_idf6_uses_managed_idf_components(
idf_version: cv.Version,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""From IDF 6.0 there is no clash, so Arduino uses managed components too."""
_setup_core(Platform.ESP32, Framework.ARDUINO, Toolchain.ESP_IDF, idf_version)
idf_calls, lib_calls = _record_calls(monkeypatch)
asyncio.run(noise.to_code({}))
assert idf_calls == [
{"name": "esphome/noise-c", "ref": noise.NOISE_C_VERSION},
{"name": "esphome/libsodium", "ref": noise.LIBSODIUM_VERSION},
]
assert lib_calls == []
def test_to_code_non_esp32_uses_add_library(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Off ESP32 entirely (e.g. host) there are no IDF components at all."""
_setup_core(Platform.HOST, Framework.NATIVE, Toolchain.PLATFORMIO)
idf_calls, lib_calls = _record_calls(monkeypatch)
asyncio.run(noise.to_code({}))
assert lib_calls == [
("esphome/noise-c", noise.NOISE_C_VERSION),
("esphome/libsodium", noise.LIBSODIUM_VERSION),
]
assert idf_calls == []
def test_versions_match_the_repo_manifests() -> None:
"""A bump that misses one of the duplicated pins would ship two libsodium versions."""
from pathlib import Path
import yaml
repo_root = Path(__file__).resolve().parents[4]
manifest = yaml.safe_load(
(repo_root / "esphome" / "idf_component.yml").read_text(encoding="utf-8")
)
deps = manifest["dependencies"]
assert deps["esphome/noise-c"]["version"] == noise.NOISE_C_VERSION
assert deps["esphome/libsodium"]["version"] == noise.LIBSODIUM_VERSION
# Both are skipped on Arduino below IDF 6.0, where the PlatformIO library
# path is used instead (see noise._use_managed_components).
for name in ("esphome/noise-c", "esphome/libsodium"):
assert deps[name]["rules"] == [
{"if": "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0"}
]
# Every noise-c pin in platformio.ini, not just one of them
pins = re.findall(
r"esphome/noise-c@(\S+)",
(repo_root / "platformio.ini").read_text(encoding="utf-8"),
)
assert set(pins) == {noise.NOISE_C_VERSION}
assert len(pins) >= 1
+123 -28
View File
@@ -5,8 +5,12 @@ from unittest.mock import Mock, patch
import pytest
from esphome.build_helpers import pch
from esphome.components import nrf52
from esphome.components.zephyr.const import KEY_BOARD
from esphome.components.nrf52 import framework
from esphome.components.nrf52.toolchain import get_elf_path
from esphome.components.zephyr.const import KEY_BOARD, KEY_SYSBUILD
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain
from esphome.core import CORE, EsphomeError
@@ -53,8 +57,6 @@ def test_cmake_lists_pch_block_disabled(tmp_path: Path) -> None:
def test_the_zephyr_compiler_decides_on_windows(
windows_gcc_rule: None, version: tuple[int, ...], on: bool
) -> None:
from esphome.build_helpers import pch
# platformdirs would pick its Windows backend from the patched sys.platform
with (
patch.object(nrf52, "toolchain_tool", lambda name: Path(f"/sdk/{name}.exe")),
@@ -64,8 +66,16 @@ def test_the_zephyr_compiler_decides_on_windows(
assert asked.call_args.args[0] == (Path("/sdk/g++.exe"),)
def _write_checksum(tmp_path: Path, app: str, conf: str = "CONFIG_X=y\n") -> Path:
def _write_checksum(
tmp_path: Path,
app: str,
conf: str = "CONFIG_X=y\n",
configured: bool = True,
sysbuild: bool = False,
version: cv.Version | None = None,
) -> Path:
"""Write the checksum for a build dir whose app image sits in ``app``."""
version = version or cv.Version(2, 9, 2)
CORE.build_path = tmp_path
header = tmp_path / "src" / "esphome" / "core" / "pch_prefix.h"
header.parent.mkdir(parents=True, exist_ok=True)
@@ -75,33 +85,82 @@ def _write_checksum(tmp_path: Path, app: str, conf: str = "CONFIG_X=y\n") -> Pat
(source_dir / "prj.conf").write_text(conf)
(source_dir / "CMakeLists.txt").write_text("not part of the checksum\n")
build_dir = tmp_path / ".pioenvs" / "livingroom"
(build_dir / app).mkdir(parents=True, exist_ok=True)
(build_dir / app / "CMakeCache.txt").write_text("")
if configured:
(build_dir / app).mkdir(parents=True, exist_ok=True)
(build_dir / app / "CMakeCache.txt").write_text("")
with (
patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: "2.9.2"}}),
patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}),
patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: version}}),
patch.object(
nrf52,
"zephyr_data",
return_value={KEY_BOARD: "board", KEY_SYSBUILD: sysbuild},
),
):
nrf52._write_pch_checksum(build_dir, source_dir)
return build_dir / app / SUM
@pytest.mark.parametrize("app", ["zephyr", "."])
def test_pch_checksum_is_written_next_to_the_gch(tmp_path: Path, app: str) -> None:
"""Sysbuild nests the app image; without it the build dir is the app."""
sum_path = _write_checksum(tmp_path, app)
@pytest.mark.parametrize(
("app", "version"), [("zephyr", cv.Version(2, 9, 2)), (".", cv.Version(2, 9, 1))]
)
def test_pch_checksum_is_written_next_to_the_gch(
tmp_path: Path, app: str, version: cv.Version
) -> None:
"""The SDK version decides the layout, like get_elf_path."""
sum_path = _write_checksum(tmp_path, app, version=version)
assert len(sum_path.read_text().strip()) == 64
def test_pch_checksum_tracks_the_kconfig_side_inputs(tmp_path: Path) -> None:
"""West projects and the sysbuild flag reach autoconf without a .conf
line; the sum must move with them or stale objects get served."""
first = _write_checksum(tmp_path, "zephyr").read_text()
with patch.object(nrf52, "wanted_west_projects", return_value={"extra"}):
second = _write_checksum(tmp_path, "zephyr").read_text()
assert first != second
third = _write_checksum(tmp_path, "zephyr", sysbuild=True).read_text()
assert first != third
def test_pch_checksum_tracks_the_zephyr_configuration(tmp_path: Path) -> None:
first = _write_checksum(tmp_path, "zephyr").read_text()
assert _write_checksum(tmp_path, "zephyr", "CONFIG_X=n\n").read_text() != first
def test_pch_checksum_waits_for_the_first_configure(tmp_path: Path) -> None:
CORE.build_path = tmp_path
build_dir = tmp_path / ".pioenvs" / "livingroom"
nrf52._write_pch_checksum(build_dir, tmp_path / "zephyr")
assert not build_dir.exists()
@pytest.mark.parametrize("version", [cv.Version(2, 9, 2), cv.Version(2, 9, 1)])
def test_pch_checksum_lands_where_the_build_writes_the_image(
tmp_path: Path, version: cv.Version
) -> None:
"""One layout rule: the sum must sit in get_elf_path's app dir, or a
layout drift silently costs the first build's sharing."""
app = "zephyr" if version >= cv.Version(2, 9, 2) else "."
sum_path = _write_checksum(tmp_path, app, version=version)
CORE.name = "livingroom"
with patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: version}}):
expected = get_elf_path().parent.parent / SUM
assert sum_path.resolve() == expected.resolve()
def test_pch_checksum_written_before_the_first_configure(tmp_path: Path) -> None:
"""The first build's compiles hash the sum in place of the .gch."""
assert _write_checksum(tmp_path, "zephyr", configured=False).is_file()
def _fake_build_env(ccache: str | None) -> dict[str, str]:
"""The real get_build_env with only the install path lookups stubbed."""
with (
patch.object(framework, "_get_version_str", return_value="v1"),
patch.object(framework, "_get_python_env_path", return_value=Path("/penv")),
patch.object(
framework,
"get_python_env_executable_path",
return_value=Path("/penv/bin/python"),
),
patch.object(framework, "_get_framework_path", return_value=Path("/fw")),
patch.object(framework, "_get_toolchain_version", return_value="t1"),
patch.object(framework, "_get_toolchain_path", return_value=Path("/tc")),
):
return framework.get_build_env(ccache)
@pytest.fixture
@@ -110,7 +169,10 @@ def run_cmd(tmp_path: Path) -> Mock:
CORE.build_path = tmp_path / "build"
CORE.name = "livingroom"
CORE.toolchain = Toolchain.SDK_NRF
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 2, 0)}
(tmp_path / "build" / "zephyr").mkdir(parents=True)
with (
patch.dict("os.environ", {}, clear=True),
patch.object(nrf52, "check_and_install"),
patch.object(nrf52, "_generate_cmake_lists", return_value=False),
patch.object(
@@ -118,30 +180,63 @@ def run_cmd(tmp_path: Path) -> Mock:
"get_build_paths",
return_value={"python_executable": "python3", "framework_path": tmp_path},
),
patch.object(nrf52, "get_build_env", return_value={}),
patch.object(nrf52, "get_build_env", side_effect=_fake_build_env),
patch.object(nrf52, "resolve_ccache_path", return_value="/usr/bin/ccache"),
patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}),
patch.object(nrf52, "run_command_ok", return_value=False) as run,
):
yield run
def test_ccache_pch_settings_reach_west(run_cmd: Mock) -> None:
# clear=True also drops ambient CCACHE_* overrides; the header is on
# explicitly since Windows hosts start with it off
def test_shared_ccache_settings_reach_west(run_cmd: Mock, tmp_path: Path) -> None:
# The header is on explicitly since Windows hosts start with it off
with (
patch.dict("os.environ", {"ESPHOME_PCH_ENABLE": "1"}, clear=True),
patch.dict("os.environ", {"ESPHOME_PCH_ENABLE": "1"}),
pytest.raises(EsphomeError, match="nRF52 native build failed"),
):
nrf52.run_compile(None, {})
env = run_cmd.call_args.kwargs["env"]
assert env["CCACHE_PCH_EXTSUM"] == "true"
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
# Without depend mode a Kconfig flip reuses a stale .gch
assert env["CCACHE_DEPEND"] == "1"
# The full managed set, not a bespoke subset
assert env["CCACHE_DIR"].endswith("ccache")
assert env["CCACHE_NOHASHDIR"] == "true"
assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve())
assert "CCACHE_DISABLE" not in env
def test_disabled_leaves_the_west_environment_alone(
run_cmd: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
def test_user_exported_ccache_values_win(run_cmd: Mock) -> None:
user = {"ESPHOME_PCH_ENABLE": "1", "CCACHE_DEPEND": "0", "CCACHE_DIR": "/mine"}
with (
patch.dict("os.environ", user),
pytest.raises(EsphomeError, match="nRF52 native build failed"),
):
nrf52.run_compile(None, {})
assert "CCACHE_PCH_EXTSUM" not in run_cmd.call_args.kwargs["env"]
env = run_cmd.call_args.kwargs["env"]
assert env["CCACHE_DEPEND"] == "0"
assert env["CCACHE_DIR"] == "/mine"
def test_no_ccache_disables_the_zephyr_launcher(run_cmd: Mock) -> None:
"""ESPHOME_CCACHE_ENABLE=0 must also stop Zephyr's self-enabled ccache."""
with (
patch.object(nrf52, "resolve_ccache_path", return_value=None),
pytest.raises(EsphomeError, match="nRF52 native build failed"),
):
nrf52.run_compile(None, {})
env = run_cmd.call_args.kwargs["env"]
assert env["CCACHE_DISABLE"] == "1"
assert "CCACHE_DEPEND" not in env
def test_disabled_pch_still_gets_the_shared_settings(run_cmd: Mock) -> None:
with (
patch.dict("os.environ", {"ESPHOME_PCH_ENABLE": "0"}),
pytest.raises(EsphomeError, match="nRF52 native build failed"),
):
nrf52.run_compile(None, {})
env = run_cmd.call_args.kwargs["env"]
assert "CCACHE_PCH_EXTSUM" not in env
assert env["CCACHE_DEPEND"] == "1"
@@ -0,0 +1,130 @@
"""nrf52 sdk-nrf build: the hex file used for the Adafruit UF2 and DFU package."""
from collections.abc import Iterator
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from esphome.components import nrf52
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7
from esphome.components.zephyr.const import KEY_BOARD, KEY_BOOTLOADER
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain
from esphome.core import CORE
@pytest.fixture
def build_dir(tmp_path: Path) -> Path:
CORE.config_path = tmp_path / "test.yaml"
CORE.build_path = tmp_path / "build"
CORE.name = "livingroom"
CORE.toolchain = Toolchain.SDK_NRF
return CORE.relative_pioenvs_path(CORE.name)
@pytest.fixture
def run_cmd(tmp_path: Path) -> Iterator[Mock]:
with (
patch.object(nrf52, "check_and_install"),
patch.object(nrf52, "_generate_cmake_lists", return_value=False),
patch.object(
nrf52,
"get_build_paths",
return_value={"python_executable": "python3", "framework_path": tmp_path},
),
patch.object(nrf52, "get_build_env", return_value={}),
patch.object(
nrf52,
"zephyr_data",
return_value={
KEY_BOARD: "board",
KEY_BOOTLOADER: BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
},
),
patch.object(nrf52.pch, "pch_enabled", return_value=False),
patch.object(nrf52, "run_command_ok", return_value=True) as run,
):
yield run
def _uf2_hex(run_cmd: Mock) -> str:
cmd = next(c.args[0] for c in run_cmd.call_args_list if "-f" in c.args[0])
return cmd[-1]
def _dfu_hex(run_cmd: Mock) -> str:
cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0])
return cmd[cmd.index("--application") + 1]
def _build(
build_dir: Path,
version: cv.Version,
outputs: list[str],
run_cmd: Mock,
leftovers: tuple[str, ...] = (),
) -> None:
"""Run run_compile; *outputs* appear when west builds, *leftovers* before."""
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: version}
(build_dir / "zephyr" / "zephyr").mkdir(parents=True)
# Without a CMake cache run_compile wipes the build dir first
for name in ["CMakeCache.txt", *leftovers]:
(build_dir / name).write_text("")
def west_build(cmd: list[str], **kwargs: object) -> bool:
if "west" in cmd:
for name in outputs:
(build_dir / name).write_text("")
return True
run_cmd.side_effect = west_build
nrf52.run_compile(None, {})
def test_uf2_and_dfu_use_merged_hex(build_dir: Path, run_cmd: Mock) -> None:
_build(
build_dir,
cv.Version(2, 9, 2),
["merged.hex", "zephyr/zephyr/zephyr.hex"],
run_cmd,
)
merged = str(build_dir / "zephyr" / "merged.hex")
assert _uf2_hex(run_cmd) == merged
assert _dfu_hex(run_cmd) == merged
def test_uf2_and_dfu_fall_back_to_zephyr_hex(build_dir: Path, run_cmd: Mock) -> None:
"""The nRF Connect SDK 3.4.0 build produces no merged.hex."""
_build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd)
app_hex = build_dir / "zephyr" / "zephyr.hex"
assert app_hex.is_file()
assert _uf2_hex(run_cmd) == str(app_hex)
assert _dfu_hex(run_cmd) == str(app_hex)
def test_older_sdk_does_not_fall_back_to_zephyr_hex(
build_dir: Path, run_cmd: Mock
) -> None:
"""Before SDK 3.4.0 a missing merged.hex must not be replaced by zephyr.hex."""
_build(build_dir, cv.Version(3, 2, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd)
assert not any("-f" in c.args[0] for c in run_cmd.call_args_list)
assert _dfu_hex(run_cmd) == str(build_dir / "zephyr" / "merged.hex")
def test_stale_merged_hex_is_removed_before_building(
build_dir: Path, run_cmd: Mock
) -> None:
"""A merged.hex left by an older SDK build must not be packaged."""
_build(
build_dir,
cv.Version(3, 4, 0),
["zephyr/zephyr/zephyr.hex"],
run_cmd,
leftovers=("merged.hex", "zephyr/merged.hex"),
)
assert not (build_dir / "merged.hex").exists()
assert not (build_dir / "zephyr" / "merged.hex").exists()
app_hex = str(build_dir / "zephyr" / "zephyr.hex")
assert _uf2_hex(run_cmd) == app_hex
assert _dfu_hex(run_cmd) == app_hex
@@ -0,0 +1,52 @@
"""Tests for the socket component's IPv4 allow list codegen helper."""
from ipaddress import IPv4Address, IPv4Network
from unittest.mock import MagicMock, patch
import pytest
from esphome.components import socket
import esphome.config_validation as cv
def test_network_order_swaps_to_sockaddr_value() -> None:
"""The emitted uint32 must equal s_addr on the little endian targets."""
assert socket._network_order(IPv4Address("192.168.175.20")) == 0x14AFA8C0
assert socket._network_order(IPv4Address("255.255.255.0")) == 0x00FFFFFF
assert socket._network_order(IPv4Address("0.0.0.0")) == 0
def test_add_ipv4_allow_emits_nothing_for_an_empty_list() -> None:
setter = MagicMock()
with (
patch.object(socket.cg, "add") as add,
patch.object(socket.cg, "add_define") as add_define,
):
socket.add_ipv4_allow(setter, [], "bridge")
add.assert_not_called()
add_define.assert_not_called()
setter.assert_not_called()
def test_add_ipv4_allow_wires_the_setter_with_cleared_host_bits() -> None:
setter = MagicMock()
networks = [IPv4Network("192.168.175.33/24", strict=False)]
with (
patch.object(socket.cg, "add") as add,
patch.object(socket.cg, "add_define") as add_define,
patch.object(socket.cg, "progmem_array") as array,
):
socket.add_ipv4_allow(setter, networks, "bridge")
rendered = str(array.call_args.args[1])
assert str(socket._network_order(IPv4Address("192.168.175.0"))) in rendered
assert str(socket._network_order(IPv4Address("255.255.255.0"))) in rendered
setter.assert_called_once_with(array.return_value, 1)
add.assert_called_once()
add_define.assert_called_once_with("USE_SOCKET_IPV4_ALLOW")
def test_schema_caps_the_list_length() -> None:
"""The sanity cap rejects a list past 255 entries."""
assert len(socket.IPV4_ALLOW_SCHEMA([f"10.0.{i}.0/24" for i in range(255)])) == 255
with pytest.raises(cv.Invalid):
socket.IPV4_ALLOW_SCHEMA([f"10.0.{i}.0/24" for i in range(256)])
@@ -0,0 +1,23 @@
"""Tests for the socket component's IPv4 host validator."""
import pytest
from esphome.components import socket
import esphome.config_validation as cv
@pytest.mark.parametrize("value", ["192.0.2.10", "bridge.local", "bridge"])
def test_ipv4_host_accepts_ipv4_address_and_hostname(value: str) -> None:
assert socket.ipv4_host(value) == value
@pytest.mark.parametrize("value", ["2001:db8::10", "::1", "::ffff:192.0.2.10"])
def test_ipv4_host_rejects_ipv6_address(value: str) -> None:
with pytest.raises(cv.Invalid, match="IPv6 addresses are not supported"):
socket.ipv4_host(value)
@pytest.mark.parametrize("value", ["bridge host", ""])
def test_ipv4_host_rejects_invalid_host(value: str) -> None:
with pytest.raises(cv.Invalid):
socket.ipv4_host(value)
@@ -0,0 +1,51 @@
"""Tests for the socket component's source filtering and require functions."""
from unittest.mock import patch
from esphome.components import socket
from esphome.core import Define
def test_helper_files_filtered_until_required() -> None:
"""ipv4_resolve.cpp and tcp_client_link.cpp compile only when required."""
with patch("esphome.config_helpers.CORE") as mock_core:
mock_core.defines = set()
filtered = socket.FILTER_SOURCE_FILES()
assert "ipv4_resolve.cpp" in filtered
assert "tcp_client_link.cpp" in filtered
assert "tcp_listener.cpp" in filtered
mock_core.defines = {Define("USE_SOCKET_IPV4_RESOLVE")}
filtered = socket.FILTER_SOURCE_FILES()
assert "ipv4_resolve.cpp" not in filtered
assert "tcp_client_link.cpp" in filtered
mock_core.defines = {
Define("USE_SOCKET_IPV4_RESOLVE"),
Define("USE_SOCKET_TCP_CLIENT_LINK"),
Define("USE_SOCKET_TCP_LISTENER"),
}
filtered = socket.FILTER_SOURCE_FILES()
assert "ipv4_resolve.cpp" not in filtered
assert "tcp_client_link.cpp" not in filtered
assert "tcp_listener.cpp" not in filtered
def test_require_tcp_client_link_pulls_in_the_resolver() -> None:
"""require_tcp_client_link() sets both defines; the link reads the resolver."""
with patch.object(socket.cg, "add_define") as add_define:
socket.require_tcp_client_link()
assert {call.args[0] for call in add_define.call_args_list} == {
"USE_SOCKET_IPV4_RESOLVE",
"USE_SOCKET_TCP_CLIENT_LINK",
}
def test_require_tcp_listener_pulls_in_the_link() -> None:
with patch.object(socket.cg, "add_define") as add_define:
socket.require_tcp_listener()
assert {call.args[0] for call in add_define.call_args_list} == {
"USE_SOCKET_IPV4_RESOLVE",
"USE_SOCKET_TCP_CLIENT_LINK",
"USE_SOCKET_TCP_LISTENER",
}
@@ -0,0 +1,221 @@
"""Tests for esp32's _write_idf_component_yml() managed-component wiring.
A library already declared as a managed IDF component must not also be converted,
or ESP-IDF sees the same requirement twice and refuses to build.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import yaml
from esphome.components import esp32
import esphome.config_validation as cv
from esphome.const import (
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Framework,
Platform,
Toolchain,
)
from esphome.core import CORE
def _setup_core(tmp_path: Path) -> None:
CORE.reset()
CORE.name = "testdevice"
CORE.build_path = tmp_path
CORE.toolchain = Toolchain.ESP_IDF
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: str(Platform.ESP32),
KEY_TARGET_FRAMEWORK: str(Framework.ESP_IDF),
}
def test_write_idf_component_yml_passes_managed_components(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Names registered via add_idf_component are passed as ``managed`` so the
converter skips them."""
_setup_core(tmp_path)
CORE.data[esp32.KEY_ESP32] = {
esp32.KEY_COMPONENTS: {
"esphome/noise-c": {
esp32.KEY_REPO: None,
esp32.KEY_REF: "0.1.15",
esp32.KEY_PATH: None,
},
},
}
captured: dict[str, set[str] | None] = {}
# A converted (non-managed) library the batch still resolves, so the loop
# wiring its override_path into the manifest is exercised for real too.
converted = MagicMock()
converted.get_sanitized_name.return_value = "esphome/other-lib"
converted.path = tmp_path / "pio_components" / "other-lib"
def fake_generate_idf_components(libraries, managed=None):
captured["managed"] = managed
return [converted]
monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components)
esp32._write_idf_component_yml()
assert captured["managed"] == {"esphome/noise-c"}
# The managed component itself is still written into the manifest deps
# directly (from KEY_COMPONENTS), just not converted a second time.
yml_path = tmp_path / "src" / "idf_component.yml"
assert yml_path.is_file()
contents = yml_path.read_text(encoding="utf-8")
assert "esphome/noise-c" in contents
assert "0.1.15" in contents
# The converted library the batch DID return is still wired in.
assert "esphome/other-lib" in contents
assert str(converted.path) in contents
def test_write_idf_component_yml_empty_managed_when_no_components(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No add_idf_component calls means an empty managed set, the old behavior."""
_setup_core(tmp_path)
CORE.data[esp32.KEY_ESP32] = {esp32.KEY_COMPONENTS: {}}
captured: dict[str, set[str] | None] = {}
def fake_generate_idf_components(libraries, managed=None):
captured["managed"] = managed
return []
monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components)
esp32._write_idf_component_yml()
assert captured["managed"] == set()
@pytest.mark.parametrize(
("version", "libsodium_stubbed"),
[
(cv.Version(5, 5, 4), True),
(cv.Version(5, 99, 99), True),
(cv.Version(6, 0, 0), False),
(cv.Version(6, 1, 0), False),
],
)
def test_arduino_excluded_idf_components_depends_on_idf_version(
version: cv.Version, libsodium_stubbed: bool
) -> None:
"""espressif/libsodium is stubbed below IDF 6.0 only; unmapped entries always."""
CORE.reset()
CORE.data[esp32.KEY_ESP32] = {esp32.KEY_IDF_VERSION: version}
excluded = esp32.arduino_excluded_idf_components()
assert ("espressif__libsodium" in excluded) is libsodium_stubbed
assert "espressif__cbor" in excluded
@pytest.mark.parametrize(
("framework", "version", "bundled"),
[
(Framework.ARDUINO, cv.Version(5, 5, 5), True),
(Framework.ARDUINO, cv.Version(6, 0, 0), False),
(Framework.ESP_IDF, cv.Version(5, 5, 5), False),
],
)
def test_arduino_bundles_libsodium(
framework: Framework, version: cv.Version, bundled: bool, tmp_path: Path
) -> None:
"""Only Arduino below IDF 6.0 brings its own libsodium."""
_setup_core(tmp_path)
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(framework)
CORE.data[esp32.KEY_ESP32] = {esp32.KEY_IDF_VERSION: version}
assert esp32.arduino_bundles_libsodium() is bundled
@pytest.mark.parametrize(
("version", "libsodium_stubbed"),
[(cv.Version(5, 5, 4), True), (cv.Version(6, 0, 0), False)],
)
def test_write_idf_component_yml_arduino_stubs_follow_idf_version(
version: cv.Version,
libsodium_stubbed: bool,
tmp_path: Path,
) -> None:
"""The manifest stubs follow the IDF version: espressif/libsodium below 6.0 only."""
_setup_core(tmp_path)
CORE.toolchain = Toolchain.PLATFORMIO
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(Framework.ARDUINO)
CORE.data[esp32.KEY_ESP32] = {
esp32.KEY_COMPONENTS: {},
esp32.KEY_IDF_VERSION: version,
esp32.KEY_ARDUINO_LIBRARIES: set(),
}
esp32._write_idf_component_yml()
contents = (tmp_path / "src" / "idf_component.yml").read_text(encoding="utf-8")
assert ("espressif/libsodium" in contents) is libsodium_stubbed
assert "espressif/cbor" in contents
stub_dir = (
tmp_path
/ "component_stubs"
/ esp32._idf_component_stub_name("espressif__libsodium")
)
assert stub_dir.is_dir() is libsodium_stubbed
def test_write_idf_component_yml_stub_yields_to_converted_library_of_same_name(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""IDF names a component after its directory and the later registration wins,
so the espressif/libsodium stub and the converted esphome/libsodium raced on
path order (#20102). The stub entry points at the converted library instead."""
_setup_core(tmp_path)
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(Framework.ARDUINO)
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(3, 3, 11)
CORE.data[esp32.KEY_ESP32] = {
esp32.KEY_COMPONENTS: {},
esp32.KEY_IDF_VERSION: cv.Version(5, 5, 5),
esp32.KEY_ARDUINO_LIBRARIES: set(),
}
# A stale stub from an earlier build must not linger beside the real library
stale_stub = tmp_path / "component_stubs" / "libsodium"
stale_stub.mkdir(parents=True)
converted = MagicMock()
converted.get_sanitized_name.return_value = "esphome/libsodium"
converted.path = tmp_path / "pio_components" / "esphome" / "libsodium"
monkeypatch.setattr(
esp32, "generate_idf_components", lambda libraries, managed=None: [converted]
)
esp32._write_idf_component_yml()
manifest = yaml.safe_load(
(tmp_path / "src" / "idf_component.yml").read_text(encoding="utf-8")
)["dependencies"]
assert manifest["espressif/libsodium"] == {
"version": "*",
"override_path": str(converted.path),
}
assert manifest["esphome/libsodium"] == {"override_path": str(converted.path)}
assert not stale_stub.exists()
# Stubs without a converted namesake are unaffected
assert (tmp_path / "component_stubs" / "cbor").is_dir()
assert manifest["espressif/cbor"]["override_path"] == str(
tmp_path / "component_stubs" / "cbor"
)
+141
View File
@@ -0,0 +1,141 @@
"""Tests for the motion platforms' update_interval warning (shake, free_fall, moving)."""
import logging
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome import config, yaml_util
from esphome.core import CORE
CONFIG_TEMPLATE = """
esphome:
name: test
esp8266:
board: esp01_1m
i2c:
sda: GPIO4
scl: GPIO5
motion:
- platform: lsm6ds
id: lsm6ds_motion
update_interval: {update_interval}
{platform_config}
"""
EVENT_CONFIG = """
event:
- platform: motion
motion_id: lsm6ds_motion
name: "Shake event"
"""
FREE_FALL_CONFIG = """
binary_sensor:
- platform: motion
motion_id: lsm6ds_motion
type: free_fall
name: "Free fall"
"""
MOVING_CONFIG = """
binary_sensor:
- platform: motion
motion_id: lsm6ds_motion
type: moving
name: "Moving"
"""
FACE_UP_CONFIG = """
binary_sensor:
- platform: motion
motion_id: lsm6ds_motion
type: face_up
name: "Face up"
"""
def _read_config(tmp_path: Path, update_interval: str, platform_config: str):
test_file = tmp_path / "test.yaml"
test_file.write_text(
CONFIG_TEMPLATE.format(
update_interval=update_interval, platform_config=platform_config
)
)
parsed_yaml = yaml_util.load_yaml(test_file)
with (
patch.object(yaml_util, "load_yaml", return_value=parsed_yaml),
patch.object(CORE, "config_path", test_file),
):
return config.read_config({})
@pytest.mark.parametrize(
("platform_config", "feature_name"),
[
(EVENT_CONFIG, "shake"),
(FREE_FALL_CONFIG, "free-fall"),
(MOVING_CONFIG, "moving"),
],
ids=["shake", "free_fall", "moving"],
)
def test_warns_on_slow_update_interval(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
platform_config: str,
feature_name: str,
) -> None:
"""A parent update_interval slower than 100ms should log a warning."""
with caplog.at_level(logging.WARNING):
result = _read_config(tmp_path, "250ms", platform_config)
assert result is not None, "Slow update_interval should still be a valid config"
warning_text = (
f"{feature_name} detection works best with an update_interval of 100ms or less"
)
assert any(warning_text in record.message for record in caplog.records)
@pytest.mark.parametrize(
("platform_config", "feature_name"),
[
(EVENT_CONFIG, "shake"),
(FREE_FALL_CONFIG, "free-fall"),
(MOVING_CONFIG, "moving"),
],
ids=["shake", "free_fall", "moving"],
)
def test_no_warning_on_fast_update_interval(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
platform_config: str,
feature_name: str,
) -> None:
"""A parent update_interval of 100ms or less should not log a warning."""
with caplog.at_level(logging.WARNING):
result = _read_config(tmp_path, "50ms", platform_config)
assert result is not None
warning_text = f"{feature_name} detection works best with an update_interval"
assert not any(warning_text in record.message for record in caplog.records)
def test_no_warning_for_face_up_with_slow_update_interval(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""face_up/face_down track steady orientation, not a fast event, so no warning."""
with caplog.at_level(logging.WARNING):
result = _read_config(tmp_path, "250ms", FACE_UP_CONFIG)
assert result is not None
assert not any(
"detection works best with an update_interval" in record.message
for record in caplog.records
)
+122
View File
@@ -26,6 +26,7 @@ from esphome.const import (
)
from esphome.core import CORE, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config
from esphome.core.config import (
CONF_SUSPEND_LOOP,
Area,
make_app_name_cpp,
preload_core_config,
@@ -248,6 +249,97 @@ def test_area_id_collision(
assert "ID duplicate_id redefined! Check esphome->area->id." in captured.out
@pytest.mark.parametrize(
("fixture", "expected_platform"),
[
("suspend_loop_host.yaml", "host"),
("suspend_loop_rp2.yaml", "rp2"),
],
)
def test_suspend_loop_fail(
yaml_file: Callable[[str], str],
capsys: pytest.CaptureFixture[str],
fixture: str,
expected_platform: str,
) -> None:
"""Test that suspend_loop fails."""
result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR)
assert result is None
# Check for the specific error message in stdout
captured = capsys.readouterr()
assert (
f"Suspend loop is not available on {expected_platform} platform" in captured.out
)
def test_loop_interval_warn_esp32(
yaml_file: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that too high loop_interval prints warning."""
result = load_config_from_fixture(
yaml_file, "loop_interval_esp32.yaml", FIXTURES_DIR
)
assert result is not None
assert (
"loop_interval of 7s exceeds the 2400ms maximum sleep on this platform; the loop will still "
"wake every 2400ms. Raise esp32.watchdog_timeout to sleep longer."
in caplog.text
)
@pytest.mark.parametrize(
("fixture", "interval", "max_loop"),
[
("loop_interval_bk72xx.yaml", "5000ms", "4000"),
("loop_interval_nrf52.yaml", "700ms", "600"),
],
)
def test_loop_interval_warn(
yaml_file: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
fixture: str,
interval: str,
max_loop: str,
) -> None:
"""Test that too high loop_interval prints warning."""
result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR)
assert result is not None
assert (
f"loop_interval of {interval} exceeds the {max_loop}ms maximum sleep on this platform; the loop will still "
f"wake every {max_loop}ms." in caplog.text
)
@pytest.mark.asyncio
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
async def test_suspend_loop_and_loop_interval(
yaml_file: Callable[[str], Path],
) -> None:
"""Test suspend_loop and loop_interval on esp32"""
result = load_config_from_fixture(
yaml_file, "suspend_loop_esp32.yaml", FIXTURES_DIR
)
assert result is not None
esphome_config = result["esphome"]
assert esphome_config.get(CONF_SUSPEND_LOOP)
with patch("esphome.core.config.cg") as mock_cg:
mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock()
mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock()
await config.to_code(result[CONF_ESPHOME])
mock_cg.add_define.assert_any_call("ESPHOME_SUSPEND_LOOP")
mock_cg.add_define.assert_any_call("ESPHOME_DEBUG_SCHEDULER")
mock_cg.App.set_loop_interval.assert_called_once_with(
cv.TimePeriodMilliseconds(milliseconds=50)
)
def test_device_without_area(yaml_file: Callable[[str], str]) -> None:
"""Test that devices without area_id work correctly."""
result = load_config_from_fixture(
@@ -1341,6 +1433,8 @@ async def test_add_platformio_options_native_idf(
"lib_ignore": "libsodium",
"upload_speed": "115200",
"board_build.f_flash": "80000000L",
# Silently dropped on arduino only; warns here
"board_upload.flash_size": "2MB",
}
)
@@ -1351,6 +1445,9 @@ async def test_add_platformio_options_native_idf(
# nothing else lands in platformio_options on the native toolchain.
assert CORE.platformio_options == {"lib_ignore": ["libsodium"]}
assert "esphome->platformio_options->board_build.f_flash is ignored" in caplog.text
assert (
"esphome->platformio_options->board_upload.flash_size is ignored" in caplog.text
)
assert "upload_speed" not in caplog.text
# build_flags has a first-class esphome equivalent, so it is deprecated.
# lib_deps/lib_ignore are kept as valid platformio_options (no warning).
@@ -1464,13 +1561,21 @@ async def test_add_platformio_options_native_arduino(
"board_build.ldscript": ["eagle.flash.2m.ld", "eagle.flash.4m2m.ld"],
"board_build.filesystem": "littlefs",
"upload_speed": "115200",
# The Athom shape: maximum_size is the elf2bin fallback,
# flash_size is dropped silently (PlatformIO never reads it)
"board_upload.maximum_size": "2097152",
"board_upload.flash_size": "2MB",
}
)
assert CORE.platformio_options["board_build.f_cpu"] == "160000000L"
assert CORE.platformio_options["board_build.ldscript"] == "eagle.flash.4m2m.ld"
assert CORE.platformio_options["board_upload.maximum_size"] == "2097152"
assert "board_upload.flash_size" not in CORE.platformio_options
assert "board_build.f_cpu is ignored" not in caplog.text
assert "board_build.ldscript is ignored" not in caplog.text
assert "board_upload.maximum_size is ignored" not in caplog.text
assert "board_upload.flash_size is ignored" not in caplog.text
assert (
"esphome->platformio_options->board_build.filesystem is ignored" in caplog.text
)
@@ -1480,3 +1585,20 @@ async def test_add_platformio_options_native_arduino(
assert "board_build.ldscript is ignored" in caplog.text
assert "'arduino' toolchain" in caplog.text
assert "upload_speed" not in caplog.text
def test_filter_source_files_drops_util_cpp_without_mqtt() -> None:
"""util.cpp compiles only on MQTT builds; the header stubs it otherwise."""
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp8266",
KEY_TARGET_FRAMEWORK: "arduino",
}
CORE.defines = set()
excluded = config.FILTER_SOURCE_FILES()
assert "util.cpp" in excluded
# The platform map still contributes through the composed function.
assert "static_task.cpp" in excluded
CORE.defines = {core.Define("USE_API"), core.Define("USE_MQTT")}
assert "util.cpp" not in config.FILTER_SOURCE_FILES()
@@ -0,0 +1,7 @@
esphome:
name: test
suspend_loop: true
loop_interval: 5000ms
bk72xx:
board: generic-bk7252
@@ -0,0 +1,7 @@
esphome:
name: test
loop_interval: 7s
esp32:
board: esp32dev
watchdog_timeout: 6s
@@ -0,0 +1,6 @@
esphome:
name: test
loop_interval: 700ms
nrf52:
board: adafruit_feather_nrf52840
@@ -0,0 +1,9 @@
esphome:
name: test
debug_scheduler: true
suspend_loop: true
loop_interval: 50ms
esp32:
board: esp32dev
watchdog_timeout: 6s
@@ -0,0 +1,5 @@
esphome:
name: test
suspend_loop: true
host:
@@ -0,0 +1,6 @@
esphome:
name: test
suspend_loop: true
rp2:
board: rpipicow
@@ -1,7 +1,8 @@
"""Minimal idf_tools stand-in for get_tool_downloads.py tests."""
"""Minimal idf_tools stand-in for the espidf helper-script tests."""
from collections.abc import Iterable
import os
import pathlib
CURRENT_PLATFORM = "linux-amd64"
TOOLS_FILE = "tools/tools.json"
@@ -54,6 +55,7 @@ class _Tool:
installed: Iterable[str] = (),
broken: bool = False,
) -> None:
self.name = "" # filled in from the _TOOLS key below
self.versions = versions
self._recommended = recommended
self.versions_installed = list(installed)
@@ -69,6 +71,19 @@ class _Tool:
if self._broken:
raise ToolBinaryError("broken binary")
def get_path_for_version(self, version: str) -> str:
return str(pathlib.Path(g.idf_tools_path) / "tools" / self.name / version)
def install(self, version: str) -> None:
dest = pathlib.Path(self.get_path_for_version(version))
dest.mkdir(exist_ok=True, parents=True)
if self.name in os.environ.get("TEST_FAIL_INSTALL", "").split(","):
# Fail mid-install like a torn unpack: the partial dir is left
# behind and check_binary_valid's failure path exits the process
(dest / ".partial").write_text("torn", encoding="utf-8")
raise SystemExit(1)
(dest / ".installed").write_text("ok", encoding="utf-8")
_TOOLS = {
"cmake": _Tool(
@@ -97,8 +112,41 @@ _TOOLS = {
"no-download-tool": _Tool({"4.0": _Version(None)}, "4.0"),
}
for _name, _tool in _TOOLS.items():
_tool.name = _name
def load_tools_info() -> dict[str, _Tool]:
# Test hook: strip verification metadata from the named tools
for name in os.environ.get("TEST_NO_SHA", "").split(","):
if (tool := _TOOLS.get(name)) is not None:
for version in tool.versions.values():
if (download := version.get_download_for_platform("")) is not None:
download.sha256 = ""
if os.environ.get("TEST_BROKEN_COLLIDE"):
# Test hook: a broken tool claims shared.tar.gz first; a later good
# tool shares the basename under a different sha256
first = _Tool(
{"6.0": _Version(_Download("https://gh.test/shared.tar.gz", 66, "f1"))},
"6.0",
broken=True,
)
first.name = "broken-first-tool"
shadowed = _Tool(
{"7.0": _Version(_Download("https://other.test/shared.tar.gz", 77, "f2"))},
"7.0",
)
shadowed.name = "shadowed-tool"
return {**_TOOLS, "broken-first-tool": first, "shadowed-tool": shadowed}
if os.environ.get("TEST_COLLIDE"):
# Test hook: a second tool whose archive shares cmake's basename
# but carries a different sha256
collide = _Tool(
{"5.0": _Version(_Download("https://other.test/cmake.tar.gz", 55, "ee"))},
"5.0",
)
collide.name = "collide-tool"
return {**_TOOLS, "collide-tool": collide}
return _TOOLS
@@ -13,3 +13,10 @@ test_list:
- a: alpha
- b: beta
- c: The value of C is $c
- {}
- values:
- var1: '1'
- a: alpha
- b: delta
- c: The value of C is saw
- {}
@@ -13,3 +13,22 @@ test_list:
file: inc1.yaml
vars:
b: "beta"
condition: true
- !include
file: inc1.yaml
vars:
b: "gamma"
c: "saw"
condition: false
- !include
file: inc1.yaml
vars:
b: "delta"
c: "saw"
condition: ${var1 == "1"}
- !include
file: inc1.yaml
vars:
b: "epsilon"
c: "saw"
condition: ${var1 == "2"}
@@ -7,4 +7,10 @@ test_list:
file: inc3.yaml
vars:
y: 8
condition: true
<<: !include
file: inc3.yaml
vars:
y: 10
condition: false
after: Content after
@@ -0,0 +1,2 @@
---
true
@@ -11,8 +11,8 @@ esphome:
# should be substituted as 'original',
# not overwritten by vars in the !include above
name: ${name}
name_add_mac_suffix: true
libraries: !include {file: includes/list.yaml, vars: {var1: Wire}}
name_add_mac_suffix: !include includes/true.yaml
libraries: !include {file: includes/list.yaml, vars: {var1: Wire}, condition: "true"}
esp8266:
board: !include {file: includes/scalar.yaml, vars: {var1: nodemcu}}
board: !include {file: includes/scalar.yaml, vars: {var1: nodemcu}, condition: true}
@@ -0,0 +1,3 @@
---
esphome:
libraries: !include {file: includes/list.yaml, vars: {var1: Wire}, condition: 1}
@@ -0,0 +1,3 @@
---
esphome:
libraries: !include {vars: {var1: Wire}}
+1 -1
View File
@@ -1384,7 +1384,7 @@ def test_force_load_skips_duplicate_include_file() -> None:
self.parent_file = Path("root.yaml")
self.load_calls = 0
def has_unresolved_expressions(self) -> bool:
def has_unresolved_file(self) -> bool:
return False
def load(self) -> dict[str, Any]:
+37
View File
@@ -4,6 +4,7 @@ import math
import pytest
from esphome import cpp_generator as cg, cpp_types as ct
from esphome.core import CORE, ID
class TestExpressions:
@@ -808,3 +809,39 @@ async def test_templatable__lambda_with_std_string() -> None:
result = await cg.templatable(lambda_obj, [], ct.std_string)
assert isinstance(result, cg.LambdaExpression)
class TestSharedProgmemArray:
def test_identical_contents_share_one_array(self) -> None:
CORE.config = {}
a = cg.shared_progmem_array("table", ct.uint8, [1, 2, 3])
b = cg.shared_progmem_array("table", ct.uint8, [1, 2, 3])
c = cg.shared_progmem_array("table", ct.uint8, [4])
assert a is b
assert str(a) != str(c)
assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2
assert not any("PROGMEM" in str(st) for st in CORE.main_statements)
def test_share_false_always_emits_a_new_array(self) -> None:
CORE.config = {}
a = cg.shared_progmem_array("table", ct.uint8, [1], share=False)
b = cg.shared_progmem_array("table", ct.uint8, [1], share=False)
assert str(a) != str(b)
assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2
def test_same_contents_different_type_are_separate(self) -> None:
CORE.config = {}
a = cg.shared_progmem_array("table", ct.uint8, [1])
b = cg.shared_progmem_array("table", ct.uint16, [1])
assert str(a) != str(b)
def test_name_avoids_config_ids(self) -> None:
CORE.config = {"sensor": [{"id": ID("table", is_declaration=True)}]}
array = cg.shared_progmem_array("table", ct.uint8, [1])
assert str(array) == "table_2"
def test_name_avoids_registered_variables(self) -> None:
CORE.config = {}
CORE.register_variable(ID("table", is_declaration=True), cg.MockObj("table"))
array = cg.shared_progmem_array("table", ct.uint8, [1])
assert str(array) == "table_2"
+29
View File
@@ -52,6 +52,35 @@ def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path)
)
def test_esp32_skip_bootloader_tree_offers_no_factory_entry(tmp_path: Path) -> None:
"""A --skip-bootloader tree has no factory image; do not offer one.
An unreadable tree (PlatformIO, capability probes passing a bare
path) reads as a full build and keeps the entry.
"""
from esphome.espidf.toolchain import SKIP_BOOTLOADER_DEFINE
storage = _wizard_storage()
storage.firmware_bin_path = tmp_path / "firmware.bin"
files = [entry["file"] for entry in _download_types("esp32", storage)]
assert files == ["firmware.factory.bin", "firmware.ota.bin"]
cache = tmp_path / "CMakeCache.txt"
cache.write_text(f"{SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n")
files = [entry["file"] for entry in _download_types("esp32", storage)]
assert files == ["firmware.ota.bin"]
# A capability probe passes the path as a plain string.
storage.firmware_bin_path = str(tmp_path / "firmware.bin")
files = [entry["file"] for entry in _download_types("esp32", storage)]
assert files == ["firmware.ota.bin"]
cache.write_text(f"{SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n")
files = [entry["file"] for entry in _download_types("esp32", storage)]
assert files == ["firmware.factory.bin", "firmware.ota.bin"]
def _nrf52_files(tmp_path: Path, *built: str) -> list[str]:
"""The files nrf52 offers for a build directory holding *built*."""
(tmp_path / "zephyr").mkdir()
+206 -1
View File
@@ -5,12 +5,26 @@
import json
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import yaml
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
from esphome.core import CORE
from esphome.espidf import clang_tidy
from esphome.espidf.clang_tidy import _Settings, _setup_core, _write_tidy_project
from esphome.espidf.clang_tidy import (
_arduino_excluded_stubs,
_convert_pio_libs,
_esphome_manifest_deps,
_Settings,
_setup_core,
_write_tidy_project,
)
import esphome.espidf.component as espidf_component
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -137,3 +151,194 @@ def test_generate_compile_commands_configures_twice(
)
assert result == tmp_path / "build" / "compile_commands.json"
assert mock_write.call_args_list[1].args[1] == ["esp_timer", "lwip"]
def test_generate_compile_commands_hands_converted_libs_to_arduino_stubs(
tmp_path: Path,
) -> None:
"""On Arduino the stub generator sees the converted libraries, so a stub
sharing a converted library's directory name can point at it instead."""
converted = {"esphome/libsodium": {"override_path": str(tmp_path / "libsodium")}}
stubs = {"espressif/libsodium": {"version": "*", "override_path": "x"}}
with (
patch.object(clang_tidy, "_setup_core"),
patch.object(clang_tidy, "_convert_pio_libs", return_value=converted),
patch.object(
clang_tidy, "_arduino_excluded_stubs", return_value=stubs
) as mock_stubs,
patch.object(clang_tidy, "_write_tidy_project") as mock_write,
patch("esphome.espidf.toolchain.run_reconfigure", return_value=0),
patch("esphome.build_gen.espidf.get_available_components", return_value=[]),
):
clang_tidy._generate_compile_commands(
tmp_path, _settings(target_framework="arduino"), tmp_path / "platformio.ini"
)
mock_stubs.assert_called_once_with(tmp_path, converted)
extra_deps = mock_write.call_args_list[0].args[2]
assert extra_deps["esphome/libsodium"] == converted["esphome/libsodium"]
assert extra_deps["espressif/libsodium"] == stubs["espressif/libsodium"]
def test_esphome_manifest_deps_reads_repo_manifest() -> None:
"""Top-level dependency names, independent of the per-dependency rules."""
manifest = yaml.safe_load(
(REPO_ROOT / "esphome" / "idf_component.yml").read_text(encoding="utf-8")
)
deps = _esphome_manifest_deps()
assert isinstance(deps, set)
assert "esphome/noise-c" in deps
assert "esphome/libsodium" in deps
# Cross-check against a fresh parse instead of hardcoding the manifest's
# whole key list, so this doesn't need updating whenever a dependency is
# added or removed.
assert deps == set(manifest["dependencies"])
def test_convert_pio_libs_arduino_framework_passes_empty_managed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Below IDF 6.0 the manifest gates noise-c/libsodium off on Arduino, so they
still go through the converter."""
_set_idf_version(cv.Version(5, 5, 5))
monkeypatch.setattr(clang_tidy, "_parse_lib_deps", lambda ini, framework: [])
captured: dict[str, set[str] | None] = {}
# A converted library the batch resolves, so the loop wiring its
# override_path into the returned deps mapping is exercised for real too.
converted = SimpleNamespace(
get_sanitized_name=lambda: "esphome/other-lib",
path=tmp_path / "other-lib",
)
def fake_generate_idf_components(libraries, managed=None):
captured["managed"] = managed
return [converted]
monkeypatch.setattr(
espidf_component, "generate_idf_components", fake_generate_idf_components
)
result = _convert_pio_libs(tmp_path / "platformio.ini", "arduino")
assert captured["managed"] == set()
assert result == {
"esphome/other-lib": {"override_path": str(tmp_path / "other-lib")}
}
def test_convert_pio_libs_arduino_idf_6_passes_manifest_deps(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""From IDF 6.0 the manifest enables them on Arduino too, so the converter
must skip them."""
_set_idf_version(cv.Version(6, 0, 0))
monkeypatch.setattr(clang_tidy, "_parse_lib_deps", lambda ini, framework: [])
captured: dict[str, set[str] | None] = {}
def fake_generate_idf_components(libraries, managed=None):
captured["managed"] = managed
return []
monkeypatch.setattr(
espidf_component, "generate_idf_components", fake_generate_idf_components
)
assert _convert_pio_libs(tmp_path / "platformio.ini", "arduino") == {}
assert captured["managed"] == _esphome_manifest_deps()
def test_convert_pio_libs_espidf_framework_passes_manifest_deps(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Manifest-provided components are passed as ``managed`` so the converter
skips them."""
_set_idf_version(cv.Version(5, 5, 5), "espidf")
monkeypatch.setattr(clang_tidy, "_parse_lib_deps", lambda ini, framework: [])
captured: dict[str, set[str] | None] = {}
def fake_generate_idf_components(libraries, managed=None):
captured["managed"] = managed
return []
monkeypatch.setattr(
espidf_component, "generate_idf_components", fake_generate_idf_components
)
result = _convert_pio_libs(tmp_path / "platformio.ini", "espidf")
assert captured["managed"] == _esphome_manifest_deps()
assert "esphome/noise-c" in captured["managed"]
assert result == {}
def _set_idf_version(version: cv.Version, framework: str = "arduino") -> None:
CORE.reset()
CORE.data[KEY_ESP32] = {KEY_IDF_VERSION: version}
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
KEY_TARGET_FRAMEWORK: framework,
}
def test_arduino_excluded_stubs_skips_components_esphome_manifest_provides(
tmp_path: Path,
) -> None:
"""A component ESPHome's own manifest declares (espressif/lan867x for ethernet)
is not stubbed; one only arduino-esp32 bundles still is."""
_set_idf_version(cv.Version(5, 5, 4))
deps = _arduino_excluded_stubs(tmp_path)
# lan867x is a real ESPHome dependency (esphome/idf_component.yml), so it
# must be excluded from the stub set.
assert "espressif/lan867x" not in deps
# espressif/libsodium (arduino-esp32's bundled copy) is a different
# package from ESPHome's own esphome/libsodium, so below IDF 6.0, where
# arduino-esp32 still declares it, it is stubbed.
assert "espressif/libsodium" in deps
stub_info = deps["espressif/libsodium"]
assert stub_info["version"] == "*"
stub_path = Path(stub_info["override_path"])
assert (stub_path / "CMakeLists.txt").is_file()
def test_arduino_excluded_stubs_points_libsodium_at_converted_library(
tmp_path: Path,
) -> None:
"""Below IDF 6.0 the converted esphome/libsodium shares its directory name
with the espressif/libsodium stub; IDF would keep whichever registers last,
so the stub entry points at the converted library instead (#20102)."""
_set_idf_version(cv.Version(5, 5, 4))
converted_path = str(tmp_path / "pio" / "esphome" / "libsodium")
converted = {"esphome/libsodium": {"override_path": converted_path}}
deps = _arduino_excluded_stubs(tmp_path, converted)
assert deps["espressif/libsodium"] == {
"version": "*",
"override_path": converted_path,
}
assert not (tmp_path / "component_stubs" / "libsodium").exists()
# Stubs without a converted namesake are unaffected.
assert (tmp_path / "component_stubs" / "cbor" / "CMakeLists.txt").is_file()
def test_arduino_excluded_stubs_skips_libsodium_from_idf_6(tmp_path: Path) -> None:
"""From IDF 6.0 arduino-esp32 drops espressif/libsodium; a stub would clash
with esphome/libsodium."""
_set_idf_version(cv.Version(6, 0, 0))
deps = _arduino_excluded_stubs(tmp_path)
assert "espressif/libsodium" not in deps
# Other arduino-bundled components are still stubbed.
assert "espressif/cbor" in deps
+106
View File
@@ -803,6 +803,112 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies(
assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]]
def test_generate_idf_components_managed_filters_top_level_and_dependencies(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
esp32_idf_core: None,
) -> None:
# managed (e.g. noise-c/libsodium already declared via add_idf_component)
# must drop B at the top level and C when discovered as a dependency of A,
# exactly like lib_ignore -- neither may be resolved, downloaded, or wired
# into a manifest.
manifests = {
"esphome/A": {
"name": "A",
"dependencies": [
{"owner": "esphome", "name": "C", "version": "==1.10021.0"}
],
},
"esphome/B": {"name": "B"},
}
download_salts: list[str] = []
def fake_download(self, force=False, salt="", namespace=""):
download_salts.append(salt)
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
monkeypatch.setattr(IDFComponent, "download", fake_download)
resolve_calls: list[str] = []
def fake_resolve(owner, pkgname, requirements):
resolve_calls.append(pkgname)
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz", None
monkeypatch.setattr(
esphome.platformio.library, "_resolve_registry_version", fake_resolve
)
top = generate_idf_components(
[Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)],
managed={"esphome/B", "esphome/C"},
)
assert [c.name for c in top] == ["esphome/A"]
# Managed libraries were never resolved (and therefore never downloaded).
assert resolve_calls == ["A"]
# The managed dependency is not wired into A's manifest.
assert top[0].dependencies == []
# managed changes the generated wiring just like lib_ignore, so the cache
# path is salted the same way.
assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]]
def test_generate_idf_components_lib_ignore_and_managed_combine_into_salt(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
esp32_idf_core: None,
) -> None:
# lib_ignore and managed both contribute to the same exclusion set, so a
# config using both gets a salt reflecting the union of the two sources
# rather than either alone.
manifests = {
"esphome/A": {"name": "A"},
"esphome/D": {"name": "D"},
"esphome/E": {"name": "E"},
}
download_salts: list[str] = []
def fake_download(self, force=False, salt="", namespace=""):
download_salts.append(salt)
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
(self.path / "src").mkdir(parents=True, exist_ok=True)
(self.path / "src" / "x.c").write_text("int x;")
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
monkeypatch.setattr(IDFComponent, "download", fake_download)
resolve_calls: list[str] = []
def fake_resolve(owner, pkgname, requirements):
resolve_calls.append(pkgname)
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz", None
monkeypatch.setattr(
esphome.platformio.library, "_resolve_registry_version", fake_resolve
)
monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["D"]})
top = generate_idf_components(
[
Library("esphome/A", "1.0.0", None),
Library("esphome/D", "1.0.0", None),
Library("esphome/E", "1.0.0", None),
],
managed={"esphome/E"},
)
assert [c.name for c in top] == ["esphome/A"]
assert resolve_calls == ["A"]
# The salt reflects BOTH lib_ignore's "D" and managed's "E" together.
assert download_salts == [hashlib.sha256(b"d,e").hexdigest()[:8]]
def test_generate_idf_components_handles_dependency_cycle(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@@ -0,0 +1,801 @@
"""Tests for the local IDF component-registry mirror."""
from __future__ import annotations
from collections.abc import Callable
import json
from pathlib import Path
import subprocess
from unittest.mock import MagicMock, patch
import zipfile
from filelock import FileLock
import pytest
from esphome.core import EsphomeError
from esphome.espidf import component_mirror
_LOCK_TEXT = """\
dependencies:
bblanchon/arduinojson:
component_hash: 5e6aff2bc5bc988b36c8e90c01e637495ee895ad3af93911860c21ec6da79b6a
dependencies: []
source:
registry_url: https://components.espressif.com/
type: service
version: 7.4.3
espressif/mdns:
component_hash: 3ba256ac95e07c274be53cbd73f06cb846c403b61e8fbdf1be57bdb79db7a63e
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com
type: service
version: 1.12.0
esphome/libsodium:
source:
path: /pio_components/idf/cb5ad5b7/esphome/libsodium
type: local
version: '*'
fastled/fastled:
source:
git: https://github.com/fastled/fastled
type: git
version: 3.7.0
other/private:
source:
registry_url: https://registry.example.com
type: service
version: 1.0.0
idf:
source:
type: idf
version: 5.5.5
direct_dependencies:
- bblanchon/arduinojson
- espressif/mdns
manifest_hash: f4e38c031cbb70d5070c5806fc580cce194b8c9a0b919f52a7e952f7d496dda4
target: esp32
version: 2.0.0
"""
_MANIFEST_TEXT = """\
dependencies:
espressif/mdns:
version: 1.12.0
espressif/esp-tflite-micro:
version: 1.3.3~1
fastled/fastled:
version: 3.7.0
git: https://github.com/fastled/fastled
esphome/libsodium:
version: '*'
override_path: /stub/libsodium
espressif/ranged:
version: ^1.2.0
esphome/shorthand:
version: ==2.1.0
esphome/unquoted:
version: 2
"""
_ARDUINOJSON = component_mirror.ServiceDep("bblanchon", "arduinojson", "7.4.3")
_MDNS = component_mirror.ServiceDep("espressif", "mdns", "1.12.0")
_TFLITE = component_mirror.ServiceDep("espressif", "esp-tflite-micro", "1.3.3~1")
_SHORTHAND = component_mirror.ServiceDep("esphome", "shorthand", "2.1.0")
_MANIFEST_DEPS = [_MDNS, _TFLITE, _SHORTHAND]
_NS_CMP_1 = component_mirror.ServiceDep("ns", "cmp", "1.0.0")
_NS_CMP_2 = component_mirror.ServiceDep("ns", "cmp", "2.0.0")
@pytest.fixture(autouse=True)
def _isolate_mirror(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(tmp_path / "idf_install"))
def _write_lock(project_dir: Path, text: str = _LOCK_TEXT) -> Path:
lock = project_dir / "dependencies.lock"
lock.parent.mkdir(parents=True, exist_ok=True)
lock.write_text(text, encoding="utf-8")
return lock
def _ns_cmp_lock(version: str) -> str:
return (
"dependencies:\n"
" ns/cmp:\n"
" source:\n"
" type: service\n"
f" version: {version}\n"
)
def _write_manifest(project_dir: Path, text: str = _MANIFEST_TEXT) -> Path:
manifest = project_dir / "src" / "idf_component.yml"
manifest.parent.mkdir(parents=True, exist_ok=True)
manifest.write_text(text, encoding="utf-8")
return manifest
def _add_to_mirror(mirror: Path, dep: component_mirror.ServiceDep) -> None:
"""Lay out one component the way `registry sync` does."""
version_dir = f"components/{dep.namespace}/{dep.name}/{dep.version}"
archive = f"{version_dir}/{dep.namespace}__{dep.name}-v{dep.version}.zip"
checksums = f"{version_dir}/CHECKSUMS.json"
json_path = mirror / "components" / dep.namespace / f"{dep.name}.json"
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(
json.dumps(
{
"name": dep.name,
"namespace": dep.namespace,
"versions": [
{"version": dep.version, "url": archive, "checksums": checksums}
],
}
),
encoding="utf-8",
)
archive_path = mirror / archive
archive_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(archive_path, "w") as zf:
zf.writestr("idf_component.yml", "")
(mirror / checksums).write_text("{}", encoding="utf-8")
# ---------------------------------------------------------------------------
# parse_lock_service_deps / parse_manifest_service_deps / project_service_deps
# ---------------------------------------------------------------------------
def test_parse_lock_keeps_only_default_registry_service_deps(tmp_path: Path) -> None:
"""git, local, idf and non-default registry sources cannot be mirrored."""
deps = component_mirror.parse_lock_service_deps(_write_lock(tmp_path))
assert deps == [_ARDUINOJSON, _MDNS]
def test_parse_lock_missing_file(tmp_path: Path) -> None:
assert component_mirror.parse_lock_service_deps(tmp_path / "none.lock") == []
@pytest.mark.parametrize(
"text",
[
pytest.param("{unbalanced", id="corrupt-yaml"),
pytest.param("[]", id="not-a-mapping"),
pytest.param("dependencies:\n espressif/mdns: not-a-dict\n", id="bad-entry"),
pytest.param(_ns_cmp_lock("1.2"), id="non-string-version"),
],
)
def test_parse_lock_tolerates_bad_content(tmp_path: Path, text: str) -> None:
assert component_mirror.parse_lock_service_deps(_write_lock(tmp_path, text)) == []
def test_parse_lock_unreadable_file_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Read trouble other than a missing file says so instead of raising."""
lock = tmp_path / "dependencies.lock"
lock.mkdir() # read_text raises OSError, not FileNotFoundError
assert component_mirror.parse_lock_service_deps(lock) == []
assert "Could not read" in caplog.text
def test_parse_lock_non_utf8_file_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A non-UTF-8 lock is a warning, never a failed build."""
lock = tmp_path / "dependencies.lock"
lock.write_bytes(b"dependencies:\n # caf\xe9\n")
assert component_mirror.parse_lock_service_deps(lock) == []
assert "Could not read" in caplog.text
def test_parse_lock_defaults_registry_url(tmp_path: Path) -> None:
"""A service entry without registry_url is a default-registry dependency."""
lock = _write_lock(tmp_path, _ns_cmp_lock("1.0.0"))
assert component_mirror.parse_lock_service_deps(lock) == [_NS_CMP_1]
def test_parse_manifest_keeps_only_exact_registry_pins(tmp_path: Path) -> None:
"""Only exact pins survive, including the == shorthand prefix."""
deps = component_mirror.parse_manifest_service_deps(_write_manifest(tmp_path))
assert deps == _MANIFEST_DEPS
def test_parse_manifest_missing_file(tmp_path: Path) -> None:
assert component_mirror.parse_manifest_service_deps(tmp_path / "none.yml") == []
def test_parse_manifest_lowercases_mixed_case_keys(tmp_path: Path) -> None:
"""The registry stores lowercase paths; a mixed-case key must match them."""
manifest = tmp_path / "idf_component.yml"
manifest.write_text("dependencies:\n Espressif/MDNS:\n version: 1.2.0\n")
deps = component_mirror.parse_manifest_service_deps(manifest)
assert deps == [component_mirror.ServiceDep("espressif", "mdns", "1.2.0")]
def test_parse_manifest_ranged_specs(tmp_path: Path) -> None:
"""Ranges, not exact pins, git/override entries or an empty version,
become refresh specs."""
manifest = _write_manifest(
tmp_path, _MANIFEST_TEXT + " ns/empty:\n version: ''\n"
)
specs = component_mirror.parse_manifest_ranged_specs(manifest)
assert specs == ["espressif/ranged^1.2.0"]
def test_project_service_deps_merges_lock_and_manifest(tmp_path: Path) -> None:
"""The lock wins for a component in both; the manifest fills the rest."""
lock = _write_lock(tmp_path)
manifest = _write_manifest(tmp_path)
assert component_mirror.project_service_deps(lock, manifest) == [
_ARDUINOJSON,
_MDNS,
_TFLITE,
_SHORTHAND,
]
def test_project_service_deps_manifest_only(tmp_path: Path) -> None:
"""A fresh build has no lock yet; the manifest alone drives the sync."""
manifest = _write_manifest(tmp_path)
assert (
component_mirror.project_service_deps(tmp_path / "dependencies.lock", manifest)
== _MANIFEST_DEPS
)
# ---------------------------------------------------------------------------
# missing_deps
# ---------------------------------------------------------------------------
def test_missing_deps_covered_and_not(tmp_path: Path) -> None:
mirror = tmp_path / "mirror"
_add_to_mirror(mirror, _MDNS)
deps = [
_MDNS,
component_mirror.ServiceDep("espressif", "mdns", "1.13.0"),
_ARDUINOJSON,
]
assert component_mirror.missing_deps(mirror, deps) == deps[1:]
@pytest.mark.parametrize(
"damage",
[
pytest.param(
lambda m: next(m.rglob("*.zip")).unlink(),
id="archive-gone",
),
pytest.param(
lambda m: (m / "components/espressif/mdns/1.12.0/CHECKSUMS.json").unlink(),
id="checksums-gone",
),
pytest.param(
lambda m: (m / "components/espressif/mdns.json").write_text("{broken"),
id="corrupt-index",
),
pytest.param(
lambda m: next(m.rglob("*.zip")).write_bytes(b"PK\x03\x04torn"),
id="truncated-archive",
),
],
)
def test_missing_deps_damaged_mirror(
tmp_path: Path, damage: Callable[[Path], object]
) -> None:
"""Every file the index references must exist, and a broken index
counts as missing, not covered."""
mirror = tmp_path / "mirror"
_add_to_mirror(mirror, _MDNS)
damage(mirror)
assert component_mirror.missing_deps(mirror, [_MDNS]) == [_MDNS]
def test_missing_deps_deletes_a_torn_archive(tmp_path: Path) -> None:
"""A torn archive is removed on detection, so the manager falls back to
the registry even before a heal sync runs."""
mirror = tmp_path / "mirror"
_add_to_mirror(mirror, _MDNS)
torn = next(mirror.rglob("*.zip"))
torn.write_bytes(b"PK\x03\x04torn")
assert component_mirror.missing_deps(mirror, [_MDNS]) == [_MDNS]
assert not torn.exists()
def test_missing_deps_covered_without_checksums_field(tmp_path: Path) -> None:
"""An older payload without a checksums field only needs its archive."""
mirror = tmp_path / "mirror"
_add_to_mirror(mirror, _MDNS)
index = mirror / "components/espressif/mdns.json"
doc = json.loads(index.read_text())
del doc["versions"][0]["checksums"]
index.write_text(json.dumps(doc))
assert component_mirror.missing_deps(mirror, [_MDNS]) == []
# ---------------------------------------------------------------------------
# _publish_index / _promote
# ---------------------------------------------------------------------------
def _staged_index(tmp_path: Path, versions: list) -> Path:
src = tmp_path / "staged.json"
src.write_text(json.dumps({"versions": versions}), encoding="utf-8")
return src
def test_publish_index_first_sync(tmp_path: Path) -> None:
"""No live index yet: the staged one is published as is."""
src = _staged_index(tmp_path, [{"version": "2.0.0"}])
dst = tmp_path / "live.json"
component_mirror._publish_index(src, dst)
assert json.loads(dst.read_text()) == {"versions": [{"version": "2.0.0"}]}
@pytest.mark.parametrize(
"live", ["not json", '{"versions": null}', '{"versions": {"a": 1}}']
)
def test_publish_index_replaces_a_corrupt_live_index(tmp_path: Path, live: str) -> None:
"""An unreadable live index is replaced wholesale; that is the heal."""
src = _staged_index(tmp_path, [{"version": "2.0.0"}])
dst = tmp_path / "live.json"
dst.write_text(live)
component_mirror._publish_index(src, dst)
assert json.loads(dst.read_text()) == {"versions": [{"version": "2.0.0"}]}
def test_publish_index_merges_and_filters(tmp_path: Path) -> None:
"""Unfetched live versions survive; non-dict entries drop, not raise."""
src = _staged_index(tmp_path, [{"version": "2.0.0"}, "junk"])
dst = tmp_path / "live.json"
dst.write_text(
json.dumps({"versions": [{"version": "1.0.0"}, {"version": "2.0.0"}, "bad"]})
)
component_mirror._publish_index(src, dst)
assert json.loads(dst.read_text()) == {
"versions": [{"version": "2.0.0"}, {"version": "1.0.0"}]
}
def test_publish_index_unreadable_live_index_raises(tmp_path: Path) -> None:
"""An I/O error is not corruption; raising keeps the live versions and
a later run retries."""
src = _staged_index(tmp_path, [{"version": "2.0.0"}])
dst = tmp_path / "live.json"
dst.mkdir() # read_text raises IsADirectoryError, not FileNotFoundError
with pytest.raises(OSError):
component_mirror._publish_index(src, dst)
def test_promote_moves_archives_before_indexes(tmp_path: Path) -> None:
"""A concurrent reader must never see an index entry without its files."""
staging = tmp_path / "staging"
_add_to_mirror(staging, _NS_CMP_2)
order: list[str] = []
real_rename = component_mirror.rename_with_retry
real_write = component_mirror.write_file
def recording_rename(src: Path, dst: Path, **kwargs) -> None:
order.append(src.name)
real_rename(src, dst, **kwargs)
def recording_write(path: Path, text: str) -> None:
order.append(path.name)
real_write(path, text)
with (
patch.object(component_mirror, "rename_with_retry", recording_rename),
patch.object(component_mirror, "write_file", recording_write),
):
component_mirror._promote(staging, tmp_path / "mirror")
assert order[-1] == "cmp.json"
assert set(order[:-1]) == {"CHECKSUMS.json", "ns__cmp-v2.0.0.zip"}
# ---------------------------------------------------------------------------
# component_mirror_env
# ---------------------------------------------------------------------------
def test_component_mirror_env_sets_file_url(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("IDF_COMPONENT_LOCAL_STORAGE_URL", raising=False)
monkeypatch.delenv("IDF_COMPONENT_CHECK_NEW_VERSION", raising=False)
env = component_mirror.component_mirror_env()
mirror = component_mirror.get_mirror_path()
assert env["IDF_COMPONENT_LOCAL_STORAGE_URL"] == mirror.as_uri()
assert env["IDF_COMPONENT_CHECK_NEW_VERSION"] == "0"
assert mirror.is_dir()
def test_component_mirror_env_preserves_user_values(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A user local storage list keeps precedence; their new-version choice wins."""
monkeypatch.setenv("IDF_COMPONENT_LOCAL_STORAGE_URL", "file:///user/mirror")
monkeypatch.setenv("IDF_COMPONENT_CHECK_NEW_VERSION", "1")
env = component_mirror.component_mirror_env()
mirror_uri = component_mirror.get_mirror_path().as_uri()
assert env["IDF_COMPONENT_LOCAL_STORAGE_URL"] == f"file:///user/mirror;{mirror_uri}"
assert "IDF_COMPONENT_CHECK_NEW_VERSION" not in env
def test_component_mirror_env_unwritable_cache(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A read-only cache disables the feature with a warning, not a failure."""
with patch.object(Path, "mkdir", side_effect=OSError("read-only")):
assert component_mirror.component_mirror_env() == {}
assert "Component mirror unavailable" in caplog.text
# ---------------------------------------------------------------------------
# sync_component_mirror
# ---------------------------------------------------------------------------
def _run_sync(
project_dir: Path,
*,
returncode: int = 0,
stderr: str = "",
side_effect: Exception | Callable | None = None,
get_python=lambda: "/penv/python",
get_env=lambda: {"PATH": "/penv"},
) -> tuple[bool, MagicMock]:
with patch.object(
component_mirror.subprocess,
"run",
return_value=subprocess.CompletedProcess([], returncode, "", stderr),
side_effect=side_effect,
) as mock_run:
ok = component_mirror.sync_component_mirror(
project_dir / "dependencies.lock",
project_dir / "src" / "idf_component.yml",
get_python,
get_env,
)
return ok, mock_run
def _fake_registry_sync(returncode: int = 0):
"""A subprocess.run stand-in that lays files out like `registry sync`."""
def run(cmd, **kwargs) -> subprocess.CompletedProcess:
_add_to_mirror(Path(cmd[-1]), _NS_CMP_2)
return subprocess.CompletedProcess(cmd, returncode, "", "sync failed")
return run
def _assert_sync_lock_released() -> None:
lock = FileLock(str(component_mirror.get_mirror_path() / ".sync.lock"))
lock.acquire(blocking=False)
lock.release()
def test_sync_runs_the_manager_for_missing_deps(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
_write_lock(tmp_path)
ok, mock_run = _run_sync(tmp_path)
# The stub stages nothing, so the post-promote recheck reports failure.
assert not ok
assert "uncovered" in caplog.text
mirror = component_mirror.get_mirror_path()
# The whole set goes in one invocation: one manager startup.
mock_run.assert_called_once()
assert mock_run.call_args.args[0] == [
"/penv/python",
"-m",
"idf_component_manager",
"registry",
"sync",
"--resolution",
"latest",
"--component",
"bblanchon/arduinojson==7.4.3",
"--component",
"espressif/mdns==1.12.0",
str(mirror / ".staging"),
]
assert mock_run.call_args.kwargs["env"] == {"PATH": "/penv"}
_assert_sync_lock_released()
def test_sync_skips_when_covered(tmp_path: Path) -> None:
"""A covered project resolves no environment and runs no subprocess."""
_write_lock(tmp_path)
mirror = component_mirror.get_mirror_path()
_add_to_mirror(mirror, _MDNS)
_add_to_mirror(mirror, _ARDUINOJSON)
def _boom() -> str:
raise AssertionError("environment resolved for a covered mirror")
ok, mock_run = _run_sync(tmp_path, get_python=_boom, get_env=_boom)
assert ok
mock_run.assert_not_called()
def test_sync_failure_is_tolerated(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
_write_lock(tmp_path)
ok, mock_run = _run_sync(tmp_path, returncode=1, stderr="boom")
assert not ok
mock_run.assert_called_once()
assert "Could not mirror" in caplog.text
_assert_sync_lock_released()
def test_sync_subprocess_errors_are_tolerated(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
_write_lock(tmp_path)
ok, _ = _run_sync(tmp_path, side_effect=OSError("no such file"))
assert not ok
assert "Could not mirror" in caplog.text
_assert_sync_lock_released()
def test_sync_refreshes_ranged_specs_only_before_a_fresh_solve(
tmp_path: Path,
) -> None:
"""Without a lock the batch also re-resolves ranged pins, as dev would;
with a lock the ranged candidate is left alone."""
_write_manifest(tmp_path)
ok, mock_run = _run_sync(tmp_path)
assert not ok # the stub stages nothing, so the recheck fails
assert "espressif/ranged^1.2.0" in mock_run.call_args.args[0]
_write_lock(tmp_path)
ok, mock_run = _run_sync(tmp_path)
assert not ok
assert "espressif/ranged^1.2.0" not in mock_run.call_args.args[0]
def test_sync_ranged_refresh_runs_with_everything_else_covered(
tmp_path: Path,
) -> None:
"""A covered build still refreshes its ranged pins when the lock is
gone, in one invocation."""
manifest = _write_manifest(tmp_path)
mirror = component_mirror.get_mirror_path()
for dep in component_mirror.parse_manifest_service_deps(manifest):
_add_to_mirror(mirror, dep)
ok, mock_run = _run_sync(tmp_path)
assert ok
mock_run.assert_called_once()
assert "espressif/ranged^1.2.0" in mock_run.call_args.args[0]
def test_sync_timeout_retries_ranged_specs_too(tmp_path: Path) -> None:
"""The fallback covers refresh specs, not only the exact pins."""
_write_manifest(
tmp_path,
"dependencies:\n ns/one:\n version: ^1.0\n ns/two:\n version: ^2.0\n",
)
effects = [
subprocess.TimeoutExpired(cmd=[], timeout=120),
subprocess.CompletedProcess([], 0, "", ""),
subprocess.CompletedProcess([], 0, "", ""),
]
ok, mock_run = _run_sync(tmp_path, side_effect=effects)
assert ok
retried = [call.args[0][-2] for call in mock_run.call_args_list[1:]]
assert retried == ["ns/one^1.0", "ns/two^2.0"]
def test_sync_timeout_falls_back_to_one_component_at_a_time(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A timed-out batch retries per component, so the ones that finish
stay promoted and later runs fetch only the rest."""
_write_lock(tmp_path)
def slow_sync(cmd, **kwargs):
specs = [arg for arg in cmd if "==" in arg]
if specs != ["bblanchon/arduinojson==7.4.3"]:
# The batch, and later the mdns retry, exceed the timeout.
raise subprocess.TimeoutExpired(cmd=cmd, timeout=120)
namespace, _, rest = specs[0].partition("/")
name, _, version = rest.partition("==")
_add_to_mirror(
Path(cmd[-1]), component_mirror.ServiceDep(namespace, name, version)
)
return subprocess.CompletedProcess(cmd, 0, "", "")
ok, mock_run = _run_sync(tmp_path, side_effect=slow_sync)
assert not ok
assert mock_run.call_count == 3 # batch, arduinojson, mdns
assert "retrying one component" in caplog.text
assert "kept 1 of 2" in caplog.text
mirror = component_mirror.get_mirror_path()
assert component_mirror.missing_deps(mirror, [_ARDUINOJSON, _MDNS]) == [_MDNS]
assert not (mirror / ".staging").exists()
_assert_sync_lock_released()
def test_sync_timeout_then_per_component_completes(tmp_path: Path) -> None:
"""When every component fits on its own, the fallback finishes the job."""
_write_lock(tmp_path)
def slow_sync(cmd, **kwargs):
specs = [arg for arg in cmd if "==" in arg]
if len(specs) > 1:
raise subprocess.TimeoutExpired(cmd=cmd, timeout=120)
namespace, _, rest = specs[0].partition("/")
name, _, version = rest.partition("==")
_add_to_mirror(
Path(cmd[-1]), component_mirror.ServiceDep(namespace, name, version)
)
return subprocess.CompletedProcess(cmd, 0, "", "")
ok, mock_run = _run_sync(tmp_path, side_effect=slow_sync)
assert ok
assert mock_run.call_count == 3 # batch, then one call per component
mirror = component_mirror.get_mirror_path()
assert component_mirror.missing_deps(mirror, [_ARDUINOJSON, _MDNS]) == []
def test_sync_timeout_then_component_error_stops(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A hard error in the fallback loop stops it like any other failure."""
_write_lock(tmp_path)
effects = [
subprocess.TimeoutExpired(cmd=[], timeout=120),
subprocess.CompletedProcess([], 1, "", "boom"),
]
ok, mock_run = _run_sync(tmp_path, side_effect=effects)
assert not ok
assert mock_run.call_count == 2
assert "Could not mirror" in caplog.text
_assert_sync_lock_released()
def test_sync_unexpected_error_logs_a_traceback(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An operational failure warns in one line; a bug keeps its traceback."""
_write_lock(tmp_path)
with patch.object(component_mirror, "_promote", side_effect=KeyError("url")):
ok, _ = _run_sync(tmp_path)
assert not ok
record = next(r for r in caplog.records if "Could not mirror" in r.message)
assert record.exc_info
def test_sync_single_component_timeout_is_not_retried(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""With one missing component the batch was already the retry; a build
pays the timeout once."""
_write_lock(tmp_path, _ns_cmp_lock("2.0.0"))
ok, mock_run = _run_sync(
tmp_path, side_effect=subprocess.TimeoutExpired(cmd=[], timeout=120)
)
assert not ok
mock_run.assert_called_once()
assert "kept 0 of 1" in caplog.text
_assert_sync_lock_released()
def test_sync_environment_resolution_failure_is_tolerated(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
_write_lock(tmp_path)
def _boom() -> str:
raise EsphomeError("python not found")
ok, mock_run = _run_sync(tmp_path, get_python=_boom)
assert not ok
mock_run.assert_not_called()
assert "Could not mirror" in caplog.text
def test_sync_skips_when_another_process_holds_the_lock(tmp_path: Path) -> None:
_write_lock(tmp_path)
mirror = component_mirror.get_mirror_path()
mirror.mkdir(parents=True)
held = FileLock(str(mirror / ".sync.lock"))
held.acquire(blocking=False)
try:
ok, mock_run = _run_sync(tmp_path)
finally:
held.release()
assert ok
mock_run.assert_not_called()
def test_sync_ignores_a_leftover_lock_file(tmp_path: Path) -> None:
"""A lock file from a dead process does not block: the OS lock is gone."""
_write_lock(tmp_path)
mirror = component_mirror.get_mirror_path()
mirror.mkdir(parents=True)
(mirror / ".sync.lock").touch()
_, mock_run = _run_sync(tmp_path)
mock_run.assert_called() # the dead lock did not block the sync
def test_sync_lock_oserror_is_a_failure(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A broken cache is not contention; the retry guard should apply."""
_write_lock(tmp_path)
with patch("filelock.FileLock.acquire", side_effect=PermissionError("ro")):
ok, mock_run = _run_sync(tmp_path)
assert not ok
mock_run.assert_not_called()
assert "Could not lock" in caplog.text
def test_sync_undeletable_staging_is_a_failure(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An undeletable staging tree aborts the sync; stale files must
never be promoted."""
_write_lock(tmp_path)
with patch.object(component_mirror, "rmtree", side_effect=OSError("stuck staging")):
ok, mock_run = _run_sync(tmp_path)
assert not ok
mock_run.assert_not_called()
assert "Could not mirror" in caplog.text
_assert_sync_lock_released()
def test_sync_promotes_staged_files_and_merges_the_index(tmp_path: Path) -> None:
"""New files land atomically and existing versions survive the merge."""
mirror = component_mirror.get_mirror_path()
_add_to_mirror(mirror, _NS_CMP_1)
_write_lock(tmp_path, _ns_cmp_lock("2.0.0"))
ok, _ = _run_sync(tmp_path, side_effect=_fake_registry_sync())
assert ok
doc = json.loads((mirror / "components" / "ns" / "cmp.json").read_text())
assert {entry["version"] for entry in doc["versions"]} == {"1.0.0", "2.0.0"}
assert (mirror / "components/ns/cmp/2.0.0/ns__cmp-v2.0.0.zip").is_file()
assert (mirror / "components/ns/cmp/1.0.0/ns__cmp-v1.0.0.zip").is_file()
assert not (mirror / ".staging").exists()
def test_sync_failure_leaves_no_staging_behind(tmp_path: Path) -> None:
"""A failed sync promotes nothing and removes its staging directory."""
_write_lock(tmp_path, _ns_cmp_lock("2.0.0"))
ok, _ = _run_sync(tmp_path, side_effect=_fake_registry_sync(returncode=1))
assert not ok
mirror = component_mirror.get_mirror_path()
assert not (mirror / ".staging").exists()
assert not (mirror / "components" / "ns").exists()
def test_sync_failed_index_publish_keeps_the_live_index(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed publish keeps the live index; a later run retries."""
mirror = component_mirror.get_mirror_path()
_add_to_mirror(mirror, _NS_CMP_1)
_write_lock(tmp_path, _ns_cmp_lock("2.0.0"))
with patch.object(
component_mirror, "write_file", side_effect=EsphomeError("disk full")
):
ok, _ = _run_sync(tmp_path, side_effect=_fake_registry_sync())
assert not ok
assert "Could not mirror" in caplog.text
doc = json.loads((mirror / "components" / "ns" / "cmp.json").read_text())
assert {entry["version"] for entry in doc["versions"]} == {"1.0.0"}
_assert_sync_lock_released()
+480 -31
View File
@@ -36,6 +36,7 @@ from esphome.espidf.framework import (
_patch_tools_json_demote_unused_tools,
_patch_tools_json_for_linux_arm64,
_prefetch_idf_tool_archives,
_preinstall_idf_tool_archives,
_read_stamp,
_stamp_covers,
_windows_long_paths_enabled,
@@ -44,6 +45,7 @@ from esphome.espidf.framework import (
check_esp_idf_install,
get_framework_env,
get_idf_tools_path,
idf_tools_version,
)
from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path
@@ -402,6 +404,7 @@ def espidf_mocks(setup_core: Path):
patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"),
patch("esphome.espidf.framework._patch_tools_json_demote_unused_tools"),
patch("esphome.espidf.framework._prefetch_idf_tool_archives"),
patch("esphome.espidf.framework._preinstall_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),
@@ -964,7 +967,7 @@ def test_prefetch_all_entries_unverifiable_is_a_noop(tmp_path: Path) -> None:
patch("esphome.framework_helpers.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert not _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
download.assert_not_called()
@@ -1000,7 +1003,7 @@ def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
):
# Materialize the lazy mock before threads race its first creation
tracker = progress_cls.return_value.tracker.return_value
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
dist = get_idf_tools_path() / "dist"
# Archives download concurrently, so the call order is not fixed.
@@ -1050,11 +1053,16 @@ def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None:
assert download.call_count == 6
def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
def test_prefetch_reverifies_already_downloaded_archives(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A pre-existing archive is not skipped: download_with_resume keeps it
only when the sha256 matches, so the pre-extraction can trust it."""
dist = get_idf_tools_path() / "dist"
dist.mkdir(parents=True)
(dist / "cmake-3.30.2.tar.gz").write_bytes(b"cached")
with (
caplog.at_level(logging.INFO),
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
@@ -1064,9 +1072,12 @@ def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
# only the missing archive is downloaded
assert download.call_count == 1
assert download.call_args[0][1] == dist / "ninja.zip"
assert sorted(call[0][1] for call in download.call_args_list) == [
dist / "cmake-3.30.2.tar.gz",
dist / "ninja.zip",
]
# The log distinguishes verifying cached archives from real downloads
assert "Downloading 2 ESP-IDF tool archive(s) (1 cached, verifying)" in caplog.text
@pytest.mark.parametrize(
@@ -1097,9 +1108,12 @@ def test_prefetch_failures_never_raise(
),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
ran = _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert expected_log in caplog.text
# Nothing verified the archives end to end, so the pre-extraction must
# not trust dist/
assert not ran
def test_prefetch_total_failure_logs_error(
@@ -1123,6 +1137,31 @@ def test_prefetch_total_failure_logs_error(
assert "Every ESP-IDF tool prefetch failed" in caplog.text
def test_prefetch_partial_failure_does_not_claim_dist_is_verified(
tmp_path: Path,
) -> None:
"""A failed entry may leave a cached archive nobody could re-hash or
remove behind; the pre-extraction would extract it unchecked."""
def _fail_ninja(url: str, *args, **kwargs) -> None:
if "ninja" in url:
raise OSError("still there, unverified")
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch(
"esphome.framework_helpers.download_with_resume",
side_effect=_fail_ninja,
),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.framework_helpers._BatchDownloadProgress"),
):
assert not _prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
def test_prefetch_one_failed_archive_does_not_stop_the_rest(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
@@ -1187,22 +1226,33 @@ def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None:
cmd = run.call_args[0][0]
assert cmd[-3:] == ["esp32,esp32c3", "required", "cmake"]
assert cmd[1].endswith("get_tool_downloads.py")
# the script inherits the caller's env plus the framework tools PYTHONPATH
# the script inherits the caller's env plus an explicit PYTHONPATH:
# sibling scripts, the esphome package root, the framework's idf_tools
env = run.call_args[1]["env"]
assert env["IDF_TOOLS_PATH"] == "/x"
assert env["PYTHONPATH"] == str(tmp_path / "tools")
assert env["PYTHONPATH"] == os.pathsep.join(
(
str(_ESPIDF_SCRIPTS_DIR),
str(_ESPIDF_SCRIPTS_DIR.parents[1]),
str(tmp_path / "tools"),
)
)
def test_framework_install_prefetches_before_installer(
espidf_mocks: SimpleNamespace,
) -> None:
"""The prefetch runs before idf_tools.py install so the installer finds
the archives already in dist/."""
"""The prefetch downloads and the pre-extraction both run before
idf_tools.py install so the installer finds the tools in place."""
calls: list[str] = []
with (
patch(
"esphome.espidf.framework._prefetch_idf_tool_archives",
side_effect=lambda *a, **k: calls.append("prefetch"),
side_effect=lambda *a, **k: calls.append("prefetch") or True,
),
patch(
"esphome.espidf.framework._preinstall_idf_tool_archives",
side_effect=lambda *a, **k: calls.append("preinstall"),
),
):
espidf_mocks.run_ok.side_effect = lambda *a, **k: (
@@ -1210,7 +1260,24 @@ def test_framework_install_prefetches_before_installer(
)
check_esp_idf_install(_IDF_VERSION, force=True)
assert calls.index("prefetch") < calls.index("install")
assert calls.index("prefetch") < calls.index("preinstall") < calls.index("install")
def test_framework_install_skips_preextraction_when_prefetch_did_not_run(
espidf_mocks: SimpleNamespace,
) -> None:
"""Without a prefetch nothing verified the archives in dist/, so the
installer alone decides what to trust."""
with (
patch(
"esphome.espidf.framework._prefetch_idf_tool_archives",
return_value=False,
),
patch("esphome.espidf.framework._preinstall_idf_tool_archives") as preinstall,
):
check_esp_idf_install(_IDF_VERSION, force=True)
preinstall.assert_not_called()
# ---------------------------------------------------------------------------
@@ -1219,13 +1286,16 @@ def test_framework_install_prefetches_before_installer(
_IDF_TOOLS_STUB_DIR = Path(__file__).parent / "fixtures" / "idf_tools_stub"
_ESPIDF_SCRIPTS_DIR = Path(__file__).parents[2] / "esphome" / "espidf"
def _run_downloads_script(
tmp_path: Path, *args: str, env_extra: dict[str, str] | None = None
def _run_espidf_script(
tmp_path: Path,
script_name: str,
*args: str,
env_extra: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run the real get_tool_downloads.py against the stub idf_tools module."""
script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py"
"""Run a real espidf helper script against the stub idf_tools module."""
env = os.environ | {
"PYTHONPATH": str(_IDF_TOOLS_STUB_DIR),
"IDF_TOOLS_PATH": str(tmp_path / "tp"),
@@ -1233,7 +1303,12 @@ def _run_downloads_script(
if env_extra:
env |= env_extra
return subprocess.run(
[sys.executable, str(script), str(tmp_path / "fw"), *args],
[
sys.executable,
str(_ESPIDF_SCRIPTS_DIR / script_name),
str(tmp_path / "fw"),
*args,
],
capture_output=True,
text=True,
env=env,
@@ -1245,7 +1320,7 @@ def test_get_tool_downloads_lists_missing_tools(tmp_path: Path) -> None:
"""Installed versions are skipped, tools that fail their binary check are
still listed, rename_dist decides the dist filename, and idf_tools' stdout
chatter stays off the JSON channel."""
result = _run_downloads_script(tmp_path, "esp32", "required")
result = _run_espidf_script(tmp_path, "get_tool_downloads.py", "esp32", "required")
assert result.returncode == 0, result.stderr
downloads = {d["name"]: d for d in json.loads(result.stdout)}
@@ -1261,8 +1336,9 @@ def test_get_tool_downloads_lists_missing_tools(tmp_path: Path) -> None:
def test_get_tool_downloads_applies_mirror_rewrite(tmp_path: Path) -> None:
result = _run_downloads_script(
result = _run_espidf_script(
tmp_path,
"get_tool_downloads.py",
"esp32",
"required",
env_extra={"TEST_MIRROR_PREFIX": "https://mirror.test/"},
@@ -1273,13 +1349,13 @@ def test_get_tool_downloads_applies_mirror_rewrite(tmp_path: Path) -> None:
assert all(d["url"].startswith("https://mirror.test/") for d in downloads)
def _run_downloads_inprocess(
def _run_espidf_script_inprocess(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
script_name: str,
*args: str,
) -> list[dict]:
"""Execute get_tool_downloads.py in-process against the stub idf_tools.
) -> None:
"""Execute an espidf helper script in-process against the stub idf_tools.
Unlike the subprocess variant this runs under coverage, exercising the
script's own lines.
@@ -1290,10 +1366,24 @@ def _run_downloads_inprocess(
stub = importlib.util.module_from_spec(spec)
spec.loader.exec_module(stub)
monkeypatch.setitem(sys.modules, "idf_tools", stub)
# _tool_resolution binds idf_tools objects at import; force a fresh
# import against this test's stub instance
monkeypatch.delitem(sys.modules, "_tool_resolution", raising=False)
# python <script> puts the script's directory on sys.path; runpy does not
monkeypatch.syspath_prepend(str(_ESPIDF_SCRIPTS_DIR))
monkeypatch.setenv("IDF_TOOLS_PATH", str(tmp_path / "tp"))
script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py"
script = _ESPIDF_SCRIPTS_DIR / script_name
monkeypatch.setattr(sys, "argv", [str(script), str(tmp_path / "fw"), *args])
runpy.run_path(str(script))
def _run_downloads_inprocess(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
*args: str,
) -> list[dict]:
_run_espidf_script_inprocess(tmp_path, monkeypatch, "get_tool_downloads.py", *args)
return json.loads(capsys.readouterr().out)
@@ -1468,19 +1558,59 @@ def test_demote_unused_tools_already_patched_is_noop(tmp_path: Path) -> None:
# ---------------------------------------------------------------------------
def test_get_idf_version_parses_stdout(tmp_path: Path) -> None:
def _write_idf_version_header(root: Path, major: int, minor: int) -> None:
include = root / "components" / "esp_common" / "include"
include.mkdir(parents=True)
(include / "esp_idf_version.h").write_text(
f"#define ESP_IDF_VERSION_MAJOR {major}\n"
"/** Minor version number (x.X.x) */\n"
f"#define ESP_IDF_VERSION_MINOR {minor}\n"
"#define ESP_IDF_VERSION_PATCH 0\n",
encoding="utf-8",
)
def test_get_idf_version_reads_version_txt(tmp_path: Path) -> None:
"""version.txt wins and gives major.minor, as idf_tools returns it."""
(tmp_path / "version.txt").write_text("v5.5.5\n", encoding="utf-8")
_write_idf_version_header(tmp_path, 6, 1)
assert _get_idf_version(tmp_path) == "5.5"
def test_get_idf_version_falls_back_to_the_header(tmp_path: Path) -> None:
"""A version.txt that does not match (a git ref) defers to the header."""
(tmp_path / "version.txt").write_text("vrelease/v6.1\n", encoding="utf-8")
_write_idf_version_header(tmp_path, 6, 1)
assert _get_idf_version(tmp_path) == "6.1"
def test_get_idf_version_raises_without_a_source(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="Can't get ESP-IDF version"):
_get_idf_version(tmp_path)
def test_get_idf_version_wraps_an_unreadable_source(tmp_path: Path) -> None:
"""A source that cannot be decoded keeps the RuntimeError contract."""
(tmp_path / "version.txt").write_bytes(b"\xff\xfev")
with pytest.raises(RuntimeError, match="Can't get ESP-IDF version") as info:
_get_idf_version(tmp_path)
assert isinstance(info.value.__cause__, UnicodeError)
def test_idf_tools_version_runs_the_framework_script(tmp_path: Path) -> None:
with patch(
"esphome.espidf.framework.run_command", return_value=(True, "5.1.2\n", "")
):
assert _get_idf_version(tmp_path) == "5.1.2"
"esphome.espidf.framework.run_command", return_value=(True, "5.5\n", "")
) as run:
assert idf_tools_version(tmp_path) == "5.5"
assert run.call_args.args[0][1].endswith("get_idf_version.py")
def test_get_idf_version_raises_on_failure(tmp_path: Path) -> None:
def test_idf_tools_version_raises_on_failure(tmp_path: Path) -> None:
with (
patch("esphome.espidf.framework.run_command", return_value=(False, "", "boom")),
pytest.raises(RuntimeError, match="Can't get ESP-IDF version"),
):
_get_idf_version(tmp_path)
idf_tools_version(tmp_path)
def test_get_idf_tool_paths_parses_json(tmp_path: Path) -> None:
@@ -1511,6 +1641,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", "")
@@ -2003,3 +2157,298 @@ def test_check_windows_path_length_long_path_warns(
assert "long path support" in message
# The install is global now; the remedy is the prefix env, not moving the project.
assert "ESPHOME_ESP_IDF_PREFIX" in message
# ---------------------------------------------------------------------------
# _preinstall_idf_tool_archives
# ---------------------------------------------------------------------------
def test_preinstall_streams_script_with_workers(tmp_path: Path) -> None:
"""The pre-extraction streams install_tool_archives.py with the worker
count and the same targets/tools the installer will get."""
with (
patch(
"esphome.espidf.framework._run_idf_tools_script",
return_value=(True, None, None),
) as run_script,
patch("esphome.framework_helpers.get_usable_cpu_count", return_value=3),
):
_preinstall_idf_tool_archives(
tmp_path, "esp32,esp32c3", ["required", "cmake"], {"IDF_TOOLS_PATH": "x"}
)
run_script.assert_called_once_with(
tmp_path,
"install_tool_archives.py",
"ESP-IDF tool archive extraction",
args=["esp32,esp32c3", "3", "required", "cmake"],
env={"IDF_TOOLS_PATH": "x"},
stream_output=True,
)
def test_preinstall_caps_workers(tmp_path: Path) -> None:
"""A high core count is capped; the workers share one disk."""
with (
patch(
"esphome.espidf.framework._run_idf_tools_script",
return_value=(True, None, None),
) as run_script,
patch("esphome.framework_helpers.get_usable_cpu_count", return_value=64),
):
_preinstall_idf_tool_archives(tmp_path, "esp32", ["required"], None)
from esphome.framework_helpers import BATCH_EXTRACT_WORKERS
assert run_script.call_args.kwargs["args"][1] == str(BATCH_EXTRACT_WORKERS)
def test_preinstall_script_failure_only_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed pre-extraction leaves the install to the sequential path."""
with (
patch(
"esphome.espidf.framework._run_idf_tools_script",
return_value=(False, None, None),
),
patch("esphome.framework_helpers.get_usable_cpu_count", return_value=1),
):
_preinstall_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert "pre-extraction failed" in caplog.text
def test_preinstall_exception_only_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unexpected error must not become a new way for the install to
fail, and keeps its traceback at DEBUG like the prefetch sibling."""
with (
caplog.at_level(logging.DEBUG),
patch(
"esphome.espidf.framework._run_idf_tools_script",
side_effect=TypeError("bad call"),
),
patch("esphome.framework_helpers.get_usable_cpu_count", return_value=1),
):
_preinstall_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert any("pre-extraction failed" in r.message for r in caplog.records)
detail = next(
r for r in caplog.records if r.message == "Pre-extraction failure detail"
)
assert detail.exc_info is not None
# ---------------------------------------------------------------------------
# install_tool_archives.py (against the stub idf_tools module in fixtures/)
# ---------------------------------------------------------------------------
def _make_dist(tmp_path: Path, *names: str) -> None:
dist = tmp_path / "tp" / "dist"
dist.mkdir(parents=True, exist_ok=True)
for name in names:
(dist / name).write_bytes(b"x")
def test_install_tool_archives_extracts_pending_in_parallel(tmp_path: Path) -> None:
"""Subprocess end-to-end: tools with a prefetched archive install
concurrently; installed and broken tools stay with the installer."""
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip", "x.tar.gz", "y.tar.gz")
result = _run_espidf_script(
tmp_path, "install_tool_archives.py", "esp32", "8", "required"
)
assert result.returncode == 0, result.stderr
tools = tmp_path / "tp" / "tools"
assert (tools / "cmake" / "3.30.2" / ".installed").is_file()
assert (tools / "ninja" / "1.12.1" / ".installed").is_file()
assert not (tools / "installed-tool").exists()
assert not (tools / "broken-tool").exists()
# The worker count clamps to the pending count
assert (
"Extracting 2 ESP-IDF tool archive(s) with 2 worker(s): "
"cmake@3.30.2, ninja@1.12.1" in result.stdout
)
assert "leaving broken broken-tool to the installer" in result.stderr
def test_install_tool_archives_skips_colliding_dest(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A second tool sharing a dist filename with a different sha256 was
never verified by the prefetch; it stays with the installer."""
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
monkeypatch.setenv("TEST_COLLIDE", "1")
_run_espidf_script_inprocess(
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "8", "required"
)
tools = tmp_path / "tp" / "tools"
assert (tools / "cmake" / "3.30.2" / ".installed").is_file()
assert not (tools / "collide-tool").exists()
assert (
"leaving collide-tool@5.0 to the installer: cmake.tar.gz "
"holds a different archive" in capsys.readouterr().err
)
def test_install_tool_archives_broken_tool_still_claims_its_dest(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""The prefetch verified the broken tool's archive, so a later tool
sharing its filename under another sha256 must not extract from it."""
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip", "shared.tar.gz")
monkeypatch.setenv("TEST_BROKEN_COLLIDE", "1")
_run_espidf_script_inprocess(
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "8", "required"
)
tools = tmp_path / "tp" / "tools"
assert not (tools / "broken-first-tool").exists()
assert not (tools / "shadowed-tool").exists()
assert (
"leaving shadowed-tool@7.0 to the installer: shared.tar.gz "
"holds a different archive" in capsys.readouterr().err
)
def test_install_tool_archives_single_pending_stays_sequential(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""One pending archive has nothing to parallelize; the installer keeps
its normal output."""
_make_dist(tmp_path, "cmake.tar.gz")
_run_espidf_script_inprocess(
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
)
assert not (tmp_path / "tp" / "tools").exists()
out = capsys.readouterr().out
assert "Extracting" not in out
# A resolution drift that empties pending stays observable
assert "1 prefetched tool archive(s)" in out
def test_install_tool_archives_failed_install_left_to_installer(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A per-tool failure removes the torn dest dir so the installer cannot
trust it, and the other tools still install; the nonzero exit lets the
caller point at the streamed detail."""
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
monkeypatch.setenv("TEST_FAIL_INSTALL", "ninja")
with pytest.raises(SystemExit) as excinfo:
_run_espidf_script_inprocess(
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
)
assert excinfo.value.code == 1
tools = tmp_path / "tp" / "tools"
assert (tools / "cmake" / "3.30.2" / ".installed").is_file()
assert not (tools / "ninja" / "1.12.1").exists()
err = capsys.readouterr().err
assert "pre-extracting ninja@1.12.1 failed" in err
assert "1 of 2 pre-extractions failed" in err
def test_install_tool_archives_all_failed_exits_nonzero(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Every job failing is a systematic fault; the nonzero exit lets the
caller log it."""
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
monkeypatch.setenv("TEST_FAIL_INSTALL", "cmake,ninja")
with pytest.raises(SystemExit) as excinfo:
_run_espidf_script_inprocess(
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
)
assert excinfo.value.code == 1
assert "2 of 2 pre-extractions failed" in capsys.readouterr().err
tools = tmp_path / "tp" / "tools"
assert not (tools / "cmake" / "3.30.2").exists()
assert not (tools / "ninja" / "1.12.1").exists()
def test_install_tool_archives_inprocess_dedupes_and_skips(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""In-process full flow: duplicate tool@version specs collapse to one
job, broken and installed tools are skipped, both pending tools install."""
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip", "x.tar.gz", "y.tar.gz")
_run_espidf_script_inprocess(
tmp_path,
monkeypatch,
"install_tool_archives.py",
"esp32",
"8",
"cmake",
"ninja",
"cmake@3.30.2",
"installed-tool",
"broken-tool",
)
captured = capsys.readouterr()
assert (
"Extracting 2 ESP-IDF tool archive(s) with 2 worker(s): "
"cmake@3.30.2, ninja@1.12.1" in captured.out
)
assert "extracted cmake@3.30.2" in captured.out
assert "extracted ninja@1.12.1" in captured.out
assert "leaving broken broken-tool to the installer" in captured.err
tools = tmp_path / "tp" / "tools"
assert (tools / "cmake" / "3.30.2" / ".installed").is_file()
assert (tools / "ninja" / "1.12.1" / ".installed").is_file()
assert not (tools / "installed-tool").exists()
assert not (tools / "broken-tool").exists()
def test_install_tool_archives_surviving_torn_dir_escalates(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A torn dir that survives cleanup could fool the installer; the exit
is nonzero even though the other tool succeeded."""
import esphome.helpers
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
monkeypatch.setenv("TEST_FAIL_INSTALL", "ninja")
monkeypatch.setattr(
esphome.helpers, "rmtree", MagicMock(side_effect=OSError("busy"))
)
with pytest.raises(SystemExit) as excinfo:
_run_espidf_script_inprocess(
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
)
assert excinfo.value.code == 1
err = capsys.readouterr().err
assert "could not remove" in err
assert "1 of 2 pre-extractions failed" in err
# The surviving dir is named again after the summary, where the caller's
# warning points
assert err.rstrip().endswith("delete it manually if the build fails")
assert "partial tool dir survived" in err
assert (tmp_path / "tp" / "tools" / "cmake" / "3.30.2" / ".installed").is_file()
def test_install_tool_archives_skips_unverifiable_archives(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""An entry the prefetch could not verify is never extracted, even with
an archive on disk."""
_make_dist(tmp_path, "cmake.tar.gz", "ninja-v1.zip")
monkeypatch.setenv("TEST_NO_SHA", "cmake,ninja")
_run_espidf_script_inprocess(
tmp_path, monkeypatch, "install_tool_archives.py", "esp32", "4", "required"
)
assert not (tmp_path / "tp" / "tools").exists()
assert "0 prefetched tool archive(s)" in (capsys.readouterr().out)
+490 -27
View File
@@ -8,11 +8,16 @@ import json
import os
from pathlib import Path
import subprocess
from unittest.mock import call, patch
from unittest.mock import MagicMock, call, patch
import pytest
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT
from esphome.components.esp32.const import (
KEY_ESP32,
KEY_FLASH_SIZE,
KEY_IDF_VERSION,
KEY_VARIANT,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
@@ -210,6 +215,38 @@ def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep)
def test_get_idf_env_serves_the_component_mirror(setup_core: Path) -> None:
"""The mirror env rides the managed-IDF branch, next to its sync gate."""
toolchain._cache().env.clear()
with (
patch.object(
toolchain,
"get_framework_env",
return_value={"PATH": "/penv"},
),
patch.object(toolchain, "_get_esphome_esp_idf_paths", return_value=((), {})),
patch.object(
toolchain,
"component_mirror_env",
return_value={"IDF_COMPONENT_LOCAL_STORAGE_URL": "file:///mirror"},
),
):
env = toolchain._get_idf_env(version="5.5.4")
assert env["IDF_COMPONENT_LOCAL_STORAGE_URL"] == "file:///mirror"
def test_get_idf_env_user_idf_skips_the_mirror(setup_core: Path) -> None:
"""A user-managed IDF gets neither the framework env nor the mirror."""
toolchain._cache().env.clear()
with (
patch.dict(os.environ, {"IDF_PATH": str(setup_core)}),
patch.object(toolchain, "component_mirror_env") as mock_env,
):
env = toolchain._get_idf_env(version="5.5.4")
mock_env.assert_not_called()
assert "IDF_COMPONENT_LOCAL_STORAGE_URL" not in env
def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None:
"""A PYTHONPATH from the parent environment must not reach idf.py.
@@ -357,6 +394,73 @@ def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None:
assert not CORE.relative_build_path("build/CMakeCache.txt").exists()
@pytest.mark.parametrize(
("cmake_rc", "expected"),
[(0, ["sync", "cmake", "sync"]), (1, ["sync", "cmake"])],
)
def test_run_reconfigure_syncs_mirror_around_the_configure(
setup_core: Path, cmake_rc: int, expected: list[str]
) -> None:
"""Every configure path syncs: before, so the solve installs from the
mirror; after a success, so solver-added lock entries are mirrored."""
_setup_build(setup_core)
calls: list[str] = []
with (
_fake_tools() as mock_run,
patch.object(
toolchain,
"_sync_component_mirror",
side_effect=lambda: calls.append("sync"),
),
# The real hint printer resolves an IDF install (or downloads one).
patch.object(toolchain, "_print_hints"),
):
mock_run.side_effect = lambda *a, **k: calls.append("cmake") or cmake_rc
assert toolchain.run_reconfigure() == cmake_rc
assert calls == expected
def test_run_compile_syncs_mirror_when_up_to_date(setup_core: Path) -> None:
"""No reconfigure still refreshes the mirror once for ninja-driven
cmake re-runs."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
with (
_up_to_date_compile(),
patch.object(toolchain, "_sync_component_mirror") as mock_sync,
):
assert toolchain.run_compile(config, verbose=False) == 0
mock_sync.assert_called_once()
def test_sync_component_mirror_skips_user_idf(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A user-managed IDF never gets the mirror env, so a sync is unread."""
monkeypatch.setenv("IDF_PATH", "/opt/esp-idf")
with patch.object(toolchain, "sync_component_mirror") as mock_sync:
toolchain._sync_component_mirror()
mock_sync.assert_not_called()
def test_sync_component_mirror_failure_not_retried(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""One failed attempt (e.g. offline) must not stall every later call."""
monkeypatch.delenv("IDF_PATH", raising=False)
_setup_build(setup_core)
with patch.object(
toolchain, "sync_component_mirror", return_value=False
) as mock_sync:
toolchain._sync_component_mirror()
toolchain._sync_component_mirror()
mock_sync.assert_called_once()
def test_run_compile_reconfigures_after_full_write_outside_testing_mode(
setup_core: Path,
) -> None:
@@ -430,8 +534,8 @@ def _record_compile_calls(
def record_save(components: list[str]) -> None:
calls.append(("save", components))
def record_ninja(target: str, **kwargs: object) -> int:
if target == "all":
def record_ninja(*targets: str, **kwargs: object) -> int:
if "all" in targets:
calls.append(("build",))
return 0
@@ -630,11 +734,159 @@ def test_component_cache_ignores_corrupt_file(setup_core: Path, tmp_path: Path)
assert toolchain.load_cached_builtin_components() is None
@pytest.fixture(autouse=True)
def _clear_ldgen_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Isolate tests from ambient ldgen escape hatch and strict knobs."""
monkeypatch.delenv("ESPHOME_LDGEN_STRICT", raising=False)
monkeypatch.delenv("ESPHOME_LDGEN_FULL_DEPS", raising=False)
def _write_fragments_build_ninja(tmp_path: Path, fragments: list[Path]) -> None:
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True, exist_ok=True)
frag_list = ";".join(str(f) for f in fragments)
(build_dir / "build.ninja").write_text(
f' COMMAND = python ldgen.py --fragments-list "{frag_list}" --input x\n'
)
def test_warn_if_app_archive_mapped_warns(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A fragment naming the app archive, even with trailing text or leading
whitespace, triggers the loud warning."""
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text("[mapping:evil]\n archive: libsrc.a # app\nentries:\n")
_write_fragments_build_ninja(tmp_path, [frag])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" in caplog.text
def test_warn_if_app_archive_mapped_scans_past_unreadable(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unreadable fragment doesn't stop later fragments being checked."""
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text("[mapping:evil]\narchive: libsrc.a\n")
_write_fragments_build_ninja(tmp_path, [tmp_path / "missing.lf", frag])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" in caplog.text
def test_warn_if_app_archive_mapped_strict_no_fragments_list(
setup_core: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Under strict, a build.ninja the check can't parse fails the build."""
monkeypatch.setenv("ESPHOME_LDGEN_STRICT", "1")
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "build.ninja").write_text("rule CXX\n command = gcc\n")
with pytest.raises(EsphomeError, match="no --fragments-list"):
toolchain._warn_if_app_archive_mapped()
def test_warn_if_app_archive_mapped_strict_raises(
setup_core: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Under ESPHOME_LDGEN_STRICT a mapped app archive fails the build."""
monkeypatch.setenv("ESPHOME_LDGEN_STRICT", "1")
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text("[mapping:evil]\narchive: libsrc.a\n")
_write_fragments_build_ninja(tmp_path, [frag])
with pytest.raises(EsphomeError, match="maps the app archive"):
toolchain._warn_if_app_archive_mapped()
def test_warn_if_app_archive_mapped_glob(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A glob archive spec that selects the app archive is also flagged."""
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text("[mapping:evil]\narchive: lib*\n")
_write_fragments_build_ninja(tmp_path, [frag])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" in caplog.text
def test_warn_if_app_archive_mapped_clean(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Normal fragments, including IDF's stock archive: * catch-all,
produce no warning."""
_setup_build(setup_core)
frag = tmp_path / "linker.lf"
frag.write_text(
"[mapping:freertos]\narchive: libfreertos.a\n[mapping:default]\narchive: *\n"
)
_write_fragments_build_ninja(tmp_path, [frag])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" not in caplog.text
def test_warn_if_app_archive_mapped_missing_fragment(
setup_core: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unreadable fragment file is non-fatal."""
_setup_build(setup_core)
_write_fragments_build_ninja(tmp_path, [tmp_path / "missing.lf"])
toolchain._warn_if_app_archive_mapped()
assert "maps the app archive" not in caplog.text
def test_warn_if_app_archive_mapped_no_build_ninja(setup_core: Path) -> None:
"""No build.ninja yet is a quiet no-op."""
_setup_build(setup_core)
toolchain._warn_if_app_archive_mapped()
def test_warn_if_app_archive_mapped_no_fragments_list(setup_core: Path) -> None:
"""A build.ninja without a fragments-list argument is a quiet no-op."""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "build.ninja").write_text("rule CXX\n command = gcc\n")
toolchain._warn_if_app_archive_mapped()
def test_run_compile_runs_fragment_check(setup_core: Path) -> None:
"""The fragment belt runs by default on every compile."""
_setup_build(setup_core)
with (
_up_to_date_compile(),
patch.object(toolchain, "_warn_if_app_archive_mapped") as mock_check,
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
mock_check.assert_called_once()
def test_run_compile_full_deps_skips_fragment_check(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ESPHOME_LDGEN_FULL_DEPS disables the fragment belt with the override."""
monkeypatch.setenv("ESPHOME_LDGEN_FULL_DEPS", "1")
_setup_build(setup_core)
with (
_up_to_date_compile(),
patch.object(toolchain, "_warn_if_app_archive_mapped") as mock_check,
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
mock_check.assert_not_called()
@pytest.mark.parametrize("limit", [1, None])
def test_run_compile_passes_compile_process_limit(
setup_core: Path, limit: int | None
) -> None:
"""compile_process_limit is the job limit for both ninja runs."""
"""compile_process_limit is the job limit of the one ninja run."""
_setup_build(setup_core)
esphome = {} if limit is None else {CONF_COMPILE_PROCESS_LIMIT: limit}
@@ -642,8 +894,14 @@ def test_run_compile_passes_compile_process_limit(
assert toolchain.run_compile({CONF_ESPHOME: esphome}, verbose=False) == 0
assert mock_run.call_args_list == [
call("all", verbose=False, jobs=limit, progress=True),
call("size", verbose=False, jobs=limit, extra_env=toolchain._size_env()),
call(
"all",
"size",
verbose=False,
jobs=limit,
progress=True,
extra_env=toolchain._size_env(),
),
]
@@ -745,6 +1003,7 @@ def test_run_reconfigure_cmake_argv_matches_idf_py(setup_core: Path) -> None:
"-DESP_PLATFORM=1",
f"-DSDKCONFIG={sdkconfig}",
"-DCCACHE_ENABLE=0",
"-DESPHOME_SKIP_BOOTLOADER=0",
project,
]
kwargs = mock_run.call_args.kwargs
@@ -779,6 +1038,7 @@ def test_run_reconfigure_cmake_argv_matches_idf6_py(
"-DPYTHON=/tools/python",
"-DESP_PLATFORM=1",
f"-DCCACHE_ENABLE={expected}",
"-DESPHOME_SKIP_BOOTLOADER=0",
str(build_dir.parent),
]
@@ -790,7 +1050,8 @@ def test_run_reconfigure_without_sdkconfig_or_filter(setup_core: Path) -> None:
assert toolchain.run_reconfigure(verbose=True) == 0
cmd = mock_run.call_args.args[0]
assert not any(arg.startswith("-DSDKCONFIG=") for arg in cmd)
assert cmd[-2] == "-DCCACHE_ENABLE=0"
assert cmd[-3] == "-DCCACHE_ENABLE=0"
assert cmd[-2] == "-DESPHOME_SKIP_BOOTLOADER=0"
assert mock_run.call_args.kwargs["filter_lines"] is None
@@ -857,6 +1118,7 @@ _CONFIGURED = (
"PYTHON_DEPS_CHECKED:UNINITIALIZED=1\n"
"PYTHON:UNINITIALIZED=/tools/python\n"
"ESP_PLATFORM:UNINITIALIZED=1\n"
"ESPHOME_SKIP_BOOTLOADER:UNINITIALIZED=0\n"
)
@@ -929,14 +1191,17 @@ def test_run_ninja_filters_and_reports_failure(
patch.object(toolchain, "_print_hints") as mock_hints,
):
mock_run.return_value = 1
assert toolchain._run_ninja("all", verbose=False, jobs=None, progress=True) == 1
assert (
toolchain._run_ninja("all", "size", verbose=False, jobs=None, progress=True)
== 1
)
log_path = mock_run.call_args.kwargs["log_path"]
assert log_path.name == "ninja_all_output.log"
assert log_path.name == "ninja_all_size_output.log"
mock_hints.assert_called_once_with(log_path)
assert mock_run.call_args.args[0] == ["/tools/ninja", "all"]
assert mock_run.call_args.args[0] == ["/tools/ninja", "all", "size"]
assert mock_run.call_args.kwargs["filter_lines"] is toolchain.FILTER_IDF_LINES
assert mock_run.call_args.kwargs["progress"] is True
assert "ninja all failed with exit code 1" in caplog.text
assert "ninja all size failed with exit code 1" in caplog.text
@pytest.mark.parametrize("reconfigure_rc", [0, 5])
@@ -959,17 +1224,12 @@ def test_run_compile_reconfigures_when_cache_entries_change(
assert mock_ninja.called is (reconfigure_rc == 0)
@pytest.mark.parametrize("failing", ["all", "size"])
def test_run_compile_stops_on_ninja_failure(setup_core: Path, failing: str) -> None:
"""A failed build skips size; either failure skips the summary."""
def test_run_compile_stops_on_ninja_failure(setup_core: Path) -> None:
"""A failed ninja run skips the summary."""
_setup_build(setup_core)
with _up_to_date_compile(lambda target, **kw: 7 if target == failing else 0) as (
mock_ninja,
mock_summary,
):
with _up_to_date_compile(lambda *targets, **kw: 7) as (mock_ninja, mock_summary):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 7
targets = [c.args[0] for c in mock_ninja.call_args_list]
assert targets == (["all"] if failing == "all" else ["all", "size"])
assert [c.args for c in mock_ninja.call_args_list] == [("all", "size")]
mock_summary.assert_not_called()
@@ -980,11 +1240,11 @@ def test_run_compile_testing_mode_builds_memory_ld_first(
"""Testing mode builds and patches memory.ld before the main build."""
_setup_build(setup_core)
CORE.testing_mode = True
targets: list[str] = []
targets: list[tuple[str, ...]] = []
def record(target: str, **kwargs: object) -> int:
targets.append(target)
return memory_ld_rc if target.endswith("memory.ld") else 0
def record(*run_targets: str, **kwargs: object) -> int:
targets.append(run_targets)
return memory_ld_rc if run_targets[0].endswith("memory.ld") else 0
with (
_up_to_date_compile(record),
@@ -993,10 +1253,10 @@ def test_run_compile_testing_mode_builds_memory_ld_first(
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == memory_ld_rc
memory_ld = str(Path("esp-idf", "esp_system", "ld", "memory.ld"))
if memory_ld_rc:
assert targets == [memory_ld]
assert targets == [(memory_ld,)]
mock_patch.assert_not_called()
else:
assert targets == [memory_ld, "all", "size"]
assert targets == [(memory_ld,), ("all", "size")]
mock_patch.assert_called_once()
@@ -1108,3 +1368,206 @@ def test_get_cmake_cache_value_reads_the_configured_cache(setup_core: Path) -> N
with patch.object(toolchain, "_get_cmake_output", return_value=output):
assert toolchain.get_cmake_cache_value("ESPHOME_PCH") == "OFF"
assert toolchain.get_cmake_cache_value("ESPHOME_MISSING") is None
def test_skip_bootloader_requires_flag_and_matching_macro(
setup_core: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The switch is explicit, and an IDF macro change wins over it."""
from esphome.build_gen import espidf as build_gen
assert toolchain._skip_bootloader() is False
CORE.skip_bootloader = True
toolchain._cache().skip_bootloader = None # decision is memoized per run
with (
patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")),
patch.object(build_gen, "idf_macro_matches", return_value=True),
):
assert toolchain._skip_bootloader() is True
toolchain._cache().skip_bootloader = None
with (
patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")),
patch.object(build_gen, "idf_macro_matches", return_value=False) as mock_match,
caplog.at_level("WARNING"),
):
assert toolchain._skip_bootloader() is False
assert toolchain._skip_bootloader() is False
mock_match.assert_called_once() # the memo also dedupes the warning
assert "--skip-bootloader ignored" in caplog.text
def test_configure_defines_follow_skip_bootloader(setup_core: Path) -> None:
with (
patch.object(toolchain, "_get_idf_tool", return_value="/tools/python"),
patch.object(toolchain, "_cache_entries", return_value={}),
):
with patch.object(toolchain, "_skip_bootloader", return_value=True):
assert (
toolchain._configure_defines()[toolchain.SKIP_BOOTLOADER_DEFINE] == "1"
)
with patch.object(toolchain, "_skip_bootloader", return_value=False):
assert (
toolchain._configure_defines()[toolchain.SKIP_BOOTLOADER_DEFINE] == "0"
)
def test_tree_skips_bootloader_reads_the_define(tmp_path: Path) -> None:
build = tmp_path / "build"
build.mkdir()
assert toolchain.tree_skips_bootloader(build) is False # not configured
cache = build / "CMakeCache.txt"
cache.write_text(f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n")
assert toolchain.tree_skips_bootloader(build) is True
cache.write_text(f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n")
assert toolchain.tree_skips_bootloader(build) is False
def test_tree_skips_bootloader_unreadable_cache_reads_full(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Unreadable trees fall back to the safe full build, with a trace."""
build = tmp_path / "build"
(build / "CMakeCache.txt").mkdir(parents=True) # read raises OSError
with caplog.at_level("DEBUG"):
assert toolchain.tree_skips_bootloader(build) is False
assert "assuming a full build" in caplog.text
def test_create_factory_bin_merges_the_listed_flash_files(
setup_core: Path,
) -> None:
"""Every listed file lands in the esptool merge argv, address sorted."""
_setup_build(setup_core)
CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = "4MB"
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
(build / "CMakeCache.txt").write_text(
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n"
)
(build / "app.bin").write_bytes(b"app")
(build / "boot.bin").write_bytes(b"boot")
(build / "flasher_args.json").write_text(
'{"flash_files": {"0x10000": "app.bin", "0x0": "boot.bin"},'
' "extra_esptool_args": {"chip": "esp32"}}'
)
with (
patch.object(toolchain, "_get_idf_env", return_value={}),
patch.object(toolchain, "_get_idf_tool", return_value="/py"),
patch.object(
toolchain.subprocess, "run", return_value=MagicMock(returncode=0)
) as mock_run,
):
assert toolchain.create_factory_bin() is True
argv = mock_run.call_args.args[0]
boot = argv.index("0x0")
assert argv[boot + 1].endswith("boot.bin")
assert argv[boot + 2] == "0x10000"
def test_create_factory_bin_fails_on_a_missing_listed_flash_file(
setup_core: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A partial factory image would not boot; never write one."""
_setup_build(setup_core)
CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = "4MB"
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
(build / "CMakeCache.txt").write_text(
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n"
)
(build / "flasher_args.json").write_text('{"flash_files": {"0x0": "missing.bin"}}')
stale = toolchain.get_factory_firmware_path()
stale.write_bytes(b"old")
with patch.object(toolchain.subprocess, "run") as mock_run:
assert toolchain.create_factory_bin() is False
mock_run.assert_not_called()
assert "Flash file not found" in caplog.text
# The image from an earlier build must not be served as this one.
assert not stale.exists()
def test_create_factory_bin_skip_mode_creates_nothing(
setup_core: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The flip into skip mode already cleaned up; the merge just no-ops."""
_setup_build(setup_core)
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
(build / "CMakeCache.txt").write_text(
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
)
with (
patch.object(toolchain.subprocess, "run") as mock_run,
caplog.at_level("INFO"),
):
assert toolchain.create_factory_bin() is True
mock_run.assert_not_called()
assert "no factory image" in caplog.text
def test_run_reconfigure_flip_into_skip_mode_cleans_up(setup_core: Path) -> None:
"""Full-mode leftovers are stale for OTA --bootloader and downloads, and
a partial cleanup would poison the flip back to full mode."""
_setup_build(setup_core)
build = CORE.relative_build_path("build")
(build / "bootloader").mkdir(parents=True)
(build / "bootloader" / "bootloader.bin").write_bytes(b"old")
# bootloader-prefix deliberately absent: cleanup skips what is not there.
stale = toolchain.get_factory_firmware_path()
stale.parent.mkdir(parents=True, exist_ok=True)
stale.write_bytes(b"old")
with (
patch.object(toolchain, "_skip_bootloader", return_value=True),
patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"),
patch.object(toolchain, "_tool_env", return_value={}),
patch.object(toolchain, "run_build_tool", return_value=0),
patch.object(toolchain, "_idf_py") as mock_idf_py,
):
mock_idf_py.return_value.binary_dir_arg = False
assert toolchain.run_reconfigure() == 0
assert not (build / "bootloader").exists()
assert not stale.exists()
def test_run_reconfigure_skip_steady_state_cleans_nothing(setup_core: Path) -> None:
"""Cleanup belongs to the flip; a reconfigure of a skip tree touches nothing."""
_setup_build(setup_core)
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
(build / "CMakeCache.txt").write_text(
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
)
marker = build / "bootloader"
marker.mkdir()
with (
patch.object(toolchain, "_skip_bootloader", return_value=True),
patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"),
patch.object(toolchain, "_tool_env", return_value={}),
patch.object(toolchain, "run_build_tool", return_value=0),
patch.object(toolchain, "_idf_py") as mock_idf_py,
):
mock_idf_py.return_value.binary_dir_arg = False
assert toolchain.run_reconfigure() == 0
assert marker.exists()
def test_create_factory_bin_full_mode_needs_flasher_args(setup_core: Path) -> None:
"""Past the skip gate, a full tree without flasher_args fails as before."""
_setup_build(setup_core)
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
(build / "CMakeCache.txt").write_text(
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=0\n"
)
assert toolchain.create_factory_bin() is False
def test_missing_image_hint_names_the_flag(setup_core: Path) -> None:
_setup_build(setup_core)
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
assert toolchain.missing_image_hint() is None # stock tree
(build / "CMakeCache.txt").write_text(
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
)
assert "--skip-bootloader" in toolchain.missing_image_hint()
+49 -2
View File
@@ -12,6 +12,7 @@ from pathlib import Path
import socket
import struct
from unittest.mock import Mock, call, patch
import zlib
import pytest
from pytest import CaptureFixture
@@ -354,6 +355,7 @@ def test_perform_ota_successful_md5_auth(
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
| espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE
]
)
)
@@ -601,12 +603,16 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
def _no_auth_handshake(version: int) -> list[bytes]:
def _no_auth_handshake(version: int, server_features: int | None = None) -> list[bytes]:
"""Recv responses for a handshake without auth, up to the MD5 check."""
if server_features is None:
features = [bytes([espota2.RESPONSE_HEADER_OK])]
else:
features = [bytes([espota2.RESPONSE_FEATURE_FLAGS]), bytes([server_features])]
return [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([version]), # Version number
bytes([espota2.RESPONSE_HEADER_OK]), # Features response
*features,
bytes([espota2.RESPONSE_AUTH_OK]), # No auth required
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK
bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK
@@ -1054,6 +1060,7 @@ def test_perform_ota_successful_sha256_auth(
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
| espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE
]
)
)
@@ -1110,6 +1117,7 @@ def test_perform_ota_sha256_fallback_to_md5(
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
| espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE
]
)
)
@@ -1219,6 +1227,7 @@ def test_perform_ota_extended_protocol_app(
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
| espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE
]
)
)
@@ -1279,6 +1288,7 @@ def test_perform_ota_successful_partition_table(
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
| espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE
]
)
)
@@ -1507,3 +1517,40 @@ def test_check_error_passes_non_error_when_expect_is_none() -> None:
espota2.check_error([espota2.RESPONSE_OK], None)
espota2.check_error([espota2.RESPONSE_HEADER_OK], None)
espota2.check_error([espota2.RESPONSE_FEATURE_FLAGS], None)
# Device replies after the MD5 check for a one-chunk upload
_UPLOAD_TAIL = [
bytes([espota2.RESPONSE_CHUNK_OK]),
bytes([espota2.RESPONSE_RECEIVE_OK]),
bytes([espota2.RESPONSE_UPDATE_END_OK]),
]
@pytest.mark.usefixtures("mock_time")
@pytest.mark.parametrize(
"server_features",
[
espota2.SERVER_FEATURE_SUPPORTS_DEFLATE,
# Binding offer: deflate wins over gzip
espota2.SERVER_FEATURE_SUPPORTS_DEFLATE
| espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION,
],
)
def test_perform_ota_with_deflate(mock_socket: Mock, server_features: int) -> None:
"""The device gets a raw deflate stream, both sizes and the image MD5."""
original_content = b"firmware" * 100
mock_socket.recv.side_effect = (
_no_auth_handshake(espota2.OTA_VERSION_2_0, server_features) + _UPLOAD_TAIL
)
espota2.perform_ota(mock_socket, None, io.BytesIO(original_content), "test.bin")
sent = [c[0][0] for c in mock_socket.sendall.call_args_list]
# magic, features, ota type, size, image size, md5, data, end ack
sent_size = struct.unpack(">I", sent[3])[0]
assert sent[4] == len(original_content).to_bytes(espota2.SIZE_FIELD_BYTES, "big")
payload = sent[6]
assert len(payload) == sent_size < len(original_content)
assert zlib.decompress(payload, -espota2.DEFLATE_WINDOW_BITS) == original_content
assert sent[5] == hashlib.md5(original_content).hexdigest().encode()
+8 -8
View File
@@ -27,7 +27,6 @@ from esphome.framework_helpers import (
_7z_extract_all,
_BatchDownloadProgress,
_detect_archive_root,
_rename_with_retry,
_tar_extract_all,
_zip_extract_all,
archive_extract_all,
@@ -40,6 +39,7 @@ from esphome.framework_helpers import (
get_project_link_flags,
get_python_env_executable_path,
get_system_python_path,
rename_with_retry,
rmdir,
run_batch_downloads,
run_command,
@@ -1013,7 +1013,7 @@ class TestDownloadWithResume:
with (
patch("requests.get", return_value=_mock_response(b"data")) as mock_get,
patch(
"esphome.framework_helpers._rename_with_retry",
"esphome.framework_helpers.rename_with_retry",
side_effect=[PermissionError("locked"), None],
) as rename,
):
@@ -1030,7 +1030,7 @@ class TestDownloadWithResume:
with (
patch("requests.get", return_value=_mock_response(b"data")),
patch(
"esphome.framework_helpers._rename_with_retry",
"esphome.framework_helpers.rename_with_retry",
side_effect=PermissionError("locked"),
),
pytest.raises(EsphomeError, match="after 1 attempts"),
@@ -2019,7 +2019,7 @@ class TestZipExtractAllBranches:
# ---------------------------------------------------------------------------
# _rename_with_retry
# rename_with_retry
# ---------------------------------------------------------------------------
@@ -2028,7 +2028,7 @@ class TestRenameWithRetry:
src = tmp_path / "src.txt"
src.write_text("data")
dst = tmp_path / "dst.txt"
_rename_with_retry(src, dst)
rename_with_retry(src, dst)
assert dst.read_text() == "data"
assert not src.exists()
@@ -2050,7 +2050,7 @@ class TestRenameWithRetry:
patch.object(Path, "rename", flaky_rename),
patch("esphome.framework_helpers.time.sleep"),
):
_rename_with_retry(src, dst, attempts=3)
rename_with_retry(src, dst, attempts=3)
assert dst.read_text() == "data"
def test_raises_after_all_attempts_fail(self, tmp_path: Path) -> None:
@@ -2062,14 +2062,14 @@ class TestRenameWithRetry:
patch("esphome.framework_helpers.time.sleep"),
pytest.raises(PermissionError),
):
_rename_with_retry(src, dst, attempts=3)
rename_with_retry(src, dst, attempts=3)
def test_attempts_zero_is_noop(self, tmp_path: Path) -> None:
"""Zero attempts means the for-loop body never runs; src is untouched."""
src = tmp_path / "src.txt"
src.write_text("data")
dst = tmp_path / "dst.txt"
_rename_with_retry(src, dst, attempts=0)
rename_with_retry(src, dst, attempts=0)
assert src.exists()
assert not dst.exists()
+3 -1
View File
@@ -192,7 +192,9 @@ def test_native_upload_fast_path_does_not_import_heavy_modules(
fixture_path,
probe_env,
"native_upload_fast_path.py",
extra=("esphome.components.esp8266",),
# The cache-hit modules guard the espidf toolchain import chain:
# a top-level yaml import there would tax every upload subprocess.
extra=("esphome.components.esp8266", *CACHE_HIT_HEAVY_MODULES),
)
assert not leaked, (
f"native upload_using_esptool pulls in heavy modules: {leaked}. "
+130
View File
@@ -1548,6 +1548,7 @@ class MockArgs:
partition_table: bool = False
bootloader: bool = False
states: bool | None = None
device: list[str] | None = None
def test_upload_program_serial_esp32(
@@ -5357,6 +5358,30 @@ def _setup_build_info_test(
return build_info_path, firmware_path
def test_compile_program_quietly_ignores_skip_bootloader_elsewhere(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A full build is always safe; orchestrators cannot see YAML overrides."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device")
CORE.skip_bootloader = True
config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
with (
patch(
"esphome.components.esp8266.check_rosetta",
side_effect=EsphomeError("stop here"),
),
pytest.raises(EsphomeError, match="stop here"),
caplog.at_level("DEBUG"),
):
compile_program(MockArgs(), config)
assert [
r.levelno for r in caplog.records if "--skip-bootloader ignored" in r.message
] == [logging.DEBUG]
assert CORE.skip_bootloader is False
def test_compile_program_esp8266_runs_rosetta_check(tmp_path: Path) -> None:
"""Test that compile_program runs the Rosetta preflight for ESP8266 targets."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device")
@@ -6437,6 +6462,30 @@ def test_parse_args_logs_states() -> None:
assert args.states is True
@pytest.mark.parametrize("command", ["logs", "run"])
@pytest.mark.parametrize(
("env_value", "expected"),
[(None, False), ("false", False), ("0", False), ("true", True), ("1", True)],
)
def test_parse_args_serial_logging_reset_env(
monkeypatch: pytest.MonkeyPatch,
command: str,
env_value: str | None,
expected: bool,
) -> None:
"""The serial reset environment default must be a boolean for both commands."""
if env_value is None:
monkeypatch.delenv("ESPHOME_SERIAL_LOGGING_RESET", raising=False)
else:
monkeypatch.setenv("ESPHOME_SERIAL_LOGGING_RESET", env_value)
args = parse_args(["esphome", command, "device.yaml"])
assert args.reset is expected
if not expected:
args = parse_args(["esphome", command, "--reset", "device.yaml"])
assert args.reset is True
def test_parse_args_argcomplete_only_runs_when_completing() -> None:
"""Only import and invoke argcomplete when _ARGCOMPLETE is set.
@@ -6822,6 +6871,70 @@ def test_upload_using_esptool_arduino_toolchain(
assert cmd_list[firmware_offset_idx + 1] == str(factory)
def test_upload_using_esptool_skip_bootloader_tree_names_the_flag(
tmp_path: Path,
) -> None:
"""A serial flash needs the factory image the skip flag did not build."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test")
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"}
CORE.toolchain = Toolchain.ESP_IDF
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
from esphome.espidf import toolchain as espidf_toolchain
(build / "CMakeCache.txt").write_text(
f"{espidf_toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
)
config = {CONF_ESPHOME: {"platformio_options": {}}}
with pytest.raises(EsphomeError, match="compiled with --skip-bootloader"):
upload_using_esptool(config, "/dev/ttyUSB0", None, None)
def test_command_run_rejects_serial_device_with_skip_bootloader(
tmp_path: Path,
) -> None:
"""The compile could never be flashed over serial; fail before it runs."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test")
CORE.skip_bootloader = True
CORE.toolchain = Toolchain.ESP_IDF
args = MockArgs(device=["/dev/ttyUSB0"])
with patch("esphome.__main__.write_cpp") as mock_write:
assert command_run(args, {}) == 1
mock_write.assert_not_called()
def test_upload_program_ota_bootloader_skip_tree_names_the_flag(
mock_run_ota: Mock,
mock_get_port_type: Mock,
tmp_path: Path,
) -> None:
"""OTA --bootloader on a skip tree errors before picking the binary."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test")
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"}
CORE.toolchain = Toolchain.ESP_IDF
build = CORE.relative_build_path("build")
build.mkdir(parents=True)
from esphome.espidf import toolchain as espidf_toolchain
(build / "CMakeCache.txt").write_text(
f"{espidf_toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
)
mock_get_port_type.return_value = "NETWORK"
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
"allow_partition_access": True,
}
]
}
args = MockArgs(bootloader=True)
with pytest.raises(EsphomeError, match="compiled with --skip-bootloader"):
upload_program(config, args, ["192.168.1.100"])
mock_run_ota.assert_not_called()
@pytest.mark.parametrize(
("toolchain", "pio_project_written"),
[
@@ -7049,6 +7162,23 @@ def test_compile_program_espidf_idedata_success_is_silent(
assert "idedata" not in caplog.text
def test_compile_program_espidf_failed_factory_bin_fails_the_build() -> None:
"""A compile whose factory image could not be produced must not exit 0."""
CORE.toolchain = Toolchain.ESP_IDF
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
KEY_TARGET_FRAMEWORK: "esp-idf",
}
with (
patch("esphome.espidf.toolchain.run_compile", return_value=0),
patch("esphome.espidf.toolchain.create_factory_bin", return_value=False),
patch("esphome.espidf.toolchain.create_ota_bin") as mock_ota,
patch("esphome.__main__._check_and_emit_build_info"),
):
assert compile_program(MagicMock(), {}) == 1
mock_ota.assert_not_called()
def test_compile_program_espidf_idedata_none_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
+168 -4
View File
@@ -11,7 +11,7 @@ from unittest.mock import ANY, call, patch
import platformdirs
import pytest
from esphome.components.nrf52 import _resolve_toolchain
from esphome.components.nrf52 import _resolve_toolchain, framework
from esphome.components.nrf52.framework import (
_PLATFORMIO_PENV_REQUIREMENTS,
_REQUIREMENTS,
@@ -20,13 +20,14 @@ from esphome.components.nrf52.framework import (
_get_penv_site_packages,
_get_platformio_penv_path,
_get_toolchain_platform_info,
_install_toolchain,
_needs_venv_rebuild,
_wanted_west_projects,
check_and_install,
get_build_env,
get_sdk_nrf_tools_path,
include_west_project,
setup_platformio_python_env,
wanted_west_projects,
)
from esphome.components.zephyr.const import KEY_SYSBUILD, KEY_ZEPHYR
import esphome.config_validation as cv
@@ -538,7 +539,7 @@ class TestCheckAndInstall:
"""Zephyr 4.1 moved the Cortex-M core headers to the cmsis_6 module."""
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(sdk_version)}
assert ("cmsis_6" in _wanted_west_projects()) is has_cmsis_6
assert ("cmsis_6" in wanted_west_projects()) is has_cmsis_6
def test_default_projects_never_read_the_stamp(
self,
@@ -808,6 +809,63 @@ class TestCheckAndInstall:
assert substitutions["machine"] == "x86_64"
assert substitutions["extension"] == "tar.xz"
def test_toolchain_download_uses_gnu_url_for_sdk_3_4_0(
self,
tmp_path: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""For nRF Connect SDK >= 3.4.0 the toolchain archive name includes 'toolchain_gnu_'."""
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse("3.4.0")}
sdk_version = "3.4.0"
tools = get_sdk_nrf_tools_path()
python_env = tools / "penvs" / f"v{sdk_version}"
framework = tools / "frameworks" / f"v{sdk_version}"
toolchain_dir = tools / "toolchains" / "1.0.1"
for d in (python_env, framework, toolchain_dir):
d.mkdir(parents=True, exist_ok=True)
(framework / "zephyr" / "scripts").mkdir(parents=True, exist_ok=True)
(framework / "zephyr" / "scripts" / "requirements.txt").touch()
_mark_venv_ready(python_env)
(framework / ".ready").touch()
check_and_install()
# Two download calls: minimal SDK first, toolchain second
toolchain_call = mock_nrf52_ops.download_from_mirrors.call_args_list[1]
mirrors = toolchain_call.args[0]
assert all("toolchain_gnu_" in m for m in mirrors)
def test_toolchain_extracts_under_gnu_for_sdk_3_4_0(
self,
tmp_path: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""SDK 1.0+ toolchain archive must land in gnu/arm-zephyr-eabi/.
Zephyr-sdkConfig.cmake validates the toolchain at gnu/arm-zephyr-eabi/
in SDK 1.0+; if the archive is extracted to arm-zephyr-eabi/ instead,
cmake reports the package as not found.
"""
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse("3.4.0")}
sdk_version = "3.4.0"
tools = get_sdk_nrf_tools_path()
python_env = tools / "penvs" / f"v{sdk_version}"
framework = tools / "frameworks" / f"v{sdk_version}"
toolchain_dir = tools / "toolchains" / "1.0.1"
for d in (python_env, framework, toolchain_dir):
d.mkdir(parents=True, exist_ok=True)
(framework / "zephyr" / "scripts").mkdir(parents=True, exist_ok=True)
(framework / "zephyr" / "scripts" / "requirements.txt").touch()
_mark_venv_ready(python_env)
(framework / ".ready").touch()
check_and_install()
# Two extract calls: minimal SDK first (to toolchain root), toolchain second
extract_calls = mock_nrf52_ops.archive_extract_all.call_args_list
_, toolchain_extract_dir = extract_calls[1].args[:2]
assert toolchain_extract_dir == toolchain_dir / "gnu" / "arm-zephyr-eabi"
# ---------------------------------------------------------------------------
# setup_platformio_python_env tests
@@ -1020,8 +1078,9 @@ def test_get_build_env(
containerized non-root builds and was removed.
"""
monkeypatch.setenv("SOME_PREEXISTING_VAR", "kept")
monkeypatch.delenv("CCACHE_DISABLE", raising=False)
env = get_build_env()
env = get_build_env(None)
tools = get_sdk_nrf_tools_path()
venv_bin_dir = get_python_env_executable_path(
@@ -1038,6 +1097,84 @@ def test_get_build_env(
assert "Zephyr-sdk_DIR" not in env
# The rest of the process environment is inherited
assert env["SOME_PREEXISTING_VAR"] == "kept"
# No managed settings without a resolved binary; the self-enabled
# Zephyr ccache must not cache
assert "CCACHE_DIR" not in env or "CCACHE_DIR" in os.environ
assert env["CCACHE_DISABLE"] == "1"
def test_get_build_env_with_ccache(
nrf52_dirs: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A resolved ccache brings the shared managed settings."""
for key in (
"CCACHE_DIR",
"CCACHE_DEPEND",
"CCACHE_NOHASHDIR",
"CCACHE_BASEDIR",
"CCACHE_DISABLE",
):
monkeypatch.delenv(key, raising=False)
CORE.build_path = tmp_path / "build"
env = get_build_env("/usr/bin/ccache")
assert env["CCACHE_DIR"] == str(get_sdk_nrf_tools_path() / "ccache")
assert env["CCACHE_DEPEND"] == "1"
assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve())
assert "CCACHE_DISABLE" not in env
# Only the per build map entry leaves the hash; user maps stay in
assert env["CCACHE_IGNOREOPTIONS"] == (
f"-fmacro-prefix-map={(tmp_path / 'build' / 'zephyr').as_posix()}"
"=CMAKE_SOURCE_DIR"
)
def test_get_build_env_skips_the_map_entry_on_whitespace(
nrf52_dirs: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The ignore list splits on spaces; a spaced path cannot be
expressed, so the entry is left hashed rather than emitted broken."""
monkeypatch.delenv("CCACHE_IGNOREOPTIONS", raising=False)
CORE.build_path = tmp_path / "with space" / "build"
env = get_build_env("/usr/bin/ccache")
assert "CCACHE_IGNOREOPTIONS" not in env
def test_get_build_env_sdk_3_4_0_uses_toolchain_root(
setup_core: Path,
) -> None:
"""For NCS >= 3.4.0, ZEPHYR_SDK_INSTALL_DIR still points at the toolchain root."""
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse("3.4.0")}
env = get_build_env(None)
tools = get_sdk_nrf_tools_path()
assert env["ZEPHYR_SDK_INSTALL_DIR"] == str(tools / "toolchains" / "1.0.1")
assert "Zephyr-sdk_DIR" not in env
def test_install_toolchain_keeps_other_live_toolchain_leftovers(
setup_core: Path,
) -> None:
"""Pruning must not delete a partial download of the other toolchain in use."""
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse("3.4.0")}
toolchains = get_sdk_nrf_tools_path() / "toolchains"
toolchains.mkdir(parents=True)
own = toolchains / "1.0.1.toolchain.archive.part"
other = toolchains / f"{TOOLCHAIN_VERSION}.toolchain.archive.part"
retired = toolchains / "0.16.8.toolchain.archive.part"
for leftover in (own, other, retired):
leftover.write_text("")
with patch(
"esphome.components.nrf52.framework.download_and_extract",
side_effect=lambda *args, **kwargs: args[3].mkdir(parents=True),
):
_install_toolchain()
assert not own.exists()
assert other.exists()
assert not retired.exists()
assert (toolchains / "1.0.1" / ".ready").exists()
# ---------------------------------------------------------------------------
@@ -1129,3 +1266,30 @@ def test_resolve_toolchain_rejects_unsupported() -> None:
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
_resolve_toolchain({})
def test_patch_gen_defines_relativizes_the_dts_path(tmp_path: Path) -> None:
"""The absolute dts.pre path is the only per device byte in the
devicetree header; the patch makes gen_defines emit the basename."""
gen = tmp_path / "zephyr" / "scripts" / "dts" / "gen_defines.py"
gen.parent.mkdir(parents=True)
gen.write_text("s = f'DTS input file:\\n {edt.dts_path}\\n'\n")
framework._patch_gen_defines_dts_path(tmp_path)
assert "{os.path.basename(edt.dts_path)}" in gen.read_text()
assert not list(gen.parent.glob("*.tmp")) # no leftovers
before = gen.read_text()
framework._patch_gen_defines_dts_path(tmp_path) # idempotent
assert gen.read_text() == before
framework._patch_gen_defines_dts_path(tmp_path / "absent") # tolerant
def test_patch_gen_defines_warns_when_the_anchor_is_gone(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A reformatted upstream must not silently cost the sharing."""
gen = tmp_path / "zephyr" / "scripts" / "dts" / "gen_defines.py"
gen.parent.mkdir(parents=True)
gen.write_text("s = 'something else entirely'\n")
with caplog.at_level("WARNING"):
framework._patch_gen_defines_dts_path(tmp_path)
assert "gen_defines.py no longer matches" in caplog.text
+16
View File
@@ -24,6 +24,7 @@ def _set_sdk_version(version: cv.Version) -> None:
def test_binutils_come_from_the_pinned_zephyr_sdk() -> None:
_set_sdk_version(cv.Version(2, 9, 0))
bin_path = (
get_sdk_nrf_tools_path()
/ "toolchains"
@@ -37,6 +38,21 @@ def test_binutils_come_from_the_pinned_zephyr_sdk() -> None:
assert toolchain.get_readelf_path() == bin_path / f"arm-zephyr-eabi-readelf{suffix}"
def test_binutils_use_new_toolchain_for_sdk_3_4_0() -> None:
_set_sdk_version(cv.Version(3, 4, 0))
bin_path = (
get_sdk_nrf_tools_path()
/ "toolchains"
/ "1.0.1"
/ "gnu"
/ "arm-zephyr-eabi"
/ "bin"
)
suffix = ".exe" if os.name == "nt" else ""
assert toolchain.get_objdump_path() == bin_path / f"arm-zephyr-eabi-objdump{suffix}"
assert toolchain.get_readelf_path() == bin_path / f"arm-zephyr-eabi-readelf{suffix}"
def test_elf_uses_the_nested_layout_from_sdk_2_9_2(nrf52_build: Path) -> None:
_set_sdk_version(cv.Version(2, 9, 2))
# A flat image left by an older SDK must not be picked over the current
+83 -12
View File
@@ -573,6 +573,65 @@ def test_undefined_variable_warning(
assert "'undefined_var' is undefined" in caplog.text
def test_undefined_variable_warning_avoided_by_include_condition(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Compare two different ways to conditionally enable an !include file
that defines and uses new variables. One way generates a undefined
variable warning when disabled and the other does not."""
include_file = tmp_path / "include.yaml"
include_file.write_text("substitutions:\n var: 1\nresult: ${var}\n")
method_a_file = tmp_path / "method_a.yaml"
method_a_file.write_text(
"packages:\n"
" - !include\n"
" file: include.yaml\n"
" condition: ${enable == 'y'}\n"
)
method_b_file = tmp_path / "method_b.yaml"
method_b_file.write_text(
"substitutions:\n"
" content: !include include.yaml\n"
"packages:\n"
" - ${content if enable == 'y' else {}}\n"
)
def process_file(file, enable):
with caplog.at_level(logging.WARNING):
args = {"enable": enable}
config = yaml_util.load_yaml(file)
config = do_packages_pass(config, command_line_substitutions=args)
return substitutions.do_substitution_pass(
config, command_line_substitutions=args
)
# Case: method A with include enabled produces a result and no warnings
config = process_file(method_a_file, "y")
assert caplog.text == ""
assert config["packages"][0]["result"] == 1
# Case: method A with include disabled produces no result and no warnings
# because the file was not even loaded
config = process_file(method_a_file, "n")
assert caplog.text == ""
assert config["packages"][0] == {}
# Case: method B with include enabled produces a result and no warnings (same as method A)
config = process_file(method_b_file, "y")
assert caplog.text == ""
assert config["packages"][0]["result"] == 1
# Case: method B with include disabled produces no result and an undefined variable warning
# because the file is loaded into a variable and its own variables cannot be expanded
# (different from method A)
config = process_file(method_b_file, "n")
assert (
"Could not resolve substitution variable 'content': 'var' is undefined"
in caplog.text
)
assert config["packages"][0] == {}
def test_password_field_warnings_suppressed(
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -717,11 +776,9 @@ def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None:
# Each call to the loader returns a fresh IncludeFile pointing at itself,
# so PACKAGE_SCHEMA always sees an IncludeFile and never a dict.
def always_returns_include(path: Path) -> yaml_util.IncludeFile:
return yaml_util.IncludeFile(parent, path.name, None, always_returns_include)
return yaml_util.IncludeFile(parent, path.name, always_returns_include)
package_config = yaml_util.IncludeFile(
parent, "test.yaml", None, always_returns_include
)
package_config = yaml_util.IncludeFile(parent, "test.yaml", always_returns_include)
processor = _PackageProcessor({}, None)
with pytest.raises(
cv.Invalid,
@@ -791,6 +848,22 @@ def test_include_filename_jinja_expression_with_path_separator(
assert config["result"] == {"value": 42}
def test_include_condition_substitution_undefined_var(tmp_path: Path) -> None:
"""!include with an undefined substitution variable raises cv.Invalid.
The error message must reference the unresolved condition template so the
user knows which include failed, rather than seeing a bare file-not-found.
"""
main_file = tmp_path / "main.yaml"
main_file.write_text(
"result: !include\n file: file.yaml\n condition: ${undefined_var}\n"
)
config = yaml_util.load_yaml(main_file)
with pytest.raises(cv.Invalid, match=r"\$\{undefined_var\}"):
substitutions.do_substitution_pass(config)
def test_raise_first_undefined_logs_extras_at_debug(
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -838,7 +911,7 @@ def test_do_substitution_pass_included_substitutions_must_be_mapping(
def loader(path: Path):
return ["not", "a", "mapping"]
include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader)
include = yaml_util.IncludeFile(parent, "subs.yaml", loader)
config = OrderedDict({CONF_SUBSTITUTIONS: include})
with pytest.raises(
@@ -861,7 +934,7 @@ def test_do_packages_pass_included_substitutions_must_be_mapping(
def loader(path: Path):
return ["not", "a", "mapping"]
include = yaml_util.IncludeFile(parent, "subs.yaml", None, loader)
include = yaml_util.IncludeFile(parent, "subs.yaml", loader)
config = OrderedDict(
{
CONF_SUBSTITUTIONS: include,
@@ -888,9 +961,7 @@ def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> No
def loader(path: Path):
raise EsphomeError(f"Error reading file {path}: No such file")
package_config = yaml_util.IncludeFile(
parent, "${undefined_var}.yaml", None, loader
)
package_config = yaml_util.IncludeFile(parent, "${undefined_var}.yaml", loader)
processor = _PackageProcessor({}, None)
with pytest.raises(cv.Invalid, match="unresolved substitutions"):
processor.resolve_package(package_config, substitutions.ContextVars(), [])
@@ -906,7 +977,7 @@ def test_resolve_include_error_shows_expanded_from_when_substituted(
def failing_loader(_path: Path) -> None:
raise EsphomeError("File not found")
include = yaml_util.IncludeFile(parent, "${device}.yaml", None, failing_loader)
include = yaml_util.IncludeFile(parent, "${device}.yaml", failing_loader)
context = substitutions.ContextVars({"device": "my_device"})
with pytest.raises(cv.Invalid) as exc_info:
@@ -927,7 +998,7 @@ def test_resolve_include_error_no_expanded_from_for_literal_filename(
def failing_loader(_path: Path) -> None:
raise EsphomeError("File not found")
include = yaml_util.IncludeFile(parent, "literal.yaml", None, failing_loader)
include = yaml_util.IncludeFile(parent, "literal.yaml", failing_loader)
with pytest.raises(cv.Invalid) as exc_info:
substitutions.resolve_include(include, [], substitutions.ContextVars())
@@ -946,7 +1017,7 @@ def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None:
included.write_text('!lambda |-\n return "${foo}";\n')
include = yaml_util.IncludeFile(
tmp_path / "main.yaml", "lambda.yaml", {"foo": "bar"}, yaml_util.load_yaml
tmp_path / "main.yaml", "lambda.yaml", yaml_util.load_yaml, vars={"foo": "bar"}
)
config = OrderedDict({"value": include.load()})
result = substitutions.do_substitution_pass(config)
+187 -33
View File
@@ -43,17 +43,37 @@ def clear_core_frontmatter() -> None:
core.CORE.frontmatter = {}
def test_include_with_vars(fixture_path: Path) -> None:
def test_include(fixture_path: Path) -> None:
"""Test !include with and without vars, with and without conditions"""
yaml_file = fixture_path / "yaml_util" / "includetest.yaml"
actual = yaml_util.load_yaml(yaml_file)
actual = substitutions.do_substitution_pass(actual, None)
assert actual["esphome"]["name"] == "original"
assert actual["esphome"]["name_add_mac_suffix"]
assert actual["esphome"]["libraries"][0] == "Wire"
assert actual["esp8266"]["board"] == "nodemcu"
assert actual["wifi"]["ssid"] == "my_custom_ssid"
def test_include_with_no_file(fixture_path: Path) -> None:
"""Ensure that an error is emitted when the file field is missing."""
yaml_file = fixture_path / "yaml_util" / "includetest_no_file.yaml"
with pytest.raises(EsphomeError, match=r"Must include 'file'"):
yaml_util.load_yaml(yaml_file)
def test_include_with_invalid_condition_type(fixture_path: Path) -> None:
"""Ensure that an error is emitted when the condition field is an invalid type."""
yaml_file = fixture_path / "yaml_util" / "includetest_invalid_condition_type.yaml"
with pytest.raises(
EsphomeError, match=r"Include 'condition' must be a boolean or string"
):
yaml_util.load_yaml(yaml_file)
def test_loading_a_missing_file(fixture_path):
"""We throw EsphomeError when loading a missing file."""
yaml_file = fixture_path / "yaml_util" / "missing.yaml"
@@ -64,7 +84,7 @@ def test_loading_a_missing_file(fixture_path):
def test_parsing_with_custom_loader(fixture_path):
"""Test custom loader used for vscode connection
Default loader is tested in test_include_with_vars
Default loader is tested in test_include
"""
yaml_file = fixture_path / "yaml_util" / "includetest.yaml"
@@ -78,10 +98,11 @@ def test_parsing_with_custom_loader(fixture_path):
# substitute config to expand includes:
substitutions.substitute(config, [], substitutions.ContextVars(), False)
assert len(loader_calls) == 3
assert len(loader_calls) == 4
assert loader_calls[0].parts[-2:] == ("includes", "included.yaml")
assert loader_calls[1].parts[-2:] == ("includes", "list.yaml")
assert loader_calls[2].parts[-2:] == ("includes", "scalar.yaml")
assert loader_calls[1].parts[-2:] == ("includes", "true.yaml")
assert loader_calls[2].parts[-2:] == ("includes", "list.yaml")
assert loader_calls[3].parts[-2:] == ("includes", "scalar.yaml")
def test_construct_secret_simple(fixture_path: Path) -> None:
@@ -570,7 +591,7 @@ def test_represent_remove() -> None:
def test_represent_include_file() -> None:
"""Test that IncludeFile objects are dumped as !include scalars."""
include = yaml_util.IncludeFile(
Path("/fake/main.yaml"), "path/to/file.yaml", None, lambda _: {}
Path("/fake/main.yaml"), "path/to/file.yaml", lambda _: {}
)
assert yaml_util.dump({"key": include}) == "key: !include 'path/to/file.yaml'\n"
@@ -580,13 +601,27 @@ def test_represent_include_file_with_vars() -> None:
include = yaml_util.IncludeFile(
Path("/fake/main.yaml"),
"path/to/file.yaml",
{"key": "value"},
lambda _: {},
vars={"key": "value"},
)
assert (
yaml_util.dump({"key": include})
== "key: !include\n file: path/to/file.yaml\n vars:\n key: value\n"
)
def test_represent_include_file_with_condition() -> None:
"""Test that IncludeFile with condition is dumped as !include mapping form."""
include = yaml_util.IncludeFile(
Path("/fake/main.yaml"),
"path/to/file.yaml",
lambda _: {},
condition="true",
)
assert (
yaml_util.dump({"key": include})
== "key: !include\n file: path/to/file.yaml\n condition: 'true'\n"
)
result = yaml_util.dump({"key": include})
assert "!include" in result
assert "file: path/to/file.yaml" in result
assert "key: value" in result
def test_represent_include_file_with_data_base_mixin() -> None:
@@ -596,7 +631,7 @@ def test_represent_include_file_with_data_base_mixin() -> None:
subclass. add_multi_representer must match this subclass through the MRO.
"""
include = yaml_util.IncludeFile(
Path("/fake/main.yaml"), "common/spi.yaml", None, lambda _: {}
Path("/fake/main.yaml"), "common/spi.yaml", lambda _: {}
)
wrapped = yaml_util.make_data_base(include)
assert isinstance(wrapped, yaml_util.ESPHomeDataBase)
@@ -609,7 +644,7 @@ def test_represent_include_file_with_data_base_mixin() -> None:
def test_include_file_repr(tmp_path: Path) -> None:
"""repr() includes the filename so it appears usefully in error messages."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(parent, "some/nested.yaml", None, lambda _: {})
include = yaml_util.IncludeFile(parent, "some/nested.yaml", lambda _: {})
assert repr(include) == "IncludeFile(some/nested.yaml)"
@@ -624,7 +659,7 @@ def test_include_file_load_caches_result(tmp_path: Path) -> None:
call_count += 1
return content
include = yaml_util.IncludeFile(parent, "child.yaml", None, counting_loader)
include = yaml_util.IncludeFile(parent, "child.yaml", counting_loader)
first = include.load()
second = include.load()
@@ -641,7 +676,7 @@ def test_include_file_load_caches_none_result(tmp_path: Path) -> None:
nonlocal call_count
call_count += 1
include = yaml_util.IncludeFile(parent, "empty.yaml", None, counting_loader)
include = yaml_util.IncludeFile(parent, "empty.yaml", counting_loader)
first = include.load()
second = include.load()
@@ -653,7 +688,7 @@ def test_include_file_load_caches_none_result(tmp_path: Path) -> None:
def test_include_file_load_raises_on_unresolved_expressions(tmp_path: Path) -> None:
"""load() raises if the filename contains unresolved substitutions or expressions."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(parent, "${undefined_var}.yaml", None, lambda _: {})
include = yaml_util.IncludeFile(parent, "${undefined_var}.yaml", lambda _: {})
with pytest.raises(cv.Invalid, match="unresolved"):
include.load()
@@ -670,13 +705,83 @@ def test_include_file_load_raises_on_unresolved_expressions(tmp_path: Path) -> N
("price-100$.yaml", False), # $ at end, not followed by valid substitution
],
)
def test_include_file_has_unresolved_expressions(
def test_include_file_has_unresolved_file(
tmp_path: Path, filename: str, expected: bool
) -> None:
"""has_unresolved_expressions() detects substitution patterns in the filename."""
"""has_unresolved_file() detects substitution patterns in the filename."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(parent, filename, None, lambda _: {})
assert include.has_unresolved_expressions() == expected
include = yaml_util.IncludeFile(parent, filename, lambda _: {})
assert include.has_unresolved_file() == expected
@pytest.mark.parametrize(
("condition", "expected"),
[
(None, False),
(True, False),
(False, False),
("true", False),
("false", False),
("$has_feature", True), # whole substitution
("${has_feature}", True), # whole substitution
("tr$ue", True), # partial substitution
("$.", False), # malformed substitution
("${1 == 1}", True), # Jinja expression
("${", False), # malformed expression
],
)
def test_include_file_has_unresolved_condition(
tmp_path: Path, condition: bool | str | None, expected: bool
) -> None:
"""has_unresolved_condition() detects substitution patterns in the condition."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(
parent, "device.yaml", lambda _: {}, condition=condition
)
assert include.has_unresolved_condition() == expected
@pytest.mark.parametrize(
("condition", "expected_result_or_error"),
[
(None, True),
(True, True),
(False, False),
("true", True),
("True", True),
("TRUE", True),
("yes", True),
("on", True),
("enable", True),
("false", False),
("False", False),
("FALSE", False),
("no", False),
("off", False),
("disable", False),
(
"$sub",
"Cannot evaluate include condition for 'device.yaml' with unresolved substitutions",
),
("", "Cannot convert include condition for 'device.yaml' to a boolean"),
("trues", "Cannot convert include condition for 'device.yaml' to a boolean"),
],
)
def test_include_file_should_load(
tmp_path: Path,
condition: bool | str | None,
expected_result_or_error: bool | str,
) -> None:
"""should_load() evaluates the condition and raises an error if it is malformed."""
parent = tmp_path / "main.yaml"
include = yaml_util.IncludeFile(
parent, "device.yaml", lambda _: {}, condition=condition
)
if isinstance(expected_result_or_error, bool):
assert include.should_load() == expected_result_or_error
else:
with pytest.raises(cv.Invalid, match=expected_result_or_error):
include.should_load()
def test_mapping_include_non_string_file_rejected(tmp_path: Path) -> None:
@@ -691,16 +796,16 @@ def test_include_file_templated_filename_stays_raw_string(tmp_path: Path) -> Non
"""A templated filename keeps its verbatim text (issue #18545)."""
parent = tmp_path / "main.yaml"
expr = '${ "bluetooth/proxy.yaml" if enable_bluetooth_proxy else "../empty.yaml" }'
include = yaml_util.IncludeFile(parent, expr, None, lambda _: {})
include = yaml_util.IncludeFile(parent, expr, lambda _: {})
assert include.file == expr
assert include.has_unresolved_expressions()
assert include.has_unresolved_file()
assert repr(include) == f"IncludeFile({expr})"
def test_represent_include_file_templated() -> None:
"""Dumping a templated IncludeFile emits the raw expression unchanged."""
expr = '${ "a/b.yaml" if flag else "../c.yaml" }'
include = yaml_util.IncludeFile(Path("/fake/main.yaml"), expr, None, lambda _: {})
include = yaml_util.IncludeFile(Path("/fake/main.yaml"), expr, lambda _: {})
assert yaml_util.dump({"key": include}) == f"key: !include '{expr}'\n"
@@ -710,12 +815,12 @@ def test_include_in_list_context() -> None:
parent = Path("/fake/main.yaml")
# The nested IncludeFile resolves to a plain string value
inner = yaml_util.IncludeFile(parent, "inner.yaml", None, lambda _: "gamma")
inner = yaml_util.IncludeFile(parent, "inner.yaml", lambda _: "gamma")
# The outer IncludeFile returns a list whose last element is itself an IncludeFile,
# exercising the substitution pass's ability to recurse into loaded content.
outer = yaml_util.IncludeFile(
parent, "items.yaml", None, lambda _: ["alpha", "beta", inner]
parent, "items.yaml", lambda _: ["alpha", "beta", inner]
)
config = OrderedDict({"values": outer})
@@ -736,15 +841,48 @@ def test_top_level_include_resolved_by_load_yaml(tmp_path: Path) -> None:
assert result["key"] == "value"
@pytest.mark.parametrize(
("condition", "expected_result_or_error"),
[
("true", True),
("false", False),
('"TRUE"', True),
('"FALSE"', False),
('"x"', "Cannot convert include condition for 'child.yaml' to a boolean"),
(
"$sub",
"Cannot evaluate include condition for 'child.yaml' with unresolved substitutions",
),
],
)
def test_top_level_include_with_condition_resolved_by_load_yaml(
tmp_path: Path, condition: bool | str | None, expected_result_or_error: bool | str
) -> None:
"""load_yaml evaluates the condition for a top-level !include."""
child = tmp_path / "child.yaml"
child.write_text("key: value\n")
main = tmp_path / "main.yaml"
main.write_text(f"!include {{ file: child.yaml, condition: {condition} }}\n")
if isinstance(expected_result_or_error, bool):
result = yaml_util.load_yaml(main)
assert isinstance(result, dict)
if expected_result_or_error:
assert result["key"] == "value"
else:
assert result == {}
else:
with pytest.raises(cv.Invalid, match=expected_result_or_error):
result = yaml_util.load_yaml(main)
def test_include_plain_filename_loads_after_deferred_refactor() -> None:
"""!include with a plain filename (no $ expressions) still loads correctly.
Regression guard: the deferred-loading refactor must not break the simple case.
"""
parent = Path("/fake/main.yaml")
include = yaml_util.IncludeFile(
parent, "child.yaml", None, lambda _: {"answer": 42}
)
include = yaml_util.IncludeFile(parent, "child.yaml", lambda _: {"answer": 42})
config = OrderedDict({"result": include})
config = substitutions.do_substitution_pass(config)
@@ -756,7 +894,23 @@ def test_yaml_merge_include_with_filename_substitution_raises() -> None:
"""<<: !include ${expr} raises a clear error — substitutions in merge-key filenames
are not yet supported, and the error message must say so."""
yaml_text = "base:\n existing: value\n <<: !include ${filename}.yaml\n"
with pytest.raises(EsphomeError, match="not supported yet"):
with pytest.raises(
EsphomeError,
match="Substitution in include filename with merge keys is not supported yet",
):
yaml_util.parse_yaml(
Path("/fake/main.yaml"), io.StringIO(yaml_text), lambda _: {}
)
def test_yaml_merge_include_with_condition_substitution_raises() -> None:
"""<<: !include { file: ${expr}, condition: {} } raises a clear error — substitutions in merge-key conditions
are not yet supported, and the error message must say so."""
yaml_text = "base:\n existing: value\n <<: !include\n file: filename.yaml\n condition: ${expr}\n"
with pytest.raises(
EsphomeError,
match="Substitution in include condition with merge keys is not supported yet",
):
yaml_util.parse_yaml(
Path("/fake/main.yaml"), io.StringIO(yaml_text), lambda _: {}
)
@@ -775,8 +929,8 @@ def test_yaml_merge_chain_include_resolves() -> None:
"""Chained includes in merge keys resolve through multiple IncludeFile layers."""
parent = Path("/fake/main.yaml")
inner = yaml_util.IncludeFile(parent, "inner.yaml", None, lambda _: {"x": 1})
outer = yaml_util.IncludeFile(parent, "outer.yaml", None, lambda _: inner)
inner = yaml_util.IncludeFile(parent, "inner.yaml", lambda _: {"x": 1})
outer = yaml_util.IncludeFile(parent, "outer.yaml", lambda _: inner)
yaml_text = "base:\n existing: value\n <<: !include outer.yaml\n"
config = yaml_util.parse_yaml(parent, io.StringIO(yaml_text), lambda _: outer)
@@ -791,7 +945,7 @@ def test_yaml_merge_chain_include_depth_exceeded() -> None:
parent = Path("/fake/main.yaml")
def self_referencing_loader(path: Path) -> yaml_util.IncludeFile:
return yaml_util.IncludeFile(parent, path.name, None, self_referencing_loader)
return yaml_util.IncludeFile(parent, path.name, self_referencing_loader)
yaml_text = "base:\n <<: !include loop.yaml\n"
with pytest.raises(EsphomeError, match="Maximum include chain depth"):
@@ -1061,7 +1215,7 @@ class _StubInclude:
self._raise = raise_on_load
self.load_calls = 0
def has_unresolved_expressions(self) -> bool:
def has_unresolved_file(self) -> bool:
return self._unresolved
def load(self) -> object: