Merge remote-tracking branch 'origin/dev' into feat/nrf52_pwm

This commit is contained in:
Tomasz Duda
2026-08-02 16:00:00 +02:00
1098 changed files with 45588 additions and 14294 deletions
@@ -0,0 +1,157 @@
"""Tests for locating build artifacts across the supported toolchain layouts."""
from pathlib import Path
import pytest
from esphome.analyze_memory.toolchain import (
find_elf_path,
find_idedata_path,
idedata_candidates,
)
from esphome.espidf.idedata import _cc_path_from_cxx
from esphome.platformio.toolchain import IDEData
def _make_build_dir(tmp_path: Path, name: str = "mydevice") -> Path:
"""Create <tmp_path>/.esphome/build/<name>, mirroring a real data dir."""
build_path = tmp_path / ".esphome" / "build" / name
build_path.mkdir(parents=True)
return build_path
def _touch(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("", encoding="utf-8")
return path
def test_find_elf_path_native_esp_idf(tmp_path: Path) -> None:
"""The native ESP-IDF toolchain writes the ELF under build/."""
build_path = _make_build_dir(tmp_path)
elf = _touch(build_path / "build" / "firmware.elf")
assert find_elf_path(build_path) == elf
def test_find_elf_path_platformio(tmp_path: Path) -> None:
"""The PlatformIO toolchain writes the ELF under .pioenvs/<name>/."""
build_path = _make_build_dir(tmp_path)
elf = _touch(build_path / ".pioenvs" / build_path.name / "firmware.elf")
assert find_elf_path(build_path) == elf
def test_find_elf_path_libretiny(tmp_path: Path) -> None:
"""The LibreTiny toolchain names the unwrapped ELF raw_firmware.elf."""
build_path = _make_build_dir(tmp_path)
elf = _touch(build_path / ".pioenvs" / build_path.name / "raw_firmware.elf")
assert find_elf_path(build_path) == elf
@pytest.mark.parametrize(
"relative_elf",
[
# SDK < 2.9.2
"zephyr/zephyr.elf",
# SDK >= 2.9.2 nests the artifacts one level deeper
"zephyr/zephyr/zephyr.elf",
],
)
def test_find_elf_path_zephyr(tmp_path: Path, relative_elf: str) -> None:
"""Zephyr (nRF52) keeps the ELF under .pioenvs/<name>/zephyr/."""
build_path = _make_build_dir(tmp_path)
elf = _touch(build_path / ".pioenvs" / build_path.name / relative_elf)
assert find_elf_path(build_path) == elf
def test_find_elf_path_missing(tmp_path: Path) -> None:
"""An unknown layout resolves to None rather than a bogus path."""
assert find_elf_path(_make_build_dir(tmp_path)) is None
def test_find_idedata_path_in_data_dir(tmp_path: Path) -> None:
"""The idedata cache sits in the data dir that holds the build dir."""
build_path = _make_build_dir(tmp_path)
idedata = _touch(tmp_path / ".esphome" / "idedata" / f"{build_path.name}.json")
assert find_idedata_path(build_path) == idedata
def test_find_idedata_path_in_pioenvs(tmp_path: Path) -> None:
"""Test builds may keep idedata alongside the PlatformIO env."""
build_path = _make_build_dir(tmp_path)
idedata = _touch(build_path / ".pioenvs" / build_path.name / "idedata.json")
assert find_idedata_path(build_path) == idedata
def test_find_idedata_path_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A missing idedata resolves to None."""
# Keep the cwd/home fallbacks from finding an unrelated file on this machine
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
assert find_idedata_path(_make_build_dir(tmp_path)) is None
def test_idedata_candidates_are_what_find_probes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Every advertised candidate is one find_idedata_path actually accepts.
The candidates are reported to the user when idedata is missing, so a list
that drifts from the lookup would send someone hunting in the wrong place.
"""
# Two candidates are relative to the cwd and to home; keep the test from
# writing into the real ones.
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
build_path = _make_build_dir(tmp_path)
candidates = idedata_candidates(build_path)
assert candidates, "no candidates advertised"
for candidate in candidates:
_touch(candidate)
assert find_idedata_path(build_path) == candidate
candidate.unlink()
@pytest.mark.parametrize(
("cxx_path", "expected"),
[
("/tools/bin/xtensa-esp32-elf-g++", "/tools/bin/xtensa-esp32-elf-gcc"),
("/tools/bin/riscv32-esp-elf-g++", "/tools/bin/riscv32-esp-elf-gcc"),
(
r"C:\tools\bin\xtensa-esp32-elf-g++.exe",
r"C:\tools\bin\xtensa-esp32-elf-gcc.exe",
),
# Nothing to rewrite; leave the path alone
("/tools/bin/clang++", "/tools/bin/clang++"),
],
)
def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None:
"""cc_path is derived from the C++ compiler that compile_commands.json names."""
assert _cc_path_from_cxx(cxx_path) == expected
def test_native_idedata_resolves_toolchain_tools() -> None:
"""The binutils paths are derived from the native ESP-IDF cc_path.
Without cc_path, IDEData.objdump_path raises KeyError and the memory
analysis silently degrades to no component or symbol detail.
"""
idedata = IDEData(
{
"cc_path": _cc_path_from_cxx("/tools/bin/xtensa-esp32-elf-g++"),
"cxx_path": "/tools/bin/xtensa-esp32-elf-g++",
}
)
assert idedata.objdump_path == "/tools/bin/xtensa-esp32-elf-objdump"
assert idedata.readelf_path == "/tools/bin/xtensa-esp32-elf-readelf"
@@ -0,0 +1,57 @@
"""Tests for script/ci_memory_impact_extract.py."""
import io
from pathlib import Path
import sys
import pytest
# Add script directory to path so we can import the module
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "script"))
from ci_memory_impact_extract import main # noqa: E402
_COMPILE_OUTPUT = (
"RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)\n"
"Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)\n"
)
@pytest.fixture(autouse=True)
def _no_github_output(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
def _run(monkeypatch: pytest.MonkeyPatch, compile_output: str, argv: list[str]) -> int:
monkeypatch.setattr(sys, "stdin", io.StringIO(compile_output))
monkeypatch.setattr(sys, "argv", ["ci_memory_impact_extract.py", *argv])
return main()
def test_missing_detailed_analysis_fails(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A build with no usable ELF fails instead of posting a comment without detail."""
build_dir = tmp_path / ".esphome" / "build" / "mydevice"
build_dir.mkdir(parents=True)
out_json = tmp_path / "analysis.json"
rc = _run(
monkeypatch,
_COMPILE_OUTPUT,
["--build-dir", str(build_dir), "--output-json", str(out_json)],
)
assert rc == 1
# The totals are still written so the failure can be diagnosed from the artifact
assert out_json.is_file()
def test_undetected_build_dir_fails(monkeypatch: pytest.MonkeyPatch) -> None:
"""Compile output without a build path cannot be analyzed, so it fails."""
assert _run(monkeypatch, _COMPILE_OUTPUT, []) == 1
def test_unparseable_output_fails(monkeypatch: pytest.MonkeyPatch) -> None:
"""Output with no memory totals at all is still a failure."""
assert _run(monkeypatch, "nothing useful here\n", []) == 1
@@ -0,0 +1,123 @@
"""Tests for RAM symbol analysis in the RAM strings analyzer."""
from pathlib import Path
from unittest.mock import patch
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer, SectionInfo
# nm -S --size-sort output with the newlib lock mutexes: nine global
# symbols that are all aliases of two local StaticSemaphore_t objects.
NM_OUTPUT_WITH_ALIASES = """\
3ffb4400 00000010 B small_symbol
3ffb43c8 00000054 B __lock___atexit_recursive_mutex
3ffb43c8 00000054 B __lock___env_recursive_mutex
3ffb43c8 00000054 B __lock___malloc_recursive_mutex
3ffb43c8 00000054 B __lock___sfp_recursive_mutex
3ffb43c8 00000054 B __lock___sinit_recursive_mutex
3ffb43c8 00000054 b s_common_recursive_mutex
3ffb441c 00000054 B __lock___arc4random_mutex
3ffb441c 00000054 B __lock___at_quick_exit_mutex
3ffb441c 00000054 B __lock___dd_hash_mutex
3ffb441c 00000054 B __lock___tz_mutex
3ffb441c 00000054 b s_common_mutex
"""
def _make_analyzer(tmp_path) -> RamStringsAnalyzer:
"""Create an analyzer with a dummy ELF and a .dram0.bss section."""
elf = tmp_path / "firmware.elf"
elf.write_bytes(b"\x7fELF")
analyzer = RamStringsAnalyzer(str(elf), platform="esp32")
analyzer.sections[".dram0.bss"] = SectionInfo(".dram0.bss", 0x3FFB0000, 0x10000)
return analyzer
def _run_symbol_analysis(analyzer: RamStringsAnalyzer, nm_output: str) -> None:
"""Run _analyze_symbols with mocked nm output."""
with (
patch(
"esphome.analyze_memory.ram_strings.find_tool",
return_value="nm",
),
patch.object(analyzer, "_run_command", return_value=nm_output),
):
analyzer._analyze_symbols()
def test_aliased_symbols_counted_once(tmp_path: Path) -> None:
"""Symbols sharing an address are one object, not one per name."""
analyzer = _make_analyzer(tmp_path)
_run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES)
# Three distinct addresses, so three symbols
assert len(analyzer.ram_symbols) == 3
total = sum(s.size for s in analyzer.ram_symbols)
assert total == 0x10 + 0x54 + 0x54
def test_aliases_recorded_on_first_symbol(tmp_path: Path) -> None:
"""Extra names at the same address are kept as aliases."""
analyzer = _make_analyzer(tmp_path)
_run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES)
by_addr = {s.address: s for s in analyzer.ram_symbols}
assert len(by_addr[0x3FFB43C8].aliases) == 5
assert len(by_addr[0x3FFB441C].aliases) == 4
assert by_addr[0x3FFB4400].aliases == []
assert "s_common_mutex" in by_addr[0x3FFB441C].aliases
def test_alias_count_shown_in_report(tmp_path: Path) -> None:
"""The large symbols table notes how many aliases were merged."""
analyzer = _make_analyzer(tmp_path)
_run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES)
report = analyzer.generate_report()
assert "(+5 aliases)" in report
assert "(+4 aliases)" in report
# Each lock name appears at most once in the report
assert report.count("__lock___") == 2
def test_global_name_preferred_over_local_alias(tmp_path: Path) -> None:
"""A global name becomes the primary even when nm lists a local first."""
analyzer = _make_analyzer(tmp_path)
nm_output = """\
3ffb43c8 00000054 b s_common_recursive_mutex
3ffb43c8 00000054 B __lock___atexit_recursive_mutex
3ffb43c8 00000054 B __lock___malloc_recursive_mutex
"""
_run_symbol_analysis(analyzer, nm_output)
(symbol,) = analyzer.ram_symbols
assert symbol.name == "__lock___atexit_recursive_mutex"
assert symbol.sym_type == "B"
assert sorted(symbol.aliases) == [
"__lock___malloc_recursive_mutex",
"s_common_recursive_mutex",
]
def test_alias_note_survives_name_truncation(tmp_path: Path) -> None:
"""Long names are truncated but the alias note is kept intact."""
analyzer = _make_analyzer(tmp_path)
long_name = "a_very_long_symbol_name_that_exceeds_the_column_width_by_far"
nm_output = f"""\
3ffb43c8 00000054 B {long_name}
3ffb43c8 00000054 B other_name
"""
_run_symbol_analysis(analyzer, nm_output)
report = analyzer.generate_report()
row = next(line for line in report.splitlines() if "(+1 aliases)" in line)
name_column = row[:50].rstrip()
assert name_column.endswith("(+1 aliases)")
assert name_column.startswith("a_very_long_symbol_name")
def test_symbols_outside_ram_sections_skipped(tmp_path: Path) -> None:
"""Symbols outside known RAM sections are ignored entirely."""
analyzer = _make_analyzer(tmp_path)
nm_output = "40080000 00000100 B not_in_ram\n"
_run_symbol_analysis(analyzer, nm_output)
assert analyzer.ram_symbols == []
+35
View File
@@ -184,6 +184,18 @@ def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> Non
assert "-Wl,--gc-sections" in content
def test_get_component_cmakelists_globs_alternate_cpp_extensions() -> None:
"""Both app_sources glob variants include .cc/.cxx/.c++ so vendored sources
are compiled, matching the extensions PlatformIO's builder globs by default."""
CORE.build_flags = set()
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
for ext in ("cc", "cxx", "c++"):
assert content.count(f'"${{CMAKE_CURRENT_SOURCE_DIR}}/*.{ext}"') == 2
assert content.count(f'"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.{ext}"') == 2
def test_get_project_cmakelists_emits_managed_components_property(
tmp_path: Path,
) -> None:
@@ -243,6 +255,7 @@ def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None:
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
patch.object(CORE, "name", "test"),
patch.object(CORE, "cpp_standard", None),
patch.object(CORE, "cxx_build_flags", set()),
):
from esphome.build_gen.espidf import get_project_cmakelists
@@ -251,6 +264,28 @@ def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None:
assert "CXX_COMPILE_OPTIONS" not in content
def test_get_project_cmakelists_cxx_build_flags(tmp_path: Path) -> None:
"""Flags registered via cg.add_cxx_build_flag() are appended to
CXX_COMPILE_OPTIONS (C++-only, GCC warns if they reach C compiles)
between include(project.cmake) and project()."""
with (
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
patch.object(CORE, "name", "test"),
patch.object(CORE, "cpp_standard", None),
patch.object(CORE, "cxx_build_flags", {"-Wno-volatile"}),
):
from esphome.build_gen.espidf import get_project_cmakelists
content = get_project_cmakelists(minimal=True)
flag_line = 'idf_build_set_property(CXX_COMPILE_OPTIONS "-Wno-volatile" APPEND)'
assert flag_line in content
include_pos = content.index("tools/cmake/project.cmake")
flag_pos = content.index(flag_line)
project_pos = content.index("project(test)")
assert include_pos < flag_pos < project_pos
def test_get_component_cmakelists_no_compile_features() -> None:
"""The C++ standard is pinned project-wide via CXX_COMPILE_OPTIONS in the
top-level CMakeLists; the src component must not set its own."""
@@ -200,3 +200,32 @@ def test_get_ini_content_no_cpp_standard(
content = platformio.get_ini_content()
assert "-std=" not in content
def test_write_cxx_flags_script_emits_registered_flags(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Flags registered via cg.add_cxx_build_flag() are emitted as CXXFLAGS,
sorted, so they apply to C++ compiles only."""
CORE.build_path = str(tmp_path)
monkeypatch.setattr(CORE, "cxx_build_flags", {"-Wno-volatile", "-Wno-deprecated"})
platformio.write_cxx_flags_script()
content = (tmp_path / platformio.CXX_FLAGS_FILE_NAME).read_text()
assert (
'env.Append(CXXFLAGS=["-Wno-deprecated"])\n'
'env.Append(CXXFLAGS=["-Wno-volatile"])\n'
) in content
def test_write_cxx_flags_script_no_flags(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
CORE.build_path = str(tmp_path)
monkeypatch.setattr(CORE, "cxx_build_flags", set())
platformio.write_cxx_flags_script()
content = (tmp_path / platformio.CXX_FLAGS_FILE_NAME).read_text()
assert "CXXFLAGS" not in content
+62 -8
View File
@@ -2,21 +2,22 @@
from __future__ import annotations
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
from esphome.components import esp32
from esphome.components.api import client as api_client
from esphome.core import EsphomeError
from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM
from esphome.core import CORE, EsphomeError
def test_decoder_swallows_esphome_error() -> None:
"""A failing stack-trace decode must not propagate.
on_log runs inside an asyncio protocol callback; if EsphomeError
escapes, the loop reports "Fatal error: protocol.data_received()
call failed.", tears the connection down, and ReconnectLogic loops
forever as the device replays the same crash trace on every
reconnect.
aioesphomeapi isolates exceptions raised by log handlers, so an
escaping one logs a full traceback for every line it fires on rather
than being reported once as an unavailable decoder.
"""
config = {"esphome": {"name": "test"}}
@@ -43,6 +44,32 @@ def test_decoder_swallows_platform_handler_error() -> None:
assert processor.backtrace_state is False
def test_decoder_swallows_non_esphome_error() -> None:
"""Decoding failures that aren't EsphomeError must be contained too.
A missing build directory surfaces as FileNotFoundError from the toolchain
subprocess. aioesphomeapi isolates it, so the session survives, but it logs
a traceback for every PC/BT line and decoding is never disabled, which
buries the crash dump the user is trying to read.
"""
config = {"esphome": {"name": "test"}}
with patch.object(
esp32,
"process_stacktrace",
side_effect=FileNotFoundError(
2, "No such file or directory", "/build/ol/build"
),
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
processor.process_line("BT0: 0x4010496e")
# Disabled after the first failure rather than retried per backtrace line.
assert mock_process.call_count == 1
assert processor.backtrace_state is False
def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
"""_run_idedata raises EsphomeError with no message; the warning
must show a useful explanation rather than empty parens.
@@ -61,7 +88,7 @@ def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
def test_decoder_short_circuits_after_failure() -> None:
"""After one failure, subsequent lines must not retry the decoder.
_decode_pc shells out to PlatformIO; a crash dump can contain many
_decode_pc shells out to the toolchain; a crash dump can contain many
PC/BT lines and retrying the failing subprocess for each one would
stall log streaming.
"""
@@ -112,3 +139,30 @@ def test_decoder_uses_platform_handler_when_provided() -> None:
assert calls == [(config, "BT0: 0x4010496e", False)]
assert mock_generic.called is False
assert processor.backtrace_state is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
("extra_config", "expected_deep_sleep"),
[({"deep_sleep": {}}, True), ({}, False)],
)
async def test_async_run_logs_passes_deep_sleep(
extra_config: dict, expected_deep_sleep: bool
) -> None:
"""async_run_logs tells async_run whether the device deep sleeps, from the config."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
config = {"esphome": {"name": "test"}, "api": {CONF_PORT: 6053}, **extra_config}
# async_run blocks forever after connecting; raise to unwind async_run_logs
# once we have captured how it was called.
sentinel = RuntimeError("stop the wait")
with (
patch.object(
api_client, "async_run", AsyncMock(side_effect=sentinel)
) as mock_run,
patch.object(api_client, "APIClient"),
pytest.raises(RuntimeError, match="stop the wait"),
):
await api_client.async_run_logs(config, ["1.2.3.4"])
assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep
@@ -0,0 +1,169 @@
"""Tests for the micro_wake_word model source validation and downloads."""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.components import micro_wake_word as mww
import esphome.config_validation as cv
from esphome.const import (
CONF_FILE,
CONF_MODEL,
CONF_PATH,
CONF_REF,
CONF_TYPE,
CONF_URL,
)
@pytest.fixture
def mock_download_content_many() -> MagicMock:
"""Patch the concurrent download helper so no network is involved."""
with patch(
"esphome.components.micro_wake_word.external_files.download_content_many"
) as m:
yield m
def test_shorthand_model_name_resolves_without_network(
mock_download_content_many: MagicMock,
) -> None:
config = mww._validate_source_shorthand("okay_nabu")
assert config[CONF_TYPE] == mww.TYPE_HTTP
assert config[CONF_URL] == (
"https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/okay_nabu.json"
)
mock_download_content_many.assert_not_called()
def test_shorthand_git_with_ref_not_captured_as_model_name(
setup_core: Path, tmp_path: Path
) -> None:
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "model.json").write_text("{}")
with patch(
"esphome.components.micro_wake_word.git.clone_or_update",
return_value=(repo_dir, None),
):
config = mww._validate_source_shorthand("github://user/repo/model.json@main")
assert config[CONF_TYPE] == "git"
assert config[CONF_URL] == "https://github.com/user/repo.git"
assert config[CONF_FILE] == "model.json"
assert config[CONF_REF] == "main"
def test_shorthand_local_path_not_captured_as_model_name(
setup_core: Path, tmp_path: Path
) -> None:
manifest = tmp_path / "model.json"
manifest.write_text("{}")
config = mww.MODEL_SOURCE_SCHEMA(str(manifest))
assert config[CONF_TYPE] == "local"
assert Path(config[CONF_PATH]) == manifest
@pytest.mark.parametrize(
"value", ["some/path/file", "name@ref", "bad:name", "okay_nabu\n", "héllo"]
)
def test_model_name_rejects_non_identifiers(value: str) -> None:
with pytest.raises(cv.Invalid):
mww._validate_source_model_name(value)
def _http_model(name: str) -> dict:
return {
CONF_MODEL: {
CONF_TYPE: mww.TYPE_HTTP,
CONF_URL: f"https://example.com/models/{name}.json",
}
}
def _write_manifest(model_config: dict, contents: str) -> Path:
path = mww._compute_local_file_path(model_config[CONF_MODEL])
path.mkdir(parents=True, exist_ok=True)
manifest = path / "manifest.json"
manifest.write_text(contents)
return path
def test_download_http_models_batches_manifests_then_models(
setup_core: Path, mock_download_content_many: MagicMock
) -> None:
names = ("okay_nabu", "hey_mycroft", "vad")
models = {name: _http_model(name) for name in names}
paths = {
name: _write_manifest(models[name], json.dumps({"model": f"{name}.tflite"}))
for name in names
}
config = {
mww.CONF_MODELS: [
models["okay_nabu"],
models["hey_mycroft"],
# non-http sources must be ignored
{CONF_MODEL: {CONF_TYPE: "local", CONF_PATH: "x"}},
],
mww.CONF_VAD: models["vad"],
}
assert mww._download_http_models(config) is config
assert mock_download_content_many.call_count == 2
manifest_items = list(mock_download_content_many.call_args_list[0].args[0])
assert manifest_items == [
(f"https://example.com/models/{name}.json", paths[name] / "manifest.json")
for name in names
]
model_items = list(mock_download_content_many.call_args_list[1].args[0])
assert model_items == [
(f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite")
for name in names
]
def test_download_http_models_no_http_sources_skips_download(
mock_download_content_many: MagicMock,
) -> None:
config = {mww.CONF_MODELS: [{CONF_MODEL: {CONF_TYPE: "local", CONF_PATH: "x"}}]}
assert mww._download_http_models(config) is config
mock_download_content_many.assert_not_called()
@pytest.mark.parametrize(
("contents", "message"),
[
("not json", "Invalid manifest file"),
("[1, 2]", "must contain a JSON object"),
("{}", "missing the 'model' key"),
],
)
def test_download_http_models_bad_manifest_raises(
setup_core: Path,
mock_download_content_many: MagicMock,
contents: str,
message: str,
) -> None:
model = _http_model("okay_nabu")
config = {mww.CONF_MODELS: [model]}
_write_manifest(model, contents)
with pytest.raises(cv.Invalid, match=message):
mww._download_http_models(config)
# manifests were still fetched in one batch; the model batch never ran
assert mock_download_content_many.call_count == 1
def test_download_http_models_collects_all_manifest_errors(
setup_core: Path, mock_download_content_many: MagicMock
) -> None:
models = {name: _http_model(name) for name in ("one", "two")}
config = {mww.CONF_MODELS: list(models.values())}
_write_manifest(models["one"], "not json")
_write_manifest(models["two"], "[1]")
with pytest.raises(cv.MultipleInvalid) as excinfo:
mww._download_http_models(config)
assert len(excinfo.value.errors) == 2
+59 -2
View File
@@ -1,9 +1,15 @@
"""Tests for ESP8266 component."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import MagicMock, patch
import pytest
from esphome.components.esp8266 import lambdas_use_scanf_float
from esphome.core import Lambda
from esphome.components import esp8266
from esphome.components.esp8266 import check_rosetta, lambdas_use_scanf_float
from esphome.core import EsphomeError, Lambda
from esphome.types import ConfigType
@@ -60,3 +66,54 @@ def test_lambdas_use_scanf_float_nested() -> None:
"""Test detection in deeply nested config."""
config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}}
assert lambdas_use_scanf_float(config) is True
@pytest.fixture
def apple_silicon_run(monkeypatch: pytest.MonkeyPatch) -> Generator[MagicMock]:
"""Simulate an Apple Silicon Mac and yield the mocked subprocess.run."""
monkeypatch.setattr(esp8266, "IS_MACOS", True)
with (
patch("esphome.components.esp8266.platform.machine", return_value="arm64"),
patch("esphome.components.esp8266.subprocess.run") as mock_run,
):
yield mock_run
@pytest.mark.parametrize(
("is_macos", "machine"),
[
(False, "arm64"),
(True, "x86_64"),
],
)
def test_check_rosetta_skips_other_systems(
monkeypatch: pytest.MonkeyPatch, is_macos: bool, machine: str
) -> None:
"""The check only probes on Apple Silicon Macs."""
monkeypatch.setattr(esp8266, "IS_MACOS", is_macos)
with (
patch("esphome.components.esp8266.platform.machine", return_value=machine),
patch("esphome.components.esp8266.subprocess.run") as mock_run,
):
check_rosetta()
mock_run.assert_not_called()
def test_check_rosetta_installed(apple_silicon_run: MagicMock) -> None:
"""No error when the x86_64 probe succeeds (Rosetta present)."""
apple_silicon_run.return_value = MagicMock(returncode=0)
check_rosetta()
apple_silicon_run.assert_called_once()
def test_check_rosetta_missing(apple_silicon_run: MagicMock) -> None:
"""A failing x86_64 probe raises an actionable error."""
apple_silicon_run.return_value = MagicMock(returncode=1)
with pytest.raises(EsphomeError, match="softwareupdate --install-rosetta"):
check_rosetta()
def test_check_rosetta_arch_unavailable(apple_silicon_run: MagicMock) -> None:
"""The build proceeds when arch(1) cannot be executed."""
apple_silicon_run.side_effect = OSError("no such file")
check_rosetta()
@@ -137,3 +137,45 @@ def test_process_stacktrace_esp32_crash_handler(
state = process_stacktrace(config, line_bt1, False)
mock_esp32_decode_pc.assert_called_once_with(config, "42005ABC")
assert state is False
mock_esp32_decode_pc.reset_mock()
# Reason line carries no address, must not trigger a decode
line_reason = "[E][esp32.crash:079]: Reason: Fault - LoadProhibited (cause 28)"
state = process_stacktrace(config, line_reason, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
mock_esp32_decode_pc.reset_mock()
# EXCVADDR pointing at code (e.g. jumping through a corrupted pointer) decodes
line_excvaddr = "[E][esp32.crash:081]: EXCVADDR: 0x400D9ABC (faulting address)"
state = process_stacktrace(config, line_excvaddr, False)
mock_esp32_decode_pc.assert_called_once_with(config, "400D9ABC")
assert state is False
mock_esp32_decode_pc.reset_mock()
# EXCVADDR pointing at data (heap/null) is not a code address, must be ignored
line_excvaddr_data = (
"[E][esp32.crash:081]: EXCVADDR: 0x0000001C (faulting address)"
)
state = process_stacktrace(config, line_excvaddr_data, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
mock_esp32_decode_pc.reset_mock()
# RISC-V MTVAL pointing at code decodes
line_mtval = "[E][esp32.crash:081]: MTVAL: 0x42001234 (faulting address)"
state = process_stacktrace(config, line_mtval, False)
mock_esp32_decode_pc.assert_called_once_with(config, "42001234")
assert state is False
mock_esp32_decode_pc.reset_mock()
# RISC-V MTVAL pointing at data must be ignored
line_mtval_data = "[E][esp32.crash:081]: MTVAL: 0x3FC80123 (faulting address)"
state = process_stacktrace(config, line_mtval_data, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
@@ -0,0 +1,52 @@
"""Tests for LibreTiny board detection, including renamed-board migration."""
import pytest
from esphome.components.libretiny import _detect_variant
from esphome.components.libretiny.const import (
FAMILY_LN882H,
KEY_COMPONENT_DATA,
KEY_LIBRETINY,
)
from esphome.components.ln882x import COMPONENT_DATA
import esphome.config_validation as cv
from esphome.const import CONF_BOARD, CONF_FAMILY
from esphome.core import CORE
@pytest.fixture
def ln882x_core_data() -> None:
"""Populate CORE the way the ln882x component schema does."""
CORE.data[KEY_LIBRETINY] = {KEY_COMPONENT_DATA: COMPONENT_DATA}
def test_detect_variant_known_board_passes(ln882x_core_data: None) -> None:
"""A current board id resolves its family without warnings."""
result = _detect_variant({CONF_BOARD: "generic-ln882h"})
assert result[CONF_BOARD] == "generic-ln882h"
assert result[CONF_FAMILY] == FAMILY_LN882H
def test_detect_variant_renamed_board_migrates(
ln882x_core_data: None, caplog: pytest.LogCaptureFixture
) -> None:
"""A pre-rename board id validates against the new id, with a warning."""
result = _detect_variant({CONF_BOARD: "generic-ln882hki"})
assert result[CONF_BOARD] == "generic-ln882h"
assert result[CONF_FAMILY] == FAMILY_LN882H
assert "renamed to 'generic-ln882h'" in caplog.text
def test_detect_variant_renamed_board_does_not_mutate_input(
ln882x_core_data: None,
) -> None:
"""Migration copies the config; the caller's dict keeps the old id."""
value = {CONF_BOARD: "generic-ln882hki"}
_detect_variant(value)
assert value[CONF_BOARD] == "generic-ln882hki"
def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> None:
"""Ids outside the rename map keep the family-override error."""
with pytest.raises(cv.Invalid, match="This board is unknown"):
_detect_variant({CONF_BOARD: "not-a-real-board"})
+95
View File
@@ -0,0 +1,95 @@
"""Tests for the ``rp2`` target-platform component.
``rp2`` is the canonical name for the Raspberry Pi RP-series target
platform. ``rp2040`` is a deprecated alias declared via
``ALIASES = ["rp2040"]`` on the rp2 component — the framework
(see ``esphome/loader.py`` and ``esphome/config.py``) handles both
Python-import aliasing (via a ``sys.meta_path`` finder) and YAML-key
aliasing (via a pre-pass in ``validate_config``), so there is no
hand-rolled shim in ``esphome/components/rp2040/``.
These tests pin down the canonical board helpers; the alias contract
itself (Python imports, YAML key rename, deprecation warning) is covered
by the framework tests under ``tests/unit_tests/``.
"""
def test_board_id_has_wifi_for_known_wifi_board() -> None:
"""``rpipicow`` is the canonical Pico W → True."""
from esphome.components import rp2
assert rp2.board_id_has_wifi("rpipicow") is True
def test_board_id_has_wifi_for_known_non_wifi_board() -> None:
"""Plain ``rpipico`` has no CYW43 → False."""
from esphome.components import rp2
assert rp2.board_id_has_wifi("rpipico") is False
def test_board_id_has_wifi_for_rp2350_w_variant() -> None:
"""``rpipico2w`` is the RP2350 Pico 2 W → True."""
from esphome.components import rp2
assert rp2.board_id_has_wifi("rpipico2w") is True
def test_board_id_has_wifi_for_unknown_board_returns_true() -> None:
"""Unknown ids fail open so a custom board is not rejected.
The validator falls back to ESPHome's compile-time check; the
helper returning True here means the wizard emits a ``wifi:``
block and any genuinely-unsupported config trips the existing
"no CYW43" guard at compile time.
"""
from esphome.components import rp2
assert rp2.board_id_has_wifi("not-a-real-board-id") is True
def test_rp2_declares_rp2040_as_alias() -> None:
"""The framework-level deprecation hook is on the ``rp2`` component.
The legacy ``rp2040:`` YAML key works because the rp2 component
opts in via ``ALIASES``; without this declaration the rename
framework wouldn't route legacy configs.
"""
from esphome.components import rp2
assert "rp2040" in rp2.ALIASES
assert rp2.ALIAS_REMOVAL_VERSION == "2027.7.0"
def test_rp2040_python_import_resolves_to_rp2() -> None:
"""``from esphome.components import rp2040`` must work for external
custom components and external tooling (device-builder, the dashboard
wizard, etc.) that still import from the legacy module path.
The ``_AliasFinder`` on ``sys.meta_path`` rewrites the lookup to
the canonical module — both should be the same object.
"""
from esphome.components import (
rp2,
rp2040, # routed via _AliasFinder
)
assert rp2040 is rp2
def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None:
"""Submodule imports (e.g. ``esphome.components.rp2040.boards``) must
also route to the canonical equivalents — the board-generator script
and the dashboard wizard both rely on this path.
"""
from esphome.components.rp2 import (
boards as rp2_boards,
generate_boards as rp2_generate,
)
from esphome.components.rp2040 import (
boards as rp2040_boards,
generate_boards as rp2040_generate,
)
assert rp2040_boards is rp2_boards
assert rp2040_generate is rp2_generate
@@ -1,92 +0,0 @@
"""Tests for RP2040 component public helpers and variant detection."""
import pytest
from esphome.components.rp2040 import _detect_variant, board_id_has_wifi
from esphome.components.rp2040.const import VARIANT_RP2040, VARIANT_RP2350
import esphome.config_validation as cv
from esphome.const import CONF_BOARD, CONF_VARIANT
def test_board_id_has_wifi_for_known_wifi_board() -> None:
"""``rpipicow`` is the canonical Pico W → True."""
assert board_id_has_wifi("rpipicow") is True
def test_board_id_has_wifi_for_known_non_wifi_board() -> None:
"""Plain ``rpipico`` has no CYW43 → False."""
assert board_id_has_wifi("rpipico") is False
def test_board_id_has_wifi_for_rp2350_w_variant() -> None:
"""``rpipico2w`` is the RP2350 Pico 2 W → True."""
assert board_id_has_wifi("rpipico2w") is True
def test_board_id_has_wifi_for_unknown_board_returns_true() -> None:
"""Unknown ids fail open so a custom board is not rejected.
The validator falls back to ESPHome's compile-time check; the
helper returning True here means the wizard emits a ``wifi:``
block and any genuinely-unsupported config trips the existing
"no CYW43" guard at compile time.
"""
assert board_id_has_wifi("not-a-real-board-id") is True
def test_detect_variant_derives_variant_from_board() -> None:
"""Board alone resolves to the matching variant."""
result = _detect_variant({CONF_BOARD: "rpipicow"})
assert result[CONF_BOARD] == "rpipicow"
assert result[CONF_VARIANT] == VARIANT_RP2040
def test_detect_variant_derives_variant_from_rp2350_board() -> None:
"""An RP2350 board resolves to ``RP2350``."""
result = _detect_variant({CONF_BOARD: "rpipico2"})
assert result[CONF_BOARD] == "rpipico2"
assert result[CONF_VARIANT] == VARIANT_RP2350
def test_detect_variant_only_picks_default_board_rp2040() -> None:
"""Variant alone picks Pico W as the canonical RP2040 board."""
result = _detect_variant({CONF_VARIANT: VARIANT_RP2040})
assert result[CONF_BOARD] == "rpipicow"
assert result[CONF_VARIANT] == VARIANT_RP2040
def test_detect_variant_only_picks_default_board_rp2350() -> None:
"""Variant alone picks Pico 2 W as the canonical RP2350 board."""
result = _detect_variant({CONF_VARIANT: VARIANT_RP2350})
assert result[CONF_BOARD] == "rpipico2w"
assert result[CONF_VARIANT] == VARIANT_RP2350
def test_detect_variant_matching_explicit_variant_passes() -> None:
"""Specifying both a board and the matching variant is allowed."""
result = _detect_variant({CONF_BOARD: "rpipico2", CONF_VARIANT: VARIANT_RP2350})
assert result[CONF_BOARD] == "rpipico2"
assert result[CONF_VARIANT] == VARIANT_RP2350
def test_detect_variant_mismatched_variant_raises() -> None:
"""Board/variant mismatch must be rejected and name the offending board."""
with pytest.raises(
cv.Invalid, match=r"does not match the selected board 'rpipicow'"
):
_detect_variant({CONF_BOARD: "rpipicow", CONF_VARIANT: VARIANT_RP2350})
def test_detect_variant_unknown_board_without_variant_raises() -> None:
"""Unknown board with no variant tells the user how to recover."""
with pytest.raises(cv.Invalid, match="please specify the chip variant"):
_detect_variant({CONF_BOARD: "not-a-real-board"})
def test_detect_variant_unknown_board_with_variant_passes() -> None:
"""Unknown board + explicit variant is accepted (with a warning)."""
result = _detect_variant(
{CONF_BOARD: "not-a-real-board", CONF_VARIANT: VARIANT_RP2040}
)
assert result[CONF_BOARD] == "not-a-real-board"
assert result[CONF_VARIANT] == VARIANT_RP2040
@@ -1,4 +1,4 @@
"""Tests for rp2040 generate_boards.py."""
"""Tests for rp2 generate_boards.py."""
from __future__ import annotations
@@ -8,7 +8,7 @@ import textwrap
import pytest
from esphome.components.rp2040.generate_boards import load_boards, parse_variant_pins
from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins
PICO_PINS_HEADER = textwrap.dedent("""\
#pragma once
+3 -3
View File
@@ -87,8 +87,8 @@ def test_has_native_wifi_esp32_variant_case_insensitive() -> None:
def test_has_native_wifi_dispatches_rp2040_to_board_check() -> None:
"""RP2040 platform routes through ``rp2040.board_id_has_wifi``."""
assert has_native_wifi(platform=Platform.RP2040, board="rpipicow") is True
assert has_native_wifi(platform=Platform.RP2040, board="rpipico") is False
assert has_native_wifi(platform=Platform.RP2, board="rpipicow") is True
assert has_native_wifi(platform=Platform.RP2, board="rpipico") is False
def test_has_native_wifi_returns_false_for_nrf52() -> None:
@@ -134,7 +134,7 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None:
def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None:
"""RP2040 without a board id falls open to True (custom-board default)."""
assert has_native_wifi(platform=Platform.RP2040) is True
assert has_native_wifi(platform=Platform.RP2) is True
def _wifi_config(
+73
View File
@@ -1113,6 +1113,48 @@ def test_config_hash_different_for_different_configs() -> None:
assert hash1 != hash2
def test_config_hash_ignores_build_path() -> None:
"""Test that config_hash does not depend on the build_path value.
build_path embeds ESPHOME_BUILD_PATH and OS path separators, so it must
not make the hash differ between machines.
"""
CORE.reset()
CORE.config = {"esphome": {"name": "test", "build_path": "build\\test"}}
hash1 = CORE.config_hash
CORE.reset()
CORE.config = {"esphome": {"name": "test", "build_path": "/build/test"}}
hash2 = CORE.config_hash
assert hash1 == hash2
def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None:
"""Test that Path values under the config dir hash the same everywhere.
Simulates the same project checked out at two different locations; the
absolute paths differ but the layout relative to the config dir is the
same, so the hashes must match.
"""
dir1 = tmp_path / "machine_a" / "project"
dir2 = tmp_path / "machine_b" / "somewhere" / "else"
dir1.mkdir(parents=True)
dir2.mkdir(parents=True)
CORE.reset()
CORE.config_path = dir1 / "device.yaml"
CORE.config = {"esphome": {"name": "test"}, "file": dir1 / "fonts" / "arial.ttf"}
hash1 = CORE.config_hash
CORE.reset()
CORE.config_path = dir2 / "device.yaml"
CORE.config = {"esphome": {"name": "test"}, "file": dir2 / "fonts" / "arial.ttf"}
hash2 = CORE.config_hash
assert hash1 == hash2
def test_make_app_name_cpp_no_mac_simple() -> None:
"""Test simple name without MAC suffix returns string literal."""
cpp_expr, global_decl, byte_len = make_app_name_cpp(
@@ -1307,3 +1349,34 @@ async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None:
mock_cg.add_library.assert_any_call(
"noise-c", None, "https://github.com/esphome/noise-c.git"
)
def test_esphome_build_internals_are_yaml_only() -> None:
"""Raw build-system inputs in the ``esphome:`` block are ``YAML_ONLY``.
These knobs (compiler flags, raw PlatformIO options, C/C++ includes,
libraries, build host parallelism, the min-version gate, …) are not
meaningful as visual-editor form fields and a wrong value breaks the
build, so they must never render in a schema-aware UI.
"""
# CONFIG_SCHEMA is cv.All(cv.Schema({...}), validate_hostname).
inner = config.CONFIG_SCHEMA.validators[0].schema
markers = {str(k): k for k in inner}
yaml_only_fields = {
CONF_BUILD_PATH,
"platformio_options",
"build_flags",
"environment_variables",
"includes",
"includes_c",
"libraries",
"debug_scheduler",
}
for field in yaml_only_fields:
assert markers[field].visibility is cv.Visibility.YAML_ONLY, field
# Packaging / build-host knobs are real but rarely-touched overrides:
# surface them under the editor's advanced disclosure, not yaml-only.
for field in ("min_version", "compile_process_limit"):
assert markers[field].visibility is cv.Visibility.ADVANCED, field
# A regular device-config field stays on the main form.
assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None
@@ -0,0 +1,120 @@
"""Minimal idf_tools stand-in for get_tool_downloads.py tests."""
from collections.abc import Iterable
import os
CURRENT_PLATFORM = "linux-amd64"
TOOLS_FILE = "tools/tools.json"
class ToolBinaryError(RuntimeError):
pass
class _G:
idf_path: str | None = None
idf_tools_path: str | None = None
tools_json: str | None = None
g = _G()
class IDFEnv:
@classmethod
def get_idf_env(cls) -> "IDFEnv":
return cls()
def add_and_check_targets(idf_env_obj: IDFEnv, targets_str: str) -> list[str]:
return targets_str.split(",")
class _Download:
def __init__(self, url: str, size: int, sha256: str, rename_dist: str = "") -> None:
self.url = url
self.size = size
self.sha256 = sha256
self.rename_dist = rename_dist
class _Version:
def __init__(self, download: _Download | None) -> None:
self._download = download
def get_download_for_platform(self, platform_name: str) -> _Download | None:
return self._download
class _Tool:
def __init__(
self,
versions: dict[str, _Version],
recommended: str | None,
installed: Iterable[str] = (),
broken: bool = False,
) -> None:
self.versions = versions
self._recommended = recommended
self.versions_installed = list(installed)
self._broken = broken
def compatible_with_platform(self) -> bool:
return True
def get_recommended_version(self) -> str | None:
return self._recommended
def find_installed_versions(self) -> None:
if self._broken:
raise ToolBinaryError("broken binary")
_TOOLS = {
"cmake": _Tool(
{"3.30.2": _Version(_Download("https://gh.test/cmake.tar.gz", 11, "aa"))},
"3.30.2",
),
"ninja": _Tool(
{
"1.12.1": _Version(
_Download("https://gh.test/ninja-mac.zip", 22, "bb", "ninja-v1.zip")
)
},
"1.12.1",
),
"installed-tool": _Tool(
{"1.0": _Version(_Download("https://gh.test/x.tar.gz", 33, "cc"))},
"1.0",
installed=["1.0"],
),
"broken-tool": _Tool(
{"2.0": _Version(_Download("https://gh.test/y.tar.gz", 44, "dd"))},
"2.0",
broken=True,
),
"no-recommended-tool": _Tool({"3.0": _Version(None)}, None),
"no-download-tool": _Tool({"4.0": _Version(None)}, "4.0"),
}
def load_tools_info() -> dict[str, _Tool]:
return _TOOLS
def expand_tools_arg(
tools_spec: list[str], overall_tools: dict[str, _Tool], targets: list[str]
) -> list[str]:
if "required" in tools_spec:
return list(overall_tools)
return [t for t in tools_spec if "@" not in t] + [t for t in tools_spec if "@" in t]
def get_idf_download_url_apply_mirrors(
args: object = None, download_url: str = ""
) -> str:
print(f"Changed download URL: {download_url}") # noise on stdout, like idf_tools
prefix = os.environ.get("TEST_MIRROR_PREFIX")
if prefix:
return prefix + download_url
return download_url
+333 -2
View File
@@ -22,10 +22,12 @@ from esphome.bundle import (
_add_bytes_to_tar,
_default_target_dir,
_find_used_secret_keys,
add_bundle_file,
extract_bundle,
is_bundle_path,
prepare_bundle_for_compile,
read_bundle_manifest,
remap_bundle_path,
)
from esphome.core import CORE, EsphomeError
from esphome.yaml_util import force_load_include_files
@@ -477,7 +479,10 @@ def test_read_bundle_manifest_corrupted_tar(tmp_path: Path) -> None:
def test_read_bundle_manifest(tmp_path: Path) -> None:
bundle_path = _make_bundle(
tmp_path,
manifest_overrides={ManifestKey.HAS_SECRETS: True},
manifest_overrides={
ManifestKey.HAS_SECRETS: True,
ManifestKey.CONFIG_DIR: "/original/config",
},
extra_files={"secrets.yaml": b"wifi: test\n"},
)
@@ -488,6 +493,7 @@ def test_read_bundle_manifest(tmp_path: Path) -> None:
assert manifest.esphome_version == "2026.2.0-test"
assert manifest.config_filename == "test.yaml"
assert manifest.has_secrets is True
assert manifest.config_dir == "/original/config"
def test_read_bundle_manifest_minimal(tmp_path: Path) -> None:
@@ -507,6 +513,266 @@ def test_read_bundle_manifest_minimal(tmp_path: Path) -> None:
assert result.esphome_version == "unknown"
assert not result.files
assert result.has_secrets is False
assert result.config_dir is None
def test_read_bundle_manifest_non_string_config_dir(tmp_path: Path) -> None:
"""A malformed config_dir value is dropped rather than propagated."""
bundle_path = _make_bundle(
tmp_path, manifest_overrides={ManifestKey.CONFIG_DIR: 42}
)
assert read_bundle_manifest(bundle_path).config_dir is None
# ---------------------------------------------------------------------------
# remap_bundle_path
# ---------------------------------------------------------------------------
ORIGINAL_CONFIG_DIR = "/original/config"
def _bundle_manifest_dict(**overrides: Any) -> dict[str, Any]:
"""Manifest content an extracted bundle would contain."""
manifest: dict[str, Any] = {
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
ManifestKey.CONFIG_FILENAME: "test.yaml",
ManifestKey.CONFIG_DIR: ORIGINAL_CONFIG_DIR,
}
manifest.update(overrides)
return manifest
def _setup_extracted_dir(
tmp_path: Path,
manifest: dict[str, Any] | str | None,
files: dict[str, str] | None = None,
) -> Path:
"""Create a directory shaped like an extracted bundle and point CORE at it."""
extract_dir = _setup_config_dir(tmp_path, files)
if manifest is not None:
content = manifest if isinstance(manifest, str) else json.dumps(manifest)
(extract_dir / MANIFEST_FILENAME).write_text(content)
return extract_dir
def test_remap_bundle_path_success(tmp_path: Path) -> None:
"""A stale absolute path resolves to the bundled copy next to the config."""
extract_dir = _setup_extracted_dir(
tmp_path, _bundle_manifest_dict(), files={"boards/partitions.csv": "csv\n"}
)
remapped = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/boards/partitions.csv")
assert remapped == extract_dir / "boards" / "partitions.csv"
assert remapped.is_file()
@pytest.mark.parametrize(
"value",
[
pytest.param(r"C:\Users\nick\esphome\boards\partitions.csv", id="backslashes"),
pytest.param("C:/Users/nick/esphome/boards/partitions.csv", id="forward"),
pytest.param(r"c:\users\NICK\esphome\boards\partitions.csv", id="case"),
],
)
def test_remap_bundle_path_windows_bundle_on_posix(tmp_path: Path, value: str) -> None:
"""A bundle created on Windows remaps on a build server with another layout."""
extract_dir = _setup_extracted_dir(
tmp_path,
_bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}),
files={"boards/partitions.csv": "csv\n"},
)
remapped = remap_bundle_path(value)
assert remapped == extract_dir / "boards" / "partitions.csv"
assert remapped.is_file()
def test_remap_bundle_path_windows_bundle_path_not_under_config_dir(
tmp_path: Path,
) -> None:
"""A Windows path outside the original config dir is left alone."""
_setup_extracted_dir(
tmp_path,
_bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}),
files={"partitions.csv": "csv\n"},
)
assert remap_bundle_path(r"D:\other\partitions.csv") is None
def test_remap_bundle_path_windows_profile_with_spaces(tmp_path: Path) -> None:
r"""A Windows profile like C:\Users\First Last remaps like any other dir."""
extract_dir = _setup_extracted_dir(
tmp_path,
_bundle_manifest_dict(
**{ManifestKey.CONFIG_DIR: r"C:\Users\First Last\esphome"}
),
files={"boards/my partitions.csv": "csv\n"},
)
remapped = remap_bundle_path(
r"C:\Users\First Last\esphome\boards\my partitions.csv"
)
assert remapped == extract_dir / "boards" / "my partitions.csv"
assert remapped.is_file()
def test_remap_bundle_path_unc_config_dir(tmp_path: Path) -> None:
"""A bundle created from a UNC share remaps like any other Windows path."""
extract_dir = _setup_extracted_dir(
tmp_path,
_bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"\\server\share\esphome"}),
files={"partitions.csv": "csv\n"},
)
remapped = remap_bundle_path(r"\\server\share\esphome\partitions.csv")
assert remapped == extract_dir / "partitions.csv"
def test_remap_bundle_path_flavor_mismatch(tmp_path: Path) -> None:
"""A POSIX style value cannot come from a Windows config dir; no remap."""
_setup_extracted_dir(
tmp_path,
_bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}),
files={"partitions.csv": "csv\n"},
)
assert remap_bundle_path("/original/config/partitions.csv") is None
def test_remap_bundle_path_rejects_traversal(tmp_path: Path) -> None:
"""A remap may never escape the extracted config tree."""
extract_dir = _setup_extracted_dir(tmp_path, _bundle_manifest_dict())
(tmp_path / "outside.csv").write_text("csv\n")
assert (extract_dir / ".." / "outside.csv").resolve().is_file()
assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/../outside.csv") is None
def test_remap_bundle_path_relative_value(tmp_path: Path) -> None:
"""Relative references resolve normally and are never remapped."""
_setup_extracted_dir(tmp_path, _bundle_manifest_dict())
assert remap_bundle_path("missing.csv") is None
def test_remap_bundle_path_no_manifest(tmp_path: Path) -> None:
"""A config dir without a manifest is not an extracted bundle."""
_setup_extracted_dir(tmp_path, None, files={"partitions.csv": "csv\n"})
assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None
@pytest.mark.parametrize(
"manifest",
[
pytest.param("{not json", id="malformed_json"),
pytest.param("[]", id="not_a_dict"),
pytest.param(
_bundle_manifest_dict(**{ManifestKey.MANIFEST_VERSION: "x"}),
id="version_not_int",
),
pytest.param(
_bundle_manifest_dict(**{ManifestKey.MANIFEST_VERSION: 0}),
id="version_zero",
),
pytest.param(
_bundle_manifest_dict(**{ManifestKey.CONFIG_FILENAME: "other.yaml"}),
id="config_filename_mismatch",
),
pytest.param(
{
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
ManifestKey.CONFIG_FILENAME: "test.yaml",
},
id="config_dir_missing",
),
pytest.param(
_bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: ""}),
id="config_dir_empty",
),
],
)
def test_remap_bundle_path_untrusted_manifest(
tmp_path: Path, manifest: dict[str, Any] | str
) -> None:
"""Manifests that do not look like this bundle's manifest are ignored."""
_setup_extracted_dir(tmp_path, manifest, files={"partitions.csv": "csv\n"})
assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None
def test_remap_bundle_path_unreadable_manifest_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A present but broken manifest is reported, not silently ignored."""
_setup_extracted_dir(tmp_path, "{not json", files={"partitions.csv": "csv\n"})
assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None
assert "ignoring unreadable" in caplog.text
def test_remap_bundle_path_outside_original_config_dir(tmp_path: Path) -> None:
"""Paths that were not under the original config dir are left alone."""
_setup_extracted_dir(tmp_path, _bundle_manifest_dict())
assert remap_bundle_path("/elsewhere/partitions.csv") is None
def test_remap_bundle_path_bundled_copy_missing(tmp_path: Path) -> None:
"""No remap when the bundle does not contain the file."""
_setup_extracted_dir(tmp_path, _bundle_manifest_dict())
assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None
def test_remap_bundle_path_manifest_read_once(tmp_path: Path) -> None:
"""The manifest lookup result is cached for the rest of the run."""
extract_dir = _setup_extracted_dir(
tmp_path, _bundle_manifest_dict(), files={"partitions.csv": "csv\n"}
)
first = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv")
assert first == extract_dir / "partitions.csv"
(extract_dir / MANIFEST_FILENAME).unlink()
second = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv")
assert second == first
def test_remap_bundle_path_round_trip(tmp_path: Path) -> None:
"""A file referenced by absolute path survives bundle create and extract.
Reproduces https://github.com/esphome/esphome/issues/17755: the config
names its partitions csv by absolute path, the bundle is extracted on a
machine where that path does not exist, and the reference must resolve
to the bundled copy.
"""
config_dir = _setup_config_dir(tmp_path, files={"partitions.csv": "nvs,data\n"})
abs_path = (config_dir / "partitions.csv").resolve()
creator = ConfigBundleCreator({"esp32": {"partitions": abs_path}})
result = creator.create_bundle()
bundle_path = tmp_path / f"device{BUNDLE_EXTENSION}"
bundle_path.write_bytes(result.data)
target = tmp_path / "build_server"
config_path = extract_bundle(bundle_path, target)
# Simulate the build server: fresh run, original config dir gone
CORE.reset()
CORE.config_path = config_path
shutil.rmtree(config_dir)
remapped = remap_bundle_path(str(abs_path))
assert remapped == target.resolve() / "partitions.csv"
assert remapped.is_file()
# ---------------------------------------------------------------------------
@@ -611,6 +877,70 @@ def test_discover_files_includes_config(tmp_path: Path) -> None:
assert "test.yaml" in paths
def test_discover_files_includes_registered_files(tmp_path: Path) -> None:
"""Files registered with add_bundle_file() are included.
The config does not name them, so discovery cannot find them on its own.
"""
config_dir = _setup_config_dir(
tmp_path,
files={"models/model.tflite": "fake model data"},
)
add_bundle_file(config_dir / "models" / "model.tflite")
creator = ConfigBundleCreator({})
files = creator.discover_files()
paths = [f.path for f in files]
assert "models/model.tflite" in paths
def test_discover_files_registered_relative_file(tmp_path: Path) -> None:
"""A relative registered path is taken as relative to the config directory.
Not the working directory, which is where Path.resolve() would put it.
"""
_setup_config_dir(
tmp_path,
files={"models/model.tflite": "fake model data"},
)
add_bundle_file(Path("models/model.tflite"))
creator = ConfigBundleCreator({})
files = creator.discover_files()
paths = [f.path for f in files]
assert "models/model.tflite" in paths
def test_discover_files_registered_file_outside_config_dir(tmp_path: Path) -> None:
"""A registered file outside the config directory is skipped, not bundled."""
_setup_config_dir(tmp_path)
outside = tmp_path / "outside.tflite"
outside.write_text("fake model data")
add_bundle_file(outside)
creator = ConfigBundleCreator({})
files = creator.discover_files()
assert [f.path for f in files] == ["test.yaml"]
def test_discover_files_registered_file_deduplicated(tmp_path: Path) -> None:
"""Registering the same file twice adds it once."""
config_dir = _setup_config_dir(
tmp_path,
files={"models/model.tflite": "fake model data"},
)
add_bundle_file(config_dir / "models" / "model.tflite")
add_bundle_file(config_dir / "models" / "model.tflite")
creator = ConfigBundleCreator({})
files = creator.discover_files()
assert [f.path for f in files].count("models/model.tflite") == 1
def test_discover_files_finds_path_objects(tmp_path: Path) -> None:
"""Path objects in validated config are discovered."""
config_dir = _setup_config_dir(
@@ -1196,7 +1526,7 @@ def test_create_bundle_produces_valid_archive(tmp_path: Path) -> None:
def test_create_bundle_manifest_content(tmp_path: Path) -> None:
_setup_config_dir(tmp_path)
config_dir = _setup_config_dir(tmp_path)
creator = ConfigBundleCreator({})
result = creator.create_bundle()
@@ -1204,6 +1534,7 @@ def test_create_bundle_manifest_content(tmp_path: Path) -> None:
manifest = result.manifest
assert manifest[ManifestKey.MANIFEST_VERSION] == CURRENT_MANIFEST_VERSION
assert manifest[ManifestKey.CONFIG_FILENAME] == "test.yaml"
assert manifest[ManifestKey.CONFIG_DIR] == str(config_dir.resolve())
assert "test.yaml" in manifest[ManifestKey.FILES]
+10 -10
View File
@@ -74,13 +74,13 @@ def _write_storage(
"framework": "arduino",
"core_platform": core_platform,
}
storage_path.write_text(json.dumps(data))
storage_path.write_text(json.dumps(data), encoding="utf-8")
def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path:
"""Write the cache file and return it."""
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body)
cache_path.write_text(body, encoding="utf-8")
return cache_path
@@ -220,7 +220,7 @@ def test_run_esphome_upload_and_logs_use_cache_when_fresh(
with (
caplog.at_level("INFO", logger="esphome.__main__"),
patch("esphome.__main__.read_config") as mock_read,
patch("esphome.config.read_config") as mock_read,
patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}),
):
assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0
@@ -242,7 +242,7 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
yaml_path.write_text("esphome:\n name: lite_test\n")
with (
patch("esphome.__main__.read_config", return_value=None) as mock_read,
patch("esphome.config.read_config", return_value=None) as mock_read,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{command: lambda args, config: 0},
@@ -266,7 +266,7 @@ def test_run_esphome_upload_does_not_refresh_cache_without_sidecar(
with (
patch(
"esphome.__main__.read_config",
"esphome.config.read_config",
return_value={"esphome": {"name": "lite_test"}},
),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
@@ -299,7 +299,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}}
with (
patch("esphome.__main__.read_config", return_value=fresh_config),
patch("esphome.config.read_config", return_value=fresh_config),
patch(
"esphome.compiled_config.save_compiled_config", wraps=save_compiled_config
) as mock_save,
@@ -322,7 +322,7 @@ def test_run_esphome_upload_with_substitution_does_not_refresh_cache(
"""`-s` substitutions skip the cache on both read and write -- saving
here would clobber the cache with a substitution-specific config."""
with (
patch("esphome.__main__.read_config", return_value={"esphome": {}}),
patch("esphome.config.read_config", return_value={"esphome": {}}),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
@@ -341,7 +341,7 @@ def test_run_esphome_compile_does_not_refresh_cache_via_fallback(
upload/logs fallback path -- the fallback save would skip the
storage_should_clean check."""
with (
patch("esphome.__main__.read_config", return_value={"esphome": {}}),
patch("esphome.config.read_config", return_value={"esphome": {}}),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
@@ -360,7 +360,7 @@ def test_run_esphome_upload_with_substitution_skips_cache(
against the prior substitution set, so reusing it would silently
ignore the override."""
with (
patch("esphome.__main__.read_config", return_value=None) as mock_read,
patch("esphome.config.read_config", return_value=None) as mock_read,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"upload": lambda args, config: 0},
@@ -374,7 +374,7 @@ def test_run_esphome_upload_with_substitution_skips_cache(
def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None:
"""The compile subcommand always re-validates -- it's what writes the cache."""
with (
patch("esphome.__main__.read_config", return_value=None) as mock_read,
patch("esphome.config.read_config", return_value=None) as mock_read,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"compile": lambda args, config: 0},
+83 -2
View File
@@ -1,6 +1,6 @@
"""Unit tests for esphome.config module."""
from collections.abc import Generator
from collections.abc import Callable, Generator
import logging
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
@@ -8,7 +8,8 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
from esphome import config, yaml_util
from esphome.core import CORE
from esphome.core import CORE, AutoLoad
from esphome.types import ConfigType
@pytest.fixture
@@ -116,6 +117,86 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None:
assert "web_server" in platforms, f"Expected web_server platform in {platforms}"
# ---------------------------------------------------------------------------
# LEGACY_CONFIG_MIGRATE hook on LoadValidationStep -- the removable shim that
# lets a platform component rewrite a pre-platform top-level config.
# ---------------------------------------------------------------------------
def _run_load_step(
domain: str,
conf: object,
migrate: Callable[[ConfigType], list | None] | None,
) -> config.Config:
"""Run a LoadValidationStep for a platform component with a given migrate hook."""
component = Mock()
component.is_platform_component = True
component.multi_conf_no_default = False
component.legacy_config_migrate = migrate
result = config.Config()
with (
patch("esphome.config.get_component", return_value=component),
patch("esphome.config._process_auto_load"),
patch("esphome.config._process_platform_config"),
):
config.LoadValidationStep(domain, conf).run(result)
return result
def test_legacy_migrate_rewrites_conf() -> None:
"""A legacy config that the hook migrates is replaced with the new list."""
migrated = [{"platform": "file", "id": "a"}]
migrate = Mock(return_value=migrated)
result = _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate)
migrate.assert_called_once_with([{"id": "a", "file": "x.png"}])
assert result["image"] == migrated
def test_legacy_migrate_none_keeps_new_format() -> None:
"""When the hook returns None the already-new config is left untouched."""
new_format = [{"platform": "file", "id": "a"}]
migrate = Mock(return_value=None)
result = _run_load_step("image", new_format, migrate)
migrate.assert_called_once_with(new_format)
assert result["image"] == new_format
def test_legacy_migrate_absent_hook_is_noop() -> None:
"""A platform component without the hook normalizes without migration."""
result = _run_load_step("image", {"id": "a"}, None)
# Bare dict still gets wrapped into a list by the normal normalization path.
assert result["image"] == [{"id": "a"}]
def test_legacy_migrate_skipped_for_empty_conf() -> None:
"""An empty config short-circuits before the hook is consulted."""
migrate = Mock(return_value=[{"platform": "file"}])
result = _run_load_step("image", [], migrate)
migrate.assert_not_called()
assert result["image"] == []
def test_legacy_migrate_skipped_for_autoload() -> None:
"""An auto-loaded (AutoLoad) config is never migrated."""
migrate = Mock(return_value=[{"platform": "file"}])
auto = AutoLoad()
auto["id"] = "a"
result = _run_load_step("image", auto, migrate)
migrate.assert_not_called()
# AutoLoad is dict-like, so normalization wraps it into a single-entry list.
assert result["image"] == [auto]
def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path:
"""Create a config where two `<<` includes both define `logger:`.
+497 -13
View File
@@ -1,3 +1,5 @@
import json
import logging
from pathlib import Path
import string
@@ -39,7 +41,7 @@ from esphome.const import (
PLATFORM_ESP8266,
PLATFORM_HOST,
PLATFORM_LN882X,
PLATFORM_RP2040,
PLATFORM_RP2,
PLATFORM_RTL87XX,
SCHEDULER_DONT_RUN,
TYPE_GIT,
@@ -438,7 +440,7 @@ def hex_int__valid(value):
("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C6, "16", "16", "14", "14"),
("arduino", PLATFORM_ESP32, VARIANT_ESP32H2, "18", "17", "18", "17"),
("esp-idf", PLATFORM_ESP32, VARIANT_ESP32H2, "19", "19", "17", "17"),
("arduino", PLATFORM_RP2040, None, "20", "20", "20", "20"),
("arduino", PLATFORM_RP2, None, "20", "20", "20", "20"),
("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"),
("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"),
("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"),
@@ -469,7 +471,7 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple)
"esp32_c3": "11",
"esp32_c6": "14",
"esp32_h2": "17",
"rp2040": "20",
"rp2": "20",
"bk72xx": "21",
"rtl87xx": "22",
"ln882x": "23",
@@ -517,7 +519,7 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple)
("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"),
("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"),
("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"),
("arduino", PLATFORM_RP2040, "RP2040 using arduino framework"),
("arduino", PLATFORM_RP2, "RP2 using arduino framework"),
("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"),
("host", PLATFORM_HOST, "HOST using host framework"),
],
@@ -540,7 +542,7 @@ def test_require_framework_version(framework, platform, message):
esp_idf=cv.Version(0, 5, 0),
esp32_arduino=cv.Version(0, 5, 0),
esp8266_arduino=cv.Version(0, 5, 0),
rp2040_arduino=cv.Version(0, 5, 0),
rp2_arduino=cv.Version(0, 5, 0),
bk72xx_arduino=cv.Version(0, 5, 0),
host=cv.Version(0, 5, 0),
extra_message="test 1",
@@ -556,7 +558,7 @@ def test_require_framework_version(framework, platform, message):
esp_idf=cv.Version(2, 0, 0),
esp32_arduino=cv.Version(2, 0, 0),
esp8266_arduino=cv.Version(2, 0, 0),
rp2040_arduino=cv.Version(2, 0, 0),
rp2_arduino=cv.Version(2, 0, 0),
bk72xx_arduino=cv.Version(2, 0, 0),
host=cv.Version(2, 0, 0),
extra_message="test 2",
@@ -567,7 +569,7 @@ def test_require_framework_version(framework, platform, message):
esp_idf=cv.Version(1, 5, 0),
esp32_arduino=cv.Version(1, 5, 0),
esp8266_arduino=cv.Version(1, 5, 0),
rp2040_arduino=cv.Version(1, 5, 0),
rp2_arduino=cv.Version(1, 5, 0),
bk72xx_arduino=cv.Version(1, 5, 0),
host=cv.Version(1, 5, 0),
max_version=True,
@@ -584,7 +586,7 @@ def test_require_framework_version(framework, platform, message):
esp_idf=cv.Version(0, 5, 0),
esp32_arduino=cv.Version(0, 5, 0),
esp8266_arduino=cv.Version(0, 5, 0),
rp2040_arduino=cv.Version(0, 5, 0),
rp2_arduino=cv.Version(0, 5, 0),
bk72xx_arduino=cv.Version(0, 5, 0),
host=cv.Version(0, 5, 0),
max_version=True,
@@ -599,6 +601,194 @@ def test_require_framework_version(framework, platform, message):
)("test")
def _setup_core_for_framework(platform: str, framework: str) -> None:
"""Wire CORE.data with the minimum keys for require_framework_version /
SplitDefault to evaluate without raising KeyError."""
from esphome.const import (
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
)
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: platform,
KEY_TARGET_FRAMEWORK: framework,
KEY_FRAMEWORK_VERSION: cv.Version(1, 0, 0),
}
def test_only_on_rp2_passes_on_rp2_platform() -> None:
"""``cv.only_on_rp2`` is the canonical family gate. It accepts any value
untouched when the configured platform is rp2."""
_setup_core_for_framework(PLATFORM_RP2, "arduino")
assert cv.only_on_rp2("anything") == "anything"
def test_only_on_rp2_rejects_other_platforms() -> None:
"""The same gate raises ``Invalid`` outside the rp2 platform."""
_setup_core_for_framework(PLATFORM_ESP32, "arduino")
with pytest.raises(Invalid, match="rp2"):
cv.only_on_rp2("anything")
def test_only_on_rp2040_delegates_and_warns_once(caplog) -> None:
"""``cv.only_on_rp2040`` is a deprecation shim — it logs a one-shot
warning, dedupes via CORE.data, and delegates to ``only_on_rp2``.
Repeated calls in the same run must not log again."""
import logging
_setup_core_for_framework(PLATFORM_RP2, "arduino")
# Reset the dedupe flag so this test is independent of order.
CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None)
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
assert cv.only_on_rp2040("ok") == "ok"
first_warnings = [r for r in caplog.records if "only_on_rp2040" in r.message]
assert len(first_warnings) == 1
assert "2027.7.0" in first_warnings[0].message
# Second call dedupes — no additional warning is emitted.
assert cv.only_on_rp2040("ok") == "ok"
warnings_after_second = [
r for r in caplog.records if "only_on_rp2040" in r.message
]
assert len(warnings_after_second) == 1
def test_only_on_rp2040_still_gates_on_non_rp2(caplog) -> None:
"""The deprecation shim must still raise on non-rp2 platforms — it
delegates to ``only_on_rp2``, so the gating behavior is preserved."""
import logging
_setup_core_for_framework(PLATFORM_ESP32, "arduino")
CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None)
with (
caplog.at_level(logging.WARNING, logger="esphome.config_validation"),
pytest.raises(Invalid, match="rp2"),
):
cv.only_on_rp2040("anything")
def test_require_framework_version_esp32_variant_specific_key() -> None:
"""ESP32 variant-specific kwargs (``esp32_c3_arduino``) must win over
the base ``esp32_arduino`` key when the configured variant matches."""
from esphome.components.esp32 import KEY_ESP32
from esphome.const import (
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
KEY_VARIANT,
)
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: PLATFORM_ESP32,
KEY_TARGET_FRAMEWORK: "arduino",
KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0),
}
CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32C3}
# Variant-specific entry permits this version; base key would reject it.
assert (
cv.require_framework_version(
esp32_arduino=cv.Version(5, 0, 0), # would reject
esp32_c3_arduino=cv.Version(1, 0, 0), # wins, ok
)("test")
== "test"
)
def test_require_framework_version_rp2_variant_specific_key() -> None:
"""RP2 variant kwargs (``rp2_2040_arduino``) must win over the base
``rp2_arduino`` key when ``CORE.data['rp2']['variant']`` is wired."""
from esphome.const import (
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
)
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: PLATFORM_RP2,
KEY_TARGET_FRAMEWORK: "arduino",
KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0),
}
CORE.data["rp2"] = {"variant": "RP2040"}
# Variant key wins — base ``rp2_arduino`` (which would reject) is ignored.
assert (
cv.require_framework_version(
rp2_arduino=cv.Version(5, 0, 0), # would reject
rp2_2040_arduino=cv.Version(1, 0, 0), # wins, ok
)("test")
== "test"
)
# Without a variant kwarg the base ``rp2_arduino`` is used (fallback).
CORE.data["rp2"] = {"variant": "RP2350"}
assert (
cv.require_framework_version(
rp2_arduino=cv.Version(1, 0, 0),
)("test")
== "test"
)
def test_split_default_rp2_variant_keys() -> None:
"""``SplitDefault`` resolves ``rp2_<chip>_<framework>`` first, falling
back to ``rp2_<chip>`` and ``rp2_<framework>`` before the base key."""
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: PLATFORM_RP2,
KEY_TARGET_FRAMEWORK: "arduino",
}
CORE.data["rp2"] = {"variant": "RP2040"}
schema = cv.Schema(
{
cv.SplitDefault(
"full",
rp2="base",
rp2_arduino="base-framework",
rp2_2040="variant-only",
rp2_2040_arduino="variant-framework",
): str,
}
)
# Most specific (variant + framework) wins.
assert schema({}).get("full") == "variant-framework"
# Drop the most-specific kwarg → variant-only wins.
schema = cv.Schema(
{
cv.SplitDefault(
"full",
rp2="base",
rp2_arduino="base-framework",
rp2_2040="variant-only",
): str,
}
)
assert schema({}).get("full") == "variant-only"
# RP2350 variant — no rp2_2350_* kwargs → fall through to base framework.
CORE.data["rp2"] = {"variant": "RP2350"}
schema = cv.Schema(
{
cv.SplitDefault(
"full",
rp2="base",
rp2_arduino="base-framework",
rp2_2040="not-this",
): str,
}
)
assert schema({}).get("full") == "base-framework"
def test_only_with_single_component_loaded() -> None:
"""Test OnlyWith with single component when component is loaded."""
CORE.loaded_integrations = {"mqtt"}
@@ -986,9 +1176,10 @@ def test_update_interval__never_passes_through() -> None:
def test_optional_default_visibility_is_none() -> None:
"""An ``Optional`` with no ``visibility`` kwarg reports ``None``.
Consumers can read the attribute directly with plain attribute
access; absence (``None``) means "render on the editor's main
form."
The marker stays faithful to what the author wrote: ESPHome does
not encode the default on it. Resolving ``None`` to an effective
visibility is the consumer's job — a schema-aware editor treats an
unset ``Optional`` as ``ADVANCED`` (see :class:`Visibility`).
"""
o = cv.Optional("foo")
assert o.visibility is None
@@ -1006,6 +1197,17 @@ def test_optional_visibility_yaml_only() -> None:
assert o.visibility is cv.Visibility.YAML_ONLY
def test_optional_visibility_ui() -> None:
"""``visibility=Visibility.UI`` is recorded on the marker.
``UI`` promotes an ``Optional`` onto the editor's main form,
overriding the consumer's default of ``ADVANCED`` for unset
optionals.
"""
o = cv.Optional("foo", visibility=cv.Visibility.UI)
assert o.visibility is cv.Visibility.UI
def test_visibility_str_values_match_dump_emission() -> None:
"""``Visibility`` is a ``StrEnum`` whose values are the literal
strings the schema dumper emits.
@@ -1015,6 +1217,7 @@ def test_visibility_str_values_match_dump_emission() -> None:
field — pinning the on-the-wire spelling here keeps the dump
contract stable.
"""
assert str(cv.Visibility.UI) == "ui"
assert str(cv.Visibility.ADVANCED) == "advanced"
assert str(cv.Visibility.YAML_ONLY) == "yaml_only"
@@ -1137,6 +1340,57 @@ def test_visibility_marker_is_per_field_no_mutation() -> None:
assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY
def test_entity_metadata_visibility_hints() -> None:
"""Entity and value-describing metadata is classified for visual editors.
The headline ``name`` stays on the main form (``UI``); descriptive
metadata (device_class, unit, …), presentation options, and per-entity
integration plumbing (MQTT, web_server ordering) fall to the advanced
disclosure (``ADVANCED``).
"""
advanced = cv.Visibility.ADVANCED
entity_base = {str(k): k for k in cv.ENTITY_BASE_SCHEMA.schema}
assert entity_base["name"].visibility is cv.Visibility.UI
for field in (
"icon",
"internal",
"disabled_by_default",
"entity_category",
"device_id",
):
assert entity_base[field].visibility is advanced, field
mqtt = {str(k): k for k in cv.MQTT_COMPONENT_SCHEMA.schema}
for field in ("qos", "retain", "discovery", "state_topic", "availability"):
assert mqtt[field].visibility is advanced, field
from esphome.components import binary_sensor, number, sensor
from esphome.components.web_server import WEBSERVER_SORTING_SCHEMA
sensor_markers = {str(k): k for k in sensor.sensor_schema().schema}
for field in (
"unit_of_measurement",
"accuracy_decimals",
"device_class",
"state_class",
"force_update",
):
assert sensor_markers[field].visibility is advanced, field
binary = {str(k): k for k in binary_sensor.binary_sensor_schema().schema}
assert binary["device_class"].visibility is advanced
number_markers = {str(k): k for k in number.number_schema(number.Number).schema}
assert number_markers["mode"].visibility is advanced
assert number_markers["device_class"].visibility is advanced
# The whole per-entity web_server block is advanced; children inherit
# via the consumer cascade, so only the parent key carries the hint.
web = {str(k): k for k in WEBSERVER_SORTING_SCHEMA.schema}
assert web["web_server"].visibility is advanced
def _wrap_str(value: str) -> ESPHomeDataBase:
"""Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value."""
return make_data_base(value)
@@ -1184,9 +1438,41 @@ def test_version_parse_with_extra() -> None:
assert version.extra == "dev20240101"
def test_version_parse_invalid() -> None:
def test_version_parse_without_patch() -> None:
"""A two-part version parses with patch defaulting to 0, so framework
shorthands like '6.0' and '6.0-rc1' are accepted."""
version = cv.Version.parse("6.0")
assert (version.major, version.minor, version.patch, version.extra) == (
6,
0,
0,
"",
)
version = cv.Version.parse("6.0-rc1")
assert (version.major, version.minor, version.patch, version.extra) == (
6,
0,
0,
"rc1",
)
def test_version_parse_numeric_extra() -> None:
"""Four-part versions keep the trailing component as extra (pioarduino
packaging revisions, e.g. 5.5.3.1)."""
version = cv.Version.parse("5.5.3.1")
assert (version.major, version.minor, version.patch, version.extra) == (
5,
5,
3,
"1",
)
@pytest.mark.parametrize("value", ["not.a.version", "6", "a.b", ""])
def test_version_parse_invalid(value: str) -> None:
with pytest.raises(ValueError, match="Not a valid version number"):
cv.Version.parse("not.a.version")
cv.Version.parse(value)
def test_version_is_beta() -> None:
@@ -2142,6 +2428,58 @@ def test_one_of_string_and_space() -> None:
assert cv.one_of("a_b", string=True, space="_")("a b") == "a_b"
def test_one_of_string_and_underscore() -> None:
assert cv.one_of("a-b", string=True, underscore="-")("a_b") == "a-b"
assert cv.one_of("a-b", string=True, underscore="-")("a-b") == "a-b"
def test_one_of_string_lower_space_and_underscore() -> None:
validator = cv.one_of("output-mode", lower=True, space="-", underscore="-")
assert validator("output_mode") == "output-mode"
assert validator("OUTPUT_MODE") == "output-mode"
assert validator("output mode") == "output-mode"
assert validator("output-mode") == "output-mode"
def test_one_of_string_underscore_unknown() -> None:
with pytest.raises(Invalid):
cv.one_of("a-b", string=True, underscore="-")("c_d")
def test_one_of_string_underscore_default_unchanged() -> None:
with pytest.raises(Invalid):
cv.one_of("a-b", string=True)("a_b")
def test_one_of_string_and_hyphen() -> None:
assert cv.one_of("a_b", string=True, hyphen="_")("a-b") == "a_b"
assert cv.one_of("a_b", string=True, hyphen="_")("a_b") == "a_b"
def test_one_of_string_lower_space_and_hyphen() -> None:
validator = cv.one_of("output_mode", lower=True, space="_", hyphen="_")
assert validator("output-mode") == "output_mode"
assert validator("OUTPUT-MODE") == "output_mode"
assert validator("output mode") == "output_mode"
assert validator("output_mode") == "output_mode"
def test_one_of_string_hyphen_unknown() -> None:
with pytest.raises(Invalid):
cv.one_of("a_b", string=True, hyphen="_")("c-d")
def test_one_of_string_hyphen_default_unchanged() -> None:
with pytest.raises(Invalid):
cv.one_of("a_b", string=True)("a-b")
def test_one_of_string_underscore_hyphen_swap_no_cascade() -> None:
validator = cv.one_of("a-b", "a_b", string=True, underscore="-", hyphen="_")
assert validator("a_b") == "a-b"
assert validator("a-b") == "a_b"
def test_one_of_int() -> None:
assert cv.one_of(1, 2, int=True)("2") == 2
@@ -2180,6 +2518,20 @@ def test_enum_valid() -> None:
assert result.enum_value == 10
def test_enum_valid_with_underscore() -> None:
mapping = {"a-b": 1}
result = cv.enum(mapping, string=True, underscore="-")("a_b")
assert result == "a-b"
assert result.enum_value == 1
def test_enum_valid_with_hyphen() -> None:
mapping = {"a_b": 1}
result = cv.enum(mapping, string=True, hyphen="_")("a-b")
assert result == "a_b"
assert result.enum_value == 1
# ---------------------------------------------------------------------------
# lambda_ / returning_lambda
# ---------------------------------------------------------------------------
@@ -2628,3 +2980,135 @@ def test_rename_key_present() -> None:
def test_rename_key_absent() -> None:
assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5}
def test_rename_key_no_removed_in_is_silent(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
assert cv.rename_key("old", "new")({"old": 5}) == {"new": 5}
assert not caplog.records
def test_rename_key_removed_in_renames_and_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
result = cv.rename_key("old", "new", removed_in="2026.8.0")({"old": 5})
assert result == {"new": 5}
assert "'old' is deprecated, use 'new'. Will be removed in 2026.8.0" in caplog.text
def test_rename_key_removed_in_absent_key_no_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
result = cv.rename_key("old", "new", removed_in="2026.8.0")({"other": 5})
assert result == {"other": 5}
assert not caplog.records
def test_rename_key_removed_in_with_component_prefixes_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
result = cv.rename_key(
"old", "new", removed_in="2026.8.0", component="my_component"
)({"old": 5})
assert result == {"new": 5}
assert (
"[my_component] 'old' is deprecated, use 'new'. Will be removed in 2026.8.0"
in caplog.text
)
def test_rename_key_both_keys_rejected() -> None:
with pytest.raises(Invalid, match="Cannot specify more than one of"):
cv.rename_key("old", "new")({"old": 5, "new": 6})
def test_rename_key_both_keys_rejected_with_removed_in(
caplog: pytest.LogCaptureFixture,
) -> None:
with (
caplog.at_level(logging.WARNING, logger="esphome.config_validation"),
pytest.raises(Invalid, match="Cannot specify more than one of"),
):
cv.rename_key("old", "new", removed_in="2026.8.0")({"old": 5, "new": 6})
assert not caplog.records
def test_file__existing_relative_path(setup_core: Path) -> None:
(setup_core / "partitions.csv").write_text("csv\n")
assert cv.file_("partitions.csv") == setup_core / "partitions.csv"
def test_file__missing_raises(setup_core: Path) -> None:
with pytest.raises(Invalid, match="Could not find file"):
cv.file_("partitions.csv")
def test_file__remaps_bundle_absolute_path(setup_core: Path) -> None:
"""A stale absolute path in an extracted bundle resolves to the bundled copy."""
manifest = {
"manifest_version": 1,
"config_filename": "test.yaml",
"config_dir": "/original/config",
}
(setup_core / "manifest.json").write_text(json.dumps(manifest))
(setup_core / "partitions.csv").write_text("csv\n")
assert cv.file_("/original/config/partitions.csv") == setup_core / "partitions.csv"
def test_file__missing_absolute_path_without_bundle(setup_core: Path) -> None:
with pytest.raises(Invalid, match="Could not find file"):
cv.file_("/original/config/partitions.csv")
def test_file__remaps_windows_bundle_absolute_path(setup_core: Path) -> None:
"""A bundle created on Windows resolves on a host with another layout."""
manifest = {
"manifest_version": 1,
"config_filename": "test.yaml",
"config_dir": "C:\\Users\\nick\\esphome",
}
(setup_core / "manifest.json").write_text(json.dumps(manifest))
(setup_core / "partitions.csv").write_text("csv\n")
result = cv.file_("C:\\Users\\nick\\esphome\\partitions.csv")
assert result == setup_core / "partitions.csv"
def test_directory_remaps_bundle_absolute_path(setup_core: Path) -> None:
"""A stale absolute directory in an extracted bundle resolves to the bundled copy."""
manifest = {
"manifest_version": 1,
"config_filename": "test.yaml",
"config_dir": "/original/config",
}
(setup_core / "manifest.json").write_text(json.dumps(manifest))
(setup_core / "headers").mkdir()
assert cv.directory("/original/config/headers") == setup_core / "headers"
def test_directory_missing_raises(setup_core: Path) -> None:
with pytest.raises(Invalid, match="Could not find directory"):
cv.directory("/original/config/headers")
def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None:
"""A remapped path that is a directory still fails file validation."""
manifest = {
"manifest_version": 1,
"config_filename": "test.yaml",
"config_dir": "/original/config",
}
(setup_core / "manifest.json").write_text(json.dumps(manifest))
(setup_core / "headers").mkdir()
with pytest.raises(Invalid, match="is not a file"):
cv.file_("/original/config/headers")
+30
View File
@@ -591,6 +591,36 @@ class TestEsphomeCore:
assert target.is_esp32 is False
assert target.is_esp8266 is True
def test_is_rp2(self, target):
"""The canonical RP2 family gate flips on for the rp2 platform."""
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"}
assert target.is_rp2 is True
assert target.is_esp32 is False
assert target.is_esp8266 is False
def test_is_rp2040_deprecated_alias_matches_is_rp2(self, target, caplog):
"""``is_rp2040`` is kept as a deprecation shim that returns whatever
``is_rp2`` returns; both must agree across platform values. A
one-shot deprecation warning is emitted on first access and
deduped via ``CORE.data`` for the rest of the run."""
import logging
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"}
with caplog.at_level(logging.WARNING, logger="esphome.core"):
assert target.is_rp2040 is True
assert target.is_rp2040 == target.is_rp2
warnings = [r for r in caplog.records if "is_rp2040" in r.message]
assert len(warnings) == 1
assert "2027.7.0" in warnings[0].message
# Reset the dedupe so the False-platform branch also runs the shim.
target.data.pop("_core_is_rp2040_deprecated_warned", None)
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
assert target.is_rp2040 is False
assert target.is_rp2040 == target.is_rp2
def test_firmware_bin__default(self, target):
"""Default platforms produce <pioenvs>/<name>/firmware.bin."""
target.name = "test-device"
+197 -8
View File
@@ -1,3 +1,4 @@
import glob
import hashlib
import json
import os
@@ -86,6 +87,48 @@ def test_collect_filtered_files_exclude(tmp_path):
assert str(f2) not in result
def test_collect_filtered_files_exclude_pattern_in_subdir(tmp_path):
src = tmp_path / "lib" / "src"
src.mkdir(parents=True)
kept = src / "a.c"
excluded = src / "hasty.c"
kept.write_text("int a;")
excluded.write_text("int b;")
result = collect_filtered_files(tmp_path, ["+<lib/src/*.c>", "-<lib/src/hasty.c>"])
assert str(kept) in result
assert str(excluded) not in result
def test_collect_filtered_files_exclude_unnormalized_glob_output(tmp_path, monkeypatch):
# On Windows, glob keeps the pattern's literal separators for non-wildcard
# path components, so the "+" wildcard pattern and the "-" literal pattern
# yield the same file spelled differently and the exclude set difference
# misses it. Backslash is a regular filename character on POSIX (such paths
# fail the final is_file filter), so reproduce the unnormalized-output
# mismatch portably with dot segments, which normpath also collapses.
src = tmp_path / "lib" / "src"
src.mkdir(parents=True)
kept = src / "a.c"
excluded = src / "hasty.c"
kept.write_text("int a;")
excluded.write_text("int b;")
real_glob = glob.glob
def unnormalized_glob(pattern, recursive=False):
if "*" in pattern:
base = str(tmp_path)
return [base + "/lib/./src/a.c", base + "/lib/./src/hasty.c"]
return real_glob(pattern, recursive=recursive)
monkeypatch.setattr(glob, "glob", unnormalized_glob)
result = collect_filtered_files(tmp_path, ["+<lib/src/*.c>", "-<lib/src/hasty.c>"])
assert [Path(r).name for r in result] == ["a.c"]
assert str(kept) in result
def test_split_list_by_condition():
items = ["-Iinclude", "-Llib", "-Wall"]
@@ -126,30 +169,98 @@ def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path):
}
content = generate_cmakelists_txt(tmp_component)
sep = "\\\\" if os.name == "nt" else "/"
# Paths are always emitted with forward slashes so the CMakeLists is
# portable; on Windows os.path.relpath would otherwise yield backslashes
# that break CMake's list re-parsing.
assert (
content
== f"""idf_component_register(
SRCS "src{sep}main.c"
== """idf_component_register(
SRCS "src/main.c"
INCLUDE_DIRS "src"
REQUIRES dep ${{ESPHOME_PROJECT_MANAGED_COMPONENTS}} ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
REQUIRES dep ${ESPHOME_PROJECT_MANAGED_COMPONENTS} ${ESPHOME_PROJECT_BUILTIN_COMPONENTS}
)
target_compile_options(${{COMPONENT_LIB}} PUBLIC
target_compile_options(${COMPONENT_LIB} PUBLIC
"-DTEST"
)
target_compile_options(${{COMPONENT_LIB}} PRIVATE
target_compile_options(${COMPONENT_LIB} PRIVATE
"-Wall"
)
target_link_directories(${{COMPONENT_LIB}} INTERFACE
target_link_directories(${COMPONENT_LIB} INTERFACE
"lib"
)
target_link_libraries(${{COMPONENT_LIB}} INTERFACE
target_link_libraries(${COMPONENT_LIB} INTERFACE
"mylib"
)
"""
)
def test_generate_cmakelists_txt_uses_forward_slashes_on_windows(
tmp_component, monkeypatch: pytest.MonkeyPatch
) -> None:
# os.path.relpath yields backslash paths on Windows, which CMake rejects
# when it re-parses the SRCS list (e.g. "\b" in "src\backend" is an invalid
# character escape). Simulate that output and confirm the generated
# CMakeLists normalizes the separators to forward slashes.
src_dir = tmp_component.path / "src" / "backend"
src_dir.mkdir(parents=True)
(src_dir / "cipher.c").write_text("int f() {}")
tmp_component.data = {}
monkeypatch.setattr("esphome.espidf.component.os.sep", "\\")
monkeypatch.setattr(
"esphome.espidf.component.os.path.relpath",
lambda *args, **kwargs: "src\\backend\\cipher.c",
)
content = generate_cmakelists_txt(tmp_component)
assert 'SRCS "src/backend/cipher.c"' in content
assert "\\" not in content
def test_generate_cmakelists_txt_multi_token_flag(tmp_component):
# PlatformIO shell-lexes each build.flags entry, so a single entry can
# carry a flag and its argument. The generated CMakeLists must emit them
# as separate compile options, not one argument with an embedded space.
src_dir = tmp_component.path / "src"
src_dir.mkdir()
(src_dir / "main.c").write_text("int main() {}")
tmp_component.data = {"build": {"flags": ["-include cp_custom_alloc.h", "-DTEST"]}}
content = generate_cmakelists_txt(tmp_component)
assert '"-include cp_custom_alloc.h"' not in content
assert ' "-include"\n "cp_custom_alloc.h"\n' in content
def test_generate_cmakelists_txt_space_separated_classified_flags(tmp_component):
# Space-separated -I/-L/-l entries routed to INCLUDE_DIRS and the link
# handling before the shlex split was added; splitting must not leak
# them into raw compile options.
src_dir = tmp_component.path / "src"
src_dir.mkdir()
(src_dir / "main.c").write_text("int main() {}")
(tmp_component.path / "extra_inc").mkdir()
tmp_component.data = {
"build": {"flags": ["-I extra_inc", "-L extra_lib", "-l extralib", "-DTEST"]}
}
content = generate_cmakelists_txt(tmp_component)
assert 'INCLUDE_DIRS "src" "extra_inc"' in content
assert 'target_link_directories(${COMPONENT_LIB} INTERFACE\n "extra_lib"\n)' in (
content
)
assert 'target_link_libraries(${COMPONENT_LIB} INTERFACE\n "extralib"\n)' in (
content
)
assert '"-I"' not in content
assert '"-L"' not in content
assert '"-l"' not in content
def test_generate_cmakelists_txt_references_project_managed_components_variable(
tmp_component: IDFComponent,
) -> None:
@@ -372,6 +483,84 @@ def test_node_key_git_no_ref():
assert locator == ("https://github.com/foo/bar.git", None)
def test_node_key_url_in_name_is_git():
# add_library("https://github.com/x/y", None): PlatformIO accepted a bare
# git URL as the library name, so the converter must too.
key, is_git, locator = _node_key(
"https://github.com/pstolarz/OneWireNg", None, None
)
assert key == "pstolarz/OneWireNg"
assert is_git is True
assert locator == ("https://github.com/pstolarz/OneWireNg", None)
def test_node_key_url_in_name_with_ref():
key, is_git, locator = _node_key(
"https://github.com/foo/bar.git#v1.2.3", None, None
)
assert (key, is_git, locator) == (
"foo/bar",
True,
("https://github.com/foo/bar.git", "v1.2.3"),
)
def test_node_key_url_in_name_git_plus_prefix():
key, is_git, locator = _node_key("git+https://github.com/foo/bar", None, None)
assert (key, is_git, locator) == (
"foo/bar",
True,
("https://github.com/foo/bar", None),
)
def test_node_key_git_plus_prefix_in_repository():
_key, is_git, locator = _node_key("name", None, "git+https://github.com/foo/bar")
assert (is_git, locator) == (True, ("https://github.com/foo/bar", None))
def test_node_key_custom_name_equals_url_is_git():
key, is_git, locator = _node_key(
"OneWireNg=https://github.com/pstolarz/OneWireNg", None, None
)
assert (key, is_git, locator) == (
"pstolarz/OneWireNg",
True,
("https://github.com/pstolarz/OneWireNg", None),
)
def test_node_key_url_in_name_with_query_containing_equals():
# A bare URL whose query string contains ``=`` must not be split by the
# CustomName=URL handling.
key, is_git, locator = _node_key("https://host/x/y.git?ref=main", None, None)
assert (key, is_git, locator) == (
"x/y",
True,
("https://host/x/y.git?ref=main", None),
)
@pytest.mark.parametrize("name", ["http://[::1", "CustomName=http://[::1"])
def test_node_key_malformed_url_in_name_raises(name: str) -> None:
# A name that was clearly meant to be a URL but does not parse must fail
# fast instead of degrading to a confusing registry lookup error.
with pytest.raises(RuntimeError, match="Invalid PIO library URL"):
_node_key(name, None, None)
def test_node_key_name_with_equals_but_no_url_is_registry():
key, is_git, locator = _node_key("FOO=BAR", "1.0", None)
assert (key, is_git, locator) == ("FOO=BAR", False, (None, "FOO=BAR"))
def test_node_key_version_url_still_ignored_when_name_plain():
# A version that is a URL is handled by the dependency walk, not here;
# a plain name must stay a registry spec regardless of version shape.
key, is_git, _locator = _node_key("bar", "https://github.com/foo/bar", None)
assert (key, is_git) == ("bar", False)
def test_node_key_registry_owner_name():
key, is_git, locator = _node_key("foo/bar", "^1.0.0", None)
assert (key, is_git, locator) == ("foo/bar", False, ("foo", "bar"))
+756 -10
View File
@@ -3,10 +3,14 @@
# pylint: disable=protected-access
from contextlib import contextmanager
import importlib.util
import io
import json
import logging
import os
from pathlib import Path
import runpy
import subprocess
import sys
import tarfile
from types import SimpleNamespace
@@ -15,7 +19,10 @@ from unittest.mock import patch
import pytest
from esphome.espidf.framework import (
ESPHOME_STAMP_FILE,
STAMP_SCHEMA_VERSION,
_ccache_env,
_check_esphome_idf_framework_install,
_check_stamp,
_check_windows_path_length,
_clone_idf_with_submodules,
@@ -25,7 +32,11 @@ from esphome.espidf.framework import (
_get_python_env_path,
_get_python_version,
_parse_git_source,
_patch_tools_json_demote_unused_tools,
_patch_tools_json_for_linux_arm64,
_prefetch_idf_tool_archives,
_read_stamp,
_stamp_covers,
_windows_long_paths_enabled,
_write_idf_version_txt,
_write_stamp,
@@ -131,10 +142,17 @@ def test_parse_git_source_rejected(source: str) -> None:
assert _parse_git_source(source) is None
def _make_idf_tree(framework_path: Path) -> None:
"""Create the minimum tree _clone_idf_with_submodules sanity-checks for."""
def _make_idf_tree(framework_path: Path, *, gitmodules: bool = True) -> None:
"""Create the minimum tree _clone_idf_with_submodules sanity-checks for.
``gitmodules=False`` simulates a fork that vendors components in-tree
instead of declaring submodules; update_submodules skips the git call
when that file is missing.
"""
(framework_path / "tools").mkdir(parents=True)
(framework_path / "tools" / "idf_tools.py").write_text("# stub\n")
if gitmodules:
(framework_path / ".gitmodules").write_text("# stub\n")
def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None:
@@ -208,6 +226,28 @@ def test_clone_idf_with_submodules_raises_when_tree_missing(
)
def test_clone_idf_accepts_flattened_fork_without_gitmodules(
tmp_path: Path,
) -> None:
"""A fork that vendors components in-tree instead of as submodules is valid.
No .gitmodules means the submodule step is skipped entirely.
"""
framework_path = tmp_path / "idf"
framework_path.mkdir()
_make_idf_tree(framework_path, gitmodules=False)
with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock:
_clone_idf_with_submodules(
framework_path,
"https://github.com/example/flattened-esp-idf.git",
None,
)
calls = [c.args[0] for c in run_git_command_mock.call_args_list]
assert not any(c[1] == "submodule" for c in calls)
# ---------------------------------------------------------------------------
# Helpers for _tar_extract_all hard-link prefix-stripping tests
# ---------------------------------------------------------------------------
@@ -310,6 +350,21 @@ class TestTarExtractHardLinkPrefixStripping:
_IDF_VERSION = "5.1.2"
def _fake_download_from_mirrors(
mirrors: list[str],
substitutions: dict[str, str],
target: object,
**kwargs: object,
) -> str:
"""Stand-in for download_from_mirrors that creates path targets, since
the framework code opens the downloaded tarball afterwards."""
if isinstance(target, (str, os.PathLike)):
path = Path(target)
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
return "https://example.com/idf.tar.xz"
@pytest.fixture
def espidf_mocks(setup_core: Path):
"""Patch the heavy I/O of check_esp_idf_install and pre-create the framework dir."""
@@ -317,10 +372,10 @@ def espidf_mocks(setup_core: Path):
# extracted-marker touch writes into.
_get_framework_path(_IDF_VERSION).mkdir(parents=True, exist_ok=True)
with (
patch("esphome.espidf.framework.rmdir"),
patch("esphome.espidf.framework.rmdir") as rmdir_mock,
patch(
"esphome.espidf.framework.download_from_mirrors",
return_value="https://example.com/idf.tar.xz",
side_effect=_fake_download_from_mirrors,
) as download,
patch("esphome.espidf.framework.archive_extract_all") as extract,
patch("esphome.espidf.framework.create_venv") as venv,
@@ -331,8 +386,11 @@ def espidf_mocks(setup_core: Path):
patch("esphome.espidf.framework._clone_idf_with_submodules") as clone,
patch("esphome.espidf.framework._write_idf_version_txt"),
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._write_stamp"),
patch("esphome.espidf.framework._check_stamp", return_value=True),
patch("esphome.espidf.framework._stamp_covers", return_value=True),
patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION),
patch("esphome.espidf.framework._get_python_version", return_value="3.11.0"),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
@@ -344,6 +402,7 @@ def espidf_mocks(setup_core: Path):
run_ok=run_ok,
tool_paths=tool_paths,
clone=clone,
rmdir=rmdir_mock,
)
@@ -358,6 +417,27 @@ def test_check_esp_idf_install_fresh(espidf_mocks: SimpleNamespace) -> None:
espidf_mocks.extract.assert_called_once()
espidf_mocks.venv.assert_called_once()
espidf_mocks.clone.assert_not_called()
# the tool download cache (<IDF_TOOLS_PATH>/dist) is pruned after install
espidf_mocks.rmdir.assert_any_call(
get_idf_tools_path() / "dist", msg="Remove ESP-IDF tool download cache"
)
def test_check_esp_idf_install_dist_prune_failure_ignored(
espidf_mocks: SimpleNamespace,
) -> None:
"""A failure to prune the tool download cache must not fail the install."""
tools_dist = get_idf_tools_path() / "dist"
def rmdir_side_effect(directory: Path, msg: str | None = None) -> None:
if directory == tools_dist:
raise RuntimeError("cannot remove dist")
espidf_mocks.rmdir.side_effect = rmdir_side_effect
# install still succeeds despite the failed prune
framework_path, _ = check_esp_idf_install(_IDF_VERSION, force=True)
assert framework_path == _get_framework_path(_IDF_VERSION)
def test_check_esp_idf_install_git_source(espidf_mocks: SimpleNamespace) -> None:
@@ -389,6 +469,20 @@ def test_check_esp_idf_install_already_installed(espidf_mocks: SimpleNamespace)
espidf_mocks.venv.assert_not_called()
def test_corrupt_tarball_removed_when_extraction_fails(
espidf_mocks: SimpleNamespace,
) -> None:
"""A tarball that fails to extract (e.g. torn by an unclean shutdown) is
deleted so the next run re-downloads instead of failing forever."""
espidf_mocks.extract.side_effect = RuntimeError("xz: unexpected end of input")
tarball = get_idf_tools_path() / "dist" / f"esp-idf-{_IDF_VERSION}.tar.xz"
with pytest.raises(RuntimeError, match="unexpected end of input"):
check_esp_idf_install(_IDF_VERSION, force=True)
assert not tarball.exists()
def test_check_esp_idf_install_framework_failure(espidf_mocks: SimpleNamespace) -> None:
"""A failing idf_tools install raises."""
espidf_mocks.run_ok.side_effect = [False]
@@ -426,13 +520,17 @@ def _mark_installed() -> None:
def test_check_esp_idf_install_stamp_mismatch_reinstalls(
espidf_mocks: SimpleNamespace,
) -> None:
"""A stamp mismatch reinstalls tools (marker present, so no re-extract)."""
"""A stamp mismatch reinstalls tools (marker present, so no re-extract).
The python env is left alone: it depends on the framework version and
features, not on which toolchains are installed.
"""
_mark_installed()
with patch("esphome.espidf.framework._check_stamp", return_value=False):
with patch("esphome.espidf.framework._stamp_covers", return_value=False):
check_esp_idf_install(_IDF_VERSION)
espidf_mocks.extract.assert_not_called() # marker present -> no re-extract
espidf_mocks.venv.assert_called_once() # tools reinstall -> venv rebuilt
espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept
def test_check_esp_idf_install_check_command_failure_reinstalls(
@@ -445,7 +543,7 @@ def test_check_esp_idf_install_check_command_failure_reinstalls(
check_esp_idf_install(_IDF_VERSION, features=["fb"])
espidf_mocks.extract.assert_not_called()
espidf_mocks.venv.assert_called_once()
espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept
def test_check_esp_idf_install_unknown_python_version_reinstalls(
@@ -465,8 +563,8 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv(
) -> None:
"""Framework stamp matches but the python-env stamp does not -> venv rebuilt."""
# _check_stamp passes for the framework (no python_version key) and fails
# for the python env (carries python_version), so only the venv rebuilds.
# _check_stamp only guards the python env now (the framework uses
# _stamp_covers, patched True by the fixture); failing it rebuilds the venv.
def stamp_ok(_stamp_file, info: dict) -> bool:
return "python_version" not in info
@@ -478,6 +576,174 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv(
espidf_mocks.venv.assert_called_once()
def _requested_stamp(targets: list[str], tools: list[str] | None = None) -> dict:
return {
"schema_version": STAMP_SCHEMA_VERSION,
"targets": targets,
"tools": tools or ["required"],
}
@pytest.mark.parametrize(
("stored", "targets", "expected"),
[
# a stored "all" covers any target
(_requested_stamp(["all"]), ["esp32"], True),
# exact match and superset both cover
(_requested_stamp(["esp32"]), ["esp32"], True),
(_requested_stamp(["esp32", "esp32c3"]), ["esp32"], True),
# a new target is not covered
(_requested_stamp(["esp32"]), ["esp32c3"], False),
# tools and schema_version must match exactly
(_requested_stamp(["all"], tools=["cmake", "required"]), ["esp32"], False),
(_requested_stamp(["all"]) | {"schema_version": "no"}, ["esp32"], False),
# an unknown extra field participates in invalidation by default
(_requested_stamp(["all"]) | {"module_version": 1}, ["esp32"], False),
# missing/corrupt stamps never cover
(None, ["esp32"], False),
(
{"schema_version": STAMP_SCHEMA_VERSION, "tools": ["required"]},
["esp32"],
False,
),
],
)
def test_stamp_covers(stored: dict | None, targets: list[str], expected: bool) -> None:
assert _stamp_covers(stored, _requested_stamp(targets)) is expected
@contextmanager
def _framework_install_patches():
"""Patches for calling _check_esphome_idf_framework_install directly with
real stamp files (unlike espidf_mocks, which stubs the stamp layer)."""
with (
patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok,
patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.espidf.framework.rmdir"),
):
yield run_ok
def _extracted_framework_with_stamp(stamp: dict) -> Path:
framework_path = _get_framework_path(_IDF_VERSION)
framework_path.mkdir(parents=True, exist_ok=True)
(framework_path / ".esphome_extracted").touch()
_write_stamp(framework_path / ESPHOME_STAMP_FILE, stamp)
return framework_path
def test_framework_install_target_subset_skips_install() -> None:
"""A stamp holding a superset of the requested targets skips the installer."""
framework_path = _extracted_framework_with_stamp(_requested_stamp(["all"]))
with _framework_install_patches() as run_ok:
_, fresh_extract = _check_esphome_idf_framework_install(
_IDF_VERSION, ["esp32"], ["required"]
)
run_ok.assert_not_called()
assert fresh_extract is False
# the stamp is untouched
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["all"]
def test_framework_install_new_target_installs_and_merges_stamp() -> None:
"""A new target runs the installer for just that target and the stamp
records the union of everything installed so far."""
framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"]))
with _framework_install_patches() as run_ok:
_, fresh_extract = _check_esphome_idf_framework_install(
_IDF_VERSION, ["esp32c3"], ["required"]
)
assert fresh_extract is False
assert "--targets=esp32c3" in run_ok.call_args[0][0]
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["esp32", "esp32c3"]
def test_check_esp_idf_install_env_targets_override_wins(
espidf_mocks: SimpleNamespace,
) -> None:
"""An explicitly set ESPHOME_IDF_DEFAULT_TARGETS overrides per-variant targets."""
with patch("esphome.espidf.framework._IDF_DEFAULT_TARGETS_EXPLICIT", True):
check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"])
install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0]
assert "--targets=all" in install_cmd
def test_check_esp_idf_install_uses_requested_targets(
espidf_mocks: SimpleNamespace,
) -> None:
"""Without the env override, the caller's per-variant targets are installed."""
check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"])
install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0]
assert "--targets=esp32" in install_cmd
def test_framework_install_all_request_collapses_merged_stamp_to_all() -> None:
"""Requesting "all" over a per-variant stamp merges and collapses to
["all"], not ["all", "esp32"], so the stamp shape stays canonical."""
framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"]))
with _framework_install_patches() as run_ok:
_check_esphome_idf_framework_install(_IDF_VERSION, ["all"], ["required"])
run_ok.assert_called_once()
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["all"]
def test_framework_install_tools_change_resets_stamp_targets() -> None:
"""A reinstall triggered by a tools change must not carry the old stamp's
targets forward: the installer only ran for this build's targets, so a
merged stamp would let other variants skip the reinstall they need."""
framework_path = _extracted_framework_with_stamp(
_requested_stamp(["all"], tools=["cmake", "required"])
)
with _framework_install_patches() as run_ok:
_check_esphome_idf_framework_install(_IDF_VERSION, ["esp32"], ["required"])
run_ok.assert_called_once()
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["esp32"]
assert stamp["tools"] == ["required"]
@pytest.mark.parametrize(
("lib", "expect_hint"),
[
(None, True),
("libusb-1.0.so.0", False),
],
)
def test_check_esp_idf_install_failure_libusb_hint(
espidf_mocks: SimpleNamespace,
caplog: pytest.LogCaptureFixture,
lib: str | None,
expect_hint: bool,
) -> None:
"""A failed tools install only shows the libusb hint when libusb-1.0 is
actually missing."""
espidf_mocks.run_ok.return_value = False
# Fake Linux so the gate is exercised on all CI hosts; faking Linux is safe
# everywhere (unlike faking Windows, which pulls in winreg on other hosts)
with (
patch("esphome.espidf.framework.find_library", return_value=lib),
patch("esphome.espidf.framework.platform.system", return_value="Linux"),
caplog.at_level(logging.ERROR, logger="esphome.espidf.framework"),
pytest.raises(RuntimeError, match="framework installation failure"),
):
check_esp_idf_install(_IDF_VERSION, force=True)
assert ("libusb-1.0.so.0 was not found" in caplog.text) == expect_hint
def test_check_esp_idf_install_unparseable_version(
espidf_mocks: SimpleNamespace,
) -> None:
@@ -489,6 +755,29 @@ def test_check_esp_idf_install_unparseable_version(
espidf_mocks.extract.assert_called_once()
@pytest.mark.parametrize(
("version", "short_version"),
[
("6.0.0", "6.0"),
("6.0.0-rc1", "6.0-rc1"),
("5.5.4", None), # vX.Y tags only exist for X.Y.0 releases
],
)
def test_check_esp_idf_install_short_version_substitution(
espidf_mocks: SimpleNamespace, version: str, short_version: str | None
) -> None:
"""SHORT_VERSION is only offered for x.y.0 releases, so the vX.Y mirror
template is never tried for versions whose tag cannot exist."""
_get_framework_path(version).mkdir(parents=True, exist_ok=True)
check_esp_idf_install(version, force=True)
# First call downloads the framework archive; a later call fetches the
# constraints file with its own substitutions.
substitutions = espidf_mocks.download.call_args_list[0][0][1]
assert substitutions.get("SHORT_VERSION") == short_version
assert substitutions["VERSION"] == version
# ---------------------------------------------------------------------------
# _patch_tools_json_for_linux_arm64 (arm64-only ninja backport)
# ---------------------------------------------------------------------------
@@ -561,6 +850,415 @@ def test_patch_tools_json_already_patched_is_noop(tmp_path: Path) -> None:
assert tools_json.read_text(encoding="utf-8") == before
# ---------------------------------------------------------------------------
# _prefetch_idf_tool_archives
# ---------------------------------------------------------------------------
_PREFETCH_JSON = json.dumps(
[
{
"name": "cmake@3.30.2",
"url": "https://example.com/cmake.tar.gz",
"size": 123,
"sha256": "ab" * 32,
"dest": "cmake-3.30.2.tar.gz",
},
{
"name": "ninja@1.12.1",
"url": "https://example.com/ninja.zip",
"size": 45,
"sha256": "cd" * 32,
"dest": "ninja.zip",
},
]
)
def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.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)
dist = get_idf_tools_path() / "dist"
assert download.call_count == 2
assert download.call_args_list[0][0] == (
"https://example.com/cmake.tar.gz",
dist / "cmake-3.30.2.tar.gz",
)
assert download.call_args_list[0][1] == {"sha256": "ab" * 32, "size": 123}
def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
dist = get_idf_tools_path() / "dist"
dist.mkdir(parents=True)
(dist / "cmake-3.30.2.tar.gz").write_bytes(b"cached")
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.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)
# only the missing archive is downloaded
assert download.call_count == 1
assert download.call_args[0][1] == dist / "ninja.zip"
@pytest.mark.parametrize(
("run_result", "download_error", "expected_log"),
[
((False, "", "script exploded"), None, "tool downloads"), # script failure
((True, "{ not json", ""), None, "prefetch failed"), # unparsable output
(
(True, _PREFETCH_JSON, ""),
OSError("network down"),
"Could not prefetch",
), # download failure
],
)
def test_prefetch_failures_never_raise(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
run_result: tuple[bool, str, str],
download_error: Exception | None,
expected_log: str,
) -> None:
"""The prefetch is best-effort; idf_tools downloads whatever is missing."""
with (
patch("esphome.espidf.framework.run_command", return_value=run_result),
patch(
"esphome.espidf.framework.download_with_resume",
side_effect=download_error,
),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert expected_log in caplog.text
def test_prefetch_one_failed_archive_does_not_stop_the_rest(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A single archive failing its download must not abort the prefetch of
the remaining archives."""
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch(
"esphome.espidf.framework.download_with_resume",
side_effect=[OSError("network down"), None],
) as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert download.call_count == 2
assert "Could not prefetch cmake@3.30.2" in caplog.text
def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None:
with (
patch(
"esphome.espidf.framework.run_command", return_value=(True, "[]", "")
) as run,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(
tmp_path, "esp32,esp32c3", ["required", "cmake"], {"IDF_TOOLS_PATH": "/x"}
)
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
env = run.call_args[1]["env"]
assert env["IDF_TOOLS_PATH"] == "/x"
assert env["PYTHONPATH"] == 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/."""
calls: list[str] = []
with (
patch(
"esphome.espidf.framework._prefetch_idf_tool_archives",
side_effect=lambda *a, **k: calls.append("prefetch"),
),
):
espidf_mocks.run_ok.side_effect = lambda *a, **k: (
calls.append("install") or True
)
check_esp_idf_install(_IDF_VERSION, force=True)
assert calls.index("prefetch") < calls.index("install")
# ---------------------------------------------------------------------------
# get_tool_downloads.py (against the stub idf_tools module in fixtures/)
# ---------------------------------------------------------------------------
_IDF_TOOLS_STUB_DIR = Path(__file__).parent / "fixtures" / "idf_tools_stub"
def _run_downloads_script(
tmp_path: Path, *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"
env = os.environ | {
"PYTHONPATH": str(_IDF_TOOLS_STUB_DIR),
"IDF_TOOLS_PATH": str(tmp_path / "tp"),
}
if env_extra:
env |= env_extra
return subprocess.run(
[sys.executable, str(script), str(tmp_path / "fw"), *args],
capture_output=True,
text=True,
env=env,
check=False,
)
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")
assert result.returncode == 0, result.stderr
downloads = {d["name"]: d for d in json.loads(result.stdout)}
# installed-tool@1.0 is already installed and must not be listed
assert set(downloads) == {"cmake@3.30.2", "ninja@1.12.1", "broken-tool@2.0"}
assert downloads["cmake@3.30.2"]["dest"] == "cmake.tar.gz"
assert downloads["cmake@3.30.2"]["size"] == 11
assert downloads["cmake@3.30.2"]["sha256"] == "aa"
# rename_dist overrides the URL basename
assert downloads["ninja@1.12.1"]["dest"] == "ninja-v1.zip"
# the stub prints informational lines; they must be on stderr
assert "Changed download URL" in result.stderr
def test_get_tool_downloads_applies_mirror_rewrite(tmp_path: Path) -> None:
result = _run_downloads_script(
tmp_path,
"esp32",
"required",
env_extra={"TEST_MIRROR_PREFIX": "https://mirror.test/"},
)
assert result.returncode == 0, result.stderr
downloads = json.loads(result.stdout)
assert all(d["url"].startswith("https://mirror.test/") for d in downloads)
def _run_downloads_inprocess(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
*args: str,
) -> list[dict]:
"""Execute get_tool_downloads.py in-process against the stub idf_tools.
Unlike the subprocess variant this runs under coverage, exercising the
script's own lines.
"""
spec = importlib.util.spec_from_file_location(
"idf_tools", _IDF_TOOLS_STUB_DIR / "idf_tools.py"
)
stub = importlib.util.module_from_spec(spec)
spec.loader.exec_module(stub)
monkeypatch.setitem(sys.modules, "idf_tools", stub)
monkeypatch.setenv("IDF_TOOLS_PATH", str(tmp_path / "tp"))
script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py"
monkeypatch.setattr(sys, "argv", [str(script), str(tmp_path / "fw"), *args])
runpy.run_path(str(script))
return json.loads(capsys.readouterr().out)
def test_get_tool_downloads_inprocess_full_flow(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""In-process run covering the whole script: required expansion,
installed/broken tools, rename_dist, and version pinning via tool@version."""
downloads = {
d["name"]: d
for d in _run_downloads_inprocess(
tmp_path, monkeypatch, capsys, "esp32", "required"
)
}
assert set(downloads) == {"cmake@3.30.2", "ninja@1.12.1", "broken-tool@2.0"}
assert downloads["ninja@1.12.1"]["dest"] == "ninja-v1.zip"
assert downloads["cmake@3.30.2"]["url"] == "https://gh.test/cmake.tar.gz"
def test_get_tool_downloads_inprocess_explicit_tool_specs(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Explicit tool names and tool@version specs resolve; unknown tools and
unknown versions are skipped."""
downloads = _run_downloads_inprocess(
tmp_path,
monkeypatch,
capsys,
"esp32",
"cmake@3.30.2",
"no-such-tool",
"cmake@9.9.9",
)
assert [d["name"] for d in downloads] == ["cmake@3.30.2"]
# ---------------------------------------------------------------------------
# _patch_tools_json_demote_unused_tools (openocd, gdb, ULP toolchain optional)
# ---------------------------------------------------------------------------
def test_demote_unused_tools_patches_install_type(tmp_path: Path) -> None:
tools_json = _write_tools_json(
tmp_path,
{
"tools": [
{"name": "openocd-esp32", "install": "always"},
{"name": "xtensa-esp-elf-gdb", "install": "always"},
{"name": "riscv32-esp-elf-gdb", "install": "always"},
{"name": "esp32ulp-elf", "install": "always"},
{"name": "xtensa-esp-elf", "install": "always"},
{"name": "esp-rom-elfs", "install": "always"},
]
},
)
_patch_tools_json_demote_unused_tools(tmp_path)
data = json.loads(tools_json.read_text(encoding="utf-8"))
install_types = {t["name"]: t["install"] for t in data["tools"]}
assert install_types == {
"openocd-esp32": "on_request",
"xtensa-esp-elf-gdb": "on_request",
"riscv32-esp-elf-gdb": "on_request",
"esp32ulp-elf": "on_request",
# the compiler toolchain and ROM ELFs stay required
"xtensa-esp-elf": "always",
"esp-rom-elfs": "always",
}
def test_demote_unused_tools_drops_xtensa_from_riscv_targets(tmp_path: Path) -> None:
"""riscv32-esp-elf loses the xtensa chips (ULP-RISC-V only, which ESPHome
never builds) but keeps its RISC-V targets; other tools are untouched."""
tools_json = _write_tools_json(
tmp_path,
{
"tools": [
{
"name": "riscv32-esp-elf",
"install": "always",
"supported_targets": ["esp32s2", "esp32s3", "esp32c3", "esp32p4"],
},
{
"name": "xtensa-esp-elf",
"install": "always",
"supported_targets": ["esp32", "esp32s2", "esp32s3"],
},
]
},
)
_patch_tools_json_demote_unused_tools(tmp_path)
data = json.loads(tools_json.read_text(encoding="utf-8"))
riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf")
xtensa = next(t for t in data["tools"] if t["name"] == "xtensa-esp-elf")
assert riscv["supported_targets"] == ["esp32c3", "esp32p4"]
assert riscv["install"] == "always"
assert xtensa["supported_targets"] == ["esp32", "esp32s2", "esp32s3"]
def test_demote_unused_tools_bad_supported_targets_type_still_demotes(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A non-list supported_targets on riscv32-esp-elf must not abort the
other demotions; the targets patch is best-effort and logs the skip so a
silently resumed riscv download is diagnosable."""
tools_json = _write_tools_json(
tmp_path,
{
"tools": [
{
"name": "riscv32-esp-elf",
"install": "always",
"supported_targets": None,
},
{"name": "openocd-esp32", "install": "always"},
]
},
)
with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"):
_patch_tools_json_demote_unused_tools(tmp_path)
data = json.loads(tools_json.read_text(encoding="utf-8"))
openocd = next(t for t in data["tools"] if t["name"] == "openocd-esp32")
riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf")
assert openocd["install"] == "on_request"
assert riscv["supported_targets"] is None
assert "Unexpected supported_targets" in caplog.text
def test_patch_tools_json_unexpected_structure_warns_and_skips(
tmp_path: Path,
) -> None:
"""Valid JSON with an unexpected shape must skip the patch, not raise."""
tools_dir = tmp_path / "tools"
tools_dir.mkdir()
tools_json = tools_dir / "tools.json"
tools_json.write_text('["not", "a", "dict"]', encoding="utf-8")
before = tools_json.read_text(encoding="utf-8")
_patch_tools_json_demote_unused_tools(tmp_path) # AttributeError -> skip
assert tools_json.read_text(encoding="utf-8") == before
def test_demote_unused_tools_already_patched_is_noop(tmp_path: Path) -> None:
tools_json = _write_tools_json(
tmp_path,
{
"tools": [
{"name": "openocd-esp32", "install": "on_request"},
{"name": "xtensa-esp-elf-gdb", "install": "on_request"},
{"name": "riscv32-esp-elf-gdb", "install": "on_request"},
{"name": "esp32ulp-elf", "install": "on_request"},
{
"name": "riscv32-esp-elf",
"install": "always",
"supported_targets": ["esp32c3", "esp32p4"],
},
]
},
)
before = tools_json.read_text(encoding="utf-8")
_patch_tools_json_demote_unused_tools(tmp_path)
assert tools_json.read_text(encoding="utf-8") == before
# ---------------------------------------------------------------------------
# Subprocess-backed helpers (_exec -> run_command rename) and get_framework_env
# ---------------------------------------------------------------------------
@@ -787,6 +1485,54 @@ def test_check_stamp_corrupt_file(tmp_path: Path) -> None:
assert _check_stamp(f, {"a": "1"}) is False
def test_read_stamp_corrupt_file_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# A corrupt stamp forces a full reinstall on every build, so it warns
# where the normal missing-file case stays silent.
f = tmp_path / "s.json"
f.write_text("{ not json", encoding="utf-8")
with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"):
assert _read_stamp(f) is None
assert "Ignoring corrupt stamp file" in caplog.text
def test_read_stamp_unreadable_file_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# An I/O fault (permissions, disk error) is distinguished from a simply
# missing stamp with a warning before falling back to reinstall.
f = tmp_path / "s.json"
f.write_text(json.dumps({"a": "1"}), encoding="utf-8")
with (
patch.object(Path, "open", side_effect=PermissionError("denied")),
caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"),
):
assert _read_stamp(f) is None
assert "Could not read stamp file" in caplog.text
def test_read_stamp_non_dict_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Well-formed JSON that is not an object is a fault, not a first install;
# it must leave a trace before forcing reinstalls.
f = tmp_path / "s.json"
f.write_text("null", encoding="utf-8")
with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"):
assert _read_stamp(f) is None
assert "unexpected type NoneType" in caplog.text
def test_read_stamp_missing_file_is_silent(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Missing stamps are the normal first-install case and must not log.
with caplog.at_level(logging.DEBUG, logger="esphome.espidf.framework"):
assert _read_stamp(tmp_path / "nope.json") is None
assert "stamp file" not in caplog.text
def test_write_idf_version_txt_writes_when_missing(tmp_path: Path) -> None:
_write_idf_version_txt(tmp_path, "5.1.2")
assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "v5.1.2\n"
+210 -6
View File
@@ -5,10 +5,19 @@
import json
import os
from pathlib import Path
import subprocess
from unittest.mock import patch
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.core import CORE
import pytest
from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
CONF_FRAMEWORK,
CONF_SOURCE,
)
from esphome.core import CORE, EsphomeError
from esphome.espidf import toolchain
@@ -47,7 +56,7 @@ def test_get_esphome_esp_idf_paths_forwards_source_override():
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", source_url=url)
mock_install.assert_called_once_with("5.5.4", targets=None, source_url=url)
def test_get_esphome_esp_idf_paths_no_override():
@@ -58,7 +67,28 @@ def test_get_esphome_esp_idf_paths_no_override():
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", source_url=None)
mock_install.assert_called_once_with("5.5.4", targets=None, source_url=None)
def test_get_configured_targets_from_variant(monkeypatch: pytest.MonkeyPatch):
"""The configured variant restricts the toolchain install to its target."""
monkeypatch.delenv("CI", raising=False)
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
assert toolchain._get_configured_targets() == ["esp32s3"]
def test_get_configured_targets_without_variant(monkeypatch: pytest.MonkeyPatch):
"""No stored variant (e.g. tooling outside a build) keeps the default."""
monkeypatch.delenv("CI", raising=False)
CORE.data.pop(KEY_ESP32, None)
assert toolchain._get_configured_targets() is None
def test_get_configured_targets_ci_installs_all(monkeypatch: pytest.MonkeyPatch):
"""CI installs every target so the shared cache covers all variants."""
monkeypatch.setenv("CI", "true")
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
assert toolchain._get_configured_targets() is None
def _setup_build(setup_core: Path) -> tuple[Path, Path]:
@@ -100,7 +130,7 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cxx_path": "cached"}')
cache.write_text('{"cc_path": "cached-gcc", "cxx_path": "cached"}')
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
@@ -108,7 +138,31 @@ def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
result = toolchain.get_idedata()
mock_transform.assert_not_called()
assert result == {"cxx_path": "cached"}
assert result == {"cc_path": "cached-gcc", "cxx_path": "cached"}
def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None:
"""A cache predating cc_path is rebuilt even though it is newer.
Such a cache stays newer than the compile DB forever, so consumers that
derive the binutils paths from cc_path would keep failing on it.
"""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text('{"cxx_path": "cached"}')
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cc_path": "gcc", "cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result["cc_path"] == "gcc"
def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None:
@@ -131,6 +185,33 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"])
def test_get_idedata_regenerates_on_non_dict_cache(
setup_core: Path, cached: str
) -> None:
"""A newer cache holding valid JSON that is not an object is regenerated.
A bare string would otherwise pass the cc_path check by substring and be
handed to consumers expecting a dict.
"""
compile_commands, cache = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(cached)
cc_mtime = compile_commands.stat().st_mtime
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cc_path": "gcc", "cxx_path": "g++"},
) as mock_transform:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert isinstance(result, dict)
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
"""An unparseable (but newer) cache falls back to regeneration."""
compile_commands, cache = _setup_build(setup_core)
@@ -184,6 +265,129 @@ 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_cmake_output_without_build_dir(setup_core: Path) -> None:
"""A build dir that was never created raises EsphomeError.
Without this, subprocess.run(cwd=build_dir) raises FileNotFoundError, which
the log stack-trace decoder doesn't recognise as a decode failure.
"""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
assert not build_dir.exists()
with pytest.raises(EsphomeError, match="No ESP-IDF build found"):
toolchain._get_cmake_output(build_dir)
def test_get_cmake_output_without_cmake_cache(setup_core: Path) -> None:
"""A build dir that exists but was never configured raises EsphomeError."""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True)
with pytest.raises(EsphomeError, match="No ESP-IDF build found"):
toolchain._get_cmake_output(build_dir)
def test_get_cmake_output_with_configured_build(setup_core: Path) -> None:
"""A configured build still runs cmake and caches the output.
The missing-build guard must not get in the way of a real build.
"""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
build_dir.mkdir(parents=True)
(build_dir / "CMakeCache.txt").write_text("")
completed = subprocess.CompletedProcess(
args=[], returncode=0, stdout="CMAKE_ADDR2LINE:FILEPATH=/tool/addr2line\n"
)
with (
patch.object(toolchain, "_get_idf_env", return_value={}),
patch.object(toolchain.subprocess, "run", return_value=completed) as mock_run,
):
assert toolchain._get_cmake_output(build_dir) == completed.stdout
# Second call is served from the cache rather than re-running cmake.
assert toolchain._get_cmake_output(build_dir) == completed.stdout
mock_run.assert_called_once()
assert toolchain._get_cmake_tool_path("CMAKE_ADDR2LINE") == Path("/tool/addr2line")
def test_get_cmake_output_missing_build_does_not_resolve_idf_env(
setup_core: Path,
) -> None:
"""The build check runs before the env is resolved.
Resolving the env calls check_esp_idf_install(), which can download and
extract the whole framework. A doomed call must never start that.
"""
_setup_build(setup_core)
build_dir = CORE.relative_build_path("build")
with (
patch.object(toolchain, "_get_idf_env") as mock_env,
patch.object(toolchain.subprocess, "run") as mock_run,
pytest.raises(EsphomeError),
):
toolchain._get_cmake_output(build_dir)
mock_env.assert_not_called()
mock_run.assert_not_called()
def test_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None:
"""The jobs argument is exported to idf.py as IDF_PY_BUILD_JOBS."""
_setup_build(setup_core)
with (
patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")),
patch.object(toolchain, "_get_idf_env", return_value={"PATH": "/bin"}),
patch.object(toolchain, "_get_idf_tool", return_value="python"),
patch.object(toolchain.subprocess, "run") as mock_run,
):
mock_run.return_value.returncode = 0
toolchain.run_idf_py("build", jobs=2)
env = mock_run.call_args.kwargs["env"]
assert env["IDF_PY_BUILD_JOBS"] == "2"
assert env["PATH"] == "/bin"
toolchain.run_idf_py("build")
env = mock_run.call_args.kwargs["env"]
assert "IDF_PY_BUILD_JOBS" not in env
def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None:
"""compile_process_limit is forwarded to run_idf_py as the job limit."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 1}}
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "run_idf_py", return_value=0) as mock_run,
patch.object(toolchain, "print_summary"),
):
assert toolchain.run_compile(config, verbose=False) == 0
mock_run.assert_called_once_with("build", "size", jobs=1)
def test_run_compile_without_compile_process_limit(setup_core: Path) -> None:
"""When no compile_process_limit is set, no job limit is passed to idf.py."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "run_idf_py", return_value=0) as mock_run,
patch.object(toolchain, "print_summary"),
):
assert toolchain.run_compile(config, verbose=False) == 0
mock_run.assert_called_once_with("build", "size", jobs=None)
def test_get_core_framework_version_from_core_data():
"""The version is read from CORE.data when validation populated it."""
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
+841 -9
View File
@@ -2,8 +2,10 @@
# pylint: disable=protected-access
import hashlib
import importlib.util
import io
import json
import logging
import os
from pathlib import Path
@@ -16,6 +18,8 @@ import zipfile
import pytest
import requests as req
from esphome import framework_helpers
from esphome.core import EsphomeError
from esphome.framework_helpers import (
_7z_extract_all,
_detect_archive_root,
@@ -25,7 +29,9 @@ from esphome.framework_helpers import (
archive_extract_all,
create_venv,
download_from_mirrors,
download_with_resume,
get_project_compile_flags,
get_project_cxx_compile_flags,
get_project_link_flags,
get_python_env_executable_path,
get_system_python_path,
@@ -505,7 +511,7 @@ class TestArchiveExtractAll:
# ---------------------------------------------------------------------------
# download_from_mirrors
# download_from_mirrors / download_with_resume
# ---------------------------------------------------------------------------
@@ -513,6 +519,8 @@ def _mock_response(content: bytes, ok: bool = True) -> MagicMock:
r = MagicMock()
r.__enter__.return_value = r
r.__exit__.return_value = False
r.status_code = 200
r.ok = ok
if ok:
r.raise_for_status.return_value = None
else:
@@ -522,6 +530,563 @@ def _mock_response(content: bytes, ok: bool = True) -> MagicMock:
return r
def _interrupted_response(content: bytes, etag: str | None = None) -> MagicMock:
"""A response whose body yields ``content`` and then drops mid-stream.
``etag`` makes the response resumable: without a validator the retry
logic restarts from zero rather than stitching unverified bytes.
"""
def body(chunk_size):
yield content
raise req.exceptions.ChunkedEncodingError("connection dropped")
r = _mock_response(b"")
if etag is not None:
r.headers = {**r.headers, "ETag": etag}
r.iter_content.side_effect = body
return r
def _resumed_response(content: bytes) -> MagicMock:
"""An HTTP 206 response continuing an interrupted download."""
r = _mock_response(content)
r.status_code = 206
return r
class TestOpenRanged:
def test_fresh_download_sends_no_range(self) -> None:
with patch("requests.get", return_value=_mock_response(b"x")) as mock_get:
resp, offset = framework_helpers._open_ranged("https://e.com/f", 0, 30)
assert offset == 0
assert mock_get.call_args[1]["headers"] == {}
assert resp is mock_get.return_value
def test_resume_kept_on_206(self) -> None:
with patch("requests.get", return_value=_resumed_response(b"x")):
_, offset = framework_helpers._open_ranged("https://e.com/f", 7, 30)
assert offset == 7
def test_resume_downgraded_on_200(self) -> None:
"""A server that ignores the Range header forces a restart."""
with patch("requests.get", return_value=_mock_response(b"x")):
_, offset = framework_helpers._open_ranged("https://e.com/f", 7, 30)
assert offset == 0
def test_http_error_closes_response_and_raises(self) -> None:
r = _mock_response(b"", ok=False)
with (
patch("requests.get", return_value=r),
pytest.raises(req.HTTPError),
):
framework_helpers._open_ranged("https://e.com/f", 0, 30)
r.close.assert_called_once()
def test_connect_error_propagates(self) -> None:
with (
patch("requests.get", side_effect=req.ConnectionError("refused")),
pytest.raises(req.ConnectionError),
):
framework_helpers._open_ranged("https://e.com/f", 0, 30)
class TestDownloadWithResume:
def test_downloads_and_renames(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
with patch("requests.get", return_value=_mock_response(b"data")) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"data"
assert not (tmp_path / "tool.tar.gz.part").exists()
# a fresh download must not send a Range header
assert "Range" not in mock_get.call_args[1]["headers"]
def test_mid_stream_drop_resumes_with_range(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "8"}
with patch(
"requests.get",
side_effect=[first, _resumed_response(b"5678")],
) as mock_get:
download_with_resume("https://example.com/t", dest)
# earlier bytes were kept, remainder appended conditionally
assert dest.read_bytes() == b"12345678"
assert mock_get.call_args_list[1][1]["headers"] == {
"Range": "bytes=4-",
"If-Range": '"v1"',
}
def test_unverifiable_drop_without_length_restarts(self, tmp_path: Path) -> None:
"""A validator alone is not enough to stitch when nothing can prove
the stitched file complete (no sha/size and no content-length)."""
dest = tmp_path / "tool.tar.gz"
with patch(
"requests.get",
side_effect=[
_interrupted_response(b"1234", etag='"v1"'),
_mock_response(b"full"),
],
) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"full"
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_resumed_clean_but_short_body_discarded(self, tmp_path: Path) -> None:
"""A resumed stream that ends cleanly but short of the advertised
total is rejected and re-downloaded, not promoted."""
dest = tmp_path / "tool.tar.gz"
first = _interrupted_response(b"abcd", etag='"v1"')
first.headers = {**first.headers, "content-length": "8"}
# resume ends cleanly after only 2 of the 4 missing bytes
short = _resumed_response(b"ef")
full = _mock_response(b"abcdefgh")
full.headers = {**full.headers, "content-length": "8"}
with patch("requests.get", side_effect=[first, short, full]) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"abcdefgh"
# the short stitch was discarded; the final attempt started fresh
assert "Range" not in mock_get.call_args_list[2][1]["headers"]
def test_unverifiable_drop_without_validator_restarts(self, tmp_path: Path) -> None:
"""No sha/size and no server validator: the retry must not stitch."""
dest = tmp_path / "tool.tar.gz"
with patch(
"requests.get",
side_effect=[_interrupted_response(b"1234"), _mock_response(b"full")],
) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"full"
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_resume_across_invocations_from_part_file(self, tmp_path: Path) -> None:
"""A .part file left by a previous run is resumed, not restarted,
when sha/size verification will vouch for the stitched result."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"12345")
good = hashlib.sha256(b"12345678").hexdigest()
with patch("requests.get", return_value=_resumed_response(b"678")) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=8)
assert dest.read_bytes() == b"12345678"
assert mock_get.call_args[1]["headers"] == {"Range": "bytes=5-"}
def test_unverifiable_leftover_part_file_ignored(self, tmp_path: Path) -> None:
"""Without sha/size there is no way to vouch for a cross-run stitch,
so a leftover part file starts over."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"12345")
with patch("requests.get", return_value=_mock_response(b"fresh")) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"fresh"
assert "Range" not in mock_get.call_args[1]["headers"]
def test_server_without_range_support_restarts(self, tmp_path: Path) -> None:
"""HTTP 200 in response to a Range request truncates and restarts."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"sta")
good = hashlib.sha256(b"fresh").hexdigest()
with patch("requests.get", return_value=_mock_response(b"fresh")) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=5)
# the Range request was sent (verifiable resume) and downgraded
assert mock_get.call_args[1]["headers"] == {"Range": "bytes=3-"}
assert dest.read_bytes() == b"fresh"
def test_size_only_leftover_part_restarts(self, tmp_path: Path) -> None:
"""A size alone cannot detect a same-length content change on the
server, so a cross-run part without sha256 restarts from zero."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"12")
with patch("requests.get", return_value=_mock_response(b"1234")) as mock_get:
download_with_resume("https://example.com/t", dest, size=4)
assert "Range" not in mock_get.call_args[1]["headers"]
assert dest.read_bytes() == b"1234"
def test_size_only_in_run_drop_resumes_with_validator(self, tmp_path: Path) -> None:
"""Within a run the If-Range validator proves identity, so size-only
callers still resume mid-stream drops."""
dest = tmp_path / "tool.tar.gz"
with patch(
"requests.get",
side_effect=[
_interrupted_response(b"12", etag='"v1"'),
_resumed_response(b"34"),
],
) as mock_get:
download_with_resume("https://example.com/t", dest, size=4)
assert dest.read_bytes() == b"1234"
assert mock_get.call_args_list[1][1]["headers"] == {
"Range": "bytes=2-",
"If-Range": '"v1"',
}
def test_unverifiable_download_logged(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""No sha, no size, no content-length: the download is promoted with
a debug note (routine for e.g. the constraints host, so not a
warning) that completeness could not be verified."""
dest = tmp_path / "tool.tar.gz"
with (
caplog.at_level(logging.DEBUG, logger="esphome.framework_helpers"),
patch("requests.get", return_value=_mock_response(b"data")),
):
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"data"
assert "without any way to verify completeness" in caplog.text
def test_416_promotes_complete_part_when_size_unknown(self, tmp_path: Path) -> None:
"""sha256-only caller with a byte-complete part file: the server's
416 confirms nothing is missing, verification promotes in place, and
the 416 must not loop as a retryable error."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"data")
good = hashlib.sha256(b"data").hexdigest()
r416 = _mock_response(b"", ok=False)
r416.status_code = 416
with patch("requests.get", return_value=r416) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good)
assert mock_get.call_count == 1
r416.close.assert_called_once()
assert dest.read_bytes() == b"data"
def test_416_with_corrupt_part_discards_and_redownloads(
self, tmp_path: Path
) -> None:
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"bad!")
good = hashlib.sha256(b"data").hexdigest()
r416 = _mock_response(b"", ok=False)
r416.status_code = 416
with patch(
"requests.get", side_effect=[r416, _mock_response(b"data")]
) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good)
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
assert dest.read_bytes() == b"data"
def test_hash_mismatch_discards_and_retries(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
good = hashlib.sha256(b"good").hexdigest()
with patch(
"requests.get",
side_effect=[_mock_response(b"bad!"), _mock_response(b"good")],
) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
assert dest.read_bytes() == b"good"
# the corrupt part file was discarded, so the retry starts fresh
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_size_mismatch_discards_part(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
with (
patch("requests.get", return_value=_mock_response(b"xx")),
pytest.raises(EsphomeError, match="after 2 attempts"),
):
download_with_resume("https://example.com/t", dest, size=99, attempts=2)
assert not (tmp_path / "tool.tar.gz.part").exists()
assert not dest.exists()
def test_attempts_exhausted_keeps_part_file(self, tmp_path: Path) -> None:
"""Mid-stream failures keep the partial file so a later run resumes."""
dest = tmp_path / "tool.tar.gz"
first = _interrupted_response(b"12", etag='"v1"')
first.headers = {**first.headers, "content-length": "4"}
second = _interrupted_response(b"34")
second.status_code = 206
with (
patch("requests.get", side_effect=[first, second]),
pytest.raises(EsphomeError, match="after 2 attempts"),
):
download_with_resume("https://example.com/t", dest, attempts=2)
assert (tmp_path / "tool.tar.gz.part").read_bytes() == b"1234"
def test_multiple_drops_accumulate_across_attempts(self, tmp_path: Path) -> None:
"""Each attempt appends its bytes; three partial responses complete
the file."""
dest = tmp_path / "tool.tar.gz"
first = _interrupted_response(b"ab", etag='"v1"')
first.headers = {**first.headers, "content-length": "6"}
second = _interrupted_response(b"cd")
second.status_code = 206
third = _resumed_response(b"ef")
with patch(
"requests.get",
side_effect=[first, second, third],
) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"abcdef"
expected = {"Range": "bytes=2-", "If-Range": '"v1"'}
assert mock_get.call_args_list[1][1]["headers"] == expected
expected = {"Range": "bytes=4-", "If-Range": '"v1"'}
assert mock_get.call_args_list[2][1]["headers"] == expected
def test_connect_error_then_success(self, tmp_path: Path) -> None:
"""A connect error (no response at all) consumes an attempt and the
next attempt succeeds."""
dest = tmp_path / "tool.tar.gz"
with patch(
"requests.get",
side_effect=[req.ConnectionError("refused"), _mock_response(b"data")],
):
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"data"
def test_http_error_keeps_part_file(self, tmp_path: Path) -> None:
"""A transient HTTP error (e.g. 503) must not discard resume state."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"keep")
error = _mock_response(b"", ok=False)
error.status_code = 503
with (
patch("requests.get", return_value=error),
pytest.raises(EsphomeError, match="after 1 attempts"),
):
download_with_resume("https://example.com/t", dest, attempts=1)
assert (tmp_path / "tool.tar.gz.part").read_bytes() == b"keep"
def test_creates_missing_parent_directories(self, tmp_path: Path) -> None:
dest = tmp_path / "dist" / "nested" / "tool.tar.gz"
with patch("requests.get", return_value=_mock_response(b"data")):
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"data"
def test_verifies_both_size_and_sha(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get", return_value=_mock_response(b"data")):
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
assert dest.read_bytes() == b"data"
def test_corrupt_partial_resumed_then_discarded_then_redownloaded(
self, tmp_path: Path
) -> None:
"""The full recovery cycle for a corrupted partial download: the
resume completes it, verification fails, the poisoned part file is
discarded, and the next attempt re-downloads from scratch."""
dest = tmp_path / "tool.tar.gz"
# a previous run left a corrupted 4-byte prefix behind
(tmp_path / "tool.tar.gz.part").write_bytes(b"BAD!")
good = hashlib.sha256(b"data66").hexdigest()
with patch(
"requests.get",
side_effect=[
_resumed_response(b"66"), # resume "completes" the bad part
_mock_response(b"data66"), # clean retry from zero
],
) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=6)
# first attempt resumed at the corrupt offset, failed verification;
# second attempt started fresh (no Range header) and succeeded
assert mock_get.call_args_list[0][1]["headers"] == {"Range": "bytes=4-"}
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
assert dest.read_bytes() == b"data66"
assert not (tmp_path / "tool.tar.gz.part").exists()
def test_existing_dest_passing_verification_kept(self, tmp_path: Path) -> None:
"""A dest completed by an earlier run is reused without any request."""
dest = tmp_path / "tool.tar.gz"
dest.write_bytes(b"data")
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get") as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
mock_get.assert_not_called()
assert dest.read_bytes() == b"data"
@pytest.mark.parametrize(
"stale",
[
pytest.param(b"corrupt!", id="wrong-size"),
pytest.param(b"bad!", id="right-size-wrong-hash"),
],
)
def test_existing_dest_failing_verification_redownloaded(
self, tmp_path: Path, stale: bytes
) -> None:
dest = tmp_path / "tool.tar.gz"
dest.write_bytes(stale)
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get", return_value=_mock_response(b"data")):
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
assert dest.read_bytes() == b"data"
def test_existing_dest_with_size_only_kept(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
dest.write_bytes(b"data")
with patch("requests.get") as mock_get:
download_with_resume("https://example.com/t", dest, size=4)
mock_get.assert_not_called()
def test_existing_dest_with_sha_only_kept(self, tmp_path: Path) -> None:
"""sha-only verification also authorizes reusing a completed dest."""
dest = tmp_path / "tool.tar.gz"
dest.write_bytes(b"data")
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get") as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good)
mock_get.assert_not_called()
def test_meta_write_failure_is_best_effort(self, tmp_path: Path) -> None:
"""A failure to persist the resume sidecar must not fail the
download itself."""
dest = tmp_path / "f.tar.xz"
first = _mock_response(b"data")
first.headers = {**first.headers, "ETag": '"v1"', "content-length": "4"}
with (
patch("requests.get", return_value=first),
patch.object(Path, "write_text", side_effect=OSError("read-only")),
):
download_with_resume("https://example.com/f", dest)
assert dest.read_bytes() == b"data"
def test_meta_sidecar_written_and_removed(self, tmp_path: Path) -> None:
"""The validator sidecar appears while downloading and is cleaned up
with the promotion."""
dest = tmp_path / "f.tar.xz"
meta = tmp_path / "f.tar.xz.part.meta"
seen: list[bool] = []
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "8"}
responses = [first]
def get(*args: object, **kwargs: object) -> MagicMock:
if responses:
return responses.pop(0)
# the resume request: the sidecar written by the first response
# must already be on disk at this point
seen.append(meta.is_file())
return _resumed_response(b"5678")
with patch("requests.get", side_effect=get):
download_with_resume("https://example.com/f", dest)
assert dest.read_bytes() == b"12345678"
assert seen == [True] # sidecar existed during the resume attempt
assert not meta.exists() # cleaned up on success
def test_locked_promotion_keeps_verified_part(self, tmp_path: Path) -> None:
"""A rename that stays blocked (e.g. a long-lived Windows file lock)
must not delete the verified download; the next attempt retries just
the rename without touching the network."""
dest = tmp_path / "tool.tar.gz"
good = hashlib.sha256(b"data").hexdigest()
with (
patch("requests.get", return_value=_mock_response(b"data")) as mock_get,
patch(
"esphome.framework_helpers._rename_with_retry",
side_effect=[PermissionError("locked"), None],
) as rename,
):
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
# one download; the second attempt only redid the rename
assert mock_get.call_count == 1
assert rename.call_count == 2
def test_locked_promotion_exhausted_keeps_part_for_next_run(
self, tmp_path: Path
) -> None:
dest = tmp_path / "tool.tar.gz"
good = hashlib.sha256(b"data").hexdigest()
with (
patch("requests.get", return_value=_mock_response(b"data")),
patch(
"esphome.framework_helpers._rename_with_retry",
side_effect=PermissionError("locked"),
),
pytest.raises(EsphomeError, match="after 1 attempts"),
):
download_with_resume(
"https://example.com/t", dest, sha256=good, size=4, attempts=1
)
# the verified bytes survive for the next run
assert (tmp_path / "tool.tar.gz.part").read_bytes() == b"data"
def test_meta_sidecar_resumes_across_runs_without_sha(self, tmp_path: Path) -> None:
"""A later run resumes an unfinished download using the validator the
first run stored — the cross-run fix for the framework tarball."""
dest = tmp_path / "f.tar.xz"
(tmp_path / "f.tar.xz.part").write_bytes(b"1234")
(tmp_path / "f.tar.xz.part.meta").write_text(
json.dumps(
{"url": "https://example.com/f", "validator": '"v1"', "total": 8}
)
)
with patch("requests.get", return_value=_resumed_response(b"5678")) as mock_get:
download_with_resume("https://example.com/f", dest)
assert dest.read_bytes() == b"12345678"
assert mock_get.call_args[1]["headers"] == {
"Range": "bytes=4-",
"If-Range": '"v1"',
}
def test_meta_sidecar_for_other_url_ignored(self, tmp_path: Path) -> None:
"""Metadata from a different mirror URL must not authorize a stitch."""
dest = tmp_path / "f.tar.xz"
(tmp_path / "f.tar.xz.part").write_bytes(b"1234")
(tmp_path / "f.tar.xz.part.meta").write_text(
json.dumps({"url": "https://other.com/f", "validator": '"v1"', "total": 8})
)
full = _mock_response(b"12345678")
with patch("requests.get", return_value=full) as mock_get:
download_with_resume("https://example.com/f", dest)
assert "Range" not in mock_get.call_args[1]["headers"]
assert dest.read_bytes() == b"12345678"
def test_complete_part_file_promoted_without_network(self, tmp_path: Path) -> None:
"""A .part holding every byte (killed between write and rename) is
verified in place and promoted; no request is made, so no 416 loop."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"data")
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get") as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
mock_get.assert_not_called()
assert dest.read_bytes() == b"data"
def test_complete_but_corrupt_part_file_redownloaded(self, tmp_path: Path) -> None:
"""A full-size .part with a wrong hash is discarded and re-downloaded
from scratch."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"bad!")
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get", return_value=_mock_response(b"data")) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
assert "Range" not in mock_get.call_args[1]["headers"]
assert dest.read_bytes() == b"data"
def test_oversized_part_file_discarded(self, tmp_path: Path) -> None:
"""A .part larger than the expected size fails verification and is
replaced by a fresh download."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"toolong")
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get", return_value=_mock_response(b"data")):
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
assert dest.read_bytes() == b"data"
def test_malformed_content_length_degrades_gracefully(self, tmp_path: Path) -> None:
"""A garbage Content-Length must not crash the attempt; it means
"unknown", so a drop restarts instead of stitching and a clean
download still succeeds."""
dest = tmp_path / "tool.tar.gz"
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "explode"}
retry = _mock_response(b"full")
retry.headers = {**retry.headers, "content-length": "explode"}
with patch("requests.get", side_effect=[first, retry]) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"full"
# unknown length -> completeness unprovable -> no resume attempted
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_zero_byte_part_file_sends_no_range(self, tmp_path: Path) -> None:
"""An empty leftover part file is a fresh download, not a resume."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"")
with patch("requests.get", return_value=_mock_response(b"data")) as mock_get:
download_with_resume("https://example.com/t", dest)
assert mock_get.call_args[1]["headers"] == {}
assert dest.read_bytes() == b"data"
class TestDownloadFromMirrors:
def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None:
target = tmp_path / "out.bin"
@@ -545,7 +1110,101 @@ class TestDownloadFromMirrors:
)
assert mock_get.call_args[0][0] == "https://example.com/1.2.3.bin"
def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None:
def test_template_with_missing_substitution_is_skipped(
self, tmp_path: Path
) -> None:
"""A template referencing an unavailable substitution is skipped, not
formatted into a bogus URL (e.g. SHORT_VERSION only exists for x.y.0
framework versions)."""
with patch(
"requests.get",
return_value=_mock_response(b"x"),
) as mock_get:
url = download_from_mirrors(
[
"https://example.com/{SHORT_VERSION}.bin",
"https://example.com/{VERSION}.bin",
],
{"VERSION": "1.2.3"},
tmp_path / "out.bin",
)
assert url == "https://example.com/1.2.3.bin"
assert mock_get.call_count == 1
def test_all_templates_skipped_raises_esphome_error(self, tmp_path: Path) -> None:
with (
patch("requests.get") as mock_get,
pytest.raises(EsphomeError, match="No mirror URL template matched") as ei,
):
download_from_mirrors(
["https://example.com/{MISSING}.bin"],
{"VERSION": "1.2.3"},
tmp_path / "out.bin",
)
mock_get.assert_not_called()
# The skipped template and its missing substitution are named
assert "https://example.com/{MISSING}.bin" in str(ei.value)
assert "MISSING" in str(ei.value)
def test_failure_message_includes_skipped_templates(self, tmp_path: Path) -> None:
"""When downloads fail, templates that were skipped for missing
substitutions are also listed so a typo'd custom mirror is
attributable."""
with (
patch(
"requests.get",
return_value=_mock_response(b"", ok=False),
),
pytest.raises(EsphomeError, match="all mirrors") as ei,
):
download_from_mirrors(
[
"https://example.com/{TYPO}.bin",
"https://example.com/{VERSION}.bin",
],
{"VERSION": "1.2.3"},
tmp_path / "out.bin",
)
message = str(ei.value)
assert "https://example.com/1.2.3.bin" in message
assert (
"https://example.com/{TYPO}.bin\n not applicable (TYPO not available)"
in message
)
def test_malformed_template_warns_and_is_reported(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A structurally malformed template is an authoring error: warned
about even when another mirror succeeds, and named in the aggregate
error when everything fails."""
with (
patch("requests.get", return_value=_mock_response(b"x")),
caplog.at_level(logging.WARNING, logger="esphome.framework_helpers"),
):
url = download_from_mirrors(
["https://example.com/{oops.bin", "https://example.com/{VERSION}.bin"],
{"VERSION": "1.2.3"},
tmp_path / "out.bin",
)
assert url == "https://example.com/1.2.3.bin"
assert "malformed mirror URL template" in caplog.text
with (
patch("requests.get", return_value=_mock_response(b"", ok=False)),
pytest.raises(EsphomeError, match="all mirrors") as ei,
):
download_from_mirrors(
["https://example.com/{oops.bin", "https://example.com/{VERSION}.bin"],
{"VERSION": "1.2.3"},
tmp_path / "out.bin",
)
assert "https://example.com/{oops.bin\n skipped (ValueError(" in str(
ei.value
)
def test_falls_back_to_second_mirror(self) -> None:
buf = io.BytesIO()
with patch(
"requests.get",
side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")],
@@ -553,20 +1212,169 @@ class TestDownloadFromMirrors:
url = download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
tmp_path / "out.bin",
buf,
)
assert url == "https://mirror2.com/f"
assert (tmp_path / "out.bin").read_bytes() == b"second"
assert buf.getvalue() == b"second"
def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None:
def test_mid_stream_drop_resumes_same_mirror(self) -> None:
"""A mid-stream failure retries the same mirror with Range and
If-Range headers, keeping the bytes already received, before falling
to the next."""
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "8"}
buf = io.BytesIO()
with patch(
"requests.get",
side_effect=[first, _resumed_response(b"5678")],
) as mock_get:
url = download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
buf,
)
assert url == "https://mirror1.com/f"
assert buf.getvalue() == b"12345678"
assert mock_get.call_count == 2
assert mock_get.call_args_list[1][0][0] == "https://mirror1.com/f"
# the resume is conditional on the content being unchanged
assert mock_get.call_args_list[1][1]["headers"] == {
"Range": "bytes=4-",
"If-Range": '"v1"',
}
def test_mid_stream_drop_without_validator_restarts(self) -> None:
"""A server offering no ETag/Last-Modified cannot be resumed safely;
the retry restarts from zero instead of stitching unverified bytes."""
buf = io.BytesIO()
with patch(
"requests.get",
side_effect=[_interrupted_response(b"1234"), _mock_response(b"full")],
) as mock_get:
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
assert buf.getvalue() == b"full"
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_drop_after_last_byte_recovers_via_416(self) -> None:
"""A connection drop after the final body byte leaves a complete file;
the retry's 416 answer plus the length check turn it into success
instead of a wasted refetch."""
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "4"}
r416 = _mock_response(b"", ok=False)
r416.status_code = 416
buf = io.BytesIO()
with patch("requests.get", side_effect=[first, r416]) as mock_get:
url = download_from_mirrors(["https://mirror1.com/f"], {}, buf)
assert url == "https://mirror1.com/f"
assert buf.getvalue() == b"1234"
assert mock_get.call_count == 2
def test_mirror_drop_without_length_restarts(self) -> None:
"""With no content-length there is no way to prove a stitched file
complete, so the retry restarts even though a validator exists."""
buf = io.BytesIO()
with patch(
"requests.get",
side_effect=[
_interrupted_response(b"1234", etag='"v1"'),
_mock_response(b"full"),
],
) as mock_get:
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
assert buf.getvalue() == b"full"
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_path_target_resumes_across_runs(self, tmp_path: Path) -> None:
"""A path target routes through download_with_resume: a part file and
metadata from a previous run resume instead of restarting."""
dest = tmp_path / "idf.tar.xz"
(tmp_path / "idf.tar.xz.part").write_bytes(b"1234")
(tmp_path / "idf.tar.xz.part.meta").write_text(
json.dumps(
{"url": "https://mirror1.com/f", "validator": '"v1"', "total": 8}
)
)
with patch("requests.get", return_value=_resumed_response(b"5678")) as mock_get:
url = download_from_mirrors(["https://mirror1.com/f"], {}, dest)
assert url == "https://mirror1.com/f"
assert dest.read_bytes() == b"12345678"
assert mock_get.call_args[1]["headers"] == {
"Range": "bytes=4-",
"If-Range": '"v1"',
}
def test_path_target_falls_back_to_next_mirror(self, tmp_path: Path) -> None:
dest = tmp_path / "idf.tar.xz"
with patch(
"requests.get",
side_effect=[req.ConnectionError("down"), _mock_response(b"data")],
):
url = download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"], {}, dest
)
assert url == "https://mirror2.com/f"
assert dest.read_bytes() == b"data"
def test_resumed_short_body_fails_length_check(self) -> None:
"""A stitched file whose final length disagrees with the advertised
total is rejected instead of reported as success."""
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "8"}
# the resume ends early (5 of 8 bytes); the poisoned part is then
# discarded and the fresh retry also delivers a short body
short_resume = _resumed_response(b"5")
short_fresh = _mock_response(b"56")
short_fresh.headers = {**short_fresh.headers, "content-length": "8"}
buf = io.BytesIO()
with (
patch("requests.get", side_effect=[first, short_resume, short_fresh]),
pytest.raises(EsphomeError, match="all mirrors"),
):
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
def test_failed_mirror_leftovers_not_kept_for_next_mirror(self) -> None:
"""Bytes from a mirror that failed all attempts must not leak into the
next mirror's download (no bogus Range request, fresh content)."""
exhausted = [_interrupted_response(b"AAAA", etag='"a1"')]
for _ in range(2):
r = _interrupted_response(b"BB")
r.status_code = 206
exhausted.append(r)
buf = io.BytesIO()
with patch(
"requests.get",
side_effect=exhausted + [_mock_response(b"clean")],
) as mock_get:
url = download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
buf,
)
assert url == "https://mirror2.com/f"
assert buf.getvalue() == b"clean"
# the second mirror starts fresh, without a Range header
assert mock_get.call_args_list[3][0][0] == "https://mirror2.com/f"
assert "Range" not in mock_get.call_args_list[3][1]["headers"]
def test_all_mirrors_fail_raises_error_listing_every_attempt(self) -> None:
with (
patch(
"requests.get",
return_value=_mock_response(b"", ok=False),
),
pytest.raises(req.HTTPError),
pytest.raises(EsphomeError, match="all mirrors") as excinfo,
):
download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin")
download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
io.BytesIO(),
)
# Every attempted URL appears in the message, and the first mirror's
# exception (the primary URL, usually the one that matters) is chained.
assert "https://mirror1.com/f" in str(excinfo.value)
assert "https://mirror2.com/f" in str(excinfo.value)
assert isinstance(excinfo.value.__cause__, req.HTTPError)
def test_empty_mirrors_raises_value_error(self, tmp_path: Path) -> None:
with pytest.raises(ValueError, match="empty mirrors list"):
@@ -625,8 +1433,10 @@ def test_importing_framework_helpers_does_not_import_requests() -> None:
[
sys.executable,
"-c",
"import sys\nimport esphome.framework_helpers\n"
"print('\\n'.join(sys.modules))",
(
"import sys\nimport esphome.framework_helpers\n"
"print('\\n'.join(sys.modules))"
),
],
capture_output=True,
text=True,
@@ -1048,3 +1858,25 @@ class TestGetProjectLinkFlags:
):
result = get_project_link_flags()
assert result == sorted(result)
def _make_core_cxx(flags: set[str]) -> MagicMock:
core = MagicMock()
core.cxx_build_flags = flags
return core
class TestGetProjectCxxCompileFlags:
def test_returns_sorted_flags(self) -> None:
with patch(
"esphome.core.CORE",
_make_core_cxx({"-Wno-volatile", "-Wno-deprecated"}),
):
assert get_project_cxx_compile_flags() == [
"-Wno-deprecated",
"-Wno-volatile",
]
def test_empty_flags(self) -> None:
with patch("esphome.core.CORE", _make_core_cxx(set())):
assert get_project_cxx_compile_flags() == []
File diff suppressed because it is too large Load Diff
+18
View File
@@ -1074,3 +1074,21 @@ def test_progressbar_enabled_on_pipe_with_dashboard(monkeypatch) -> None:
bar = ProgressBar("Uploading", stream=stream)
assert bar.enabled is True
@pytest.mark.parametrize(
("seconds", "expected"),
[
(0, "0s"),
(42, "42s"),
(60, "1min"),
(3661, "1h 1min"),
(86400, "1d"),
(90000, "1d 1h"),
(86700, "1d 5min"),
(-5, "0s"),
],
)
def test_format_duration(seconds: float, expected: str) -> None:
"""Test that durations are rendered as short human-readable strings."""
assert helpers.format_duration(seconds) == expected
+53
View File
@@ -0,0 +1,53 @@
"""Guard the lazy-import contract of ``esphome.__main__``.
Every ``esphome`` invocation pays for whatever ``esphome.__main__``
imports at module level before the requested command runs. The
dashboard and device-builder spawn one ``esphome upload`` subprocess
per device, so keeping validation/codegen machinery out of the
top-level import directly lowers the RAM cost of each concurrent
upload (the upload/logs fast path in ``esphome.compiled_config``
never needs them).
``script/check_import_time.py`` budgets import *time* in CI; this
test pins down *which* heavy modules must stay out entirely.
"""
from __future__ import annotations
import subprocess
import sys
# Modules that must only load for the commands that actually use them
# (compile/config validation, shell completion), never from a bare
# ``import esphome.__main__``.
HEAVY_MODULES = (
"argcomplete",
"esphome.codegen",
"esphome.config",
"esphome.config_validation",
"esphome.cpp_generator",
"esphome.loader",
"voluptuous",
)
def test_main_module_does_not_import_heavy_modules() -> None:
"""A bare ``import esphome.__main__`` must not drag in validation/codegen."""
check = (
"import sys; import esphome.__main__; "
f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; "
"print(','.join(leaked))"
)
result = subprocess.run(
[sys.executable, "-c", check],
capture_output=True,
text=True,
check=True,
)
leaked = result.stdout.strip()
assert not leaked, (
f"esphome.__main__ imports heavy modules at top level: {leaked}. "
"Import them lazily inside the command that needs them instead; "
"every esphome invocation (including each parallel dashboard "
"upload subprocess) pays for top-level imports."
)
+110 -434
View File
@@ -1,19 +1,13 @@
"""Unit tests for esphome.loader module."""
import ast
import logging
from pathlib import Path
import sys
import textwrap
from types import ModuleType
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, patch
import pytest
import voluptuous as vol
from esphome import config as esphome_config, config_validation as cv
from esphome.core import CORE
import esphome.loader as loader_mod
from esphome.loader import (
AliasMeta,
ComponentManifest,
@@ -21,6 +15,7 @@ from esphome.loader import (
_build_alias_map,
_read_aliases,
_replace_component_manifest,
get_alias_metadata,
get_component,
)
from tests.testing_helpers import ComponentManifestOverride
@@ -348,17 +343,12 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub
# Component aliases (renamed-platform back-compat)
# ---------------------------------------------------------------------------
#
# These tests pin down the substrate behind `ALIASES = [...]` on component
# `__init__.py` files: the AST scanner, the resulting global alias map, the
# Python-import `sys.meta_path` finder, the `get_component` integration, and
# the YAML pre-pass that rewrites legacy top-level keys.
#
# The framework is component-agnostic, so the integration tests inject a
# synthetic alias map (pointing a fake legacy name at the real `esp32`
# component) rather than depending on any specific renamed component.
# A legacy name that is NOT a real component, used as a synthetic alias.
_FAKE_ALIAS = "esp32_legacy_alias"
# The framework here is the substrate behind `ALIASES = [...]` on component
# `__init__.py` files. These tests pin down the AST scanner, the resulting
# global alias map, the Python-import `sys.meta_path` finder, and the
# integration with `get_component`. The rp2 → rp2040 actual mapping in this
# repo is used as a real-world fixture; other cases use temp dirs / mocks so
# the framework's behavior is testable in isolation.
def _write_component(root: Path, name: str, body: str) -> None:
@@ -383,12 +373,12 @@ def test_read_aliases_extracts_removal_version(tmp_path: Path) -> None:
init.write_text(
textwrap.dedent("""\
ALIASES = ['old']
ALIAS_REMOVAL_VERSION = "2027.6.0"
ALIAS_REMOVAL_VERSION = "2027.7.0"
""")
)
aliases, removal = _read_aliases(init, ast)
assert aliases == ["old"]
assert removal == "2027.6.0"
assert removal == "2027.7.0"
def test_read_aliases_skips_dynamic_forms(tmp_path: Path) -> None:
@@ -409,28 +399,19 @@ def test_read_aliases_returns_empty_for_missing_declaration(tmp_path: Path) -> N
assert removal is None
def test_read_aliases_handles_syntax_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
def test_read_aliases_handles_syntax_error(tmp_path: Path) -> None:
"""A broken __init__.py shouldn't crash the alias scanner — it'll
surface as an ImportError elsewhere, but the scanner logs a warning and
yields nothing so other components keep working. The substring pre-filter
only skips files with no ``ALIASES`` token, so this file (which has one)
still reaches the parse."""
surface as an ImportError elsewhere, but the scanner just yields
nothing so other components keep working.
The source must contain the substring ``ALIASES`` so the scanner
actually attempts to parse the file; otherwise the early-return
optimization would short-circuit before reaching the parser and
this test would not exercise the syntax-error branch.
"""
init = tmp_path / "__init__.py"
init.write_text("ALIASES = ['x']\ndef broken( :\n")
init.write_text("ALIASES = ['oops'\ndef broken( :\n")
assert _read_aliases(init, ast) == ([], None)
assert "Could not parse" in caplog.text
def test_read_aliases_handles_read_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unreadable __init__.py logs a warning and yields nothing rather
than aborting the whole component scan."""
missing = tmp_path / "nope" / "__init__.py"
assert _read_aliases(missing, ast) == ([], None)
assert "Could not read" in caplog.text
def test_build_alias_map_aggregates_components(tmp_path: Path) -> None:
@@ -480,96 +461,64 @@ def test_build_alias_map_handles_missing_dir(tmp_path: Path) -> None:
but possible in some test contexts), we want an empty map rather than
a crash — the rest of the loader can still function."""
fake = tmp_path / "does-not-exist"
assert not fake.exists()
with patch("esphome.loader.CORE_COMPONENTS_PATH", fake):
alias_map, meta_map = _build_alias_map()
assert alias_map == {}
assert meta_map == {}
def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None:
"""An alias that names an existing component package is refused: it would
hijack a live domain, and a self-alias (alias == canonical) would send
``_lookup_module`` into infinite recursion."""
# `newcomp` declares itself as an alias — its own package already exists.
_write_component(tmp_path, "newcomp", "ALIASES = ['newcomp']\n")
from esphome.core import EsphomeError
with (
patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path),
pytest.raises(EsphomeError, match="shadows an existing component"),
):
_build_alias_map()
# ---- Live integration against the real rp2/rp2040 mapping in this repo ----
# ---- Integration against a synthetic alias map (fake legacy -> esp32) ----
def test_real_alias_map_includes_rp2040() -> None:
"""The rp2 component declares ``ALIASES = ['rp2040']`` in this repo;
the live alias map should surface it. This guards against future
refactors silently dropping the declaration."""
meta = get_alias_metadata()
assert "rp2040" in meta
assert meta["rp2040"].canonical == "rp2"
assert meta["rp2040"].removal_version == "2027.7.0"
def _patch_alias_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None:
"""Force the loader's alias map (used by the finder and get_component).
Patches the lazily-built caches so both ``_get_alias_map`` and the
installed meta-path finder resolve against ``mapping`` regardless of
what the real on-disk scan would produce.
"""
monkeypatch.setattr("esphome.loader._get_alias_map", lambda: mapping)
def test_get_component_resolves_alias(monkeypatch: pytest.MonkeyPatch) -> None:
"""``get_component(<alias>)`` should return the canonical manifest — every
def test_get_component_resolves_alias() -> None:
"""``get_component('rp2040')`` should return the rp2 manifest — every
caller of the loader (dep checker, schema validator, codegen) hits
the canonical component without knowing about the alias."""
import esphome.loader as loader_mod
_patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"})
loader_mod._COMPONENT_CACHE.pop(_FAKE_ALIAS, None)
canonical = get_component("esp32")
aliased = get_component(_FAKE_ALIAS)
assert canonical is not None
assert aliased is canonical
rp2 = get_component("rp2")
rp2040 = get_component("rp2040")
assert rp2 is not None
assert rp2040 is rp2
def test_alias_finder_resolves_top_level_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``import esphome.components.<alias>`` resolves to the canonical
module via the meta-path finder. ``_FAKE_ALIAS`` == ``esp32_legacy_alias``."""
_patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"})
sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None)
def test_alias_finder_resolves_top_level_import() -> None:
"""``import esphome.components.rp2040`` resolves to the canonical
module via the meta-path finder."""
# Remove any cached entry so we exercise the finder, not sys.modules cache.
sys.modules.pop("esphome.components.rp2040", None)
finder = _AliasFinder()
spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}", None)
spec = finder.find_spec("esphome.components.rp2040", None)
assert spec is not None
import esphome.components.esp32
import esphome.components.esp32_legacy_alias
import esphome.components.rp2
import esphome.components.rp2040
assert esphome.components.esp32_legacy_alias is esphome.components.esp32
assert esphome.components.rp2040 is esphome.components.rp2
def test_alias_finder_resolves_submodule_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``from esphome.components.<alias> import boards`` routes through to
``esphome.components.esp32.boards`` — same submodule object on both paths.
The canonical submodule is imported first so its parent module carries
the ``boards`` attribute; ``from <alias> import boards`` then resolves
the aliased parent (via the finder) and reads that same attribute,
rather than triggering a fresh file load under the alias name.
``_FAKE_ALIAS`` == ``esp32_legacy_alias``."""
_patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"})
sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None)
def test_alias_finder_resolves_submodule_import() -> None:
"""``from esphome.components.rp2040 import boards`` routes through to
``esphome.components.rp2.boards`` — same submodule object on both
paths."""
sys.modules.pop("esphome.components.rp2040.boards", None)
finder = _AliasFinder()
spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}.boards", None)
spec = finder.find_spec("esphome.components.rp2040.boards", None)
assert spec is not None
from esphome.components.esp32 import boards as canonical_boards
from esphome.components.esp32_legacy_alias import boards as aliased_boards
from esphome.components.rp2 import boards as rp2_boards
from esphome.components.rp2040 import boards as rp2040_boards
assert aliased_boards is canonical_boards
assert rp2040_boards is rp2_boards
def test_alias_finder_ignores_non_components_path() -> None:
@@ -581,9 +530,6 @@ def test_alias_finder_ignores_non_components_path() -> None:
assert finder.find_spec("os.path", None) is None
# `esphome.components` itself (no domain segment) is not a candidate.
assert finder.find_spec("esphome.components", None) is None
# A real, non-aliased component domain defers to normal import machinery
# (no component declares an alias in this repo, so the live map is empty).
assert finder.find_spec("esphome.components.logger", None) is None
# ---------------------------------------------------------------------------
@@ -593,391 +539,121 @@ def test_alias_finder_ignores_non_components_path() -> None:
# The companion to the loader-side alias map: ``esphome.config`` runs a
# pre-pass over the user's parsed YAML that rewrites legacy top-level keys
# to their canonical names, surfacing a one-shot deprecation warning. These
# tests inject a synthetic alias-metadata map so the rewrite behavior, the
# warning text, and the both-keys-present conflict can be tested in isolation.
def _patch_alias_metadata(
monkeypatch: pytest.MonkeyPatch, mapping: dict[str, AliasMeta]
) -> None:
monkeypatch.setattr("esphome.loader.get_alias_metadata", lambda: mapping)
# tests pin down the rewrite behavior, the warning text, and the
# both-keys-present conflict.
def test_resolve_component_aliases_renames_legacy_key(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
caplog: pytest.LogCaptureFixture,
) -> None:
"""A legacy alias key should be renamed to the canonical key and a
deprecation warning citing the removal version logged."""
"""A legacy alias key ``rp2040:`` should be renamed to the canonical
``rp2:`` and a deprecation warning citing the removal version logged."""
import logging
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version="2027.6.0")},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires
config = {"esphome": {"name": "test"}, "oldcomp": {"board": "x"}}
config = {"esphome": {"name": "test"}, "rp2040": {"board": "rpipicow"}}
with caplog.at_level(logging.WARNING, logger="esphome.config"):
_resolve_component_aliases(config)
assert "oldcomp" not in config
assert config["newcomp"] == {"board": "x"}
assert "rp2040" not in config
assert config["rp2"] == {"board": "rpipicow"}
assert any(
"'oldcomp:' top-level key is deprecated" in record.message
and "rename it to 'newcomp:'" in record.message
and "2027.6.0" in record.message
"'rp2040:' top-level key is deprecated" in record.message
and "rename it to 'rp2:'" in record.message
and "2027.7.0" in record.message
for record in caplog.records
)
def test_resolve_component_aliases_dedupes_warning_within_a_run(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
caplog: pytest.LogCaptureFixture,
) -> None:
"""Schema validators can run twice (auto-load discovery + final pass)
so the rename pass must emit the warning only once per alias per run.
Deduped via ``CORE.data``; cleared between runs."""
import logging
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
with caplog.at_level(logging.WARNING, logger="esphome.config"):
_resolve_component_aliases({"oldcomp": {"board": "a"}})
_resolve_component_aliases({"oldcomp": {"board": "b"}})
_resolve_component_aliases({"rp2040": {"board": "rpipicow"}})
_resolve_component_aliases({"rp2040": {"board": "rpipico2w"}})
matches = [
r
for r in caplog.records
if "'oldcomp:' top-level key is deprecated" in r.message
if "'rp2040:' top-level key is deprecated" in r.message
]
assert len(matches) == 1
def test_resolve_component_aliases_rejects_both_keys_present(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_resolve_component_aliases_rejects_both_keys_present() -> None:
"""If the user has BOTH legacy and canonical keys, silently dropping
one would hide a real misconfiguration. Raise instead."""
import voluptuous as vol
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"newcomp": {"board": "x"}, "oldcomp": {"board": "x"}}
with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"):
config = {
"rp2": {"board": "rpipicow"},
"rp2040": {"board": "rpipicow"},
}
with pytest.raises(vol.Invalid, match="Both 'rp2040:'"):
_resolve_component_aliases(config)
def test_resolve_component_aliases_rejects_canonical_key_after_legacy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The both-keys conflict must be detected even when the canonical key
appears *after* the legacy key in the config (the up-front conflict
scan, not a position-dependent check)."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"oldcomp": {"board": "x"}, "newcomp": {"board": "x"}}
with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"):
_resolve_component_aliases(config)
def test_resolve_component_aliases_rejects_multiple_aliases_of_one_component(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Two different deprecated aliases of the same canonical component is
ambiguous — silently keeping one would hide a misconfiguration."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{
"oldcomp": AliasMeta(canonical="newcomp", removal_version=None),
"legacycomp": AliasMeta(canonical="newcomp", removal_version=None),
},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"oldcomp": {"board": "x"}, "legacycomp": {"board": "y"}}
with pytest.raises(vol.Invalid, match=r"Multiple deprecated aliases of 'newcomp:'"):
_resolve_component_aliases(config)
def test_resolve_component_aliases_preserves_key_position(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The renamed canonical key keeps the legacy key's original position
rather than being moved to the end of the config."""
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"esphome": {"name": "t"}, "oldcomp": {"board": "x"}, "logger": {}}
_resolve_component_aliases(config)
assert list(config) == ["esphome", "newcomp", "logger"]
def test_resolve_component_aliases_no_op_when_no_legacy_keys(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
def test_resolve_component_aliases_no_op_when_no_legacy_keys() -> None:
"""The pre-pass must be a no-op (no warning, no mutation) for configs
that already use canonical keys."""
import logging
from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases
from esphome.core import CORE
_patch_alias_metadata(
monkeypatch,
{"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop(_ALIAS_WARNED_KEY, None)
config = {"esphome": {"name": "test"}, "newcomp": {"board": "x"}}
config = {"esphome": {"name": "test"}, "rp2": {"board": "rpipicow"}}
original = dict(config)
with caplog.at_level(logging.WARNING, logger="esphome.config"):
with caplog_at_warning() as records:
_resolve_component_aliases(config)
assert config == original
assert not any("deprecated" in r.message for r in caplog.records)
assert not any("deprecated" in r.message for r in records)
_ = logging # silence unused-import in branches that don't read records
# ---------------------------------------------------------------------------
# ComponentManifest alias properties
# ---------------------------------------------------------------------------
# Helper context manager — small enough to inline rather than pull in
# caplog for the simple "did anything warn?" case above.
import contextlib # noqa: E402
def test_component_manifest_alias_properties_default_empty() -> None:
"""``aliases`` / ``alias_removal_version`` fall back to ``[]`` / ``None``
when the component module declares neither.
@contextlib.contextmanager
def caplog_at_warning():
"""Minimal in-test caplog substitute: collect WARNING records on a
dedicated handler attached to ``esphome.config``."""
import logging
Uses a real ``ModuleType`` rather than a ``MagicMock`` so that the
``getattr(..., default)`` fallback is actually exercised — a bare mock
auto-creates any attribute on access and would never hit the default."""
mod = ModuleType("fake_component")
manifest = ComponentManifest(mod)
assert manifest.aliases == []
assert manifest.alias_removal_version is None
logger = logging.getLogger("esphome.config")
records: list[logging.LogRecord] = []
class _Handler(logging.Handler):
def emit(self, record): # noqa: D401
records.append(record)
def test_component_manifest_alias_properties_read_module_values() -> None:
"""The properties surface the module's declared values verbatim."""
mod = MagicMock()
mod.ALIASES = ["legacy"]
mod.ALIAS_REMOVAL_VERSION = "2027.6.0"
manifest = ComponentManifest(mod)
assert manifest.aliases == ["legacy"]
assert manifest.alias_removal_version == "2027.6.0"
# ---------------------------------------------------------------------------
# Real (unpatched) lazy build + cache and remaining scanner branches
# ---------------------------------------------------------------------------
def test_get_alias_map_real_build_and_caches(monkeypatch: pytest.MonkeyPatch) -> None:
"""Exercise the real lazy build over the actual components dir (no patch):
the first call scans and caches, the second returns the cached object."""
monkeypatch.setattr(loader_mod, "_ALIAS_MAP_CACHE", None)
first = loader_mod._get_alias_map()
second = loader_mod._get_alias_map()
assert isinstance(first, dict)
assert first is second # cached, not rebuilt on the second call
def test_get_alias_metadata_real_build_and_caches(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(loader_mod, "_ALIAS_META_CACHE", None)
first = loader_mod.get_alias_metadata()
second = loader_mod.get_alias_metadata()
assert isinstance(first, dict)
assert first is second
def test_build_alias_map_skips_files_and_initless_dirs(tmp_path: Path) -> None:
"""Loose files and directories without an ``__init__.py`` are ignored;
only real component packages contribute to the map."""
(tmp_path / "loose_file.py").write_text("ALIASES = ['ignored']\n")
(tmp_path / "initless").mkdir() # a dir, but no __init__.py
_write_component(tmp_path, "realcomp", "ALIASES = ['legacy']\n")
with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path):
alias_map, _ = _build_alias_map()
assert alias_map == {"legacy": "realcomp"}
def test_read_aliases_ignores_non_assignment_and_complex_targets(
tmp_path: Path,
) -> None:
"""Non-assignment statements and assignments to non-Name targets are
skipped; only simple ``NAME = ...`` assignments are read."""
init = tmp_path / "__init__.py"
init.write_text(
"import os\n" # non-Assign (Import) node -> skipped
"obj.attr = 'v'\n" # Assign with an Attribute target -> skipped
"ALIASES = ['legacy']\n"
)
aliases, _ = _read_aliases(init, ast)
assert aliases == ["legacy"]
# ---------------------------------------------------------------------------
# Finder / loader edge branches
# ---------------------------------------------------------------------------
def test_alias_finder_returns_none_when_canonical_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If an alias points at a canonical *target* that doesn't exist, the
finder declines (returns None) and lets normal import machinery report
the missing module."""
_patch_alias_map(monkeypatch, {"broken_alias": "definitely_not_a_real_component"})
finder = _AliasFinder()
assert finder.find_spec("esphome.components.broken_alias", None) is None
def test_alias_finder_reraises_when_canonical_dependency_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If the canonical module exists but fails to import one of its own
dependencies, the finder surfaces that real error instead of masking it
as an unresolved alias (which would silently fall through to a confusing
'no module named <alias>')."""
_patch_alias_map(monkeypatch, {"some_alias": "real_canonical"})
def boom(name: str) -> None:
raise ModuleNotFoundError("No module named 'missing_dep'", name="missing_dep")
monkeypatch.setattr("esphome.loader.importlib.import_module", boom)
finder = _AliasFinder()
with pytest.raises(ModuleNotFoundError, match="missing_dep"):
finder.find_spec("esphome.components.some_alias", None)
def test_install_alias_finder_is_idempotent() -> None:
"""The finder is installed once at import; calling the installer again is
a no-op (no duplicate ``_AliasFinder`` on ``sys.meta_path``)."""
before = [e for e in sys.meta_path if isinstance(e, _AliasFinder)]
assert len(before) == 1 # installed at module import time
loader_mod._install_alias_finder()
after = [e for e in sys.meta_path if isinstance(e, _AliasFinder)]
assert len(after) == 1
def test_get_component_alias_to_missing_canonical_returns_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If an alias resolves to a canonical component that can't be loaded,
``get_component`` returns None and caches no bogus manifest."""
_patch_alias_map(monkeypatch, {"ghost_alias": "definitely_not_a_real_component"})
loader_mod._COMPONENT_CACHE.pop("ghost_alias", None)
assert get_component("ghost_alias") is None
assert "ghost_alias" not in loader_mod._COMPONENT_CACHE
# ---------------------------------------------------------------------------
# YAML pre-pass: empty-map fast path + validate_config integration
# ---------------------------------------------------------------------------
def test_resolve_component_aliases_noop_when_no_aliases_declared(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When no component declares an alias, the pre-pass returns immediately
without inspecting or mutating the config."""
from esphome.config import _resolve_component_aliases
monkeypatch.setattr("esphome.loader.get_alias_metadata", dict) # empty map
config = {"esphome": {"name": "t"}, "rp2040": {"board": "x"}}
original = dict(config)
_resolve_component_aliases(config)
assert config == original
def _default_component_mock() -> Mock:
"""A permissive component mock that validates any config (ALLOW_EXTRA)."""
return Mock(
auto_load=[],
is_platform_component=False,
is_platform=False,
multi_conf=False,
multi_conf_no_default=False,
dependencies=[],
conflicts_with=[],
config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA),
)
@pytest.mark.usefixtures("setup_core")
def test_validate_config_renames_alias_key(
mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""End-to-end: a legacy top-level key is renamed to its canonical name
before the rest of ``validate_config`` runs, and validation succeeds.
A real ``esp32`` target platform is included so ``preload_core_config``
is satisfied and validation runs to completion (the renamed canonical
key is loaded via the mocked, permissive component)."""
mock_get_component.side_effect = lambda name: _default_component_mock()
monkeypatch.setattr(
"esphome.loader.get_alias_metadata",
lambda: {
"legacyfoo": AliasMeta(canonical="newcomp", removal_version="2027.6.0")
},
)
CORE.data.pop("_component_aliases_warned", None)
raw_config = {
"esphome": {"name": "test"},
"esp32": {"board": "esp32dev"},
"legacyfoo": {"opt": 1},
}
result = esphome_config.validate_config(raw_config, {})
assert not result.errors, f"unexpected errors: {result.errors}"
assert "newcomp" in result
assert "legacyfoo" not in result
@pytest.mark.usefixtures("setup_core")
def test_validate_config_reports_alias_conflict_as_error(
mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If both the legacy and canonical keys are present, ``validate_config``
surfaces the conflict as a config error (the ``vol.Invalid`` path)."""
mock_get_component.return_value = _default_component_mock()
monkeypatch.setattr(
"esphome.loader.get_alias_metadata",
lambda: {"legacyfoo": AliasMeta(canonical="newcomp", removal_version=None)},
)
CORE.data.pop("_component_aliases_warned", None)
raw_config = {
"esphome": {"name": "test"},
"newcomp": {"opt": 1},
"legacyfoo": {"opt": 2},
}
result = esphome_config.validate_config(raw_config, {})
assert result.errors
assert "Both 'legacyfoo:'" in str(result.errors)
handler = _Handler(level=logging.WARNING)
logger.addHandler(handler)
prev_level = logger.level
logger.setLevel(logging.WARNING)
try:
yield records
finally:
logger.removeHandler(handler)
logger.setLevel(prev_level)
+164 -23
View File
@@ -94,7 +94,7 @@ from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
PLATFORM_RP2,
Toolchain,
)
from esphome.core import CORE, EsphomeError
@@ -167,9 +167,9 @@ def setup_core(
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
if tmp_path is not None:
CORE.config_path = str(tmp_path / f"{name}.yaml")
CORE.config_path = tmp_path / f"{name}.yaml"
CORE.name = name
CORE.build_path = str(tmp_path / ".esphome" / "build" / name)
CORE.build_path = tmp_path / ".esphome" / "build" / name
@pytest.fixture
@@ -442,6 +442,46 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix(
assert not any("legacy substring" in rec.message for rec in caplog.records)
@pytest.mark.parametrize("field", ["public_key", "peer_public_key"])
def test_redact_with_legacy_fallback__skips_public_key_fields(
field: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Public keys are not secret; fields with a ``public`` name segment
must pass through unredacted and without the migration warning
(see issue #17718)."""
text = f"{field}: c29tZXB1YmxpY2tleQ==\n"
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(text)
assert out == text
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__public_substitution_still_redacted(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Substitution keys are user-named with no schema behind them, so the
public-key exemption does not apply there; a ``public``-named substitution
keeps the conservative silent redaction."""
text = "substitutions:\n public_key: something\nesphome:\n name: x\n"
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(text)
assert "public_key: \\033[8msomething\\033[28m" in out
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__public_must_be_a_whole_segment(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The exemption matches ``public`` as an underscore-separated segment,
not a substring; an unrelated name like ``republic_key`` keeps the
conservative redaction."""
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback("republic_key: abc\n")
assert "republic_key: \\033[8mabc\\033[28m" in out
assert any("'republic_key'" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__substitutions_redacted_without_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -578,7 +618,7 @@ def test_command_config__no_defaults_skips_strip_default_ids(
validated.user_config = {"sensor": [{"name": "x"}]}
with patch(
"esphome.__main__.strip_default_ids", side_effect=AssertionError
"esphome.config.strip_default_ids", side_effect=AssertionError
) as mock_strip:
result = command_config(args, validated)
@@ -1226,7 +1266,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel(
mock_choose_prompt: Mock,
) -> None:
"""Test interactive mode shows RP2040 BOOTSEL option via picotool."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
with (
patch(
@@ -1249,7 +1289,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel(
@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_rp2040_no_device_shows_bootsel_help() -> None:
"""Test BOOTSEL instructions shown when no RP2040 device found."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
with (
patch(
@@ -1271,7 +1311,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_ota(
) -> None:
"""Test BOOTSEL tip shown when only OTA options exist for RP2040."""
setup_core(
platform=PLATFORM_RP2040,
platform=PLATFORM_RP2,
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]},
address="192.168.1.100",
)
@@ -1300,7 +1340,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports(
mock_choose_prompt: Mock,
) -> None:
"""Test BOOTSEL tip shown when serial ports exist but no BOOTSEL device."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")]
with (
@@ -1325,7 +1365,7 @@ def test_choose_upload_log_host_rp2040_permission_error_no_options(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test permission warning shown when BOOTSEL device found but not accessible."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
with (
patch(
@@ -1355,7 +1395,7 @@ def test_choose_upload_log_host_rp2040_permission_error_with_ota(
) -> None:
"""Test permission warning shown with OTA fallback available."""
setup_core(
platform=PLATFORM_RP2040,
platform=PLATFORM_RP2,
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]},
address="192.168.1.100",
)
@@ -1412,7 +1452,7 @@ def test_choose_upload_log_host_rp2040_serial_and_bootsel(
mock_choose_prompt: Mock,
) -> None:
"""Test both serial ports and BOOTSEL option shown for RP2040."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")]
with (
@@ -1665,7 +1705,7 @@ def test_upload_using_esptool_with_file_path(
@pytest.mark.parametrize(
"platform,device",
[
(PLATFORM_RP2040, "/dev/ttyACM0"),
(PLATFORM_RP2, "/dev/ttyACM0"),
(PLATFORM_BK72XX, "/dev/ttyUSB0"), # LibreTiny platform
],
)
@@ -1720,7 +1760,7 @@ def test_upload_using_platformio_creates_signed_bin_for_rp2040(
tmp_path: Path,
) -> None:
"""Test that upload_using_platformio creates firmware.bin.signed for RP2040."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
build_dir = tmp_path / "build"
build_dir.mkdir()
@@ -1756,6 +1796,53 @@ def test_upload_using_platformio_skips_signed_bin_for_non_rp2040(
assert result == 0
def test_upload_using_platformio_skips_signed_bin_when_already_present(
tmp_path: Path,
) -> None:
"""The signed-bin copy is idempotent: if ``firmware.bin.signed`` already
exists on the RP2 build path, the upload step must not overwrite it
(and must not fail when the unsigned ``firmware.bin`` is absent)."""
setup_core(platform=PLATFORM_RP2)
build_dir = tmp_path / "build"
build_dir.mkdir()
# Pre-existing signed bin with distinct content — must be preserved.
signed_bin = build_dir / "firmware.bin.signed"
signed_bin.write_bytes(b"already signed")
# No unsigned firmware.bin on disk — the `is_file()` guard must hold.
firmware_elf = build_dir / "firmware.elf"
firmware_elf.write_bytes(b"elf")
mock_idedata = MagicMock()
mock_idedata.firmware_elf_path = str(firmware_elf)
with (
patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata),
patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0),
):
result = upload_using_platformio({}, "/dev/ttyACM0")
assert result == 0
# Pre-existing signed bin is untouched.
assert signed_bin.read_bytes() == b"already signed"
def test_upload_using_platformio_handles_port_none(tmp_path: Path) -> None:
"""The upload step must work without a serial port (PlatformIO picks the
target itself); the ``--upload-port`` flag is only appended when a port
is provided."""
setup_core(platform=PLATFORM_ESP32)
with patch(
"esphome.platformio.toolchain.run_platformio_cli_run", return_value=0
) as mock_run:
result = upload_using_platformio({}, None)
assert result == 0
args = mock_run.call_args.args
assert "--upload-port" not in args
def test_upload_program_serial_upload_failed(
mock_upload_using_esptool: Mock,
mock_get_port_type: Mock,
@@ -1783,7 +1870,7 @@ def test_upload_program_bootsel(
mock_get_port_type: Mock,
) -> None:
"""Test upload_program with BOOTSEL for RP2040."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
mock_get_port_type.return_value = "BOOTSEL"
mock_upload_using_picotool.return_value = 0
@@ -1804,7 +1891,7 @@ def test_upload_program_bootsel_failed(
mock_get_port_type: Mock,
) -> None:
"""Test upload_program when BOOTSEL upload fails."""
setup_core(platform=PLATFORM_RP2040)
setup_core(platform=PLATFORM_RP2)
mock_get_port_type.return_value = "BOOTSEL"
mock_upload_using_picotool.return_value = 1
@@ -1821,7 +1908,7 @@ def test_upload_program_bootsel_failed(
def test_upload_using_picotool_success(tmp_path: Path) -> None:
"""Test upload_using_picotool succeeds."""
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
@@ -1858,7 +1945,7 @@ def test_upload_using_picotool_success(tmp_path: Path) -> None:
def test_upload_using_picotool_no_elf(tmp_path: Path) -> None:
"""Test upload_using_picotool when ELF file is missing."""
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
@@ -1876,7 +1963,7 @@ def test_upload_using_picotool_no_elf(tmp_path: Path) -> None:
def test_upload_using_picotool_not_found(tmp_path: Path) -> None:
"""Test upload_using_picotool when picotool binary not found."""
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
@@ -1896,7 +1983,7 @@ def test_upload_using_picotool_not_found(tmp_path: Path) -> None:
def test_upload_using_picotool_permission_error(tmp_path: Path) -> None:
"""Test upload_using_picotool shows helpful message on permission error."""
setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path)
setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path)
build_dir = tmp_path / "build"
build_dir.mkdir()
@@ -5363,6 +5450,43 @@ def _setup_build_info_test(
return build_info_path, firmware_path
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")
config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
with (
patch(
"esphome.components.esp8266.check_rosetta",
side_effect=EsphomeError("Rosetta 2 is not installed"),
) as mock_check,
pytest.raises(EsphomeError, match="Rosetta 2 is not installed"),
):
compile_program(args, config)
mock_check.assert_called_once()
def test_compile_program_skips_rosetta_check_on_other_platforms(
tmp_path: Path,
mock_compile_build_info_run_compile: Mock,
mock_compile_build_info_get_idedata: Mock,
) -> None:
"""Test that the Rosetta preflight does not run for non-ESP8266 targets."""
_setup_build_info_test(tmp_path, firmware_first=True)
config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
with patch("esphome.components.esp8266.check_rosetta") as mock_check:
result = compile_program(args, config)
assert result == 0
mock_check.assert_not_called()
def test_compile_program_emits_build_info_when_firmware_rebuilt(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
@@ -6077,7 +6201,7 @@ def test_run_esphome_bundle_detection(tmp_path: Path) -> None:
"esphome.bundle.prepare_bundle_for_compile",
return_value=extracted_yaml,
) as mock_prepare,
patch("esphome.__main__.read_config", return_value=None),
patch("esphome.config.read_config", return_value=None),
):
result = run_esphome(["esphome", "compile", str(bundle_path)])
@@ -6095,7 +6219,7 @@ def test_run_esphome_non_bundle_skips_extraction(tmp_path: Path) -> None:
with (
patch("esphome.bundle.is_bundle_path", return_value=False) as mock_is_bundle,
patch("esphome.bundle.prepare_bundle_for_compile") as mock_prepare,
patch("esphome.__main__.read_config", return_value=None),
patch("esphome.config.read_config", return_value=None),
):
result = run_esphome(["esphome", "compile", str(yaml_file)])
@@ -6123,7 +6247,7 @@ def test_run_esphome_skip_external_update_per_command(
yaml_file = tmp_path / "device.yaml"
yaml_file.write_text("esphome:\n name: test\n")
with patch("esphome.__main__.read_config", return_value=None) as mock_read:
with patch("esphome.config.read_config", return_value=None) as mock_read:
run_esphome(["esphome", command, str(yaml_file)])
mock_read.assert_called_once()
@@ -6281,6 +6405,23 @@ def test_parse_args_logs_states() -> None:
assert args.states is True
def test_parse_args_argcomplete_only_runs_when_completing() -> None:
"""Only import and invoke argcomplete when _ARGCOMPLETE is set.
The shell-completion machinery sets _ARGCOMPLETE when it invokes the
CLI; a normal invocation must skip the import entirely so every
esphome subprocess (e.g. parallel dashboard uploads) avoids paying
for it.
"""
fake_argcomplete = MagicMock()
with (
patch.dict(os.environ, {"_ARGCOMPLETE": "1"}),
patch.dict(sys.modules, {"argcomplete": fake_argcomplete}),
):
parse_args(["esphome", "version"])
fake_argcomplete.autocomplete.assert_called_once()
def test_should_subscribe_states_default() -> None:
"""Test that states are shown by default when nothing is set."""
from esphome.__main__ import _should_subscribe_states
@@ -6411,7 +6552,7 @@ def test_command_run_rp2040_bootsel_redetects_serial_port() -> None:
picks up the newly enumerated serial port before showing logs."""
setup_core(
config={"logger": {}, CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}},
platform=PLATFORM_RP2040,
platform=PLATFORM_RP2,
)
args = MockArgs()
+255 -9
View File
@@ -1,20 +1,30 @@
"""Tests for esphome.components.nrf52.framework helpers."""
import hashlib
import os
from pathlib import Path
import sys
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from esphome.components.nrf52.framework import (
_TOOLCHAIN_VERSION,
_PLATFORMIO_PENV_REQUIREMENTS,
_REQUIREMENTS,
TOOLCHAIN_VERSION,
_get_penv_site_packages,
_get_platformio_penv_path,
_get_toolchain_platform_info,
check_and_install,
get_build_env,
get_sdk_nrf_tools_path,
setup_platformio_python_env,
)
from esphome.config_validation import Version
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, EsphomeError
from esphome.framework_helpers import get_python_env_executable_path
@pytest.fixture(autouse=True)
@@ -71,7 +81,7 @@ def nrf52_dirs(setup_core: Path) -> SimpleNamespace:
tools = get_sdk_nrf_tools_path()
python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}"
framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}"
toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION
toolchain_dir = tools / "toolchains" / TOOLCHAIN_VERSION
for d in (python_env, framework, toolchain_dir):
d.mkdir(parents=True, exist_ok=True)
zephyr_scripts = framework / "zephyr" / "scripts"
@@ -113,6 +123,12 @@ def mock_nrf52_ops():
# ---------------------------------------------------------------------------
def _mark_venv_ready(python_env: Path) -> None:
"""Write the venv sentinel with the current requirements hash."""
requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest()
(python_env / ".ready").write_text(requirements_hash, encoding="utf-8")
class TestCheckAndInstall:
def test_all_installed_skips_all_steps(
self,
@@ -120,7 +136,7 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""All three sentinels present → nothing downloaded or compiled."""
(nrf52_dirs.python_env / ".ready").touch()
_mark_venv_ready(nrf52_dirs.python_env)
(nrf52_dirs.python_env / ".zephyr_reqs_ready").touch()
(nrf52_dirs.framework / ".ready").touch()
(nrf52_dirs.toolchain / ".ready").touch()
@@ -157,7 +173,7 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Venv ready but framework missing → skip venv creation, run SDK init+update."""
(nrf52_dirs.python_env / ".ready").touch()
_mark_venv_ready(nrf52_dirs.python_env)
check_and_install()
@@ -173,7 +189,7 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Venv and framework ready → only toolchain downloaded and extracted."""
(nrf52_dirs.python_env / ".ready").touch()
_mark_venv_ready(nrf52_dirs.python_env)
(nrf52_dirs.python_env / ".zephyr_reqs_ready").touch()
(nrf52_dirs.framework / ".ready").touch()
@@ -185,6 +201,24 @@ class TestCheckAndInstall:
assert mock_nrf52_ops.download_from_mirrors.call_count == 2
assert mock_nrf52_ops.archive_extract_all.call_count == 2
def test_framework_clone_is_shallow(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Both the manifest repository and every project are fetched at depth 1."""
_mark_venv_ready(nrf52_dirs.python_env)
check_and_install()
init_cmd, update_cmd = (
call.args[0] for call in mock_nrf52_ops.run_command_ok.call_args_list[:2]
)
assert "init" in init_cmd
assert "-o=--depth=1" in init_cmd
assert "update" in update_cmd
assert "--fetch-opt=--depth=1" in update_cmd
def test_requirements_install_failure_raises(
self,
nrf52_dirs: SimpleNamespace,
@@ -202,7 +236,7 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Failing west init raises EsphomeError."""
(nrf52_dirs.python_env / ".ready").touch()
_mark_venv_ready(nrf52_dirs.python_env)
mock_nrf52_ops.run_command_ok.return_value = False
with pytest.raises(EsphomeError, match="Can't initialize"):
@@ -214,7 +248,7 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Failing west update raises EsphomeError."""
(nrf52_dirs.python_env / ".ready").touch()
_mark_venv_ready(nrf52_dirs.python_env)
# init succeeds, update fails
mock_nrf52_ops.run_command_ok.side_effect = [True, False]
@@ -227,7 +261,7 @@ class TestCheckAndInstall:
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""download_from_mirrors receives VERSION + platform triple from _get_toolchain_platform_info."""
(nrf52_dirs.python_env / ".ready").touch()
_mark_venv_ready(nrf52_dirs.python_env)
(nrf52_dirs.framework / ".ready").touch()
with patch(
@@ -238,12 +272,224 @@ class TestCheckAndInstall:
args, _ = mock_nrf52_ops.download_from_mirrors.call_args
substitutions = args[1]
assert substitutions["VERSION"] == _TOOLCHAIN_VERSION
assert substitutions["VERSION"] == TOOLCHAIN_VERSION
assert substitutions["sysname"] == "linux"
assert substitutions["machine"] == "x86_64"
assert substitutions["extension"] == "tar.xz"
# ---------------------------------------------------------------------------
# setup_platformio_python_env tests
# ---------------------------------------------------------------------------
def _platformio_requirements_hash() -> str:
return hashlib.sha256(
_REQUIREMENTS.read_bytes()
+ "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode()
+ f"python{sys.version_info.major}.{sys.version_info.minor}".encode()
).hexdigest()
@pytest.fixture
def platformio_penv_dir() -> Path:
"""Pre-create the PlatformIO penv dir so sentinel writes succeed.
create_venv is mocked in these tests, so the directory it would have
created must exist for ``sentinel.write_text`` to work.
"""
penv_path = _get_platformio_penv_path()
penv_path.mkdir(parents=True, exist_ok=True)
return penv_path
class TestSetupPlatformioPythonEnv:
def test_fresh_install_creates_venv_and_sets_env(
self,
platformio_penv_dir: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""No sentinel → venv created, requirements installed, env exported."""
with patch.dict(os.environ):
os.environ.pop("PYTHONPATH", None)
setup_platformio_python_env()
mock_nrf52_ops.rmdir.assert_called_once()
mock_nrf52_ops.create_venv.assert_called_once_with(
platformio_penv_dir, msg="PlatformIO toolchain"
)
mock_nrf52_ops.run_command_ok.assert_called_once()
cmd = mock_nrf52_ops.run_command_ok.call_args[0][0]
assert cmd[1:4] == ["-m", "pip", "install"]
assert "-r" in cmd
assert str(_REQUIREMENTS) in cmd
for requirement in _PLATFORMIO_PENV_REQUIREMENTS:
assert requirement in cmd
sentinel = platformio_penv_dir / ".ready"
assert sentinel.read_text(encoding="utf-8") == (
_platformio_requirements_hash()
)
assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir)
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
assert os.environ["PYTHONPATH"] == site_packages
bin_dir = str(
get_python_env_executable_path(platformio_penv_dir, "python").parent
)
assert os.environ["PATH"].split(os.pathsep)[0] == bin_dir
def test_ready_sentinel_skips_install_but_sets_env(
self,
platformio_penv_dir: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Current sentinel → no install work, env vars still exported."""
(platformio_penv_dir / ".ready").write_text(
_platformio_requirements_hash(), encoding="utf-8"
)
with patch.dict(os.environ):
setup_platformio_python_env()
mock_nrf52_ops.rmdir.assert_not_called()
mock_nrf52_ops.create_venv.assert_not_called()
mock_nrf52_ops.run_command_ok.assert_not_called()
assert os.environ["VIRTUAL_ENV"] == str(platformio_penv_dir)
def test_stale_sentinel_reinstalls(
self,
platformio_penv_dir: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A sentinel from different requirements → venv rebuilt from scratch."""
sentinel = platformio_penv_dir / ".ready"
sentinel.write_text("stale-hash", encoding="utf-8")
with patch.dict(os.environ):
setup_platformio_python_env()
mock_nrf52_ops.rmdir.assert_called_once()
mock_nrf52_ops.create_venv.assert_called_once()
mock_nrf52_ops.run_command_ok.assert_called_once()
assert sentinel.read_text(encoding="utf-8") == _platformio_requirements_hash()
def test_install_failure_raises(
self,
platformio_penv_dir: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Failing pip install raises EsphomeError and writes no sentinel."""
mock_nrf52_ops.run_command_ok.return_value = False
with (
patch.dict(os.environ),
pytest.raises(
EsphomeError, match="Install requirements for PlatformIO toolchain"
),
):
setup_platformio_python_env()
assert not (platformio_penv_dir / ".ready").exists()
def test_repeated_calls_do_not_duplicate_env_entries(
self,
platformio_penv_dir: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Compile then upload in one process must not grow PYTHONPATH/PATH."""
(platformio_penv_dir / ".ready").write_text(
_platformio_requirements_hash(), encoding="utf-8"
)
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
bin_dir = str(
get_python_env_executable_path(platformio_penv_dir, "python").parent
)
with patch.dict(os.environ):
setup_platformio_python_env()
setup_platformio_python_env()
assert os.environ["PYTHONPATH"].split(os.pathsep).count(site_packages) == 1
assert os.environ["PATH"].split(os.pathsep).count(bin_dir) == 1
def test_existing_pythonpath_preserved(
self,
platformio_penv_dir: Path,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""A pre-existing PYTHONPATH keeps its entries after the venv entry."""
(platformio_penv_dir / ".ready").write_text(
_platformio_requirements_hash(), encoding="utf-8"
)
site_packages = str(_get_penv_site_packages(platformio_penv_dir))
with patch.dict(os.environ, {"PYTHONPATH": "/existing/path"}):
setup_platformio_python_env()
assert os.environ["PYTHONPATH"] == os.pathsep.join(
[site_packages, "/existing/path"]
)
@pytest.mark.parametrize(
("os_name", "expected_parts"),
[
(
"posix",
(
"lib",
f"python{sys.version_info.major}.{sys.version_info.minor}",
"site-packages",
),
),
("nt", ("Lib", "site-packages")),
],
)
def test_get_penv_site_packages(
tmp_path: Path, os_name: str, expected_parts: tuple[str, ...]
) -> None:
penv_path = tmp_path / "penv"
with patch("os.name", os_name):
assert _get_penv_site_packages(penv_path) == penv_path.joinpath(*expected_parts)
# ---------------------------------------------------------------------------
# get_build_env tests
# ---------------------------------------------------------------------------
def test_get_build_env(
nrf52_dirs: SimpleNamespace, monkeypatch: pytest.MonkeyPatch
) -> None:
"""get_build_env exposes ZEPHYR_SDK_INSTALL_DIR pointing at the toolchain root.
ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr's FindZephyr-sdk.cmake
explicitly consumes (from the environment) and uses as a find_package
HINT. The old Zephyr-sdk_DIR environment hint proved unreliable in
containerized non-root builds and was removed.
"""
monkeypatch.setenv("SOME_PREEXISTING_VAR", "kept")
env = get_build_env()
tools = get_sdk_nrf_tools_path()
venv_bin_dir = get_python_env_executable_path(
tools / "penvs" / f"v{_TEST_SDK_VERSION}", "python"
).parent
assert env["PATH"].startswith(str(venv_bin_dir) + os.pathsep)
assert env["ZEPHYR_BASE"] == str(
tools / "frameworks" / f"v{_TEST_SDK_VERSION}" / "zephyr"
)
# Toolchain root, not the cmake/ subdir
assert env["ZEPHYR_SDK_INSTALL_DIR"] == str(
tools / "toolchains" / TOOLCHAIN_VERSION
)
assert "Zephyr-sdk_DIR" not in env
# The rest of the process environment is inherited
assert env["SOME_PREEXISTING_VAR"] == "kept"
# ---------------------------------------------------------------------------
# get_sdk_nrf_tools_path tests
# ---------------------------------------------------------------------------
+66
View File
@@ -146,6 +146,72 @@ class TestUploadProgramPyocd:
upload_program(config={}, args=None, host="PYOCD")
# ---------------------------------------------------------------------------
# PlatformIO toolchain paths
# ---------------------------------------------------------------------------
class TestRunCompilePlatformio:
def test_prepares_python_env_and_delegates_to_platformio(
self, setup_core: Path, tmp_path: Path
) -> None:
"""The PlatformIO toolchain prepares the env, then returns False so PlatformIO builds."""
from esphome.components.nrf52 import run_compile
_setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build")
with patch(
"esphome.components.nrf52.setup_platformio_python_env"
) as mock_setup:
assert run_compile(args=None, config={}) is False
mock_setup.assert_called_once_with()
class TestUploadProgramSerialPlatformio:
def _upload(self, host: str, tmp_path: Path, run_result: int) -> tuple:
from esphome.components.nrf52 import upload_program
from esphome.upload_targets import PortType
_setup_nrf52_core(toolchain=Toolchain.PLATFORMIO, build_path=tmp_path / "build")
CORE.config_path = tmp_path / "test.yaml"
with (
patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL),
patch("esphome.__main__.check_permissions"),
patch("esphome.components.nrf52.setup_platformio_python_env") as mock_setup,
patch(
"esphome.platformio.toolchain.run_platformio_cli_run",
return_value=run_result,
) as mock_run,
):
result = upload_program(config={}, args=None, host=host)
return result, mock_setup, mock_run
def test_serial_upload_prepares_env_and_runs_platformio(
self, setup_core: Path, tmp_path: Path
) -> None:
"""Serial upload with the PlatformIO toolchain runs pio with -t upload."""
host = "/dev/ttyACM0"
result, mock_setup, mock_run = self._upload(host, tmp_path, run_result=0)
assert result is True
mock_setup.assert_called_once_with()
mock_run.assert_called_once()
run_args = mock_run.call_args[0]
assert "-t" in run_args
assert "upload" in run_args
assert "--upload-port" in run_args
assert host in run_args
def test_serial_upload_failure_raises(
self, setup_core: Path, tmp_path: Path
) -> None:
"""A non-zero PlatformIO result must raise EsphomeError."""
with pytest.raises(EsphomeError, match="Upload failed"):
self._upload("/dev/ttyACM0", tmp_path, run_result=1)
# ---------------------------------------------------------------------------
# Serial DFU upload path
# ---------------------------------------------------------------------------
+117 -12
View File
@@ -133,18 +133,8 @@ def test_resolve_registry_version_raises_without_pkg_file(monkeypatch):
_resolve_registry_version("owner", "pkg", set())
def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()):
"""Fake ConvertedLibrary.download to materialize canned manifests on disk."""
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_sanitized_name().replace("/", "__")
self.path.mkdir(parents=True, exist_ok=True)
if self.name in properties:
(self.path / "library.properties").write_text(manifests[self.name])
else:
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
monkeypatch.setattr(ConvertedLibrary, "download", fake_download)
def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None:
"""Stub the registry lookup so tests never touch the network."""
monkeypatch.setattr(
lib,
"_resolve_registry_version",
@@ -157,6 +147,21 @@ def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properti
)
def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()):
"""Fake ConvertedLibrary.download to materialize canned manifests on disk."""
def fake_download(self, force=False, salt="", namespace=""):
self.path = tmp_path / self.get_require_name()
self.path.mkdir(parents=True, exist_ok=True)
if self.name in properties:
(self.path / "library.properties").write_text(manifests[self.name])
else:
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
monkeypatch.setattr(ConvertedLibrary, "download", fake_download)
_patch_registry_resolve(monkeypatch)
def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch):
# A manifest provided as library.properties (Arduino style) instead of
# library.json must still be parsed and converted.
@@ -212,6 +217,106 @@ def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monke
assert [d.name for d in top[0].dependencies] == ["C"]
def _patch_download_without_manifest(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, *, manifest_on_force: bool
) -> list[bool]:
"""Fake ConvertedLibrary.download that leaves the manifest missing.
When ``manifest_on_force`` is set, a forced re-download writes a valid
library.json, simulating a broken cache entry that heals on retry.
Returns the list of ``force`` values download was called with.
"""
calls: list[bool] = []
def fake_download(
self: ConvertedLibrary, force: bool = False, salt: str = "", namespace: str = ""
) -> None:
calls.append(force)
self.path = tmp_path / self.get_require_name()
self.path.mkdir(parents=True, exist_ok=True)
if force and manifest_on_force:
(self.path / "library.json").write_text(json.dumps({"name": "A"}))
monkeypatch.setattr(ConvertedLibrary, "download", fake_download)
_patch_registry_resolve(monkeypatch)
return calls
def test_convert_libraries_redownloads_when_manifest_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# A cached copy without any manifest (e.g. an interrupted clone or
# extraction) triggers exactly one forced re-download and then succeeds.
calls = _patch_download_without_manifest(
monkeypatch, tmp_path, manifest_on_force=True
)
top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend())
assert calls == [False, True]
assert top[0].data["name"] == "A"
def test_convert_libraries_raises_when_manifest_missing_after_retry(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# If the forced re-download still yields no manifest, the error is raised
# after exactly one retry (no retry loop). The error must name the cache
# directory so users can find the broken entry instead of guessing where
# the library was unpacked.
calls = _patch_download_without_manifest(
monkeypatch, tmp_path, manifest_on_force=False
)
with pytest.raises(RuntimeError, match="Invalid PIO library") as excinfo:
convert_libraries([Library("esphome/A", "1.0.0", None)], _backend())
assert calls == [False, True]
assert str(tmp_path / "esphome__A") in str(excinfo.value)
@pytest.mark.parametrize(
("value", "expected"),
[
(None, None),
("", None),
("http://[::1", None), # malformed IPv6 makes urlsplit raise ValueError
("foo/bar", None),
("file:///no/host", None),
("https://github.com/x/y", "https://github.com/x/y"),
],
)
def test_url_or_none(value: str | None, expected: str | None) -> None:
assert lib._url_or_none(value) == expected
def test_convert_libraries_url_in_name_resolves_as_git(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# add_library("https://github.com/x/y", None) puts a git URL in the name
# position; it must resolve as a git source and never hit the registry.
_patch_download_with_manifests(
monkeypatch, tmp_path, {"pstolarz/OneWireNg": {"name": "OneWireNg"}}
)
def fail_registry(owner: str, pkgname: str, requirements: set[str]) -> None:
raise AssertionError(f"registry consulted for {owner}/{pkgname}")
# After the helper so this stub wins over the helper's benign one
monkeypatch.setattr(lib, "_resolve_registry_version", fail_registry)
top = convert_libraries(
[Library("https://github.com/pstolarz/OneWireNg", None, None)], _backend()
)
assert [c.name for c in top] == ["pstolarz/OneWireNg"]
assert top[0].data["name"] == "OneWireNg"
source = top[0].source
assert isinstance(source, GitSource)
assert source.url == "https://github.com/pstolarz/OneWireNg"
assert source.ref is None
def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch):
# A dependency that declares an incompatible platform is skipped (the
# top-level library still builds).
+507 -1
View File
@@ -2,12 +2,14 @@
# pylint: disable=protected-access
from collections.abc import Generator
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import shutil
import sys
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, call, patch
@@ -320,6 +322,149 @@ def test_run_platformio_cli_sets_environment_variables(
assert "arg" in args
def test_ccache_env_enabled_by_default(setup_core: Path) -> None:
"""Ccache is enabled when the binary is on PATH and no override is set."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
):
env = toolchain._ccache_env()
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve())
assert env["CCACHE_DIR"].endswith("platformio-ccache")
assert env["CCACHE_NOHASHDIR"] == "true"
# Nothing may leak into os.environ: a later ESP-IDF build in the same
# process would otherwise skip its own ccache defaults.
assert "CCACHE_BASEDIR" not in os.environ
assert "ESPHOME_CCACHE_ENABLE" not in os.environ
def test_ccache_env_disabled_without_binary(setup_core: Path) -> None:
"""Ccache stays off when the binary is not on PATH."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value=None),
):
env = toolchain._ccache_env()
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
def test_ccache_env_opt_out(setup_core: Path) -> None:
"""ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
):
env = toolchain._ccache_env()
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None:
"""A truthy override value is normalized to "1" for the build scripts."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True),
patch.object(toolchain.shutil, "which", return_value=None),
):
env = toolchain._ccache_env()
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
def test_ccache_env_respects_user_values_and_refreshes_basedir(
setup_core: Path,
) -> None:
"""User CCACHE_* values win, but CCACHE_BASEDIR follows the build dir."""
user_env = {
"CCACHE_DIR": "/custom/cache",
"CCACHE_BASEDIR": "/stale/other-device",
}
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, user_env, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
):
env = toolchain._ccache_env()
# CCACHE_DIR is not returned, so the user's os.environ value applies in
# the subprocess; CCACHE_BASEDIR is always refreshed to the build dir.
assert "CCACHE_DIR" not in env
assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve())
def test_run_platformio_cli_passes_ccache_env_to_subprocess_only(
setup_core: Path, mock_run_external_process: Mock
) -> None:
"""The ccache settings reach the subprocess env without touching os.environ."""
CORE.build_path = str(setup_core / "build" / "test")
with (
patch.dict(os.environ, {}, clear=False),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
):
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
mock_run_external_process.return_value = 0
toolchain.run_platformio_cli("test", "arg")
env = mock_run_external_process.call_args[1]["env"]
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve())
assert "ESPHOME_CCACHE_ENABLE" not in os.environ
assert "CCACHE_BASEDIR" not in os.environ
def test_ccache_env_requires_build_path(setup_core: Path) -> None:
"""Enabling ccache without a build path fails loudly."""
CORE.build_path = None
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
pytest.raises(ValueError, match="CORE.build_path must be set"),
):
toolchain._ccache_env()
def test_run_platformio_cli_merges_caller_env(
setup_core: Path, mock_run_external_process: Mock
) -> None:
"""A caller-supplied env is the base and gains the ccache settings."""
CORE.build_path = str(setup_core / "build" / "test")
with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"):
mock_run_external_process.return_value = 0
toolchain.run_platformio_cli(
"test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"}
)
env = mock_run_external_process.call_args[1]["env"]
assert env["CUSTOM_VAR"] == "1"
# The normalized enable flag still lands in the subprocess env.
assert "ESPHOME_CCACHE_ENABLE" in env
def test_copy_ccache_script(setup_core: Path) -> None:
"""The shared ccache pre-script is copied into the build dir."""
CORE.build_path = setup_core / "build" / "test"
toolchain.copy_ccache_script()
dest = setup_core / "build" / "test" / "ccache.py"
source = Path(toolchain.__file__).parent / "ccache.py.script"
assert dest.read_text() == source.read_text()
@pytest.mark.parametrize(
("platform", "input_path", "expected"),
[
@@ -373,7 +518,10 @@ def test_run_platformio_cli_strips_win_long_path_prefix(
)
with (
patch.dict(os.environ, {}, clear=False),
# Pin ccache off: patching sys.platform to win32 (sys is a singleton,
# so the stdlib sees it too) would send shutil.which down the Windows
# code path, which crashes on a POSIX host.
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=False),
patch("esphome.platformio.toolchain.sys.platform", "win32"),
patch("esphome.platformio.toolchain.sys.executable", prefixed_exe),
):
@@ -1093,3 +1241,361 @@ def test_filter_platformio_lines_blocks_noisy_messages(msg: str) -> None:
def test_filter_platformio_lines_allows_other_messages(msg: str) -> None:
"""Test that non-noisy platformio output lines pass through RedirectText."""
assert _filter_through_redirect(msg) == msg + "\n"
# ---------------------------------------------------------------------------
# PlatformIO python-version cache heal
# ---------------------------------------------------------------------------
_CURRENT_MINOR = f"{sys.version_info.major}.{sys.version_info.minor}"
# Captured before the autouse guard patches the name, so tests can exercise the
# real implementation.
_REAL_GET_PLATFORMIO_CONFIG = toolchain.get_platformio_config
@pytest.fixture(autouse=True)
def _guard_real_platformio() -> Generator[None, None, None]:
"""Default the PlatformIO config lookup to None so no test in this module
touches a real ~/.platformio; the heal tests re-patch it at a temp dir."""
with patch.object(toolchain, "get_platformio_config", return_value=None):
yield
def _pio_layout(core_dir: Path) -> dict[str, Path]:
"""Return the PlatformIO dir layout with cache/packages/platforms under core."""
return {
"core_dir": core_dir,
"packages_dir": core_dir / "packages",
"platforms_dir": core_dir / "platforms",
"cache_dir": core_dir / ".cache",
}
def _split_pio_layout(tmp_path: Path) -> dict[str, Path]:
"""Container-shape layout: caches on a persistent root, core_dir ephemeral."""
persistent = tmp_path / "data" / "platformio"
return {
"core_dir": tmp_path / "root" / ".platformio",
"platforms_dir": persistent / "platforms",
"packages_dir": persistent / "packages",
"cache_dir": persistent / "cache",
}
def _seed_layout(layout: dict[str, Path]) -> None:
"""Populate each cache dir (and the core penv) with a marker file."""
for key in ("platforms_dir", "packages_dir", "cache_dir"):
layout[key].mkdir(parents=True, exist_ok=True)
(layout[key] / "marker").write_text("x", encoding="utf-8")
penv = layout["core_dir"] / "penv"
penv.mkdir(parents=True, exist_ok=True)
(penv / "marker").write_text("x", encoding="utf-8")
def _make_pio_config(layout: dict[str, Path] | Path) -> MagicMock:
"""A ProjectConfig stand-in resolving platformio dir options from *layout*."""
resolved = _pio_layout(layout) if isinstance(layout, Path) else layout
config = MagicMock()
config.get.side_effect = lambda section, option: (
str(resolved[option]) if section == "platformio" else ""
)
return config
@contextmanager
def _use_pio_config(layout: dict[str, Path] | Path) -> Generator[MagicMock, None, None]:
"""Point ``get_platformio_config`` at a temp layout for the block."""
config = _make_pio_config(layout)
with patch.object(toolchain, "get_platformio_config", return_value=config):
yield config
def _stamp_version(core_dir: Path) -> str | None:
"""Read the python version recorded in the heal stamp under *core_dir*."""
return toolchain._read_pio_stamp_python(core_dir / toolchain._PIO_PYTHON_STAMP_FILE)
def _cache_wiped(core_dir: Path) -> bool:
"""True when the seeded cache subdir markers are gone."""
return not any(
(core_dir / sub / "marker").exists()
for sub in ("packages", "platforms", ".cache")
)
@pytest.fixture
def pio_core_dir(tmp_path: Path) -> Path:
"""A populated PlatformIO core dir (packages/platforms/.cache/penv seeded)."""
core = tmp_path / "dot-platformio"
for sub in ("packages", "platforms", ".cache", "penv"):
seeded = core / sub
seeded.mkdir(parents=True)
(seeded / "marker").write_text("x", encoding="utf-8")
return core
def test_current_python_minor_matches_running_interpreter() -> None:
"""_current_python_minor returns major.minor of the running interpreter."""
assert toolchain._current_python_minor() == _CURRENT_MINOR
def test_pio_stamp_round_trip(tmp_path: Path) -> None:
"""The stamp writer/reader round-trips and records the schema version."""
stamp = tmp_path / toolchain._PIO_PYTHON_STAMP_FILE
toolchain._write_pio_stamp_python(stamp, "3.13")
assert toolchain._read_pio_stamp_python(stamp) == "3.13"
assert json.loads(stamp.read_text()) == {
"schema_version": toolchain._PIO_PYTHON_STAMP_SCHEMA,
"python_version": "3.13",
}
def test_read_pio_stamp_missing(tmp_path: Path) -> None:
"""A missing stamp file yields None."""
assert toolchain._read_pio_stamp_python(tmp_path / "nope.json") is None
def test_read_pio_stamp_malformed(tmp_path: Path) -> None:
"""A corrupt stamp file yields None instead of raising."""
stamp = tmp_path / "bad.json"
stamp.write_text("{not json", encoding="utf-8")
assert toolchain._read_pio_stamp_python(stamp) is None
def test_read_pio_stamp_unreadable_logs_warning(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A present-but-unreadable stamp yields None and warns."""
stamp = tmp_path / "stamp.json"
stamp.mkdir()
with caplog.at_level("WARNING"):
assert toolchain._read_pio_stamp_python(stamp) is None
assert "Could not read" in caplog.text
def test_read_pio_stamp_without_python_version(tmp_path: Path) -> None:
"""A stamp missing python_version yields None."""
stamp = tmp_path / "s.json"
stamp.write_text(json.dumps({"schema_version": "0"}), encoding="utf-8")
assert toolchain._read_pio_stamp_python(stamp) is None
@pytest.mark.parametrize("payload", ["42", '"x"', "[1, 2]", "null"])
def test_read_pio_stamp_non_object_json(tmp_path: Path, payload: str) -> None:
"""Valid-but-non-object JSON in the stamp yields None, not a crash."""
stamp = tmp_path / "s.json"
stamp.write_text(payload, encoding="utf-8")
assert toolchain._read_pio_stamp_python(stamp) is None
def test_clean_platformio_cache_none_config_is_noop() -> None:
"""clean_platformio_cache is a no-op when PlatformIO is unavailable."""
with patch.object(toolchain, "get_platformio_config", return_value=None):
toolchain.clean_platformio_cache()
def test_clean_platformio_cache_wipes_everything(pio_core_dir: Path) -> None:
"""clean_platformio_cache removes cache/packages/platforms and core_dir."""
with _use_pio_config(pio_core_dir):
toolchain.clean_platformio_cache()
assert not pio_core_dir.exists()
def test_heal_none_config_is_noop() -> None:
"""Heal is a no-op (no error) when PlatformIO is unavailable."""
with patch.object(toolchain, "get_platformio_config", return_value=None):
toolchain.heal_platformio_python_env()
def test_heal_fresh_cache_stamps_without_wipe(tmp_path: Path) -> None:
"""A fresh core dir (no stamp, no penv) is stamped, not wiped."""
core = tmp_path / "pio"
with _use_pio_config(core):
toolchain.heal_platformio_python_env()
assert _stamp_version(core) == _CURRENT_MINOR
def test_heal_stamp_matches_current_no_wipe(pio_core_dir: Path) -> None:
"""A stamp matching the running interpreter leaves the cache untouched."""
toolchain._write_pio_stamp_python(
pio_core_dir / toolchain._PIO_PYTHON_STAMP_FILE, _CURRENT_MINOR
)
with _use_pio_config(pio_core_dir):
toolchain.heal_platformio_python_env()
assert not _cache_wiped(pio_core_dir)
assert (pio_core_dir / "penv" / "marker").exists()
def test_heal_stale_stamp_wipes_and_restamps(pio_core_dir: Path) -> None:
"""A stamp from an older interpreter triggers a wipe + restamp; core_dir stays."""
toolchain._write_pio_stamp_python(
pio_core_dir / toolchain._PIO_PYTHON_STAMP_FILE, "2.7"
)
with _use_pio_config(pio_core_dir):
toolchain.heal_platformio_python_env()
assert _cache_wiped(pio_core_dir)
assert not (pio_core_dir / "penv").exists()
assert pio_core_dir.is_dir()
assert _stamp_version(pio_core_dir) == _CURRENT_MINOR
def test_heal_no_stamp_existing_cache_wipes_once(
pio_core_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An existing cache with no stamp is cleaned once and stamped."""
with _use_pio_config(pio_core_dir), caplog.at_level("INFO"):
toolchain.heal_platformio_python_env()
assert _cache_wiped(pio_core_dir)
assert not (pio_core_dir / "penv").exists()
assert _stamp_version(pio_core_dir) == _CURRENT_MINOR
assert "once" in caplog.text
def test_heal_no_stamp_penv_only_counts_as_cache(tmp_path: Path) -> None:
"""A core dir holding only a penv still triggers the one-time clean."""
core = tmp_path / "pio"
penv = core / "penv"
penv.mkdir(parents=True)
(penv / "marker").write_text("x", encoding="utf-8")
with _use_pio_config(core):
toolchain.heal_platformio_python_env()
assert not penv.exists()
assert _stamp_version(core) == _CURRENT_MINOR
def test_heal_oserror_is_nonfatal(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A filesystem failure during the check warns instead of aborting the build."""
blocker = tmp_path / "pio"
blocker.write_text("not a directory", encoding="utf-8")
with _use_pio_config(blocker), caplog.at_level("WARNING"):
toolchain.heal_platformio_python_env()
assert "build environment check failed" in caplog.text
def test_heal_stamp_write_failure_is_nonfatal(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed stamp write (EsphomeError from write_file) warns, not aborts."""
with (
_use_pio_config(tmp_path / "pio"),
patch.object(
toolchain,
"_write_pio_stamp_python",
side_effect=EsphomeError("disk full"),
),
caplog.at_level("WARNING"),
):
toolchain.heal_platformio_python_env()
assert "build environment check failed" in caplog.text
def test_heal_is_idempotent_across_runs(pio_core_dir: Path) -> None:
"""After a heal writes the stamp, a re-provisioned cache is not wiped again."""
with _use_pio_config(pio_core_dir):
toolchain.heal_platformio_python_env()
repop = pio_core_dir / "packages"
repop.mkdir(exist_ok=True)
(repop / "marker").write_text("x", encoding="utf-8")
toolchain.heal_platformio_python_env()
assert (pio_core_dir / "packages" / "marker").exists()
def test_pio_stamp_dir_is_platforms_parent(tmp_path: Path) -> None:
"""The stamp home is the parent of platforms_dir, not core_dir."""
layout = _split_pio_layout(tmp_path)
config = _make_pio_config(layout)
assert toolchain._pio_stamp_dir(config) == layout["platforms_dir"].parent
nested = _make_pio_config(tmp_path / "pio")
assert toolchain._pio_stamp_dir(nested) == tmp_path / "pio"
def test_heal_container_layout_stamps_persistent_root(tmp_path: Path) -> None:
"""Container shape: the stamp lands on the persistent cache root."""
layout = _split_pio_layout(tmp_path)
with _use_pio_config(layout):
toolchain.heal_platformio_python_env()
persistent = layout["platforms_dir"].parent
assert _stamp_version(persistent) == _CURRENT_MINOR
assert not (layout["core_dir"] / toolchain._PIO_PYTHON_STAMP_FILE).exists()
def test_heal_container_layout_stale_stamp_wipes_persistent_cache(
tmp_path: Path,
) -> None:
"""Container shape: a stale stamp wipes the relocated persistent caches."""
layout = _split_pio_layout(tmp_path)
_seed_layout(layout)
persistent = layout["platforms_dir"].parent
toolchain._write_pio_stamp_python(
persistent / toolchain._PIO_PYTHON_STAMP_FILE, "2.7"
)
with _use_pio_config(layout):
toolchain.heal_platformio_python_env()
for key in ("platforms_dir", "packages_dir", "cache_dir"):
assert not layout[key].exists()
assert not (layout["core_dir"] / "penv").exists()
assert _stamp_version(persistent) == _CURRENT_MINOR
def test_heal_container_layout_survives_core_dir_wipe(tmp_path: Path) -> None:
"""A python change is still detected after an image update wiped core_dir."""
layout = _split_pio_layout(tmp_path)
_seed_layout(layout)
shutil.rmtree(layout["core_dir"])
persistent = layout["platforms_dir"].parent
toolchain._write_pio_stamp_python(
persistent / toolchain._PIO_PYTHON_STAMP_FILE, "2.7"
)
with _use_pio_config(layout):
toolchain.heal_platformio_python_env()
for key in ("platforms_dir", "packages_dir", "cache_dir"):
assert not layout[key].exists()
assert _stamp_version(persistent) == _CURRENT_MINOR
def test_get_platformio_config_returns_project_config() -> None:
"""The real lookup returns a usable ProjectConfig when PlatformIO is present."""
config = _REAL_GET_PLATFORMIO_CONFIG()
assert config is not None
assert hasattr(config, "get")
def test_get_platformio_config_none_when_platformio_absent() -> None:
"""The lookup returns None when PlatformIO cannot be imported."""
with patch.dict(sys.modules, {"platformio.project.config": None}):
assert _REAL_GET_PLATFORMIO_CONFIG() is None
def test_delete_platformio_dirs_skips_missing(tmp_path: Path) -> None:
"""A named dir that does not exist is skipped without error."""
(tmp_path / "packages").mkdir()
(tmp_path / "packages" / "marker").write_text("x", encoding="utf-8")
config = _make_pio_config(tmp_path)
# platforms_dir does not exist; packages_dir does.
toolchain._delete_platformio_dirs(config, ["packages_dir", "platforms_dir"])
assert not (tmp_path / "packages").exists()
def test_heal_stale_stamp_wipes_when_penv_absent(pio_core_dir: Path) -> None:
"""The penv wipe is skipped cleanly when no penv exists."""
shutil.rmtree(pio_core_dir / "penv")
toolchain._write_pio_stamp_python(
pio_core_dir / toolchain._PIO_PYTHON_STAMP_FILE, "2.7"
)
with _use_pio_config(pio_core_dir):
toolchain.heal_platformio_python_env()
assert _cache_wiped(pio_core_dir)
assert _stamp_version(pio_core_dir) == _CURRENT_MINOR
def test_run_platformio_cli_invokes_heal(
setup_core: Path, mock_run_external_process: Mock
) -> None:
"""run_platformio_cli runs the heal before spawning PlatformIO."""
CORE.build_path = str(setup_core / "build" / "test")
mock_run_external_process.return_value = 0
with patch.object(toolchain, "heal_platformio_python_env") as mock_heal:
toolchain.run_platformio_cli("test")
mock_heal.assert_called_once()
+149
View File
@@ -0,0 +1,149 @@
"""Tests for esphome.preferences storage backend selection."""
import pytest
from esphome import preferences
from esphome.components.esp32 import KEY_ESP32
from esphome.components.esp32.const import (
VARIANT_ESP32,
VARIANT_ESP32C2,
VARIANT_ESP32C3,
VARIANT_ESP32C61,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_STORAGE,
KEY_CORE,
KEY_TARGET_PLATFORM,
KEY_VARIANT,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
)
from esphome.core import CORE
def _set_platform(platform: str) -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
def _set_esp32(variant: str) -> None:
_set_platform(PLATFORM_ESP32)
CORE.data[KEY_ESP32] = {KEY_VARIANT: variant}
def _validate(value: dict):
return cv.Schema(preferences.storage_schema())(value)
def _define_names() -> set[str]:
return {define.name for define in CORE.defines}
def test_is_in_flash() -> None:
_set_platform(PLATFORM_ESP8266)
assert preferences.is_in_flash(preferences.STORAGE_FLASH) is True
assert preferences.is_in_flash(preferences.STORAGE_RTC) is False
# The RTC storage define is ESP32-specific.
assert "USE_ESP32_RTC_PREFERENCES" not in _define_names()
def test_is_in_flash_esp32_rtc_emits_define() -> None:
_set_esp32(VARIANT_ESP32)
assert preferences.is_in_flash(preferences.STORAGE_FLASH) is True
assert "USE_ESP32_RTC_PREFERENCES" not in _define_names()
assert preferences.is_in_flash(preferences.STORAGE_RTC) is False
assert "USE_ESP32_RTC_PREFERENCES" in _define_names()
def test_request_rtc_storage_esp32_only() -> None:
_set_platform(PLATFORM_ESP8266)
preferences.request_rtc_storage()
# ESP8266 always has its RTC backend; no define is needed or emitted.
assert "USE_ESP32_RTC_PREFERENCES" not in _define_names()
def test_request_rtc_storage_esp32_emits_define() -> None:
_set_esp32(VARIANT_ESP32)
preferences.request_rtc_storage()
assert "USE_ESP32_RTC_PREFERENCES" in _define_names()
@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3])
def test_validate_rtc_storage_accepted(variant: str) -> None:
_set_esp32(variant)
assert preferences.validate_rtc_storage(True) is True
assert preferences.validate_rtc_storage(False) is False
def test_validate_rtc_storage_esp8266() -> None:
_set_platform(PLATFORM_ESP8266)
# Tolerated no-op: the ESP8266 backend always has RTC storage.
assert preferences.validate_rtc_storage(True) is True
# But it cannot be disabled, so an explicit false is an error.
with pytest.raises(cv.Invalid, match="always enabled on ESP8266"):
preferences.validate_rtc_storage(False)
@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61])
def test_validate_rtc_storage_rejected_without_rtc_memory(variant: str) -> None:
_set_esp32(variant)
with pytest.raises(cv.Invalid, match="not supported on this platform"):
preferences.validate_rtc_storage(True)
# Disabling it is always fine.
assert preferences.validate_rtc_storage(False) is False
def test_validate_rtc_storage_rejected_on_unsupported_platform() -> None:
_set_platform(PLATFORM_RP2040)
with pytest.raises(cv.Invalid, match="not supported on this platform"):
preferences.validate_rtc_storage(True)
@pytest.mark.parametrize(
("platform", "expected"),
[
# Defaults preserve each platform's historic behavior.
(PLATFORM_ESP8266, preferences.STORAGE_RTC),
(PLATFORM_RP2040, preferences.STORAGE_FLASH),
],
)
def test_default_storage_per_platform(platform: str, expected: str) -> None:
_set_platform(platform)
assert _validate({})[CONF_STORAGE] == expected
@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C2])
def test_default_storage_esp32_is_flash(variant: str) -> None:
# ESP32 defaults to flash on every variant, including those without RTC memory.
_set_esp32(variant)
assert _validate({})[CONF_STORAGE] == preferences.STORAGE_FLASH
def test_rtc_allowed_on_esp8266() -> None:
_set_platform(PLATFORM_ESP8266)
assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC
@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3])
def test_rtc_allowed_on_esp32_with_rtc_memory(variant: str) -> None:
_set_esp32(variant)
assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC
@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61])
def test_rtc_rejected_on_esp32_without_rtc_memory(variant: str) -> None:
_set_esp32(variant)
with pytest.raises(cv.Invalid, match="not supported on this platform"):
_validate({CONF_STORAGE: "rtc"})
def test_rtc_rejected_on_unsupported_platform() -> None:
_set_platform(PLATFORM_RP2040)
with pytest.raises(cv.Invalid, match="not supported on this platform"):
_validate({CONF_STORAGE: "rtc"})
def test_flash_allowed_on_unsupported_platform() -> None:
_set_platform(PLATFORM_RP2040)
assert _validate({CONF_STORAGE: "flash"})[CONF_STORAGE] == preferences.STORAGE_FLASH
+55 -1
View File
@@ -11,6 +11,7 @@ from esphome.components.bk72xx.boards import BK72XX_BOARD_PINS
from esphome.components.esp32.boards import ESP32_BOARD_PINS
from esphome.components.esp8266.boards import ESP8266_BOARD_PINS
from esphome.components.ln882x.boards import LN882X_BOARD_PINS
from esphome.components.rp2.boards import RP2_BOARD_PINS
from esphome.components.rtl87xx.boards import RTL87XX_BOARD_PINS
from esphome.core import CORE
import esphome.wizard as wz
@@ -300,6 +301,31 @@ def test_wizard_write_defaults_platform_from_board_rtl87xx(
assert "rtl87xx:" in generated_config
def test_wizard_write_defaults_platform_from_board_rp2(
default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch
):
"""
If the platform is not explicitly set, use "RP2" when the board is in
the RP2 boards list. The generated config must use the canonical
``rp2:`` top-level key (not the deprecated ``rp2040:`` alias).
"""
# Given
del default_config["platform"]
default_config["board"] = [*RP2_BOARD_PINS][0]
monkeypatch.setattr(wz, "write_file", MagicMock())
monkeypatch.setattr(CORE, "config_path", tmp_path.parent)
# When
wz.wizard_write(tmp_path, **default_config)
# Then
generated_config = wz.write_file.call_args.args[1]
assert "rp2:" in generated_config
# Guard against regressing to the legacy alias key.
assert "rp2040:" not in generated_config
def test_safe_print_step_prints_step_number_and_description(monkeypatch: MonkeyPatch):
"""
The safe_print_step function prints the step number and the passed description
@@ -450,6 +476,34 @@ def test_wizard_accepts_default_answers_esp32(
assert retval == 0
def test_wizard_accepts_default_answers_bk72xx(
tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str]
):
"""
The wizard should accept the given default answers for bk72xx. The
libretiny branch also exercises the False side of the
``elif platform == "RP2":`` checks in the platform / board-link
elif chain (without this, those branches show as partial coverage
because only the rpipico interactive test reaches them with platform
== "RP2").
"""
# Given
wizard_answers[1] = "BK72XX"
wizard_answers[2] = next(iter(BK72XX_BOARD_PINS))
config_file = tmp_path / "test.yaml"
input_mock = MagicMock(side_effect=wizard_answers)
monkeypatch.setattr("builtins.input", input_mock)
monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0)
monkeypatch.setattr(wz, "sleep", lambda _: 0)
monkeypatch.setattr(wz, "wizard_write", MagicMock())
# When
retval = wz.wizard(config_file)
# Then
assert retval == 0
def test_wizard_offers_better_node_name(
tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str]
):
@@ -612,7 +666,7 @@ def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch):
# Given
wizard_answers_rp2040 = [
"test-node", # Name of the node
"RP2040", # platform
"RP2", # platform (canonical name; ``RP2040`` was the legacy alias)
"rpipico", # board (no WiFi support)
]
config_file = tmp_path / "test.yaml"
+2 -2
View File
@@ -18,7 +18,7 @@ from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
PLATFORM_RP2,
PLATFORM_RTL87XX,
)
from esphome.core import EsphomeError
@@ -338,7 +338,7 @@ def test_storage_should_not_update_cmake_cache_when_nothing_changes(
@pytest.mark.parametrize(
"core_platform",
[PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_BK72XX, PLATFORM_RTL87XX],
[PLATFORM_ESP8266, PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_RTL87XX],
)
def test_storage_should_not_update_cmake_cache_for_non_esp32(
create_storage: Callable[..., StorageJSON],
+51
View File
@@ -1349,6 +1349,57 @@ def test_sensitive_str__is_a_str_subclass() -> None:
assert value == "hunter2"
def test_dump_path_without_relative_to_is_unchanged() -> None:
"""Test that Path values dump as str(path) when relative_to is not given."""
path = Path("some") / "dir" / "file.ttf"
output = yaml_util.dump({"file": path})
assert output.strip() == f"file: {path}"
def test_dump_path_relative_to_anchor_dir() -> None:
"""Test that Path values under relative_to dump as relative POSIX paths."""
anchor = Path("/config/esphome").absolute()
data = {"file": anchor / "fonts" / "arial.ttf"}
output = yaml_util.dump(data, relative_to=anchor)
assert output.strip() == "file: fonts/arial.ttf"
def test_dump_path_outside_anchor_dir_walks_up() -> None:
"""Test that Path values outside relative_to walk up with ".." segments."""
anchor = Path("/config/esphome").absolute()
outside = Path("/config/fonts/file.ttf").absolute()
output = yaml_util.dump({"file": outside}, relative_to=anchor)
assert output.strip() == "file: ../fonts/file.ttf"
def test_dump_path_with_dotdot_segments_is_normalized() -> None:
"""Test that ".." segments do not defeat relativization.
A path like /config/other/../esphome/fonts/x.ttf is under the anchor
once normalized, so it must dump as a plain relative path.
"""
anchor = Path("/config/esphome").absolute()
path = Path("/config/other/../esphome/fonts/x.ttf").absolute()
output = yaml_util.dump({"file": path}, relative_to=anchor)
assert output.strip() == "file: fonts/x.ttf"
def test_dump_path_dotdot_reference_outside_anchor() -> None:
"""Test the relative_config_path("../...") shape stays relative."""
anchor = Path("/config/esphome").absolute()
path = anchor / ".." / "shared" / "font.ttf"
output = yaml_util.dump({"file": path}, relative_to=anchor)
assert output.strip() == "file: ../shared/font.ttf"
def test_dump_relative_to_does_not_leak_between_calls() -> None:
"""Test that the relative_to flag is scoped to a single dump call."""
anchor = Path("/config/esphome").absolute()
path = anchor / "fonts" / "arial.ttf"
assert "fonts/arial.ttf" in yaml_util.dump({"file": path}, relative_to=anchor)
assert yaml_util.dump({"file": path}).strip() == f"file: {path}"
def test_dump__redacts_sensitive_str_by_default() -> None:
out = yaml_util.dump({"password": SensitiveStr("hunter2")})
assert "\\033[8mhunter2\\033[28m" in out