mirror of
https://github.com/esphome/esphome.git
synced 2026-10-13 09:42:50 +00:00
Merge branch 'dev' into web-server-offline-hint
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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}}
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}. "
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user