mirror of
https://github.com/esphome/esphome.git
synced 2026-10-04 18:11:29 +00:00
Merge remote-tracking branch 'origin/dev' into feat/nrf52_pwm
This commit is contained in:
@@ -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 == []
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"})
|
||||
@@ -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
|
||||
+2
-2
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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:`.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() == []
|
||||
|
||||
+1407
-66
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user